Documentation
Sign inGet started

Webhooks & events

When something happens in your workspace — an email is delivered, a recipient bounces, a sending domain verifies — Bird POSTs a signed JSON event to every webhook endpoint you have 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 via POST /v1/webhooks. Endpoint URLs must be HTTPS, and Bird rejects URLs that resolve to private, loopback, link-local, or otherwise internal addresses — the check runs at creation time and again at every delivery, so DNS tricks can't redirect deliveries to an internal target.
The Webhooks page in the Bird dashboard, listing an active endpoint with its subscribed events
Exemple de code
curl -X POST https://us1.platform.bird.com/v1/webhooks \
  -H "Authorization: Bearer bk_us1_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/bird",
    "events": ["email.delivered", "email.bounced", "email.complained"],
    "description": "Production delivery + bounce notifications"
  }'
The events array is an explicit list of event types from the catalog below — an endpoint receives only the types it lists, and you can change the list at any time with PATCH /v1/webhooks/{webhook_id} (the new filter applies to future deliveries). To receive everything, subscribe to every type; check back here when new event types ship, since subscriptions never expand on their own.
The 201 response includes the endpoint's signing secret (prefixed whsec_) exactly once — store it in your secret manager immediately. It cannot be retrieved again; if you lose it, rotate it.
Exemple de code
{
  "id": "whk_01krdgeqcxet5s7t44vh8rt9mg",
  "url": "https://example.com/webhooks/bird",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "description": "Production delivery + bounce notifications",
  "status": "active",
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
Endpoints support full CRUD: GET /v1/webhooks lists them, GET /v1/webhooks/{webhook_id} fetches one, PATCH updates any field, and DELETE removes the endpoint and stops all deliveries. A workspace can register multiple endpoints, each with its own URL, event filter, and secret.

Verify signatures

Every delivery carries three headers:
HeaderValue
webhook-idStable ID for this delivery — automatic retries of the same delivery reuse it
webhook-timestampUnix timestamp (seconds) of the delivery attempt
webhook-signaturev1,<base64 HMAC-SHA256> — may contain multiple space-delimited signatures
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, all three checks are one call:
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 will change whitespace or key order and break 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. Respond quickly and process asynchronously — queue the event and return 200 before doing real work.
Failed deliveries are retried up to 10 attempts with exponential backoff (plus a little jitter so retries don't synchronize):
AttemptDelay after previous failure
1Immediate
25 seconds
330 seconds
42 minutes
510 minutes
630 minutes
71 hour
82 hours
94 hours
108 hours
All retries of a delivery carry the same webhook-id, which is what makes deduplication work. After the tenth attempt the delivery is permanently failed — the source event is retained and can be replayed, and a replay is a new delivery with a new webhook-id.
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 synthetic, fully-signed event to your endpoint and returns the outcome synchronously — whether your endpoint accepted it, the HTTP status it returned, and the round-trip latency. Pass {"event_type": "email.delivered"} to simulate a specific event type, or omit the body for a generic test ping. An unreachable endpoint is reported in the response body, not as a request error, so you can use this to debug connectivity. 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 failed events

POST /v1/webhooks/{webhook_id}/replay queues failed events for redelivery — pass since/until timestamps to bound the window (default: the last 24 hours). The request returns 202 and events are redelivered asynchronously; each replayed event is a new delivery with a new webhook-id, and the standard retry schedule applies if it fails again. GET /v1/webhooks/{webhook_id}/attempts lists recent delivery attempts with status codes and latency when you need to see what failed and why.

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.

Auto-pause and re-enable

Endpoint status is active, degraded, or paused. Sustained delivery failures mark an endpoint degraded (a health warning — delivery continues, and the status clears itself when deliveries succeed again), and an endpoint that keeps failing for several days is automatically paused: all delivery stops and you're notified by email. A paused endpoint never resumes on its own — re-enable it with PATCH /v1/webhooks/{webhook_id} and {"status": "active"} (or from the dashboard) once it's healthy.

Event catalog

Event payloads are compact, recipient-scoped facts: enough to know what happened and correlate it to your system (email_id + recipient_id + workspace_id), not the full resource. If you need more context, fetch the email by email_id. Per-event payload schemas live in the email events reference.
EventWhen it fires
email.acceptedBird accepted the send and is preparing to deliver
email.processedBird processed the message and queued it for delivery
email.rejectedA recipient was rejected before the delivery pipeline (e.g. suppressed)
email.deliveredThe recipient's mail server accepted the message
email.deferredTemporary delivery failure — Bird is retrying
email.bouncedPermanent delivery failure for a recipient
email.out_of_band_bounceA late bounce arrived after the message was already delivered
email.complainedThe recipient marked the message as spam
email.openedThe tracking pixel loaded
email.clickedA tracked link was clicked
email.unsubscribedThe recipient unsubscribed via a footer or link
email.list_unsubscribedRFC 8058 one-click list-unsubscribe
domain.verifiedA sending domain passed DNS verification
domain.failedA previously verified domain failed a re-check
email_suppression.createdAn address was automatically suppressed after a bounce, complaint, or unsubscribe
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 follow resource.action and are never renamed; new types are added as products ship, so write your handler to ignore types it doesn't recognize.