Documentation
Sign inGet started

Webhooks & events

When something happens in your workspace (an email is delivered, a recipient bounces, a WhatsApp message is read), Bird POSTs a signed JSON event to every webhook endpoint subscribed to that event type. Bird follows the Standard Webhooks specification for headers, signing, and payload structure, so if you already verify webhooks from another Standard Webhooks platform, the same verification code works here unchanged.

Create an endpoint

Register an endpoint in the dashboard under Developers → Webhooks, or from the terminal with the bird CLI:
Exemple de code
bird webhooks create https://example.com/webhooks/bird \
  --events email.delivered,email.bounced,email.complained \
  --description "Production delivery + bounce notifications"
Endpoint management requires the webhooks scope, which rides on your user role: dashboard sessions and the CLI's login carry it, while API keys hold data-plane scopes only and cannot manage endpoints. The underlying operations start at POST /v1/webhooks.
The Webhooks page in the Bird dashboard, listing an active endpoint with its subscribed events
Endpoint URLs must be HTTPS, at most 2048 characters, and publicly reachable: URLs on private, loopback, link-local, or otherwise internal addresses are rejected with a 422 when you create or update the endpoint. Deliveries originate from Bird's delivery infrastructure, not from inside your network.
The events array is an explicit list of types from the event catalog; an endpoint receives only the types it lists (at most 100), and you can change the list at any time with PATCH /v1/webhooks/{webhook_id}, which replaces the whole set and applies to future deliveries. To receive everything, subscribe to every type: subscriptions never expand on their own when new types ship.
The create response includes the endpoint's signing secret (prefixed whsec_) exactly once. Store it in your secret manager immediately; it cannot be retrieved again, and if you lose it, rotate it.
Exemple de code
{
  "id": "whk_01ky7q639cfh99xb7ysy2x2gzj",
  "url": "https://example.com/webhooks/bird",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "description": "Production delivery + bounce notifications",
  "status": "active",
  "secret": "whsec_c+dgRBsFELJ9mR4tu2cyhK0gTMMkqntv",
  "created_at": "2026-07-23T14:48:29.740Z",
  "updated_at": "2026-07-23T14:48:29.740Z"
}
Endpoints support full CRUD: list, get, update, and delete. Deleting an endpoint stops all deliveries to it, including retries of earlier failed deliveries, and cannot be undone; to stop deliveries temporarily, set status to paused instead. A workspace can register multiple endpoints, each with its own URL, event filter, and secret.

Verify signatures

Every delivery carries three headers:
HeaderValue
webhook-idIdentifies the event delivery. Retries and replays of it reuse the same value.
webhook-timestampUnix timestamp (seconds) of this delivery attempt
webhook-signaturev1,<base64 HMAC-SHA256>, possibly several signatures space-delimited
The signature is an HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{raw request body}, keyed with your endpoint's secret (strip the whsec_ prefix and base64-decode the remainder to get the key bytes). Your handler should verify the signature, reject deliveries whose webhook-timestamp is more than 5 minutes old, and deduplicate on webhook-id: Bird delivers at-least-once, so the same delivery can arrive more than once.
With the Bird SDK, the signature and timestamp checks are one call; deduplication stays in your handler:
Exemple de code
// Pass the RAW request body; set the secret via new BirdClient({ webhooks: { secret } }).
const event = bird.webhooks.unwrap(rawBody, headers);
console.log(event.type); // discriminated union: narrow on event.type
Any Standard Webhooks reference library works too. If you verify by hand, the recipe is:
Exemple de code
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute tolerance

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  // The header can hold several signatures (e.g. during secret rotation): accept if any matches.
  return headers["webhook-signature"].split(" ").some((part) => {
    const sig = Buffer.from(part.replace(/^v1,/, ""), "base64");
    return (
      sig.length === Buffer.byteLength(expected, "base64") &&
      timingSafeEqual(sig, Buffer.from(expected, "base64"))
    );
  });
}
Always compute the HMAC over the raw request body bytes. Parsing and re-serializing the JSON changes whitespace or key order and breaks the signature.

Delivery semantics

Each delivery is one event per HTTP POST with Content-Type: application/json, no batching. Your endpoint has 5 seconds to respond; any 2xx status counts as success, and anything else (including 3xx redirects and timeouts) counts as a failure. A response that signals a permanent problem (400, 404, 410, 422) stops retries for that event immediately; other failures follow the retry schedule below. Respond quickly and process asynchronously: queue the event and return 200 before doing real work.
After the first attempt, failed deliveries are retried on this schedule, with ±20% jitter so retries don't synchronize:
RetryDelay after the previous attempt
15 seconds
25 minutes
330 minutes
42 hours
55 hours
610 hours
710 hours
That's eight attempts over roughly 27.5 hours. A 429 or connection timeout waits at least 60 seconds before the next try, and a Retry-After header on your response is honored. Every retry carries the same webhook-id, which is what makes deduplication work. After the final retry the delivery is permanently failed; replay recovers it.
Deliveries are not ordered. An email.delivered can arrive before the email.accepted for the same message, especially when retries are involved. Sort by the timestamp field inside the event payload, never by arrival order.

