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.
- Create an inbox, optionally on your own domain (Inboxes, Your own domain).
- Set its webhook and keep the signing secret (Webhooks).
- 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(activeorpaused: mail to a paused inbox is refused),max_bytes(from 1,024 bytes up to 25 MB;nullfor the full 25 MB)PUT /api/v1/inboxes/{id}/webhook{"url": "…"}— sets or changes the URL and returns a newwebhook_secret, shown once (the old secret stops working);DELETE /api/v1/inboxes/{id}/webhookremoves 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.tlsin 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
Receivedline 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 andacked_at. Pass the page'snextasafterfor the following page;nextis 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 410purged; the record stays 30 days.POST /api/v1/messages/{id}/ack— mark it taken, so?unacked=1skips 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?passwhen at least one did;nonewhen it was unsigned.dkim_signatureslists each signature found, with itsdomain(d=),selectorandresult(pass,fail,neutral,temperrororpermerror, with areasonwhen it did not pass); empty when unsigned.dmarc—passwhen 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_domainis the domain checked anddmarc_policywhat it asks receivers to do with failures (none,quarantineorreject).
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_webhookmoves 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 withPUT /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.
| Code | HTTP | Meaning |
|---|---|---|
invalid_key | 401 | The key is unknown, revoked, expired, or lacks the scope for this. |
account_suspended | 403 | This account is suspended. |
rate_limited | 429 | Too many requests in the last minute for this account's plan. |
not_found | 404 | No such item on this account. |
invalid_request | 422 | The request is not valid. |
inbox_limit | 422 | This account's plan allows no more inboxes. |
domain_unverified | 422 | The domain is not verified yet: publish its TXT record, then verify it. |
domain_taken | 409 | That domain is already registered with sendto.it. |
domain_in_use | 409 | Inboxes still use this domain; delete them first. |
address_taken | 409 | That address is already in use. |
no_webhook | 409 | This inbox has no webhook. |
invalid_url | 422 | The URL is not a valid https address. |
blocked_address | 422 | The URL points at a private or reserved network address. |
Plans and limits
| Plan | Inboxes | Messages a day | API calls a minute |
|---|---|---|---|
| Free | 3 | 200 | 60 |
| Starter | 25 | 5,000 | 120 |
| Business | 500 | 50,000 | 600 |
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.