sendto.it documentation

sendto.it turns email into HTTP. You create an inbox, get an address such as k7x9m2pqra{{ $shared }}, and each message sent to it is POSTed to your webhook as signed JSON.

  1. Create an inbox, optionally on your own domain (Inboxes, Your own domain).
  2. Set its webhook and keep the signing secret (Webhooks).
  3. Hand out the address, with a tag if you like (Tags).

API calls carry a key's secret: Authorization: Bearer ss_…. Keep it on your server: never in a web page or an app people download. Keys are issued with your account, and may be revoked or given an expiry date.

The API lives at https://sendto.it/api/v1. Bodies are JSON; times are ISO 8601 in UTC. Each account has a number of API calls a minute (Plans and limits); past it the answer is 429 with a Retry-After header.

Inboxes

POST /api/v1/inboxes
{"name": "Support log", "prefix": "support", "external_ref": "org-42",
 "webhook_url": "https://your-app.example/hooks/sendto"}

201 {"id": "inb_3ZpQ7kLm", "address": "support-k7x9m2pqra{{ $shared }}", "status": "active",
     "webhook_url": "https://your-app.example/hooks/sendto", "webhook_secret": "whsec_…", …}

On in.sendto.it an address is always a random part, after your optional prefix, so it can't be guessed. On your own verified domain you may choose the local_part instead: {"domain": "log.example.com", "local_part": "acme"}. external_ref is your own id for the inbox (letters, digits and . _ : @ -); filter with GET /api/v1/inboxes?external_ref=org-42. A chosen address already in use is refused with address_taken.

An inbox reads id, address, name, external_ref, status, webhook_url, max_bytes (the largest message it takes), failing_since (when its webhook started failing, or null) and created_at. The webhook secret is never shown again after it is issued.

  • GET /api/v1/inboxes — {"data": […]}, oldest first; ?limit= up to 1,000 (default 100). GET /api/v1/inboxes/{id}
  • PATCH /api/v1/inboxes/{id} — name, external_ref, status (active or paused: mail to a paused inbox is refused), max_bytes (from 1,024 bytes up to 25 MB; null for the full 25 MB)
  • PUT /api/v1/inboxes/{id}/webhook {"url": "…"} — sets or changes the URL and returns a new webhook_secret, shown once (the old secret stops working); DELETE /api/v1/inboxes/{id}/webhook removes it.
  • DELETE /api/v1/inboxes/{id} — mail to it is refused from then on; the address is never reused.

Mail that arrives while an inbox has no webhook is stored but not sent, and isn't sent later when a webhook is set; removing a webhook or deleting an inbox also cancels deliveries still waiting. Mail for addresses that don't exist is refused while the sending server is still connected (550), so the sender learns at once and nobody receives a bounce they didn't cause.

Tags

Anything after a + in the address reaches the same inbox and is passed on as envelope.tag: mail to support-k7x9m2pqra+order-1234{{ $shared }} arrives with "tag": "order-1234". Use tags to say what a message is about — a customer, a job, a ticket — without creating an inbox for each. The tag keeps the sender's capitals. It also works for BCC: the address a message was delivered to is in envelope.to even when no header names it.

Your own domain

POST /api/v1/domains   {"name": "log.example.com"}

201 {"name": "log.example.com", "verified": false, "verified_at": null,
     "checked_at": null, "check_result": null, "dns": [
  {"type": "TXT", "name": "_sendto.log.example.com", "value": "sendto-verify=…"},
  {"type": "MX",  "name": "log.example.com", "value": "mx.sendto.it", "priority": 10}]}

Publish both records, then POST /api/v1/domains/log.example.com/verify (we also check every 15 minutes for two weeks). Each check fills in checked_at and check_result, which says whether the TXT record was found and whether the MX record points at mx.sendto.it yet. Once the TXT record is seen the domain is yours on sendto.it and inboxes can use it; it stays verified if you remove the record later. The MX record is what brings the mail, so keep it in place.

A domain can belong to one account only (domain_taken). A subdomain such as log. leaves your main domain's mail untouched. GET /api/v1/domains lists yours; DELETE /api/v1/domains/{name} once no inbox uses it (domain_in_use until then).

How mail arrives

