# 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](/api-reference/inbound-webhook).** That page is the contract an endpoint implements; this one is the API that sets the endpoint up.


  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

| Scope | Grants |
|---|---|
| `api.inbound.view` | The 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.manage` | Add, verify and delete a receiving domain; set, clear and rotate the webhook; mint and revoke addresses. |


  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

| Endpoint | Does |
|---|---|
| `GET /v1/inbound/messages` | One 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/domains` | Every receiving domain, with DNS records and webhook state. Unpaginated. |
| `POST /v1/inbound/domains` | Record 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}/verify` | Re-check its DNS now. |
| `PUT /v1/inbound/domains/{domain}/webhook` | Set where its mail goes. Full replacement. Returns the secret when it mints one. |
| `DELETE /v1/inbound/domains/{domain}/webhook` | Stop delivering; keep the domain. |
| `POST /v1/inbound/domains/{domain}/webhook/rotate-secret` | New signing secret, shown once, no grace window. **Destructive.** |
| `GET /v1/inbound/domains/{domain}/addresses` | The live per-entity addresses at a domain. |
| `POST /v1/inbound/domains/{domain}/addresses` | Mint 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

```bash
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"}'
```

```json
{
  "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"
}
```


  `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

```bash
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"
  }'
```


  `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.

```json
{
  "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.status` | Meaning |
|---|---|
| `unset` | No webhook. The domain is omitted from the routing; its mail parks as `unknown_domain`. |
| `pending` | Saved; the routing has not been re-published yet. Normally closes within seconds, always within about a minute. |
| `published` | Live. `published_at` says since when. |
| `invalid` | The 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.

```bash
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"}}'
```

```json
{
  "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:

```json
"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.

| `outcome` | Meaning | Terminal? |
|---|---|---|
| `delivered` | The endpoint answered `2xx`. | yes |
| `rejected` | The endpoint answered a `4xx` other than `429` — a definitive no, never retried. | yes |
| `dropped` | A verdict posture discarded it. | yes |
| `no_route` | The recipient matched nothing in a non-empty `allow_patterns`. | yes |
| `parked` | Not 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](/api-reference/inbound-webhook#what-to-answer-and-what-happens-next) explains why those are treated differently.


  `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.

## Related

- [Inbound webhook](/api-reference/inbound-webhook) — the payload, what to answer, and verifying the signature
- [Inbound Receiving](https://help.sendops.dev/inbound/inbound-receiving) — the same feature for the person setting it up in the dashboard
- [Activities](/api-reference/activities) — a message correlated to a broadcast records a `reply` activity on the contact