# 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](https://www.standardwebhooks.com) 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**](https://bird.com/dashboard/w/webhooks), or via [`POST /v1/webhooks`](/docs/api/reference/create-webhook). 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](/images/docs/dashboard-webhooks.png)

```bash
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](#event-catalog) — 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](#rotating-the-signing-secret).

```json
{
  "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:

| Header              | Value                                                                         |
| ------------------- | ----------------------------------------------------------------------------- |
| `webhook-id`        | Stable ID for this delivery — automatic retries of the same delivery reuse it |
| `webhook-timestamp` | Unix timestamp (seconds) of the delivery attempt                              |
| `webhook-signature` | `v1,<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](/docs/sdks/typescript), all three checks are one call:

<!-- bird:snippet webhook.unwrap -->

```typescript
// 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](https://www.standardwebhooks.com) works too. If you verify by hand, the recipe is:

```typescript
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.

After the first attempt, failed deliveries are retried on this schedule, with ±20% jitter so retries don't synchronize:

| Retry | Delay after the previous attempt |
| ----- | -------------------------------- |
| 1     | 5 seconds                        |
| 2     | 5 minutes                        |
| 3     | 30 minutes                       |
| 4     | 2 hours                          |
| 5     | 5 hours                          |
| 6     | 10 hours                         |
| 7     | 10 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 the response is honored. All retries of a delivery carry the same `webhook-id`, which is what makes deduplication work. After the final retry the delivery is permanently failed — the source event is retained and can be [replayed](#replaying-failed-events), 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](/docs/guides/email/testing-sandbox) — 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 [**Webhooks**](https://bird.com/dashboard/w/webhooks) page in 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](/docs/guides/email/events).

| Event                      | When it fires                                                                              |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| `email.accepted`           | Bird accepted the send and is preparing to deliver                                         |
| `email.processed`          | Bird processed the message and queued it for delivery                                      |
| `email.rejected`           | A recipient was rejected before the delivery pipeline (e.g. suppressed)                    |
| `email.delivered`          | The recipient's mail server accepted the message                                           |
| `email.deferred`           | Temporary delivery failure — Bird is retrying                                              |
| `email.bounced`            | Permanent delivery failure for a recipient                                                 |
| `email.out_of_band_bounce` | A late bounce arrived after the message was already delivered                              |
| `email.complained`         | The recipient marked the message as spam                                                   |
| `email.opened`             | The tracking pixel loaded                                                                  |
| `email.clicked`            | A tracked link was clicked                                                                 |
| `email.unsubscribed`       | The recipient unsubscribed via a footer or link                                            |
| `email.list_unsubscribed`  | RFC 8058 one-click list-unsubscribe                                                        |
| `email.received`           | Bird received an inbound email — see [Receiving email](/docs/guides/email/receiving-email) |

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.

```json
{
  "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.

## Next steps

- [Webhooks API reference](/docs/api/reference/create-webhook) — full endpoint and schema documentation
- [Email events reference](/docs/guides/email/events) — per-event payload fields
- [Testing & sandbox](/docs/guides/email/testing-sandbox) — sandbox sends drive real webhook deliveries, ideal for testing handlers