Inbound Email

Search Documentation

Search across all developer documentation

Inbound Email

Mail sent to a receiving domain — in.acme.com, with its MX pointing at SES in the customer’s own AWS account — is stored in a bucket the customer owns, parsed by an intake function running in that account, and POSTed to an HTTPS endpoint of theirs as a signed JSON payload. This surface manages the domains, the webhook each one delivers to, the addresses minted at them, and the log of what arrived.

The payload itself, and how to verify its signature, is on its own page: Inbound webhook. That page is the contract an endpoint implements; this one is the API that sets the endpoint up.

SendOps never reads the mail

The role SendOps holds in the customer’s account can list stored messages and delete a delivery claim. It has no permission to read one. The intake function delivers directly from the customer’s bucket to the customer’s endpoint, so nothing on this API — on any scope — returns a body, an attachment or a download link. What it returns about a message is the envelope: sender, recipient, subject, verdicts, outcome.

Scopes

ScopeGrants
api.inbound.viewThe message log — the list and one message with its history; the receiving domains with their DNS records and webhook state; the live per-entity addresses.
api.inbound.manageAdd, verify and delete a receiving domain; set, clear and rotate the webhook; mint and revoke addresses.

The read scope is PII-heavy, and nothing masks it

Every message row carries the sender’s address, the recipient’s address and the real subject line of real mail, in the clear. No further scope unmasks anything, because a message log that could not say who wrote in could not answer the question it exists for. Grant api.inbound.view accordingly. The one thing it never returns is the webhook’s signing secret, which is shown once by the two calls that mint it — and those need api.inbound.manage.

Two writes are classified destructive, so a test-environment credential (sk_test_ / oc_test_) is refused them: deleting a domain, and rotating a secret. Everything else on the manage scope is reversible and a test credential may call it.

The endpoints

EndpointDoes
GET /v1/inbound/messagesOne page of received mail, newest first, one row per message and domain.
GET /v1/inbound/messages/{messageId}One message, plus every state it passed through.
GET /v1/inbound/domainsEvery receiving domain, with DNS records and webhook state. Unpaginated.
POST /v1/inbound/domainsRecord a receiving subdomain and create its SES identity. Returns the records to publish.
GET /v1/inbound/domains/{domain}One receiving domain.
DELETE /v1/inbound/domains/{domain}Stop tracking it. Destructive.
POST /v1/inbound/domains/{domain}/verifyRe-check its DNS now.
PUT /v1/inbound/domains/{domain}/webhookSet where its mail goes. Full replacement. Returns the secret when it mints one.
DELETE /v1/inbound/domains/{domain}/webhookStop delivering; keep the domain.
POST /v1/inbound/domains/{domain}/webhook/rotate-secretNew signing secret, shown once, no grace window. Destructive.
GET /v1/inbound/domains/{domain}/addressesThe live per-entity addresses at a domain.
POST /v1/inbound/domains/{domain}/addressesMint an address with your own JSON attached.
DELETE /v1/inbound/domains/{domain}/addresses/{localPart}Revoke one. 204 whether or not it existed.

Full request and response schemas are in the sidebar under Inbound. {domain} is the domain name — in.acme.com — though a UUID is accepted. A domain belonging to another org is a 404, never a 403.

A receiving domain’s lifecycle

curl -X POST https://api.sendops.dev/v1/inbound/domains \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "in.acme.com"}'
{
"domain": "in.acme.com",
"id": "0192c0a4-…",
"status": "pending_dns",
"last_error": "No MX record at in.acme.com pointing to inbound-smtp.eu-west-1.amazonaws.com yet.",
"region": "eu-west-1",
"cross_region": true,
"records": [
  { "type": "MX",    "name": "in.acme.com", "value": "10 inbound-smtp.eu-west-1.amazonaws.com", "verified": false },
  { "type": "CNAME", "name": "abc123._domainkey.in.acme.com", "value": "abc123.dkim.amazonses.com", "verified": false }
],
"webhook": null,
"created_at": "2026-09-09T10:14:02Z"
}

A 201 here is not 'inbound is live'

status: pending_dns is the whole point of the response. SendOps holds no DNS credentials and writes no DNS anywhere; publishing records is yours to do. A client that reports success on the 201 will then wonder where the mail went. Publish the records, then POST …/verify.

