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 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 from the terminal with the bird CLI:
Exemplo de código
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.

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 outside your network.
The events array lists up to 100 types from the event catalog. An endpoint receives only the types it lists. Use PATCH /v1/webhooks/{webhook_id} to replace the complete list for future deliveries. To receive every event, subscribe to every type. Existing subscriptions do not expand when new types become available.
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.
Exemplo de código
{
"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, get, update, and delete. 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, the signature and timestamp checks are one call; deduplication stays in your handler:
// 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# Pass the RAW request body (bytes) and the request headers.
event = client.webhooks.unwrap(request.body, request.headers)
if event.root.type == "email.delivered":
print(event.root.data.email_id)package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(
option.WithAPIKey(os.Getenv("BIRD_API_KEY")),
option.WithWebhookSecret(os.Getenv("BIRD_WEBHOOK_SECRET")),
)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/webhooks/bird", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := client.Webhooks.Unwrap(body, r.Header)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent) // ack fast, then process
payload, _ := event.AsAny()
switch p := payload.(type) {
case bird.EmailDeliveredEvent:
fmt.Println("delivered:", p.Data.EmailId, p.Data.Recipient)
case bird.EmailBouncedEvent:
fmt.Println("bounced:", p.Type)
}
})
}// Pass the raw request body because parsing changes the bytes used to compute
// the signature.
$rawBody = file_get_contents('php://input') ?: '';
try {
$event = $bird->webhooks->unwrap($rawBody, getallheaders());
// $event is the decoded payload as an array; branch on $event['type'].
echo $event['type'];
} catch (WebhookVerificationError) {
http_response_code(400); // bad signature, stale timestamp, or missing/malformed headers
}Any Standard Webhooks reference library works too. If you verify by hand, the recipe is:
Exemplo de código
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");
// During secret rotation, the header can contain several signatures. Accept any match.
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 documented retry schedule. 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 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 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 produces status: failed in the response body, while the request itself succeeds. Use this result 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 lacks a valid signing secret or subscribed event type.
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 missed events
POST /v1/webhooks/{webhook_id}/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). The response does not include a count or task ID. Track results with GET /v1/webhooks/{webhook_id}/attempts, which lists recent delivery attempts from newest to oldest with status codes and latency. Each HTTP request has its own entry, so a retried event appears once per attempt.
Rotating the signing secret
POST /v1/webhooks/{webhook_id}/rotate-secret generates a new secret and returns it once. For the next 24 hours, Bird signs each delivery with both secrets. The webhook-signature header contains the space-delimited signatures (v1,<old> v1,<new>), allowing you to deploy the new secret during the overlap. 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 as a health warning. Bird keeps delivering and retrying, and successful deliveries clear the status. An endpoint that fails continuously for about five days is automatically paused and all delivery stops; one successful delivery during that period resets the 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 page in the dashboard), then replay to recover the events it missed.
Event catalog
Event payloads contain compact, recipient-scoped facts for correlation with your system. They do not contain 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: the delivery lifecycle (email.accepted through email.delivered or email.bounced), engagement (email.opened, email.clicked), unsubscribes, and inbound email
- SMS events: the message lifecycle from sms.accepted to a terminal status
- WhatsApp events: whatsapp.accepted through whatsapp.delivered, whatsapp.read, whatsapp.failed, and whatsapp.received for an inbound message
- Verify events: the verification lifecycle (verify.verification.created, verify.verification.verified) and each passcode attempt's delivery (verify.attempt.sent, verify.attempt.delivered, verify.attempt.undelivered)
Every delivery body is the Standard Webhooks nested envelope with type, timestamp, and a type-specific data object. The webhook-id header carries the event identity. The envelope timestamp records when the event occurred. The webhook-timestamp header records the current delivery attempt and changes on every retry.
Exemplo de código
{
"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 includes email_id, recipient_id, workspace_id, the recipient address, and its envelope recipient_role. It also includes the tags and metadata from the send request, or null when not provided. Event types add their own fields to this base. Each variant has a stable field set: fields are required by default, and their presence depends only on 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: full endpoint and schema documentation
- Email events: per-event payload fields
- Testing & sandbox: sandbox sends drive real webhook deliveries, ideal for testing handlers