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.
| Resource | Events |
|---|---|
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:
| Header | Contents |
|---|---|
webhook-id | Unique message ID. Stays the same across retries of the same event — use it as your idempotency key. |
webhook-timestamp | Unix timestamp (seconds) of this delivery attempt. |
webhook-signature | Space-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"}'