API reference

REST API reference (v1)

Every endpoint is served under /api/v1 and authenticated by an Authorization: Bearer <key> header. API keys start with ms_live_. The OpenAPI 3.1 spec is at /docs/openapi.yaml. v1 only changes in backward-compatible ways; see versioning.

Authentication

Send your API key as a Bearer token on every request:

Authorization header
curl -H "Authorization: Bearer ms_live_..." "https://dash.mailsocket.app/api/v1/inboxes"

Missing or invalid keys return 401 with WWW-Authenticate: Bearer. Dashboard session cookies are not accepted. Keys are hashed at rest and can be revoked individually from the dashboard.

Prefer a client? See the official SDKs and MCP server.

Rate limits

Each API key can make 120 requests per minute and 600 requests per hour. The limits are the same on every plan; plans differ in how many waits can be open at once.

PlanRequests per keyConcurrent waits per account
Free120/min, 600/hour2
Pro120/min, 600/hour3
Scale120/min, 600/hour8
Business120/min, 600/hour16

Some endpoints have an extra limit, listed with the endpoint below (for example POST /messages/batch and POST /verify). Authenticated responses carry headers for the bucket closest to its limit:

HeaderMeaning
X-RateLimit-LimitThe ceiling for the matched bucket.
X-RateLimit-RemainingRequests remaining in the current window.
X-RateLimit-ResetUnix timestamp (seconds) when the window resets.
Retry-AfterSeconds to wait before retrying. Present on every 429, including the two wait-cap codes.

Exceeding the limit returns 429 with code rate_limited. See the error reference.

Request IDs

Every response has an X-Request-Id header, and error bodies repeat it as error.request_id. Log it, and include it when you contact support so we can find the request.

Cursor pagination

List endpoints (inboxes, inboxes/{id}/messages, domains) page with an opaque cursor instead of page numbers. Each page's response has a pagination object:

Pagination object
{
  "pagination": {
    "next_cursor": "opaque-signed-cursor",
    "has_more": true
  }
}
  • Pass the returned next_cursor back as the cursor query parameter to fetch the next page.
  • When has_more is false, next_cursor is null and there are no more rows.
  • The cursor is signed and bound to its owner and filters. A cursor from one filter set cannot be reused with another.

Plan-gated features: 402 vs 403

Two status codes mean "your plan does not include this". Match on error.code:

  • 402 plan_upgrade_required: a Free account called an operation that any paid plan unlocks. Today that is only POST /messages/batch.
  • 403 <feature>_entitlement_required: the plan does not include a named capability. webhook_entitlement_required (any paid plan), domain_entitlement_required and catch_all_entitlement_required (Business).

Retrying will not help. See pricing.

Webhooks

Webhooks push every new message in an inbox to your HTTPS endpoint (paid plans). Configure one per inbox in the dashboard under Webhooks: set the URL and copy the signing secret (whsec_…), which is shown once. You can rotate it at any time. Use POST /api/v1/inboxes/{id}/webhook/test to send a signed test delivery.

Delivery

  • A POST with Content-Type: application/json to your URL, only to public HTTPS addresses (the resolved IP is pinned; private, loopback and link-local targets are refused).
  • Headers: X-Mailsocket-Event (email.received), X-Mailsocket-Event-Id (evt_…, the same on every retry, so use it to de-duplicate), X-Mailsocket-Signature, X-Mailsocket-Signature-Version (1).
  • Any 2xx is success. 408, 425, 429 and 5xx (and network errors) are retried with backoff; any other status is a permanent failure. Delivery history is visible per inbox in the dashboard.
Payload (email.received)
{
  "id": "evt_...",
  "event": "email.received",
  "created_at": "2026-09-30T10:12:03Z",
  "data": {
    "message": { /* the same Message object as GET /messages/{id} */ }
  }
}

Verifying the signature

X-Mailsocket-Signature has the form t=<unix-seconds>,v1=<hex>. v1 is HMAC-SHA256, keyed with your whsec_… secret, over the bytes <t>. followed by the raw request body (verify before parsing the JSON). Compare in constant time and reject stale timestamps to block replays.

Python
import hashlib, hmac, time