What can be added. The domain must be equal to, or a subdomain of, a domain the org already holds in SendOps — otherwise 422 receiving_domain_not_owned. Some stack on the AWS account must carry the inbound capability — otherwise 422 inbound_not_deployed. A domain already recorded on the account is a 409.

Where the identity goes. The SES identity is created in the receiving region — the region of the stack carrying inbound — which is the region the MX must name. Only 22 AWS regions receive mail at all, so cross_region: true is ordinary rather than a misconfiguration.

Three states. pending_dns means a record is missing, and last_error names the first one. verified is terminal. error means a check could not be performed — a resolver failure, an SES call that failed — and is not “the record is missing”; polling continues, so it is never terminal.

Verification is POST …/verify: it re-resolves the MX, re-reads the identity, re-arms the background poll, and returns the domain as it now stands. It writes only what it observed and has no body.

Deletion is irreversible, and the reason is worth understanding before automating it. The MX record is the customer’s; SendOps cannot remove it, so mail keeps arriving at SES and keeps landing in the bucket. What deletion does is re-publish the routing without the domain, so within about a minute the intake function stops recognising it and parks every message as unknown_domain. Nothing is lost, but delivery stops, and re-adding the domain is a new row, a new webhook and a new secret to deploy. The SES identity is deliberately left in place: it is per-region and shared by every use of the domain there, including sending.

The webhook

curl -X PUT https://api.sendops.dev/v1/inbound/domains/in.acme.com/webhook \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "webhook_url": "https://acme.example/hooks/mail",
  "allow_patterns": ["support", "ticket-*"],
  "spam_posture": "tag",
  "virus_posture": "drop"
}'

Full replacement — and an empty allow list means everything

PUT is not a patch. An omitted allow_patterns means deliver every recipient at this domain, not “keep the list I had”. The two readings differ by the whole allow list, and it is the field on this surface most likely to be got wrong. Send the complete list every time.

webhook_url must be https, and its host must resolve to a public address. Both are checked on save and again at publish time, because what DNS answers changes. A refusal is 422 with the detail on webhook_url, and no refusal ever names an address.

allow_patterns are local-part globs — the part before the @. support matches exactly; ticket-* matches a prefix; an entry may not contain @. Matching is case-insensitive. A recipient that matches nothing in a non-empty list is recorded as no_route and never delivered.

spam_posture and virus_posture decide what a failing SES verdict does: tag delivers the message with the verdict stated in the payload, drop records dropped and delivers nothing. Defaults are tag for spam and drop for virus. SPF, DKIM and DMARC are always stated and never drop a message on their own.

The secret is returned once

The response is the domain, plus hmac_secret only when this call minted it — the first webhook save on the domain. A later PUT returns the same shape without the field: SendOps holds the secret encrypted and cannot show it twice. A URL change keeps the secret, because correcting a typo in an endpoint is not a request to re-key it. Store it when you receive it; if you lose it, rotate.

{
"domain": "in.acme.com",
"status": "verified",
"webhook": {
  "url": "https://acme.example/hooks/mail",
  "allow_patterns": ["support", "ticket-*"],
  "spam_posture": "tag",
  "virus_posture": "drop",
  "status": "pending"
},
"hmac_secret": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08…"
}

Rotation has no grace window. POST …/webhook/rotate-secret returns a new secret once; the intake function reads one secret out of the published routing, so the previous one stops verifying as soon as that routing is republished — about a minute. Deploy the new secret to the endpoint first, or accept a window in which real mail is rejected. 422 webhook_not_set when there is nothing to rotate.

status is publication, not verification

webhook.status says whether what SendOps holds has reached the routing object the intake function reads. It is not the domain’s status, which is DNS verification, and the two move independently.

webhook.statusMeaning
unsetNo webhook. The domain is omitted from the routing; its mail parks as unknown_domain.
pendingSaved; the routing has not been re-published yet. Normally closes within seconds, always within about a minute.
publishedLive. published_at says since when.
invalidThe URL failed re-validation at publish time — a host that stopped resolving, or now resolves privately. The domain was left out of the routing entirely; error says why.

A domain is included in the routing only when it is both verified and has a webhook.

DELETE …/webhook clears the URL, the allow list, the postures and the secret, and republishes without the domain’s delivery target. It is the reversible counterpart to deleting the domain: mail keeps landing in the bucket, and a fresh PUT restores delivery — with a new secret.

Per-entity addresses

