Gareth Wilson Gareth Wilson

Guide to Sent Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSent Dashboard, Webhooks > Add Webhook, or POST /v3/webhooks
Event selectionevent_types by family, with optional event_filters per sub-type
Endpoint URLhttp:// or https://. Private IP ranges are rejected
Signature headerX-Webhook-Signature, in the form v1,<base64>
Signed content{X-Webhook-ID}.{X-Webhook-Timestamp}.{raw body}
Secretwhsec_ prefix, then base64. The key is the decoded bytes
TimestampX-Webhook-Timestamp, Unix seconds, re-signed on every attempt
Dedupe keyX-Webhook-Event-ID, the same on every attempt
Event fieldevent in the body, also sent in X-Webhook-Event-Type
TimeoutPer webhook, 5 to 120 seconds, default 30
RetriesPer webhook, 1 to 5, default 3, with exponential backoff up to 60 minutes
Auto-disableAfter 10 consecutive failed events
Sender ProfilesOptional clones of your webhook, with the same URL and secret

Common events

Sent documents 38 event types in six families.

FamilyEventsFires when
messagemessage.queued, .routed, .sent, .delivered, .read, .failed and othersAn outbound message changes status (read is WhatsApp and RCS only)
messagemessage.receivedA contact sends an inbound message
templatestemplates.pending, .approved, .rejected, .paused, .disabled, .deleted, .category_updated, .quality_updatedA template is reviewed or changes
channelchannel.submitted, .changes_requested, .approved, .rejected, .activated, .deactivatedA sender channel registration progresses
contactcontact.opt_in, .opt_out, .help, .custom_keywordA contact's consent changes or they send a keyword
linklink.clicked, .downloaded, .expired, .revokedA tracked link is used or ends
callcall.initiated, .answered, .completed, .failed, .recording_readyA 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

  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.

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_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 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 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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.