The Webhook Receiver
This is the inbound half of the integration. As a message moves through Sent —
queued → routed → sent → delivered → read, or failed — and when a recipient replies,
Sent POSTs a signed event to an endpoint you registered. The receiver is a single route,
POST /webhooks/sent, and getting it right is mostly about order of operations.
If you're new to the event model, read Webhooks: getting started and the message lifecycle first — this page assumes you know what the events mean and focuses on how to receive them safely.
A webhook endpoint is a public URL that mutates your state. Treat it as a security boundary: verify every request against the raw body before you act on it. The verification itself is the next page — Signature verification — this page builds the receiver around it.
Quick path. Grab your framework's raw-body setup from
Why the raw body matters, verify with the function from
Signature verification, then follow
Acknowledge fast, process async: reject on a bad signature,
otherwise return 200 immediately and do the actual state update after. That's the whole
receiver — the pipeline below just walks through why those five steps are in
that exact order.
The pipeline
Every receiver runs the same five steps, in this exact order:
In plain English: each {diamond} is a yes/no check, read left to right — missing headers or
a bad signature reject early; only a fresh, valid event gets acknowledged and processed.
The ordering is deliberate:
- Receive the raw request body and the
X-Webhook-*headers. - Verify — reject with
400if signature headers are missing,401if the signature doesn't match or the timestamp is stale. - Acknowledge immediately with
200 { "received": true }. - Dedupe on a per-event key — Sent delivers at least once, so retries must be
no-ops. Derive the key from the payload (
message_id+message_status), not fromX-Webhook-ID(see the warning below). - Process the event after acknowledging — update your message store, branch on the event type. This post-ack async step is what we call the processor throughout the guide.
X-Webhook-ID is the endpoint's id, not a per-event id. It's identical on every
delivery to a given endpoint (it's part of the signed content, binding the signature to
that endpoint). So it's the wrong key to dedupe on — keying off it would drop every event
after the first. Build the idempotency key from the payload instead: message_id +
message_status. Your status store is also forward-only and idempotent (see
Tracking delivery status), which is the real safety net —
the dedupe guard just spares you redundant downstream work.
Why the raw body matters
The signature covers the exact bytes Sent sent — the webhook id, the timestamp, and the
raw JSON body concatenated as {id}.{timestamp}.{rawBody}. If your framework parses the JSON
and hands you a re-serialized string, key order, whitespace, and unicode escaping can all
differ from the original bytes, and the HMAC will never match.
So the rule is: capture the raw body before any JSON middleware touches it, and verify against those bytes. Parse the JSON only after verification succeeds. Each framework has its own way to opt out of automatic parsing for this one route.
// Mount express.raw ONLY on the receiver path, before the global json() parser.
// This hands the handler a Buffer of the exact bytes Sent signed.
app.use("/webhooks", express.raw({ type: "application/json" }));
// In the handler:
const rawBody: Buffer = Buffer.isBuffer(req.body)
? req.body
: Buffer.from(typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {}));# FastAPI: read the body as bytes BEFORE any Pydantic model binding.
@router.post("/sent")
async def receive(request: Request):
raw_body: bytes = await request.body()
id_ = request.headers.get("x-webhook-id")
timestamp = request.headers.get("x-webhook-timestamp")
signature = request.headers.get("x-webhook-signature")
# ... verify(raw_body, ...) then json.loads(raw_body) only after// Echo: read the whole body into bytes; do not bind a struct first.
rawBody, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": "cannot read body"})
}
id := c.Request().Header.Get("X-Webhook-ID")
timestamp := c.Request().Header.Get("X-Webhook-Timestamp")
signature := c.Request().Header.Get("X-Webhook-Signature")
// verify(rawBody, ...) then json.Unmarshal(rawBody, &event) after// Spring: bind the body as a raw String (or byte[]), NOT a parsed DTO.
@PostMapping("/webhooks/sent")
public ResponseEntity<?> receive(
@RequestBody String rawBody,
@RequestHeader("X-Webhook-ID") String id,
@RequestHeader("X-Webhook-Timestamp") String timestamp,
@RequestHeader("X-Webhook-Signature") String signature) {
// verify(rawBody, ...) then objectMapper.readValue(rawBody, ...) after
}// ASP.NET Core: read the request body stream yourself.
[HttpPost("/webhooks/sent")]
public async Task<IActionResult> Receive()
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
var id = Request.Headers["X-Webhook-ID"].ToString();
var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
var signature = Request.Headers["X-Webhook-Signature"].ToString();
// Verify(rawBody, ...) then JsonSerializer.Deserialize(rawBody) after
}// Laravel: getContent() returns the raw request body verbatim.
public function receive(Request $request)
{
$rawBody = $request->getContent();
$id = $request->header('X-Webhook-ID');
$timestamp = $request->header('X-Webhook-Timestamp');
$signature = $request->header('X-Webhook-Signature');
// verify($rawBody, ...) then json_decode($rawBody) after
}# Rails: request.raw_post is the unparsed body. Skip CSRF for this action.
def receive
raw_body = request.raw_post
id = request.headers["X-Webhook-ID"]
timestamp = request.headers["X-Webhook-Timestamp"]
signature = request.headers["X-Webhook-Signature"]
# verify(raw_body, ...) then JSON.parse(raw_body) after
endScope the raw-body handling to the receiver route only. The rest of your API still wants
normal JSON parsing. In Express that means mounting express.raw on /webhooks and json()
everywhere else; in Rails it means skip_before_action :verify_authenticity_token on that
action; in Spring it's just binding String instead of a DTO for that endpoint.
Acknowledge fast, process async
Sent waits for your 2xx. If you do slow work — database writes, downstream API calls,
sending email — before responding, you risk timing out, and Sent will retry an event you
actually handled.
After 10 consecutive delivery failures, Sent auto-disables the endpoint — not a soft warning, a hard cutoff. A slow, timing-out receiver doesn't just risk duplicate processing; ten bad responses in a row and your integration silently stops receiving webhooks at all until someone re-enables it from the Sent dashboard. This is the one fact on this page worth re-reading if you're skimming.
So acknowledge first, then process. In a single-process app that's just responding before
the await; in production, hand the verified event to a queue and return 200 immediately.
// Verified above. Parse the envelope, ack, THEN process.
let event: WebhookEvent;
try {
event = WebhookEventSchema.parse(JSON.parse(rawBody.toString("utf8")));
} catch {
return res.status(400).json({ error: "invalid payload" });
}
// Acknowledge immediately so Sent doesn't retry while we work.
res.status(200).json({ received: true });
// Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id).
const { message_id, message_status } = event.payload;
if (message_id && !idempotency.firstSeen(`${message_id}.${message_status}`)) return; // dup — no-op
void processEvent(eventType ?? event.event, event, logger);# Verified above. Ack, then hand off (BackgroundTasks or a real queue).
try:
event = json.loads(raw_body)
except ValueError:
return JSONResponse({"error": "invalid payload"}, status_code=400)
# Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id).
payload = event.get("payload", {})
key = f"{payload.get('message_id')}.{payload.get('message_status')}"
if payload.get("message_id") and not idempotency.first_seen(key):
return JSONResponse({"received": True}) # duplicate — no-op
background_tasks.add_task(process_event, event_type, event)
return JSONResponse({"received": True})// Verified above. Parse, dedupe, ack; process in a goroutine.
var event WebhookEvent
if err := json.Unmarshal(rawBody, &event); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid payload"})
}
// Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id).
key := event.Payload.MessageID + "." + event.Payload.MessageStatus
if event.Payload.MessageID != "" && !idempotency.FirstSeen(key) {
return c.JSON(http.StatusOK, echo.Map{"received": true}) // duplicate — no-op
}
go processEvent(eventType, event) // process off the response path
return c.JSON(http.StatusOK, echo.Map{"received": true})idempotency here is a placeholder for whatever's backing firstSeen — a plain in-memory
Set is fine on a single instance, but the moment you run more than one, it needs to be a
shared store: two instances can each receive the same retried delivery and both see
"unseen" if they're not checking the same place. See
Idempotency across instances for
the shared, atomic version (Redis SET NX).
When processing fails after the ack
Acknowledging first means you've told Sent the event is handled — if processEvent then throws
(a bad payload variant, your database is down, a downstream call fails), Sent will not retry
it for you. That failure is now entirely yours to catch:
- Don't fire-and-forget in production. A bare
go processEvent(...)orvoid processEvent(...)with no error handling silently drops the event on failure. At minimum, catch the error and log it with the event'smessage_idso it's findable. - Put a real queue behind the ack, not an in-process goroutine/task, once this matters for more than a demo — one with its own retry policy and a dead-letter queue for events that fail repeatedly. That queue can retry your processing even though Sent's own delivery retry is already spent.
- Alert on the DLQ, not just on errors. A message stuck
SENTforever because itsDELIVEREDevent silently failed to process looks identical to "still in flight" unless something pages you when events land in the dead-letter queue.
This is the actual failure mode that matters most for this endpoint: the inbound flow is what keeps your delivery status correct, so a swallowed processing error doesn't just lose one event — it leaves that message's status permanently stale.
The response contract
Your status code is a signal to Sent. Return the right one:
| Situation | Status | Body |
|---|---|---|
| Verified & accepted | 200 | { "received": true } |
| Missing required signature headers | 400 | { "error": "missing signature headers" } |
| Signature mismatch or stale timestamp | 401 | { "error": "invalid signature" } |
| Unparseable JSON (after a valid signature) | 400 | { "error": "invalid payload" } |
| No signing secret known yet (none registered) | 503 | { "error": "no signing secret configured" } |
The 503 case is worth calling out: because the signing secret is born when you register an
endpoint (see Endpoint management), a receiver that has never
held a secret has nothing to verify against and should say so rather than silently accept or
500. Once an endpoint is registered, verification proceeds normally.
Return 2xx even for event types you don't handle. A non-2xx tells Sent the delivery
failed and it will retry with backoff. If you only care about message.delivered and
message.failed, still 200 the message.queued and message.routed events — just log
and move on. Silence is acknowledgement.
Never echo header values, the raw signature, or the signing secret in a response or log line.
A rejection body should name the reason ("invalid signature"), never the offending value.
Where the secret comes from
One thing that surprises people: there is no SENT_DM_WEBHOOK_SECRET env var driving this in
production. The signing secret is born when a customer registers a webhook — the create
call returns it once — and the receiver verifies against that in-memory secret. That whole
lifecycle is Endpoint management; the verification math is
Signature verification.
Next steps
Error Handling & Resilience
Turn SDK exceptions into your own error envelope, catch the right exception types per language, and build in retries, rate-limit backoff, idempotency, and timeouts.
Signature Verification
The exact Svix-style HMAC scheme Sent uses to sign webhooks — decode the secret, sign id.timestamp.body, constant-time compare, enforce the replay window, and accept-any during rotation. Verbatim verifiers in seven languages.