The receipt rule SendOps deploys is a catch-all for the receiving domain: mail to any address at it already lands in the bucket. Minting an address therefore creates nothing in AWS. What it records is a JSON object of yours, returned verbatim in the entities map of every payload delivered to that address, so your system routes the mail without a lookup and without parsing a local part.

curl -X POST https://api.sendops.dev/v1/inbound/domains/in.acme.com/addresses \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"local_part": "ticket-4231", "metadata": {"ticket_id": 4231, "queue": "billing"}}'
{
"id": "0192c0b1-…",
"local_part": "ticket-4231",
"address": "ticket-4231@in.acme.com",
"metadata": { "ticket_id": 4231, "queue": "billing" },
"created_at": "2026-09-09T10:20:11Z"
}

Every delivery to that address then carries:

"entities": { "ticket-4231": { "ticket_id": 4231, "queue": "billing" } }
  • local_part is optional. Omitted, SendOps mints a- plus 24 Crockford base32 characters. Supplied, it must be an RFC 5321 dot-atom, is lower-cased, and may not begin with the reserved a-. 409 when it is already live at the domain.
  • metadata is opaque. SendOps never reads inside it, never indexes it and never matches on it. At most 4 KB, because it rides on every delivery and lives in the intake function’s memory for the life of a warm container. It must be an object; {} when omitted.
  • You may route on it. It comes from the routing SendOps published, not from the mail, so a sender who controls the headers can no more forge an entry here than they can forge a verdict. The same cannot be said of anything parsed out of a local part.
  • On a restricted domain, minting adds an allow-list entry, so the address you just created is not one your own allow_patterns refuses. On a domain with an empty allow list nothing about routing changes.

GET …/addresses lists the live addresses, oldest first. It is not the list of addresses that can receive mail — unless the allow list is non-empty, every recipient at the domain is delivered whether or not it appears here.

Revoking

DELETE …/addresses/{localPart} is 204 whether or not the address was there. What it stops, precisely: the metadata — the address leaves the routing, so deliveries stop carrying its entities entry. Delivery itself stops only on a domain with a non-empty allow list, because that list is the only thing that ever refuses a recipient — and not even then when removing the entry would leave the list empty, since an empty list means “deliver every recipient” and emptying it while taking one address away would open the whole domain. In that one case the pattern is kept and the address is still revoked. To stop delivery to a local part outright, edit allow_patterns on the webhook, where you can see what the list will become.

The local part may be allocated again afterwards. A reopened ticket must be able to have its address back, which is why this is not classified destructive.

The message log

GET /v1/inbound/messages returns one row per message and receiving domain, newest first, folded to the latest state: a message that parked and was later redelivered is one row saying delivered, not two rows to reconcile. GET …/messages/{messageId} adds history — every outcome recorded, oldest first — which is the answer to “it says delivered now, but did something go wrong first?”

Filters: from/to (default window 30 days), domain, outcome, verdict (spam_fail … dmarc_fail), and q, a free-text match over sender, recipient and subject. An unknown outcome is a 422, not an empty page.

outcomeMeaningTerminal?
deliveredThe endpoint answered 2xx.yes
rejectedThe endpoint answered a 4xx other than 429 — a definitive no, never retried.yes
droppedA verdict posture discarded it.yes
no_routeThe recipient matched nothing in a non-empty allow_patterns.yes
parkedNot delivered; reason and detail say why. The raw message is still in the bucket and a replay can still deliver it.no

detail on a park has two values worth branching on: endpoint_error means the endpoint answered 429/5xx or refused the connection, the delivery claim was released, and the message will come back through the dead-letter queue; ambiguous_transport means the request timed out and may have been processed, so the claim was kept and recovery is a deliberate redeliver. The contract explains why those are treated differently.

An empty verdict is a value

verdicts.spam and its four siblings may be "". That is not a missing field: a replayed message carries no verdicts at all, because SES’s results live only in the original notification and are gone by replay time. Treat "" as unknown rather than as fail or pass.

is_reply is true when SendOps correlated the message to something it sent; is_auto_reply is a hint read off Auto-Submitted, Precedence and the X-Auto* headers, not a verdict. Redelivering a message is a dashboard action, not on this API.

  • Inbound webhook — the payload, what to answer, and verifying the signature
  • Inbound Receiving — the same feature for the person setting it up in the dashboard
  • Activities — a message correlated to a broadcast records a reply activity on the contact