Operate your endpoints

Test sends

POST /v1/webhooks/{webhook_id}/test sends a signed synthetic event to your endpoint and returns the outcome synchronously: whether your endpoint accepted it, the HTTP status it returned, and the round-trip latency. The test body is a minimal JSON stub carrying only the event type, signed exactly like a real delivery; it does not mirror a real event payload. Pass {"event_type": "email.delivered"} to pick any type from the catalog, subscribed or not, or omit the body to use the endpoint's first subscribed event type.
Your endpoint has 10 seconds to respond. An unreachable endpoint is reported in the response body as status: failed, not as a request error, so you can use this to debug connectivity. Test sends go straight to your endpoint: they work on a paused endpoint and are not recorded in the delivery attempts log. A 412 means the endpoint cannot be tested yet, because it has no valid signing secret or no subscribed event types to fall back on.
For end-to-end testing with real event flows, send to the sandbox addresses: sandbox sends emit real webhook events through the normal delivery path, which is the best way to exercise your handler before going live.

Replaying missed events

POST /v1/webhooks/{webhook_id}/replay queues redelivery of events the endpoint missed: deliveries that failed as well as events never attempted, for example while the endpoint was paused. Events the endpoint already received successfully are skipped, so a replay never double-delivers; a redelivered event carries its original webhook-id, so your deduplication check covers replays too. Pass since/until timestamps to bound the window (default: the last 24 hours). The request returns 202 and events are redelivered asynchronously, with the standard retry schedule if they fail again.
Replays are limited to 20 per organization per UTC day; beyond that the request returns a 429 (WebhookReplayQuotaExceeded). No count or task ID is returned, so track results with GET /v1/webhooks/{webhook_id}/attempts, which lists recent delivery attempts newest first with status codes and latency: one entry per HTTP request, so a retried event appears once per try.

Rotating the signing secret

POST /v1/webhooks/{webhook_id}/rotate-secret generates a new secret and returns it once. For the next 24 hours every delivery is signed with both the old and the new secret; the webhook-signature header carries both signatures space-delimited (v1,<old> v1,<new>), so you can deploy the new secret without dropping a single event. Standard Webhooks libraries try all signatures automatically. After 24 hours the old secret stops signing. An endpoint holds at most 5 concurrently valid secrets, so rotating repeatedly inside the overlap window fails with WebhookTooManySecrets until an older secret expires.

Auto-pause and re-enable

Endpoint status is active, degraded, or paused. Recent delivery failures mark an endpoint degraded: a health warning, not a throttle. Bird keeps delivering and retrying, and the status clears itself once deliveries succeed again. An endpoint that fails continuously for about five days is automatically paused and all delivery stops; a single successful delivery along the way resets that clock. A paused endpoint never resumes on its own. Re-enable it with PATCH /v1/webhooks/{webhook_id} and {"status": "active"} (or from the Webhooks page in the dashboard), then replay to recover the events it missed.

Event catalog

Event payloads are compact, recipient-scoped facts: enough to know what happened and correlate it to your system, not the full resource. If you need more context, fetch the resource by its ID. Event types follow resource.action naming and are grouped by product; each product's events page carries the per-event payload fields:
  • Email events: the delivery lifecycle (email.accepted through email.delivered or email.bounced), engagement (email.opened, email.clicked), unsubscribes, and inbound email
  • SMS events: the message lifecycle from sms.accepted to a terminal status
  • WhatsApp events: whatsapp.accepted through whatsapp.delivered, whatsapp.read, and whatsapp.failed
Every delivery body is the Standard Webhooks nested envelope, exactly three fields: a type, a timestamp, and a type-specific data object. The event's identity rides in the webhook-id header, not the body. The envelope timestamp is when the event occurred (for email.delivered, the moment the receiving server accepted the message), distinct from the webhook-timestamp header, which is the time of this delivery attempt and changes on every retry.
Exemple de code
{
  "type": "email.delivered",
  "timestamp": "2026-06-10T14:30:00Z",
  "data": {
    "email_id": "em_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient_id": "er_01krdgeqcxet5s7t44vh8rt9mg",
    "workspace_id": "ws_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient": "user@example.com",
    "recipient_role": "to",
    "tags": [{ "name": "category", "value": "welcome" }],
    "metadata": { "order_id": "ord_123" }
  }
}
Every email event's data carries this identity base (email_id, recipient_id, workspace_id, the recipient address, and its envelope recipient_role) plus the tags and metadata from the send request (null when not provided); event types with more to say extend it with type-specific fields. Within a variant the field set is stable: fields are required by default, and presence never varies by anything other than the event type.
Event names are never renamed, and new types are added as products ship, so write your handler to ignore types it doesn't recognize.

Next steps