# 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](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 from the terminal with the [bird CLI](/docs/sdks):

```bash
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`](/docs/api/reference/create-webhook).

![The Webhooks page in the Bird dashboard, listing an active endpoint with its subscribed events](/images/docs/dashboard-webhooks.png)

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](#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}`](/docs/api/reference/update-webhook), 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](#rotating-the-signing-secret).

```json
{
  "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](/docs/api/reference/list-webhooks), [get](/docs/api/reference/get-webhook), update, and [delete](/docs/api/reference/delete-webhook). 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:

| Header              | Value                                                                          |
| ------------------- | ------------------------------------------------------------------------------ |
| `webhook-id`        | Identifies the event delivery. Retries and replays of it reuse the same value. |
| `webhook-timestamp` | Unix timestamp (seconds) of this delivery attempt                              |
| `webhook-signature` | `v1,<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](/docs/sdks/typescript), the signature and timestamp checks are one call; deduplication stays in your handler:

<!-- 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 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:

| 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 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](#replaying-missed-events) 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`](/docs/api/reference/test-webhook) 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](/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 missed events

[`POST /v1/webhooks/{webhook_id}/replay`](/docs/api/reference/create-webhook-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`](/docs/api/reference/list-webhook-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`](/docs/api/reference/rotate-webhook-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**](https://bird.com/dashboard/w/webhooks) page in the dashboard), then [replay](#replaying-missed-events) 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](/docs/guides/email/events): the delivery lifecycle (`email.accepted` through `email.delivered` or `email.bounced`), engagement (`email.opened`, `email.clicked`), unsubscribes, and [inbound email](/docs/guides/email/receiving-email)
- [SMS events](/docs/guides/sms/events): the message lifecycle from `sms.accepted` to a terminal status
- [WhatsApp events](/docs/guides/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.

```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 are never renamed, and 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](/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