# 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:

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

1. Open **Webhooks** in the Sent Dashboard and click **Add Webhook**.
2. Enter a **Display Name** and the **Endpoint URL**, and select the events to listen to. Selecting a family shows its sub-types.
3. Click **Add Webhook**.
4. Open the webhook and reveal or copy the signing secret. It starts with `whsec_`. Store it server-side, for example as `SENT_WEBHOOK_SECRET`.
5. 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.

```javascript
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:

```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_c2VudC1ndWlkZS10ZXN0LWtleS0wMTIzNDU2Nzg5YWI=`, 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](/event-gateway) verifies the `X-Webhook-Signature` header 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 `2xx` as soon as the request is verified and stored.
* Raise `retry_count` to 5 and `timeout_seconds` to 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](/docs/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_id` for links or `payload.call_id` for calls.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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_at` or the envelope `timestamp` before overwriting status.
* Treat the API as the source of truth, for example the call's `timeline` from `GET /v3/voice/calls/{id}`.
* Retry a read that can't find a `message_id` yet rather than treating the ID as invalid.

**How Hookdeck Can Help:** Hookdeck's [filters](/docs/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](https://hookdeck.com) sits in front of your receiver, checks every Sent signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Sent webhooks reliably in minutes.