# Guide to Bird Webhooks: Features and Best Practices

Bird webhooks tell your systems what happened to the messages you send and receive through Bird: an email was delivered or bounced, a WhatsApp message arrived, a verification code was confirmed, a call ended or a contact opted out. If you message customers through Bird, the omnichannel platform formerly known as MessageBird, webhooks are how delivery status and inbound messages reach your own services without polling the API.

This guide covers the current bird.com platform API: how endpoints are created and signed, the event types and their envelope, Bird's retry, replay and auto-pause rules, and the best practices for running a Bird receiver in production.

## What are Bird webhooks?

A Bird webhook endpoint is a URL plus an explicit list of event types. When one of those events happens in your workspace, Bird sends one JSON `POST` per event to every endpoint subscribed to it. A workspace can have several endpoints, each with its own URL, event list and signing secret.

Bird follows the Standard Webhooks specification for headers, signing and payload structure. Every delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature`, and the signature is an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`. If you already verify webhooks from another Standard Webhooks sender, the same code works here.

This applies to the current bird.com API. The legacy MessageBird API signed requests differently, with `MessageBird-Signature` and `MessageBird-Signature-JWT` headers, and nothing in this guide verifies those.

## Bird webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Platform tools > Webhooks**), the Bird CLI, or `POST /v1/webhooks` |
| Subscription model | Up to 100 explicit event types per endpoint. Wildcards such as `sms.*` are refused |
| Endpoint URL | HTTPS, at most 2048 characters, publicly reachable. Private and loopback addresses are rejected with `422` |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: base64 HMAC-SHA256, sent as `v1,<signature>` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | One `whsec_` secret per endpoint, returned once in the create response |
| Rotation | Both secrets sign for 24 hours. At most 5 valid secrets per endpoint |
| Timestamp | Unix seconds of the current attempt. Bird recommends rejecting values more than 5 minutes old |
| Response window | 15 seconds. Any `2xx` is success |
| Retries | Eight attempts over roughly 27.5 hours, with jitter |
| Replay | `POST /v1/webhooks/{webhook_id}/replay` redelivers failed attempts from the last three days |
| Auto-pause | After about five days of continuous failure |
| Ordering | Not guaranteed. Sort by the envelope `timestamp` |
| Source IPs | Not published |

## Common events

Bird publishes its event types on each product's events page rather than in one catalogue. Together they document 56 types, and Bird names a few more in passing (delivery-status events for mailbox email, and `amb.received`) without describing them, so treat the list as a floor. Event names follow `resource.action`, Bird says they are never renamed, and new ones are added as products ship.

| Product | Event types | Fires when |
| --- | --- | --- |
| Email | `email.accepted`, `email.processed`, `email.delivered`, `email.deferred`, `email.bounced`, `email.out_of_band_bounce`, `email.rejected`, `email.complained`, `email.opened`, `email.clicked`, `email.unsubscribed`, `email.list_unsubscribed`, `email.scheduled`, `email.canceled`, `email.received` | A sent email moves through acceptance, delivery, bounce, complaint and engagement, or an inbound email is parsed |
| Email mailboxes | `email_mailbox.message_received`, `email_mailbox.thread_created` | Inbound mail reaches a mailbox, or starts a new thread |
| SMS | `sms.accepted`, `sms.sent`, `sms.delivered`, `sms.undelivered`, `sms.failed`, `sms.rejected`, `sms.expired`, `sms.received`, `sms_suppression.created` | An SMS moves through its lifecycle, an inbound SMS arrives, or a suppression opens |
| WhatsApp | `whatsapp.accepted`, `whatsapp.sent`, `whatsapp.delivered`, `whatsapp.read`, `whatsapp.failed`, `whatsapp.rejected`, `whatsapp.received`, `whatsapp.reacted`, `whatsapp_suppression.created`, `whatsapp.group.join_request_created`, `whatsapp.group.join_request_revoked` | Message status changes, inbound messages and reactions, suppressions and group join requests |
| Verify | `verify.verification.created`, `verify.attempt.sent`, `verify.attempt.delivered`, `verify.attempt.undelivered`, `verify.verification.verified`, `verify.verification.failed` | A verification starts, each code attempt is sent and delivered or not, and the verification succeeds or fails |
| Voice | `voice_call.initiated`, `voice_call.answered`, `voice_call.ended` | A call starts, is answered and ends |
| Realtime | `realtime.channel_occupied`, `realtime.channel_vacated`, `realtime.member_added`, `realtime.member_removed`, `realtime.connection_count`, `realtime.cache_miss`, `realtime.client_event` | Channel, presence and client activity in Bird Realtime |
| Preferences | `preference.granted`, `preference.revoked`, `preference.deleted` | A consent grant or opt-out takes effect, or a preference is deleted |

