Inbound webhook

Search Documentation

Search across all developer documentation

Inbound webhook

What SendOps POSTs to your endpoint when mail arrives at one of your receiving domains, 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 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.
MethodPOST
Content-Typeapplication/json
User-AgentSendOps-Inbound/<module version>, e.g. SendOps-Inbound/1
X-SendOps-TimestampUnix seconds, as a decimal string
X-SendOps-Signaturev1=<hex hmac-sha256> — see Verifying the signature
Timeout10 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.

{
"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": "<p>Paid, thanks.</p>",
  "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

FieldTypeMeaning
schema_versionint1. Bumped only by a change that would break an endpoint written against this page. New optional fields do not bump it — see Versioning.
message_idstringThe SES message id. It is also the raw object’s key suffix in the bucket, and it is the deduplication key — see Idempotency.
domainstringThe receiving domain this delivery is for. Lower-cased.
recipientsarray of stringThe envelope recipients at domain that passed its allow list.
received_atstringRFC 3339, SES’s receipt timestamp. Absent on a replay of an object whose notification is gone.
verdictsobjectSES’s scan and authentication findings — below.
raw_urlstringPresigned GET for the whole original message. 15 minutes.
entitiesobjectPer-entity metadata, keyed by local part — below. Absent when none of the recipients has any.
messageobjectThe parsed mail — below.

Route on `recipients`, never on `message.to`

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:

PostureEffect
tagThe message is delivered, with the verdict stated in verdicts. Your own filter decides.
dropNothing 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 you attach a JSON object to it. Every delivery to that address carries the object back, keyed by local part:

"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 responseWhat the function does
2xxDone. Outcome delivered. Nothing is retried.
4xx other than 429Terminal. Outcome rejected. Nothing is retried, ever.
429Retried on the fixed schedule below.
5xx, a refused connection, a host that will not resolveRetried, then the message comes back — see below.
No response before the timeoutRetried, 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”.

A 4xx is a promise

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

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

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

import (
  "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

import hashlib
import hmac
import time

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:

A timeout is not a failure

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.
  • Inbound Email — the API that sets the domain, webhook and addresses up
  • Delivery Webhooks — the same settings in the dashboard
  • Message Log — outcomes, park reasons and Redeliver, for the person reading the log