Guide to Sent Webhooks: Features and Best Practices
Sent webhooks tell your systems what happens to the messages you send through Sent's API: a message is queued, delivered, read or failed, a contact replies or opts out, a WhatsApp template is approved, a sender channel is activated, a tracked link is clicked, or a call completes. If you send SMS, WhatsApp, RCS or voice through Sent, webhooks are how delivery status and replies reach your application.
This guide covers how Sent webhooks are configured and filtered, how the Svix-style signature works with Sent's own header names, how retries, rotation and auto-disable behave, and how to build a receiver that handles them.
What are Sent webhooks?
A Sent webhook is an endpoint you register for one or more event families. Sent sends each event as a JSON POST with a consistent envelope: field (the family), event (the full event name), timestamp and payload.
Sent signs deliveries the same way as Standard Webhooks and Svix, with one difference: the headers are named X-Webhook-ID, X-Webhook-Timestamp and X-Webhook-Signature instead of webhook-* or svix-*. The signature is v1, followed by the base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}, and the HMAC key is the base64-decoded part of your whsec_ secret.
Sent webhook features
| Feature | Details |
|---|---|
| Configuration | Sent Dashboard, Webhooks > Add Webhook, or POST /v3/webhooks |
| Event selection | event_types by family, with optional event_filters per sub-type |
| Endpoint URL | http:// or https://. Private IP ranges are rejected |
| Signature header | X-Webhook-Signature, in the form v1,<base64> |
| Signed content | {X-Webhook-ID}.{X-Webhook-Timestamp}.{raw body} |
| Secret | whsec_ prefix, then base64. The key is the decoded bytes |
| Timestamp | X-Webhook-Timestamp, Unix seconds, re-signed on every attempt |
| Dedupe key | X-Webhook-Event-ID, the same on every attempt |
| Event field | event in the body, also sent in X-Webhook-Event-Type |
| Timeout | Per webhook, 5 to 120 seconds, default 30 |
| Retries | Per webhook, 1 to 5, default 3, with exponential backoff up to 60 minutes |
| Auto-disable | After 10 consecutive failed events |
| Sender Profiles | Optional clones of your webhook, with the same URL and secret |
Common events
Sent documents 38 event types in six families.
| Family | Events | Fires when |
|---|---|---|
message | message.queued, .routed, .sent, .delivered, .read, .failed and others | An outbound message changes status (read is WhatsApp and RCS only) |
message | message.received | A contact sends an inbound message |
templates | templates.pending, .approved, .rejected, .paused, .disabled, .deleted, .category_updated, .quality_updated | A template is reviewed or changes |
channel | channel.submitted, .changes_requested, .approved, .rejected, .activated, .deactivated | A sender channel registration progresses |
contact | contact.opt_in, .opt_out, .help, .custom_keyword | A contact's consent changes or they send a keyword |
link | link.clicked, .downloaded, .expired, .revoked | A tracked link is used or ends |
call | call.initiated, .answered, .completed, .failed, .recording_ready | A voice call progresses |
Link events are only sent once you subscribe to link explicitly. To reduce noise, set event_filters with bare suffixes: {"message": ["delivered", "failed", "received"]}, not "message.delivered". Every filter key must also be in event_types, or the request is rejected with 400.
Here is Sent's message.delivered example:
{
"field": "message",
"event": "message.delivered",
"timestamp": "2025-10-31T10:10:42Z",
"payload": {
"updated_at": "2025-10-31T10:10:41Z",
"account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
"template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
"template_name": "order_confirmation",
"outbound_number": "+1987654321",
"agent_id": "agent_abc123",
"message_status": "DELIVERED",
"channel": "sms",
"body": "Hi Jane, your order has been confirmed."
}
}
payload.account_id names the account the event belongs to. For events delivered through a Sender Profile's clone, that's the profile's ID, so map it to your tenant.
Setting up Sent webhooks
- Open Webhooks in the Sent Dashboard and click Add Webhook.
- Enter a Display Name and the Endpoint URL, and select the events to listen to. Selecting a family shows its sub-types.
- Click Add Webhook.
- Open the webhook and reveal or copy the signing secret. It starts with
whsec_. Store it server-side, for example asSENT_WEBHOOK_SECRET. - Use Test Webhook from the webhook's actions menu to send a sample event.
To create webhooks from code, call POST /v3/webhooks with event_types, optional event_filters, retry_count and timeout_seconds. Add a sender_profile block to clone the webhook onto every Sender Profile, present and future. The clones share your URL and secret.
If you receive Sent webhooks through Hookdeck, use the Hookdeck source URL as the endpoint URL, create a source with the Sent type and paste the full whsec_ secret. The Sent source accepts POST only, which is how Sent delivers webhooks.
Securing Sent webhooks
Read X-Webhook-ID, X-Webhook-Timestamp and X-Webhook-Signature. Strip whsec_ from the secret and base64-decode the rest to get the key. Compute an HMAC-SHA256 over the webhook ID, a period, the timestamp, a period and the raw body bytes. Base64-encode it, prefix v1,, and compare it in constant time with each space-separated entry in the header.
const crypto = require('crypto');
const express = require('express');
function verifySentSignature(rawBody, webhookId, timestampHeader, signatureHeader, secret, toleranceSeconds = 0) {
if (!webhookId || !timestampHeader || !signatureHeader || !secret) return false;
// Optional replay window. Off by default (see below).
if (toleranceSeconds > 0) {
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
}
const key = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'base64');
const signedContent = Buffer.concat([Buffer.from(`${webhookId}.${timestampHeader}.`, 'utf8'), rawBody]);
const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key).update(signedContent).digest('base64'));
// Sent sends one signature today. The header format allows several, separated by spaces.
return signatureHeader.split(' ').some((candidate) => {
const received = Buffer.from(candidate.trim());
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/sent', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.SENT_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
const valid = verifySentSignature(
req.body,
req.get('x-webhook-id'),
req.get('x-webhook-timestamp'),
req.get('x-webhook-signature'),
secret
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
const eventId = req.get('x-webhook-event-id');
// Deduplicate on eventId, route on event.event, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import time
def verify_sent_signature(raw_body: bytes, webhook_id, timestamp_header, signature_header, secret: str,
tolerance_seconds: int = 0) -> bool:
if not webhook_id or not timestamp_header or not signature_header or not secret:
return False
# Optional replay window. Off by default (see below).
if tolerance_seconds > 0:
try:
timestamp = int(timestamp_header)
except ValueError:
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
key = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret)
signed_content = f"{webhook_id}.{timestamp_header}.".encode("utf-8") + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
# Sent sends one signature today. The header format allows several, separated by spaces.
return any(
hmac.compare_digest(candidate.strip().encode(), expected.encode())
for candidate in signature_header.split(" ") if candidate.strip()
)
# In FastAPI: raw_body = await request.body()
# verify_sent_signature(raw_body, request.headers.get("x-webhook-id"),
# request.headers.get("x-webhook-timestamp"),
# request.headers.get("x-webhook-signature"),
# os.environ["SENT_WEBHOOK_SECRET"])
Sent's SDKs manage webhooks but don't verify them. Because the scheme is Standard Webhooks, you can also use the standardwebhooks library for Node or Python, passing Sent's headers under the names it expects (webhook-id, webhook-timestamp, webhook-signature). That library always rejects timestamps more than five minutes from the current time.
Sent tells receivers to enforce a ±300-second window, and that works for requests straight from Sent, because Sent signs each retry when it sends it. Behind Hookdeck it's different. Hookdeck applies no timestamp tolerance, and its retries and replays resend Sent's original headers, so a five-minute window on your endpoint rejects any delivery Hookdeck retries later than that. The samples above leave the window off by default. Pass toleranceSeconds = 300 when your endpoint receives directly from Sent.
To check your verifier, use the secret whsec_, an X-Webhook-ID of 3f2c6a1e-5b7d-4c8e-9f10-2a3b4c5d6e7f, an X-Webhook-Timestamp of 1761905442 and this exact body:
{"field":"message","event":"message.delivered","timestamp":"2025-10-31T10:10:42Z","payload":{"message_id":"8ba7b830-9dad-11d1-80b4-00c04fd430c8"}}
The header should be:
X-Webhook-Signature: v1,FdLf96fQ8kt3YVhPm5JJ9gmOociyiZZMUOz95A/nDuI=
Make Sent webhooks production-ready. Hookdeck Event Gateway verifies the
X-Webhook-Signatureheader on every delivery, answers inside your webhook's timeout, and queues each message and call event until your service has processed it.
Sent webhook limitations and pain points
Ten failed events and the webhook is off
The Problem: Delivery statuses and replies stop arriving, and the webhook shows as disabled.
Why It Happens: Sent disables a webhook after 10 consecutive failed events. Only an event's first attempt counts towards that, so ten distinct events failing in a row is enough. With the defaults, each event gets three retries starting about a minute after the failure.
Workarounds:
- Return
2xxas soon as the request is verified and stored. - Raise
retry_countto 5 andtimeout_secondsto suit your endpoint. - Check the webhook's Events table in the dashboard, then re-enable the webhook once the endpoint is healthy.
- Reconcile message status with
GET /v3/messages/{id}for anything you missed.
How Hookdeck Can Help: Hookdeck acknowledges each delivery as soon as it arrives and stores it, so your downtime doesn't count towards Sent's limit. It then delivers to your service with retries you configure.
Rotation has no overlap
The Problem: Verification fails for a burst of deliveries right after you rotate the secret.
Why It Happens: Rotating replaces the secret in one step. The old secret is invalid as soon as the rotate call returns, and every delivery from then on, including events already queued, is signed only with the new one. Sent never sends two signatures.
Workarounds:
- Save the new secret as soon as the rotate call returns. Sent's advice is to hold a set of candidate secrets and accept a delivery if any of them verifies.
- Deliveries that fail during the gap are retried, so keep the gap short rather than trying to avoid it.
How Hookdeck Can Help: Update the secret on the Hookdeck source straight after rotating. Deliveries that arrive in between stay in the request log marked as failed verification, so you can see exactly which ones the gap affected.
The webhook ID isn't the event ID
The Problem: Deduplication drops nearly every event, or none.
Why It Happens: X-Webhook-ID, which is part of the signature, identifies the webhook configuration and is the same on every event. The event's own ID is X-Webhook-Event-ID, which stays the same across attempts. The body has no event ID of its own.
Workarounds:
- Deduplicate on
X-Webhook-Event-ID. - Group related events by their payload IDs, such as
payload.message_id,payload.record_idfor links orpayload.call_idfor calls.
How Hookdeck Can Help: Hookdeck's deduplication can key on the X-Webhook-Event-ID header, so retried events reach your service once.
Order isn't guaranteed
The Problem: A message shows as sent after it was delivered, or a call event arrives before an earlier one.
Why It Happens: Each event is delivered independently, and a retry can arrive after a later event. Sent also says a link event can arrive before the message it belongs to is readable through the API.
Workarounds:
- Compare
payload.updated_ator the envelopetimestampbefore overwriting status. - Treat the API as the source of truth, for example the call's
timelinefromGET /v3/voice/calls/{id}. - Retry a read that can't find a
message_idyet rather than treating the ID as invalid.
How Hookdeck Can Help: Hookdeck's filters can route each event family to a separate destination, so a slow handler for one family doesn't hold up the others.
Best practices
Verify against the raw body
Sent signs the exact bytes it sends. In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().
Decode the secret
The most common mistake, by Sent's own account, is using the whsec_... string as the HMAC key. Strip the prefix and base64-decode the rest.
Use one endpoint for all Sender Profiles
If you run multiple Sender Profiles, the sender_profile block clones your webhook onto every profile with the same URL and secret. Route on payload.account_id rather than creating a webhook per profile.
Return 2xx for events you don't handle
Subscribe narrowly with event_filters, and acknowledge anything else that arrives so it doesn't count as a failure.
Conclusion
Sent uses the Standard Webhooks signature under its own X-Webhook-* header names, with a base64-decoded whsec_ key. Deduplicate on X-Webhook-Event-ID, not the webhook ID, save a rotated secret straight away, and keep failures below the ten-event limit that disables a webhook. Enforce Sent's five-minute window only where requests come straight from Sent.
Hookdeck Event Gateway sits in front of your receiver, checks every Sent signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Sent webhooks reliably in minutes.