Realtime is subscribed by group (channel existence, presence and so on), but each delivery still carries the individual event type.

Every body is the same three-field envelope: `type`, `timestamp` (when the event occurred) and a type-specific `data` object. Here is Bird's `email.delivered` example:

```json
{
  "type": "email.delivered",
  "timestamp": "2026-07-23T14:51:47.107Z",
  "data": {
    "email_id": "em_01ky7qc398fmxraqtxn604zeq9",
    "recipient_id": "er_01ky7qc398fmwsc96nt6anrbqs",
    "workspace_id": "ws_01ky7m21hjffh9s8kq76gyb1xf",
    "recipient": "delivered@messagebird.dev",
    "recipient_role": "to",
    "tags": [{ "name": "category", "value": "welcome" }],
    "metadata": { "order_id": "ord_123" },
    "broadcast_id": null
  }
}

```

There is no ID in the body. The `webhook-id` header is the event's identity: retries and replays reuse it, so it is the key to deduplicate on. The `data` object holds compact facts about one recipient, not the full resource, so fetch the email, message or call from the API when you need more. The `tags` and `metadata` you attached when sending come back on the events, which is how you tie an event to your own order or user.

## Setting up Bird webhooks

1. Create the endpoint. In the dashboard, go to **Platform tools > Webhooks**. From a terminal, use the Bird CLI:
  
  
  ```bash
  bird webhooks create --url https://example.com/webhooks/bird \
    --events email.delivered,email.bounced,email.complained \
    --description "Delivery and bounce notifications"
  
  ```
  
  
  
  Or call `POST /v1/webhooks` with `url`, `events` and an optional `description`.
2. Copy the `secret` from the create response. It starts with `whsec_`, it is shown exactly once, and it belongs to this endpoint only. Store it server-side, for example as `BIRD_WEBHOOK_SECRET`.
3. Send a test with `POST /v1/webhooks/{webhook_id}/test`, passing `{"event_type": "email.delivered"}` to choose the type. Test sends are signed like real deliveries, but the body is a stub that carries only `type`. Your endpoint has 10 seconds to answer a test, and test sends are not recorded in the delivery attempts log.

`PATCH /v1/webhooks/{webhook_id}` replaces the event list for future deliveries. Existing endpoints do not pick up new event types when Bird adds them, so add them yourself. To stop deliveries for a while, set `status` to `paused` rather than deleting the endpoint: deleting it cannot be undone and drops any pending retries.

If you lose the secret, `POST /v1/webhooks/{webhook_id}/rotate-secret` returns a new one, again only once. For the next 24 hours Bird signs every delivery with both secrets, so `webhook-signature` carries two space-separated `v1,` entries and either one verifies.

## Securing Bird webhooks

Strip `whsec_` from the secret and base64-decode the rest to get the key bytes. Build the signed content from the `webhook-id` header, a period, the `webhook-timestamp` header, a period and the raw request body. Compute an HMAC-SHA256, base64-encode it, and compare it in constant time with every `v1,` entry in `webhook-signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyBirdSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader) return false;

  // The key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]))
    .digest();

  // During a rotation the header holds several space-delimited "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/bird', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.BIRD_WEBHOOK_SECRET;
  if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500); // fail closed

  if (!verifyBirdSignature(req.body, req.headers, secret)) return res.sendStatus(400);

  // Optional: Bird recommends rejecting a webhook-timestamp more than 5 minutes old.
  // const age = Math.abs(Date.now() / 1000 - Number(req.get('webhook-timestamp')));
  // if (age > 300) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('webhook-id'), then route on event.type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_bird_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_bird_signature(raw_body, request.headers, os.environ["BIRD_WEBHOOK_SECRET"])

```