Mail for in.sendto.it and for your verified domains is received by sendto.it's own servers at mx.sendto.it. They only receive: no relaying and no sign-in.

  • Encryption. Sending servers can switch to TLS (STARTTLS) with a Let's Encrypt certificate for mx.sendto.it: TLS 1.3, or TLS 1.2 for senders that need it. envelope.tls in the webhook says whether a message came encrypted.
  • Refused during the conversation, so the sender finds out: unknown, paused or deleted addresses (550) and messages larger than the inbox takes (552).
  • Deferred, which sending servers retry: more than 60 messages a minute from one sending address (451), and recipients beyond 50 in one message (452; senders send those separately).
  • Each accepted message gets a Received line from mx.sendto.it at the top of its headers.

Webhooks

POST https://your-app.example/hooks/sendto
webhook-id: msg_8Kq2…
webhook-timestamp: 1762099512
webhook-signature: v1,K5o…=
Content-Type: application/json
User-Agent: sendto.it-webhooks/1.0 (+https://sendto.it/docs)

{"type": "message.received", "id": "msg_8Kq2…", …}

They follow the Standard Webhooks scheme, so its libraries verify them. By hand: base64-decode your secret after whsec_, compute HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, base64 it, and compare with the value after v1,. Reject timestamps more than 5 minutes old.

Answer with any 2xx within 15 seconds. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (about 21 hours in all), then stop and mark the message failed; each inbox's later messages wait behind a failing one within each round. While deliveries fail, the inbox's failing_since says since when; it clears at the next success or when a new webhook is set. Webhook URLs must be HTTPS on a public address (checked when set and again before every delivery); redirects aren't followed. webhook-id is the message id on every attempt: use it to ignore repeats.

Check type: messages are message.received. A webhook.test ping, with no message, may be sent when an endpoint is being checked; answer it with a 2xx and otherwise ignore it.

What a webhook carries

{
  "type": "message.received",
  "id": "msg_8Kq2…",
  "inbox": {"id": "inb_3ZpQ7kLm", "address": "support-k7x9m2pqra{{ $shared }}", "external_ref": "org-42"},
  "received_at": "2026-11-02T14:02:11Z",
  "envelope": {"from": "bounce@vendor.example", "to": "support-k7x9m2pqra+order-1234{{ $shared }}",
               "tag": "order-1234", "client_ip": "203.0.113.7", "helo": "mail.vendor.example", "tls": true},
  "auth": {"spf": "pass", "dkim": "pass", "dmarc": "pass", "spf_domain": "vendor.example",
           "dkim_signatures": [{"domain": "vendor.example", "selector": "s1", "result": "pass"}],
           "from_domain": "vendor.example", "dmarc_policy": "reject"},
  "from": {"address": "orders@vendor.example", "name": "Vendor Orders"}, "sender": null,
  "to": [{"address": "…", "name": null}], "cc": [], "reply_to": [],
  "subject": "Order 1234 shipped", "date": "2026-11-02T14:02:05Z",
  "message_id": "<…>", "in_reply_to": null, "references": [],
  "text": "…", "html": "…",
  "headers": [["Received", "…"], ["Subject", "Order 1234 shipped"], …],
  "attachments": [{"filename": "invoice.pdf", "content_type": "application/pdf", "size": 48211,
                   "sha256": "…", "content_id": null, "inline": false, "url": "https://sendto.it/a/msg_8Kq2…/0?expires=…&signature=…"}],
  "size": 61234,
  "raw_url": "https://sendto.it/a/msg_8Kq2…/raw?expires=…&signature=…"
}

envelope is what the sending server said: from is the return path (null for bounces), to the address it was delivered to, tls whether the connection was encrypted. from, sender, to, cc and reply_to come from the message's own headers; date is in UTC, or null when the message's date can't be read. headers lists every header in order, decoded.

Text and HTML are always UTF-8, whatever the message used. Attachment and raw_url links (the message exactly as received) work for 72 hours, and never after the message is deleted: fetch what you want to keep. They always download as files, and only the link's signature is needed to fetch them, so treat the links like the message itself.

Pulling messages

Instead of (or as well as) a webhook, read messages yourself with a key that has the messages scope:

  • GET /api/v1/messages?after=&inbox=&unacked=1&limit= — oldest first, up to 100 a page: each message's id, inbox, sender, recipient, tag, size, attachment count, sender checks, webhook state and acked_at. Pass the page's next as after for the following page; next is null on the last.
  • GET /api/v1/messages/{id} — the message as a webhook would carry it (text, HTML, headers, signed attachment links). After 7 days the content is gone and this answers 410 purged; the record stays 30 days.
  • POST /api/v1/messages/{id}/ack — mark it taken, so ?unacked=1 skips it. Nothing is deleted.

AI tools (MCP) don't read messages: an email's content never goes to an assistant through sendto.it.

Sender checks

Anyone can put any address in a From line. auth says whether to believe it:

  • spf — was the sending server allowed to send for the envelope sender's domain (spf_domain; the HELO name for bounces)?
  • dkim — did a signature on the message verify? pass when at least one did; none when it was unsigned. dkim_signatures lists each signature found, with its domain (d=), selector and result (pass, fail, neutral, temperror or permerror, with a reason when it did not pass); empty when unsigned.
  • dmarc — pass when SPF or DKIM passed for the From line's own domain. This is the one to check before trusting who a message is from; from_domain is the domain checked and dmarc_policy what it asks receivers to do with failures (none, quarantine or reject).

AI tools (MCP)

AI assistants that speak the Model Context Protocol (Claude, Cursor, VS Code and others) can manage your inboxes and domains. The address is https://sendto.it/mcp, and it takes the same API key as the API, in the same header. Limits and what each key can see are exactly the API's.

# Claude Code
claude mcp add --transport http sendto https://sendto.it/mcp --header "Authorization: Bearer sk_…"

# Clients configured with JSON (the exact layout varies by client)
{"sendto": {"url": "https://sendto.it/mcp", "headers": {"Authorization": "Bearer sk_…"}}}
  • Inboxes: list_inboxes, get_inbox, create_inbox, update_inbox, delete_inbox* (as Inboxes).
  • Webhooks: set_webhook moves an inbox's existing webhook to a new URL and keeps its signing secret; remove_webhook* removes it. An inbox's first webhook is set with PUT /api/v1/inboxes/{id}/webhook, which shows the secret once (Webhooks).
  • Domains: list_domains, add_domain, verify_domain, remove_domain* (as Your own domain).

* Marked as destructive, so assistants ask you before using them. Signing secrets and emails never go through MCP: assistants can't read the mail an inbox receives. Errors come back as tool errors starting with the error code, for example domain_unverified: …. Changes are recorded like API changes, marked as made through MCP. Signing in without a key (needed for claude.ai connectors) is planned and not available yet.

Errors

Errors are {"error": {"code": "…", "message": "…"}}, with details for invalid fields. Program against the code; messages may be reworded. Codes are only ever added.

CodeHTTPMeaning
invalid_key401The key is unknown, revoked, expired, or lacks the scope for this.
account_suspended403This account is suspended.
rate_limited429Too many requests in the last minute for this account's plan.
not_found404No such item on this account.
invalid_request422The request is not valid.
inbox_limit422This account's plan allows no more inboxes.
domain_unverified422The domain is not verified yet: publish its TXT record, then verify it.
domain_taken409That domain is already registered with sendto.it.
domain_in_use409Inboxes still use this domain; delete them first.
address_taken409That address is already in use.
no_webhook409This inbox has no webhook.
invalid_url422The URL is not a valid https address.
blocked_address422The URL points at a private or reserved network address.

Plans and limits

PlanInboxesMessages a dayAPI calls a minute
Free3 20060
Starter25 5,000120
Business500 50,000600

Messages up to 25 MB as sent (attachments are about a third larger encoded than on disk), up to 50 recipients each; an inbox can take less (max_bytes). A message reaching several of your inboxes counts once for each. Days run in UTC; once an account has had its messages for the day, further mail is not stored or delivered until the next day.

Past your plan's limits. Beyond the day's messages, mail is not stored or delivered (see above), and new inboxes can't be added past the plan. Inboxes you have and messages already received keep working, and nothing is deleted. Plans are changed in ScaledApps.

Service status

GET https://sendto.it/api/health needs no key and answers 200 with "ok": true while sendto.it is working (503 otherwise). It also reports the webhook backlog (webhooks_pending, oldest_pending_seconds), how many receiving servers are online (receivers_online of receivers), and their STARTTLS certificates: receiver_certificate_days_left (the soonest expiry), receiver_certificates_ok and receiver_certificate_errors (renewals failing). Certificates renew automatically well before they expire.

Privacy and retention

  • Mail can reach us encrypted (TLS 1.3 or 1.2), webhooks and links are HTTPS only, and messages and attachments are encrypted while stored. They are deleted 7 days after they arrive, delivered or not.
  • Delivery history (when, where, the status code) is kept 30 days; it holds no message content.
  • Each time message content is opened in sendto.it's admin pages, it is recorded.
  • sendto.it only receives mail: it never relays, forwards by email, or sends bounces.
  • Questions or requests: contact@scaledapps.com. See also the terms and privacy policy.