# Inbound webhook

What SendOps POSTs to your endpoint when mail arrives at one of your [receiving domains](/api-reference/inbound), how to verify that it came from you, and what to answer. Setting the endpoint up — the domain, the URL, the allow list — is the [Inbound Email](/api-reference/inbound) surface; this page is the request that surface causes.

## Where the request comes from

**It comes from your own AWS account, not from SendOps.** The intake function runs in your account, reads the raw message out of your own S3 bucket, and POSTs to your endpoint directly. Three consequences follow, and each one surprises somebody:

- **There is no SendOps IP range to allow-list.** The source address is a Lambda network interface in your receiving region. It changes. Allow-list nothing; verify the signature.
- **SendOps never reads the mail.** The role SendOps holds in your account has list and delete on the claims prefix and list on `raw/` — no `GetObject` anywhere. That is why the message log shows a console link rather than a presigned one, and why support cannot read a message back for you.
- **An outage at SendOps does not stop delivery.** The function has its routing cached and keeps delivering. What stops is the message log and any configuration change.

| | |
|---|---|
| Method | `POST` |
| `Content-Type` | `application/json` |
| `User-Agent` | `SendOps-Inbound/<module version>`, e.g. `SendOps-Inbound/1` |
| `X-SendOps-Timestamp` | Unix seconds, as a decimal string |
| `X-SendOps-Signature` | `v1=<hex hmac-sha256>` — see [Verifying the signature](#verifying-the-signature) |
| Timeout | 10 seconds |

The `User-Agent` version is the inbound **module** version — the version of the CloudFormation fragment deployed in your account — not a SendOps release. It is the version that tells an endpoint which payload contract it is being sent, which is the only version that matters to the receiver.

## The payload

One POST per **(message, receiving domain)**. A message addressed to two of your configured receiving domains is two deliveries, each naming its own domain and carrying only that domain's recipients.

```json
{
  "schema_version": 1,
  "message_id": "abc123messageid",
  "domain": "in.acme.com",
  "recipients": ["ticket-4231@in.acme.com"],
  "received_at": "2026-09-08T10:14:02Z",
  "verdicts": {
    "spam": "pass", "virus": "pass",
    "spf": "pass", "dkim": "pass", "dmarc": "pass"
  },
  "raw_url": "https://…s3…/raw/abc123messageid?X-Amz-Signature=…",
  "entities": {
    "ticket-4231": { "ticket_id": 4231, "queue": "billing" }
  },
  "message": {
    "from": { "name": "Dana Okoro", "address": "dana@example.com" },
    "to": [{ "name": "", "address": "ticket-4231@in.acme.com" }],
    "subject": "Re: invoice 12",
    "date": "2026-09-08T11:13:58+01:00",
    "message_id": "CAF…@mail.example.com",
    "in_reply_to": "9f2…@sendops.dev",
    "reply_to_message_ids": ["9f2…@sendops.dev"],
    "is_auto_reply": false,
    "text_body": "Paid, thanks.",
    "html_body": "Paid, thanks.",
    "attachments": [
      {
        "index": 2,
        "filename": "receipt.pdf",
        "content_type": "application/pdf",
        "size": 84213,
        "content_id": "",
        "inline": false,
        "url": "https://…s3…/parts/abc123messageid/2?X-Amz-Signature=…"
      }
    ],
    "headers": { "return-path": "<dana@example.com>", "…": "…" },
    "defects": []
  }
}
```

### The envelope

| Field | Type | Meaning |
|---|---|---|
| `schema_version` | int | `1`. Bumped only by a change that would break an endpoint written against this page. New optional fields do not bump it — see [Versioning](#versioning). |
| `message_id` | string | The SES message id. It is also the raw object's key suffix in the bucket, and it is the deduplication key — see [Idempotency](#idempotency-what-is-guaranteed-and-what-is-not). |
| `domain` | string | The receiving domain this delivery is for. Lower-cased. |
| `recipients` | array of string | The **envelope** recipients at `domain` that passed its allow list. |
| `received_at` | string | RFC 3339, SES's receipt timestamp. Absent on a replay of an object whose notification is gone. |
| `verdicts` | object | SES's scan and authentication findings — below. |
| `raw_url` | string | Presigned GET for the whole original message. 15 minutes. |
| `entities` | object | Per-entity metadata, keyed by local part — below. Absent when none of the recipients has any. |
| `message` | object | The parsed mail — below. |


  `recipients` is the envelope's: who the mail was actually delivered to at this domain. `message.to` and `message.cc` are what the sender wrote, may name addresses at other domains entirely, and can say anything at all.


### `verdicts`

Five fields — `spam`, `virus`, `spf`, `dkim`, `dmarc` — each one of `pass`, `fail`, `gray`, `processing_failed`, `disabled`, or the empty string.

**The empty string is a real value, not a missing one.** A replayed message carries no verdicts at all, because SES's results live only in the original notification and are gone by replay time. An endpoint that treats `""` as `"fail"` will reject every replay; one that treats it as `"pass"` is asserting something nobody checked. Treat it as "unknown" and decide deliberately.

These are the **receiver's** findings, taken from the SES receipt where there is one and from the `X-SES-*` headers otherwise. A sender who controls the message headers cannot forge them.

What happens on a spam or virus failure is the domain's **posture**, set on the webhook:

| Posture | Effect |
|---|---|
| `tag` | The message is delivered, with the verdict stated in `verdicts`. Your own filter decides. |
| `drop` | Nothing is delivered. The outcome is recorded as `dropped` in the message log. |

Defaults: spam is `tag`, a virus is `drop`. SPF, DKIM and DMARC results are always stated and never drop a message on their own.

### `message`

The parsed mail. The rules worth stating:

- **Field names never change once shipped.** Fields are added; none is renamed or repurposed.
- **`attachments[].filename` is diagnostics only.** Never route on it, never use it as a path. It is attacker-controlled, RFC 2047-decoded, and may be empty, duplicated, or contain anything at all. `attachments[].index` is the stable identity.
- **Every leaf is accounted for exactly once**: it is the text body, the HTML body, or an entry in `attachments`. An empty `attachments` therefore means the message really carried nothing else.
- **`defects`** lists malformations that were **tolerated**. A message with a defect was still delivered; it was just incomplete. An empty array means it parsed cleanly.
- **`headers`** carries the top-level headers only, keys lower-cased, first value per key, bounded at 200 keys and 8 KB each.
- **`is_auto_reply`** is a hint read off `Auto-Submitted`, `Precedence` and the `X-Auto*` headers, which the sender controls. It is not a verdict.

### `entities` — per-entity metadata

When you [mint an address](/api-reference/inbound#per-entity-addresses) you attach a JSON object to it. Every delivery to that address carries the object back, keyed by **local part**:

```json
"entities": { "ticket-4231": { "ticket_id": 4231, "queue": "billing" } }
```

- **Keyed by local part, not by address.** Every recipient in one delivery is at `domain` by construction, so the key plus `domain` is the whole address.
- **It comes from the routing SendOps published, not from the mail.** You registered it through the API; a sender who controls the headers can no more forge an entry here than they can forge `verdicts`. **You may route on it.** The same claim cannot be made for anything parsed out of a local part in a header.
- **Only the recipients this delivery is about appear.** A recipient the allow list refused is not described.
- **The field is absent** when none of the delivered recipients has metadata, which is the ordinary case.

### Presigned URLs

`raw_url`, `attachments[].url`, `text_body_url` and `html_body_url` are presigned S3 GETs into your own bucket.

- **They live 15 minutes.** Fetch during processing, not later; do not store them.
- **Parts live under `parts/<messageId>/<index>`**, where `index` is `attachments[].index`.
- **A body is moved out of line when it exceeds 256 KB.** The matching `text_body` / `html_body` is then the empty string and `text_body_url` / `html_body_url` is set. What the URL serves is the decoded UTF-8 text, exactly what the inline field would have carried.
- **An attachment is never inline.** It is always a URL.

Bodies and attachments are bounded: a message over **30 MB** is parked before it is read into memory; one with more than **64 parts** is parked rather than delivered short; a single part is capped at **25 MB**.

## What to answer, and what happens next

| Your response | What the function does |
|---|---|
| `2xx` | Done. Outcome `delivered`. Nothing is retried. |
| `4xx` other than `429` | **Terminal.** Outcome `rejected`. Nothing is retried, ever. |
| `429` | Retried on the fixed schedule below. |
| `5xx`, a refused connection, a host that will not resolve | Retried, then the message comes back — see below. |
| No response before the timeout | Retried, then the message is **parked** — see below. |

**Three attempts inside one invocation**, at roughly 1 s, 3 s and 9 s, jittered. `Retry-After` is **not** read: the schedule is fixed, and an endpoint that needs longer than nine seconds of backoff is one this delivery is not going to succeed against.

What happens after those three are exhausted turns on whether you told us you did not take the message:

- **You answered `429` or `5xx`, or nothing accepted the connection at all.** You did not process it. The delivery claim is released and the invocation fails, so Lambda's own asynchronous retries take a fresh claim and try again; after those, the notification is in the dead-letter queue, where a redrive delivers it. **The message comes back on its own.** The message log shows `parked` with detail `endpoint_error`.
- **The request timed out, or the connection was reset mid-request.** You may have taken it. The claim is **kept** and the invocation succeeds, so nothing retries automatically — the outcome is `parked` with detail `ambiguous_transport`, and recovering it needs a deliberate redeliver. Delivery is at-least-once, so the safe reading of "we do not know" is not "send it again".


  It says "I will never accept this message" and is taken at its word: nothing retries and the message is recorded as `rejected`. Do not answer `4xx` for a transient problem — a full disk, a database that is down, a dependency that is slow. Answer `5xx` and the message comes back.


**Answer quickly.** Ten seconds is the whole budget, and exceeding it is the one failure that does not bring the message back by itself. Acknowledge with a `2xx` and do the work afterwards.

### Outcomes in the message log

`delivered`, `rejected`, `dropped`, `no_route`, `parked`. Only **`parked`** is not terminal: the raw message is still in your bucket and a replay can still deliver it. `no_route` means the recipient matched nothing in the domain's `allow_patterns`; `dropped` means a verdict posture discarded it.

## Verifying the signature

```http
X-SendOps-Timestamp: 1789208042
X-SendOps-Signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

The signed string is `<timestamp> + "." + <raw request body>`, and the signature is HMAC-SHA256 under the domain's secret, hex-encoded, prefixed `v1=`.

Four rules, and skipping any one of them is a real vulnerability rather than a style point:

1. **Sign the raw body bytes**, before any JSON parsing. Re-serializing changes key order and whitespace and the signature will never match.
2. **Compare in constant time.** A `==` on the hex string leaks the correct signature one byte at a time to anyone who can time your endpoint.
3. **Reject a timestamp more than 5 minutes from now**, in either direction. Without this a captured request replays forever.
4. **Compare the whole header including the `v1=` prefix**, or strip it explicitly. Do not substring-match.

The secret is shown **once**, when it is minted (the first webhook save) or rotated. SendOps holds it encrypted and cannot show it again. Rotation has **no grace window**: the function reads one secret out of the routing object, so the previous secret stops verifying as soon as that object is republished, about a minute later. Deploy the new secret first.

### Node

```js
const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

// express: app.post('/inbound', express.raw({ type: 'application/json' }), handler)
// `req.body` MUST be a Buffer of the raw bytes, not a parsed object.
function verify(req, secret) {
  const timestamp = req.get('X-SendOps-Timestamp');
  const signature = req.get('X-SendOps-Signature');
  if (!timestamp || !signature) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Go

```go

    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "math"
    "net/http"
    "strconv"
    "time"
)

const toleranceSeconds = 300

func verify(r *http.Request, body []byte, secret string) bool {
    ts := r.Header.Get("X-SendOps-Timestamp")
    sig := r.Header.Get("X-SendOps-Signature")

    n, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-n)) > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(ts))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig))
}
```

### Python

```python




TOLERANCE_SECONDS = 300

def verify(headers, body: bytes, secret: str) -> bool:
    ts = headers.get("X-SendOps-Timestamp", "")
    sig = headers.get("X-SendOps-Signature", "")
    try:
        age = abs(int(time.time()) - int(ts))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = "v1=" + hmac.new(
        secret.encode(), ts.encode() + b"." + body, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, sig)
```

## Idempotency: what is guaranteed, and what is not

**At-least-once, and the duplicates are bounded but real.**

Before its first attempt the function takes a delivery **claim**: a conditional PUT of `claims/<messageId>.<domain>` in your bucket. One claim per receiving domain, because a message addressed to two configured domains is two deliveries. A redelivered SNS notification, or a replay of an object that was already handled, finds the claim and delivers nothing.

That covers everything except one case, and it is the case that produces the duplicate a receiver actually sees:


  If the request reaches you, you process it, and your response does not reach the function in time, the function sees a timeout and retries. You have the message twice.


So:

- **Deduplicate on `message_id` + `domain`.** That pair is the delivery's identity, and it is exactly the claim key. Not on `message_id` alone: a message at two receiving domains is two legitimate deliveries.
- **A `5xx` releases the claim** deliberately, so the message can genuinely be redelivered rather than silently skipped by the retries. Your dedupe is what stops that becoming a double-processed message. A timeout does the opposite and keeps the claim, precisely because the message may already be yours.
- **A Redeliver from the dashboard deletes the claim on purpose.** Somebody pressed a button asking for the message again, so it arrives again — your dedupe key will see it as a repeat and that is correct.

## Versioning

`schema_version` is `1` and changes only for a break.

- **New optional fields are added without a bump.** `entities` was added this way. An endpoint written before a field existed ignores it, which is exactly what a JSON deserializer does by default — **so do not configure yours to reject unknown fields.** That is the one client-side choice that turns an additive change into an outage.
- **No field is ever renamed or repurposed.** A function already deployed in your account keeps emitting the names it was built with.
- **A bump would mean a genuinely incompatible shape**, and would be announced before it shipped.

## A worked check

Before any mail arrives, the dashboard's **Test webhook** button POSTs a synthetic, signed payload with obviously fake content — `"test": true`, all verdicts `pass`, the subject "SendOps inbound webhook test" — to the configured URL and reports what your endpoint did: status, timing, and a plain-language description of a transport failure. A `200` with `success: false` is your endpoint answering, which is the answer to the question asked; only a refusal to attempt the send at all (a URL SendOps will not dial, or the limit of ten tests an hour per domain) is an error.

The test send is dashboard-only and deliberately not on the Public API: it makes SendOps POST to an address the caller chooses, which is an outbound-request primitive, and a person on the settings page having just pasted a URL is part of what keeps it from being used as one.

If the test verifies and real mail does not arrive, the order to check is:

1. `status` on the receiving domain — `pending_dns` means the MX or the identity is not published yet, and `last_error` says which.
2. `webhook.status` — `pending` means the routing has not reached your account yet (seconds, normally); `invalid` means the URL failed re-validation at publish time and the domain was left out entirely, with `webhook.error` saying why.
3. `allow_patterns` — a recipient matching nothing in a non-empty list is `no_route`, and the message log says so.
4. The message log itself: `parked` with a reason is SendOps' side, `rejected` is your endpoint's.

## Related

- [Inbound Email](/api-reference/inbound) — the API that sets the domain, webhook and addresses up
- [Delivery Webhooks](https://help.sendops.dev/inbound/delivery-webhooks) — the same settings in the dashboard
- [Message Log](https://help.sendops.dev/inbound/message-log) — outcomes, park reasons and Redeliver, for the person reading the log