Because the scheme is Standard Webhooks, the `standardwebhooks` library on npm and PyPI also works, taking the `whsec_` value as it is. That library enforces a 5-minute timestamp window, which matters if requests can reach you late; more on that below.

To check your verifier, use the secret `whsec_YmlyZC1ndWlkZS10ZXN0LXNpZ25pbmcta2V5LTIwMjY=`, the headers `webhook-id: msg_test_01` and `webhook-timestamp: 1792659600`, and this exact body:

```
{"type":"email.delivered","timestamp":"2026-10-22T09:00:00.000Z","data":{"email_id":"em_test"}}

```

The signature header should be:

```
v1,58mhCHXah6YZZd+ahQWcrhZGOpAG5SIH7O7Ko4lRwNU=

```

Bird documents no API key, basic auth or IP allowlist for webhooks, so the signature is the only proof that a request came from Bird.

> **Make Bird webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Bird's Standard Webhooks signature with your `whsec_` secret, accepts both signatures during a rotation, and durably queues every email, SMS and WhatsApp event before your service sees it.

## Bird webhook limitations and pain points

### Using the secret as a string

**The Problem:** Every delivery fails verification even though the secret was copied correctly.

**Why It Happens:** The `whsec_` value is a serialised key. The HMAC key is the bytes you get by base64-decoding the part after the prefix. Passing the whole string, or the string without the prefix, to an HMAC function produces a well-formed signature that never matches.

**Workarounds:**

* Strip `whsec_` and base64-decode the rest before computing the HMAC.
* Test your verifier against the known-good signature in the previous section.
* Use a Standard Webhooks library, which decodes the secret for you.

**How Hookdeck Can Help:** Hookdeck's Bird source type takes the `whsec_` secret as Bird gives it to you, decodes it, and verifies `webhook-signature` before anything reaches your code.

### The secret is shown once

**The Problem:** You created an endpoint, moved on, and now nobody knows its secret.

**Why It Happens:** Bird returns the secret exactly once, in the create (or rotate) response. There is no endpoint that reads it back.

**Workarounds:**

* Store the secret in your secrets manager in the same step that creates the endpoint.
* If it is lost, call `rotate-secret` and deploy the new value inside the 24-hour overlap, while the old secret is still signing.
* Avoid rotating repeatedly inside the window. An endpoint holds at most 5 valid secrets, and further rotations fail with `WebhookTooManySecrets`.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives in one place, the source, rather than in every service that consumes Bird events, so a rotation is one update.

### The five-minute timestamp rule and late redeliveries

**The Problem:** Requests that verify correctly are rejected because they look too old.

**Why It Happens:** Bird recommends rejecting a `webhook-timestamp` more than 5 minutes old, and the Standard Webhooks libraries apply that window by default. Bird's own retries carry a fresh timestamp for each attempt, so they pass. Anything that resends the original request later does not: a queue that holds requests during your outage, or a proxy that retries with the original headers.

**Workarounds:**

* Decide where the freshness check belongs. If requests can reach your service late through your own infrastructure, apply the check at the edge, or not at all, and rely on deduplication instead.
* Keep server clocks synchronised so skew does not trip the check.

**How Hookdeck Can Help:** Hookdeck does not enforce a timestamp window when it verifies, so Bird deliveries are accepted however long they were in flight. Because Hookdeck forwards the original headers, keep any timestamp check in your own receiver optional, as in the sample above, if you want Hookdeck's retries to get through.

### Unordered, at-least-once delivery

**The Problem:** An `email.delivered` arrives after the `email.opened` for the same message, or the same event arrives twice.

