# mailsocket > Programmable email inboxes for developers and AI agents. Create an > inbox, send email to it, then read the parsed message, or block on one > call until the OTP or magic link arrives. Email in, JSON out. ## What it is - Create inbox addresses on in.inboxpipe.net via the REST API. - Every email is parsed to JSON (sender, subject, text, html, otp, links). - The core call: `messages/wait` blocks until a matching OTP or link is parsed. - Built for signup automation, end-to-end tests (pytest, Cypress, Puppeteer, CI) and AI agents. ## Base URL and authentication - API base: `https://dash.mailsocket.app/api/v1` - Auth: send your API key as a Bearer token on EVERY request: `Authorization: Bearer ms_live_...` - Keys are created in the dashboard (https://dash.mailsocket.app/app/api-keys/), prefixed `ms_live_`, hashed at rest, individually revocable, and may carry an optional expiry. Dashboard session cookies are not accepted on the API. - Missing or invalid key: 401. Account not permitted: 403. Rate limited: 429 with Retry-After. - Every response carries an X-Request-Id header; error bodies repeat it as error.request_id. - No-signup path: POST /api/v1/agents/register needs no key and returns one (step 0 below). Email verification is not required for this path. ## Limits - Requests: 120 per minute and 600 per hour per API key, on every plan. Authenticated responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. - Concurrent waits per account: 2 Free / 10 Pro / 20 Scale / 40 Business. - Wait timeout: at most 25s per call (default 20). On 204 call again, and pass the response's Mailsocket-Wait-Since header back as since to never miss a message (the SDKs do this). ## Quick start (curl): register, then wait ```bash # Needs curl and jq. Paste the whole block into bash; it runs as is. command -v jq >/dev/null || echo "This block needs jq: https://jqlang.org/download/" >&2 # 0. No signup: create an API key and a ready-to-use inbox in one call, and keep # both in shell variables. Already have a key from the dashboard? Instead set # MAILSOCKET_API_KEY yourself and INBOX=. RESP=$(curl -s -X POST "https://dash.mailsocket.app/api/v1/agents/register" \ -H "Content-Type: application/json" \ -d '{"name": "my-agent"}') # RESP = { "data": { "api_key": "ms_live_...", "key_prefix": "...", "plan": "free", # "inbox": { "id": "inbox_...", "address": "...@in.inboxpipe.net", ... } } } # api_key is shown once. Store it. export MAILSOCKET_API_KEY=$(jq -r .data.api_key <<<"$RESP") INBOX=$(jq -r .data.inbox.id <<<"$RESP") echo "Inbox address: $(jq -r .data.inbox.address <<<"$RESP")" # 1. (optional, not run here) Create another inbox, optionally with a custom # routing_key (the local part of the address) instead of a random one: # curl -X POST "https://dash.mailsocket.app/api/v1/inboxes" \ # -H "Authorization: Bearer $MAILSOCKET_API_KEY" \ # -H "Content-Type: application/json" \ # -d '{"label": "signup-test", "routing_key": "my-custom-slug"}' # -> { "data": { "id": "inbox_...", "address": "my-custom-slug@in.inboxpipe.net", ... } } # 2. Remember the newest message id in the inbox (0 if it is empty), then # send an email to that address (from your app or signup flow): SINCE=$(curl -s "https://dash.mailsocket.app/api/v1/inboxes/$INBOX/messages/latest" \ -H "Authorization: Bearer $MAILSOCKET_API_KEY" | jq -r '.data.id // 0') # 2a. No email handy (or your sandbox cannot send mail)? Send yourself a test # code instead. It puts a sample OTP email straight into your own inbox # (it does not go through the mail server). Limit: 5 per inbox, 20 per account per hour. curl -s -X POST "https://dash.mailsocket.app/api/v1/inboxes/$INBOX/test-code" \ -H "Authorization: Bearer $MAILSOCKET_API_KEY" echo # -> 201 { "data": { "message_id": "msg_...", "sent_at": "..." } } # 3. Wait for the OTP. since=$SINCE matches only mail newer than that message, # even if it arrived before this call started, so the steps work one call at # a time. 200 returns the code; 204 means none yet, so call again (same since). # An empty SINCE (no jq) stops here instead of matching older mail: curl -s "https://dash.mailsocket.app/api/v1/inboxes/$INBOX/messages/wait?require=otp&since=${SINCE:?no cutoff, install jq and rerun step 2}&timeout=25" \ -H "Authorization: Bearer $MAILSOCKET_API_KEY" # -> { "data": { "otp": "123456", "otp_context": "...", ... } } # Or skip SINCE: start the wait first, then send the email or the test code # from another shell while the wait is running. # 3a. (optional, not run here) Or subscribe to a Server-Sent Events stream of new # messages (same auth, same require/min_confidence/since params). Each event's # `data:` payload is the same message JSON as GET /messages/{id}. # curl -N "https://dash.mailsocket.app/api/v1/inboxes/$INBOX/stream?require=otp" \ # -H "Authorization: Bearer $MAILSOCKET_API_KEY" -H "Accept: text/event-stream" ``` Success responses are `{"data": ...}`; errors are `{"error": {"code": ..., "message": ..., "fields": {}, "request_id": ...}}`. Match on error.code; messages may change. ## Endpoints ### POST /api/v1/agents/register Create an API key and a first inbox with no signup (no auth, no email). Idempotency-Key is not supported here (no account to scope it to); a retry creates a new key. Body: `{"name": "optional label, max 60 chars"}` Status: 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). Body: `{"email": "someone@example.com"}` Status: 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 validation_error: email missing or blank, or bad mode (bad email = 200 verdict)., 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. Body: `{"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".` Status: 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). Params: id (string (path)), since (string), require (string), min_confidence (number) Status: 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. Params: limit (integer), cursor (string) Status: 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. ### POST /api/v1/inboxes Create an inbox. Params: Idempotency-Key (string (header)) Body: `{"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"}` Status: 201 Inbox created; the Location header points at the new inbox., 400 validation_error: the Idempotency-Key header is empty, over 255 chars, or not printable ASCII, or the body is too large to read with an Idempotency-Key., 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), conflict (address allocation failed; retry), or idempotency_in_progress (a request with the same Idempotency-Key is still running)., 415 Request media type not supported., 422 validation_error, public_not_allowed_on_custom_domain, metadata_must_be_object, metadata_too_large, or idempotency_key_reused (same Idempotency-Key, different body)., 429 Rate limit exceeded. ### GET /api/v1/inboxes/{id} Get an inbox. Params: id (string (path)) Status: 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. ### DELETE /api/v1/inboxes/{id} Delete an inbox. Params: id (string (path)) Status: 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. Params: id (string (path)), limit (integer), cursor (string), has_otp (boolean), subject_contains (string), from (string) Status: 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. ### GET /api/v1/inboxes/{id}/messages/latest Get the latest message in an inbox. Params: id (string (path)) Status: 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. ### GET /api/v1/inboxes/{id}/messages/wait Wait for an OTP or magic link. Params: id (string (path)), timeout (number (seconds)), since (string), require (string), min_confidence (number) Status: 200 A matching parsed message arrived., 204 No match yet. Call again with since=., 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. ### POST /api/v1/inboxes/{id}/test-code Send yourself a test code: puts one sample OTP email straight into your own inbox (it does not go through the mail server), so you can see wait return 200 without sending real mail. Same feature and same limits as the dashboard "Send a test code" button. Params: id (string (path)), Idempotency-Key (string (header)) Status: 201 data.message_id (msg_...) and data.sent_at. The code is parsed like real mail: it counts toward your monthly messages and fires the inbox's webhook. The Location header points at the message., 400 validation_error: the Idempotency-Key header is empty, over 255 chars, or not printable ASCII., 401 Missing or invalid API key., 403 Account is not permitted to use the API., 404 Unknown, deleted or not owned. All cases return the same response., 409 inbox_disabled: enable the inbox first; or idempotency_in_progress (a request with the same Idempotency-Key is still running)., 422 idempotency_key_reused: the same Idempotency-Key was already used on another endpoint., 429 rate_limited: at most 5 test codes per inbox and 20 per account per hour, shared with the dashboard button. error.message says how many minutes to wait; Retry-After gives the seconds. ### GET /api/v1/messages/{id} Get a message. Params: id (string (path)) Status: 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. ### PATCH /api/v1/messages/{id} Mark a message read or unread. Params: id (string (path)) Body: `{"read": true}. `read` (boolean) is the only accepted field; `read: false` marks the message unread.` Status: 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.., 429 Rate limit exceeded. ### POST /api/v1/messages/batch Apply one action (mark_read | delete | fetch) to up to 100 of your messages (paid plans). Params: Idempotency-Key (string (header)) Body: `{"action": "mark_read | delete | fetch", "ids": ["msg_...", "... up to 100 ids"]}` Status: 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., 400 validation_error: the Idempotency-Key header is empty, over 255 chars, or not printable ASCII, or the body is too large to read with an Idempotency-Key., 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., 409 idempotency_in_progress: a request with the same Idempotency-Key is still running., 422 validation_error: unknown action, or ids missing, empty or over 100; or idempotency_key_reused (same Idempotency-Key, different body). Idempotency-Key is ignored for fetch., 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). Params: id (string (path)) Status: 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). Params: email (string (path)), limit (integer) Status: 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). Params: limit (integer), cursor (string) Status: 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. ### POST /api/v1/domains Register a custom domain (Scale or Business plan). Params: Idempotency-Key (string (header)) Body: `{"name": "mail.yourcompany.com"}. `name` is a bare hostname, max 253 chars.` Status: 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., 400 validation_error: the Idempotency-Key header is empty, over 255 chars, or not printable ASCII, or the body is too large to read with an Idempotency-Key., 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, or idempotency_in_progress (a request with the same Idempotency-Key is still running)., 422 validation_error: `name` missing, malformed, or a reserved mailsocket domain; or idempotency_key_reused (same Idempotency-Key, different body)., 429 Rate limit exceeded. ### GET /api/v1/domains/{id} Get a custom domain and its DNS verification status. Params: id (string (path)) Status: 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. ### DELETE /api/v1/domains/{id} Delete a custom domain. Params: id (string (path)) Status: 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. Params: id (string (path)) Status: 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). ### PATCH /api/v1/domains/{id}/catch_all Enable or disable catch-all on a verified custom domain (Business plan). Params: id (string (path)) Body: `{"catch_all": true, "inbox_id": "optional inbox_... already bound to this domain; omit to create a catch-all inbox"}` Status: 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. ## Plans - Free ($0, available now): 3 inboxes, 1,000 / month messages, 1 day retention, webhooks: Not included, custom domains: Not included. - Pro ($12/mo, available now): 10 inboxes, 10,000 / month messages, 7 days retention, webhooks: Included, custom domains: Not included. - Scale ($49/mo, available now): 50 inboxes, 50,000 / month messages, 30 days retention, webhooks: Included, custom domains: 3 domains. - Business ($149/mo, available now): 200 inboxes, 250,000 / month messages, 90 days retention, webhooks: Included, custom domains: 10 domains. - Enterprise (Custom, contact sales): Business limits and features with custom contract terms, set up by our team. - Plan gating: 402 plan_upgrade_required means a Free account called a paid-only operation (POST /messages/batch); 403 _entitlement_required means the plan lacks a named capability (webhooks, custom domains, catch-all). ## SDKs and tools - Python: `pip install mailsocket`, then `started = time.time()` before the trigger and `Client(api_key).wait_for_otp(inbox_id, since=started)` (since = a time before the trigger) No email handy: `client.send_test_code(inbox_id)` (since mailsocket 0.3.0) as the trigger - JavaScript/TypeScript: `npm i mailsocket-sdk`, then `new MailsocketClient(apiKey)` No email handy: `await client.sendTestCode(inboxId)` (since mailsocket-sdk 0.3.0) as the trigger - MCP server (for AI agents): `uvx mailsocket-mcp` (or `pip install mailsocket-mcp`, Python 3.10+); set MAILSOCKET_API_KEY Config: {"mcpServers":{"mailsocket":{"command":"uvx","args":["mailsocket-mcp"],"env":{"MAILSOCKET_API_KEY":"ms_live_..."}}}} Official MCP Registry: app.mailsocket/mailsocket-mcp Tool send_test_code(inbox_id) (since mailsocket-mcp 0.4.0) puts a sample code in the inbox; take a cutoff first, then wait_for_otp(inbox_id, since=) - Remote MCP (no install): https://mcp.mailsocket.app/mcp over Streamable HTTP. Send `Authorization: Bearer ms_live_...` on every request (never in the URL). Remote waits cap at 55s per call; stateless; per-key concurrency limits (10 waits / 8 other calls in flight). Claude.ai, Claude Desktop connectors and Smithery need OAuth, which is planned but not available yet. Use the local stdio server above in those clients until then. - Full SDK and MCP docs: https://mailsocket.app/docs/#sdks ## More documentation - Machine-readable OpenAPI 3.1 spec: https://dash.mailsocket.app/docs/openapi.yaml - API reference: https://mailsocket.app/docs/api/ - Error codes: https://mailsocket.app/docs/errors/ - Versioning and deprecation policy: https://mailsocket.app/docs/versioning - Guides (pytest, Cypress, Puppeteer, GitHub Actions): https://mailsocket.app/docs/guides/ - Try it with no signup: https://mailsocket.app/docs/try/ - Comparisons (Mailpit, Mailosaur, AgentMail): https://mailsocket.app/docs/compare/ - Security: https://mailsocket.app/security