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_ 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.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_, email_ | 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.accepted, whatsapp.sent, whatsapp.delivered, whatsapp.read, whatsapp.failed, whatsapp.rejected, whatsapp.received, whatsapp.reacted, whatsapp_, whatsapp.group.join_, whatsapp.group.join_ | 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_, realtime.channel_vacated, realtime.member_added, realtime.member_removed, realtime.connection_, 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:
{
"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
Create the endpoint. In the dashboard, go to Platform tools > Webhooks. From a terminal, use the Bird CLI:
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/webhookswithurl,eventsand an optionaldescription.Copy the
secretfrom the create response. It starts withwhsec_, it is shown exactly once, and it belongs to this endpoint only. Store it server-side, for example asBIRD_WEBHOOK_SECRET.Send a test with
POST /v1/webhooks/{webhook_, passingid}/test {"event_to choose the type. Test sends are signed like real deliveries, but the body is a stub that carries onlytype": "email.delivered"} 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_ 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_ 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.
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:
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_, 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 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-secretand 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 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
2xxfor events you want to discard, and non-2xxonly 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 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 itdegradedbefore it pauses. - Re-enable a paused endpoint with
PATCH /v1/webhooks/{webhook_andid} {"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.
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.
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_ 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 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 for free and handle Bird webhooks reliably in minutes.