def verify(secret, header, raw_body, tolerance=300):
    fields = dict(part.split("=", 1) for part in header.split(","))
    t, supplied = fields["t"], fields["v1"]
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(supplied, expected)

Public inboxes

Inboxes are private by default. An inbox created with "public": true (shared domain only) can also be read with no key at GET /api/v1/public/emails/{email}/messages. The response includes each message's otp, magic_link, sender, recipients, subject and time, and never the body, links, OTP context or metadata. Anyone with the address can read the codes, so use it only for demos and shared fixtures, never for real user signups.

Endpoints

POST /api/v1/agents/register

Create an API key and a first inbox with no signup (no auth, no email).

Request body

application/json
{"name": "optional label, max 60 chars"}

Responses

StatusMeaning
201 api_key (shown once), key_prefix, plan, and the new inbox.
429 Registration rate limit exceeded (10 per hour per IP, plus a service-wide hourly cap).

POST /api/v1/verify

Check whether an email address looks deliverable (syntax, MX and heuristics; never an SMTP probe).

Request body

application/json
{"email": "someone@example.com"}

Responses

StatusMeaning
200 deliverable (deliverable | risky | undeliverable | unknown), syntax_valid, has_mx, is_disposable, is_role, reason.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
422 Missing or malformed email.
429 Rate limit exceeded (per key, per account, or service-wide verify capacity).

POST /api/v1/verify/batch

Check up to 100 addresses in one call; each row is charged like a single /verify call.

Request body

application/json
{"emails": ["a@x.com", ...]} or {"text": "pasted list"} or multipart file=<.csv|.txt>. Send exactly one of the three, plus optional "mode": "safe" (default) | "full".

Responses

StatusMeaning
200 data.results (one row per address, same shape as POST /verify, in input order) and data.summary (total, deliverable, risky, undeliverable, unknown).
401 Missing or invalid API key.
403 Account is not permitted, or mode "full" was requested (smtp_verify_disabled; full mode is not generally available).
422 validation_error (send exactly one input, with at least one address), batch_too_large (over 100 unique rows, 25 in full mode), unsupported_file_type, malformed_file, or file_too_large (64 KB cap).
429 The whole batch did not fit your remaining /verify budget. Nothing was charged.

GET /api/v1/inboxes/{id}/stream

Server-Sent Events stream of new messages (an EventSource-compatible alternative to /wait).

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id
since string request start time ISO-8601 datetime | message public_id | unix timestamp; messages strictly after this match
require string any otp | link | any (anything else returns 422)
min_confidence number 0.0 OTP must have otp_confidence ≥ this; clamped to [0, 1]; ignored for link

Responses