**Why It Happens:** Bird says deliveries are not ordered, and every failure is retried with the same `webhook-id`, so duplicates are normal.

**Workarounds:**

* Deduplicate on `webhook-id`.
* Order by the envelope `timestamp`, which is when the event occurred, and never by arrival.
* Make status updates monotonic, so an older status cannot overwrite a newer one.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeat deliveries based on the `webhook-id` header before they reach your service.

### No status code stops retries

**The Problem:** Returning `400` or `410` for an event you do not want does not make Bird stop sending it.

**Why It Happens:** Bird treats anything other than a `2xx` within 15 seconds, including `3xx` redirects and timeouts, as a failure, and retries every failure on the same schedule: about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours twice, with jitter of up to 20%. A `429` or a timeout raises a delay under 60 seconds to 60 seconds, and a `Retry-After` header can lengthen a wait up to twice its scheduled value but never shorten it.

**Workarounds:**

* Return `2xx` for events you want to discard, and non-`2xx` only when you want Bird to try again.
* Unsubscribe from types you do not use rather than rejecting them.

**How Hookdeck Can Help:** Hookdeck acknowledges Bird straight away and applies your own [retry](/docs/retries) policy towards your service, so the status codes your service returns no longer drive Bird's schedule.

### A long outage pauses the endpoint, and replay has limits

**The Problem:** After a long outage, Bird stops sending altogether, and some of what you missed cannot be recovered.

**Why It Happens:** An endpoint that fails continuously for about five days is paused, and a paused endpoint never resumes on its own. Events that occur while it is paused are never sent, so a replay cannot recover them. Replay itself reads a delivery log that keeps three days, gives each redelivered event one attempt rather than the full schedule, covers at most 10,000 events per request, and is limited to 20 requests per organisation per day.

**Workarounds:**

* Watch the endpoint's `status`. Bird marks it `degraded` before it pauses.
* Re-enable a paused endpoint with `PATCH /v1/webhooks/{webhook_id}` and `{"status": "active"}`, then replay. A replay sent while the endpoint is still paused redelivers nothing.
* Realtime events are retried for only up to 5 minutes and are never replayed, so treat Realtime webhooks as best-effort.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives, so your downtime never counts against Bird's failure clock, and you can replay any stored event from Hookdeck whenever your service is ready.

## Best practices

### Verify against the raw body

Bird's docs say it directly: parsing and re-serialising the JSON changes whitespace or key order and breaks the signature. In Express, mount `express.raw()` on the webhook route and parse only after verification. In Next.js, use `await request.text()`. In FastAPI, read `await request.body()`.

### Deduplicate on `webhook-id`

Store the `webhook-id` of every event you process and skip any you have seen. It survives retries and replays, which the body cannot help with, because the body has no ID of its own. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Bird gives you 15 seconds and recommends queueing the event and returning `200` before doing real work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Subscribe explicitly and revisit the list

Because wildcards are refused and subscriptions do not grow, list the exact types you handle and add new ones deliberately when Bird ships them. Bird asks receivers to ignore types they do not recognise, so log them and still answer `2xx`.

### Use tags and metadata to join events back to your data

Set `tags` and `metadata` when you send. They come back on email events, so you can map a bounce or a click to your own order or user without an extra lookup.

### Watch the attempts log

`GET /v1/webhooks/{webhook_id}/attempts` lists recent attempts with status codes and latency. Check it when something looks wrong, and alert on an endpoint that turns `degraded`.

## Conclusion

Bird signs each delivery with the Standard Webhooks scheme: a base64 HMAC-SHA256 over the `webhook-id`, the `webhook-timestamp` and the raw body, keyed with the decoded `whsec_` secret that you see only once. Decode the secret before you use it, accept any `v1,` entry so rotations do not break you, deduplicate on `webhook-id`, and sort by the envelope `timestamp` because nothing arrives in order.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Bird signature, and holds each event until your service has processed it, so an outage on your side costs latency instead of a paused endpoint.

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