Webhooks

Bird delivers state changes as signed HTTPS requests to URLs you choose, following the open Standard Webhooks specification — any Standard Webhooks library verifies a Bird webhook out of the box. One contract for every event: same envelope, same signature scheme, same retry policy.

Subscribing

Register an endpoint via the API or the CLI. Endpoint URLs must be https.

bird webhooks create https://yourdomain.com/webhooks/bird \
  --events email.delivered,email.bounced
curl -X POST https://api.bird.com/v1/webhooks \
  -H "Authorization: Bearer $BIRD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourdomain.com/webhooks/bird", "events": ["email.delivered", "email.bounced"]}'

The create response includes the signing secret (whsec_ prefix) once — store it where your webhook handler can read it; it cannot be retrieved again. Rotate it at any time with POST /v1/webhooks/{webhook_id}/rotate-secret; during rotation Bird signs with both secrets so verification never breaks.

Event types

Dotted <resource>.<past-tense-verb> names. New event types are added over time; existing ones are not renamed.

ResourceEvents
email.*accepted, processed, delivered, deferred, bounced, out_of_band_bounce, rejected, complained, opened, clicked, unsubscribed, list_unsubscribed, received
domain.*verified, failed
email_suppression.*created

Payload shape

Every delivery has the same envelope. The type field tells you which event fired, timestamp is when the event occurred, and the data shape varies by event type — the envelope is identical across resources.

{
  "type": "email.delivered",
  "timestamp": "2026-06-01T17:00:12Z",
  "data": {
    "email_id": "em_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient_id": "er_01krdgeqcxet5s7t44vh8rt9mg",
    "workspace_id": "ws_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient": "delivered@bird.dev",
    "recipient_role": "to"
  }
}

The envelope timestamp is when the event happened — for email.delivered, the moment the recipient's mail server accepted the message. It is not the time Bird sent you the request; that rides in the webhook-timestamp header and changes on every retry.

Signature verification

Every delivery carries three headers, per Standard Webhooks:

HeaderContents
webhook-idUnique message ID. Stays the same across retries of the same event — use it as your idempotency key.
webhook-timestampUnix timestamp (seconds) of this delivery attempt.
webhook-signatureSpace-delimited list of v1,<base64-hmac> entries — one per active secret, so rotation never breaks verification.

The signature is HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{raw_body}, keyed with the base64-decoded secret (the part after whsec_), base64-encoded. Verify against the raw request body — JSON-parsing and re-serializing before HMAC-ing will silently break verification. Reject deliveries whose webhook-timestamp is more than 5 minutes from now, and compare signatures in constant time.

The Bird SDKs do all of this in one call and return the typed event:

const event = bird.webhooks.unwrap(rawBody, req.headers); // throws WebhookVerificationError on a bad signature

switch (event.type) {
  case "email.delivered":
    console.log(event.data.recipient, event.timestamp);
    break;
}

Because the scheme is Standard Webhooks, the official standardwebhooks libraries work too. Verifying by hand:

// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyBird(rawBody, headers, secret, toleranceSec = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigs = headers["webhook-signature"];
  if (!id || !ts || !sigs) return false;
  if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > toleranceSec) return false;

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

  return sigs.split(" ").some((entry) => {
    const [version, sig] = entry.split(",");
    return (
      version === "v1" &&
      sig.length === expected.length &&
      timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
  });
}

Your handler may receive the same event more than once — delivery is at-least-once. Deduplicate on the webhook-id header.

Retries

A 2xx response acknowledges the delivery. Anything else — including a timeout — queues a retry on an exponential backoff schedule spanning multiple days. Retries of the same event carry the same webhook-id, so idempotent handlers process each event once. Endpoints that fail consistently for several days are disabled; re-enable them from the dashboard once your handler is healthy.

Delivery visibility

Every attempt — request, response code, response body, and round-trip time — is visible per endpoint via GET /v1/webhooks/{webhook_id}/attempts and in the dashboard.

Replay

Redeliver past events to an endpoint — after an outage on your side, or to re-trigger processing — with POST /v1/webhooks/{webhook_id}/replay. Pass a since timestamp (defaults to 24 hours ago) and optionally until. Replay skips events the endpoint already received successfully, so it never double-delivers.

curl -X POST https://api.bird.com/v1/webhooks/$WEBHOOK_ID/replay \
  -H "Authorization: Bearer $BIRD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"since": "2026-06-01T00:00:00Z"}'

Testing webhooks

Fire a test event at a registered endpoint from the CLI or the API. Useful for wiring up signature verification before you have real traffic — the test delivery is signed with the endpoint's real secret, exactly like production.

bird webhooks test $WEBHOOK_ID --event-type email.delivered
curl -X POST https://api.bird.com/v1/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $BIRD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "email.delivered"}'

Start with one channel.
Add the others when you're ready.

A test API key is yours immediately. Production unlocks when you add a payment method and verify a sender.

Get startedRead docsor

Using Claude Code, Cursor, or Codex? Point it at our MCP server — 141 tools, one per API endpoint, with scoped agent keys.