StatusMeaning
200 text/event-stream: `message` events (Message JSON), periodic heartbeats, and a final `bye` before the connection is recycled (about every 25s) so EventSource reconnects.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
422 validation_error: `require` is not one of otp | link | any.
429 too_many_wait_requests (your plan's concurrent-wait cap) or wait_capacity (service-wide cap). Honour Retry-After.

GET /api/v1/inboxes

List inboxes.

Parameters

ParamTypeDefaultConstraint
limit integer 25 1–100
cursor string none opaque, signed, bound to the key owner and filters

Responses

StatusMeaning
200 A page of inboxes.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
422 Invalid parameter.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <InboxListResponse>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

POST /api/v1/inboxes

Create an inbox.

Request body

application/json
{"label": "optional label, max 120 chars", "routing_key": "optional custom local part, 3-64 chars, a-z/A-Z/0-9/_/- (stored lowercase)", "public": "optional bool, default false; opt-in anonymous read (shared domain only)", "metadata": "optional JSON object, <=4096 bytes compact UTF-8, <=50 top-level keys"}

Responses

StatusMeaning
201 Inbox created; the Location header points at the new inbox.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
409 plan_limit_reached (plan inbox limit), slug_taken (routing_key already claimed), or conflict (address allocation failed; retry).
415 Request media type not supported.
422 validation_error, public_not_allowed_on_custom_domain, metadata_must_be_object, or metadata_too_large.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Inbox>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

GET /api/v1/inboxes/{id}

Get an inbox.

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id, e.g. inbox_...

Responses

StatusMeaning
200 The inbox, plus message_count_month and webhook_configured.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <InboxDetail>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

DELETE /api/v1/inboxes/{id}

Delete an inbox.

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id, e.g. inbox_...

Responses

StatusMeaning
204 Inbox deleted. A second delete returns 404.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
429 Rate limit exceeded.

GET /api/v1/inboxes/{id}/messages

List messages in an inbox.

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id
limit integer 25 1–100
cursor string none opaque, signed, bound to the key owner and filters
has_otp boolean none keep only messages with an OTP
subject_contains string none case-insensitive substring, capped at 200 chars
from string none case-insensitive substring of the From header

Responses

StatusMeaning
200 A page of message summaries.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
422 Invalid parameter.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <MessageSummaryListResponse>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

GET /api/v1/inboxes/{id}/messages/latest

Get the latest message in an inbox.

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id

Responses

StatusMeaning
200 The newest message (full representation).
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown inbox, or no messages yet.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Message>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

GET /api/v1/inboxes/{id}/messages/wait

Wait for an OTP or magic link.

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id
timeout number (seconds) 20 clamped to [1, 25]
since string request start time ISO-8601 datetime | message public_id | unix timestamp; messages strictly after this match
require string otp otp | link | any (anything else returns 422)
min_confidence number 0.0 OTP must have otp_confidence ≥ this; clamped to [0, 1]; ignored for link

Responses

StatusMeaning
200 A matching parsed message arrived.
204 The timeout elapsed with no match. Call again.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
422 validation_error: `require` is not one of otp | link | any (fields.require lists the allowed values).
429 too_many_wait_requests (your plan's concurrent-wait cap) or wait_capacity (service-wide cap). Honour Retry-After.

Successful responses wrap the payload as {"data": <Message>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

GET /api/v1/messages/{id}

Get a message.

Parameters

ParamTypeDefaultConstraint
id string (path) required message public id, e.g. msg_...

Responses

StatusMeaning
200 The message (full representation).
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Message>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

PATCH /api/v1/messages/{id}

Mark a message read or unread.

Parameters

ParamTypeDefaultConstraint
id string (path) required message public id, e.g. msg_...

Request body

application/json
{"read": true}. `read` (boolean) is the only accepted field; `read: false` marks the message unread.

Responses

StatusMeaning
200 The updated message. Idempotent: repeating `read: true` keeps the first read_at.
401 Missing or invalid API key.
403 Account is not permitted to use the API.
404 Unknown or not owned.
422 validation_error: the body must be {"read": true|false}. Any other key, even alongside `read`, is rejected and named in fields.<key>.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Message>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

POST /api/v1/messages/batch

Apply one action (mark_read | delete | fetch) to up to 100 of your messages (paid plans).

Request body

application/json
{"action": "mark_read | delete | fetch", "ids": ["msg_...", "... up to 100 ids"]}

Responses

StatusMeaning
200 data.results: one {id, status} per input id, in input order. status is ok | deleted | not_found (fetch also returns message). Ids you do not own are reported as not_found.
401 Missing or invalid API key.
402 plan_upgrade_required: batch operations need a paid plan.
403 Account is not permitted to use the API.
422 validation_error: unknown action, or ids missing, empty or over 100.
429 Rate limit exceeded (60 batch calls per hour per account, plus the per-key limit).

POST /api/v1/inboxes/{id}/webhook/test

Send one signed test delivery to the inbox's webhook (paid plans).

Parameters

ParamTypeDefaultConstraint
id string (path) required inbox public id

Responses

StatusMeaning
200 data.delivered, data.response_status (your receiver's HTTP status), data.signature_version, data.event_id (evt_test_...). Not recorded in delivery history and never retried.
400 webhook_ssrf_rejected: the webhook URL resolves to a private, loopback or link-local address.
401 Missing or invalid API key.
403 account_forbidden, or webhook_entitlement_required (the plan does not include webhooks).
404 not_found (unknown or not owned inbox) or webhook_not_configured.
429 rate_limited: at most 10 test sends per inbox and 30 per account per hour.
502 webhook_unreachable: timeout, connection or TLS failure reaching your receiver.

GET /api/v1/public/emails/{email}/messages

Read a public inbox's recent messages without auth (inboxes are private unless created with public: true).

Parameters

ParamTypeDefaultConstraint
email string (path) required a shared-domain address, e.g. someone@in.inboxpipe.net
limit integer 20 1–20

Responses

StatusMeaning
200 Up to `limit` newest messages. Includes otp and magic_link; excludes text, html, links, otp_context, otp_candidates and metadata. See Public inboxes above.
404 Address malformed, unknown, private (the default), disabled, deleted, or on a custom domain. All cases return the same response.
429 Per-IP rate limit exceeded (30 per minute).

GET /api/v1/domains

List your custom domains (a plan without custom domains gets an empty list).

Parameters

ParamTypeDefaultConstraint
limit integer 25 1–100
cursor string none opaque, signed, bound to the key owner

Responses

StatusMeaning
200 A page of domains, newest first.
401 Missing or invalid API key.
403 account_forbidden, or byod_disabled (custom domains are not enabled for this account).
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Domain>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

POST /api/v1/domains

Register a custom domain (Business plan).

Request body

application/json
{"name": "mail.yourcompany.com"}. `name` is a bare hostname, max 253 chars.

Responses

StatusMeaning
201 Domain created with status=pending_dns. Publish verification_token as a TXT record at verification_txt_host, and mx_target as MX. The Location header points at the domain.
401 Missing or invalid API key.
403 domain_entitlement_required (checked before the body), domain_limit_reached, byod_disabled, or account_forbidden (only keys created by the account owner).
409 domain_taken: the name is already registered.
422 validation_error: `name` missing, malformed, or a reserved mailsocket domain.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Domain>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

GET /api/v1/domains/{id}

Get a custom domain and its DNS verification status.

Parameters

ParamTypeDefaultConstraint
id string (path) required domain public id, e.g. dom_...

Responses

StatusMeaning
200 The domain (status: pending_dns | verified | failed | disabled; last_checked_at, last_error, catch_all).
401 Missing or invalid API key.
403 account_forbidden, or byod_disabled.
404 Unknown or not owned.
429 Rate limit exceeded.

Successful responses wrap the payload as {"data": <Domain>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

DELETE /api/v1/domains/{id}

Delete a custom domain.

Parameters

ParamTypeDefaultConstraint
id string (path) required domain public id

Responses

StatusMeaning
204 Domain deleted and its name released.
401 Missing or invalid API key.
403 account_forbidden (only keys created by the account owner), or byod_disabled.
404 Unknown or not owned.
409 domain_has_inboxes: remove the inboxes bound to it first.
429 Rate limit exceeded.

POST /api/v1/domains/{id}/verify

Re-check the domain's TXT and MX records now.

Parameters

ParamTypeDefaultConstraint
id string (path) required domain public id

Responses

StatusMeaning
200 The domain after the check. status becomes verified once both TXT and MX match. Idempotent on a verified domain.
401 Missing or invalid API key.
403 account_forbidden (only keys created by the account owner), or byod_disabled.
404 Unknown or not owned.
429 domain_verify_rate_limited: at most 1 per domain per 30s and 20 per account per hour (see Retry-After).

Successful responses wrap the payload as {"data": <Domain>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.

PATCH /api/v1/domains/{id}/catch_all

Enable or disable catch-all on a verified custom domain (Business plan).

Parameters

ParamTypeDefaultConstraint
id string (path) required domain public id

Request body

application/json
{"catch_all": true, "inbox_id": "optional inbox_... already bound to this domain; omit to create a catch-all inbox"}

Responses

StatusMeaning
200 The domain with the new catch_all state and catch_all_inbox_id.
401 Missing or invalid API key.
403 catch_all_entitlement_required, inbox_limit_reached (creating the catch-all inbox would exceed the plan's inbox limit), byod_disabled, or account_forbidden.
404 Unknown or not owned domain, or domain_not_found_or_unverified for inbox_id.
409 domain_not_verified: verify the domain first.
422 validation_error (catch_all missing or not boolean) or catch_all_target_invalid (inbox_id is not a live inbox bound to this domain).
429 catch_all_rate_limited: at most 6 changes per domain per hour.

Successful responses wrap the payload as {"data": <Domain>} (plus pagination on list endpoints). Field definitions live in /docs/openapi.yaml.