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.
| Plan | Requests per key | Concurrent waits per account |
| Free | 120/min, 600/hour | 2 |
| Pro | 120/min, 600/hour | 3 |
| Scale | 120/min, 600/hour | 8 |
| Business | 120/min, 600/hour | 16 |
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:
| Header | Meaning |
X-RateLimit-Limit | The ceiling for the matched bucket. |
X-RateLimit-Remaining | Requests remaining in the current window. |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets. |
Retry-After | Seconds 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.
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
| Status | Meaning |
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
| Status | Meaning |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
limit |
integer |
25 |
1–100 |
cursor |
string |
none |
opaque, signed, bound to the key owner and filters |
Responses
| Status | Meaning |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
inbox public id, e.g. inbox_... |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
inbox public id, e.g. inbox_... |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
inbox public id |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
message public id, e.g. msg_... |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
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
| Status | Meaning |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
inbox public id |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
email |
string (path) |
required |
a shared-domain address, e.g. someone@in.inboxpipe.net |
limit |
integer |
20 |
1–20 |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
limit |
integer |
25 |
1–100 |
cursor |
string |
none |
opaque, signed, bound to the key owner |
Responses
| Status | Meaning |
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
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
domain public id, e.g. dom_... |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
domain public id |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
id |
string (path) |
required |
domain public id |
Responses
| Status | Meaning |
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
| Param | Type | Default | Constraint |
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
| Status | Meaning |
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.