Every webhook delivery Bird sends carries a signature, and verifying it is what tells your endpoint that the request came from us rather than from anyone who found the URL. The mechanism is Standard Webhooks, so it is the same shape several providers use, and one detail accounts for most of the time people lose on it.
What does Bird actually sign?
Three headers ride on every delivery:
| Header | What it carries |
|---|---|
webhook-id | Identifies the delivery. Retries and replays of it reuse the same value. |
webhook-timestamp | Unix timestamp in seconds for this delivery attempt. |
webhook-signature | v1, followed by a base64 HMAC-SHA256, and possibly several signatures separated by spaces. |
The signature is an HMAC-SHA256 over one string, built by joining three things with full stops:
{webhook-id}.{webhook-timestamp}.{raw request body}
The key is your endpoint's secret with the whsec_ prefix stripped and the remainder base64-decoded to get the key bytes. Compare with a constant-time comparison rather than ==.
Why does my signature never match?
Because something parsed the body before you signed it.
The HMAC is computed over the exact bytes we sent. Most web frameworks parse JSON for you before your handler runs, and re-serializing that object changes whitespace, key order, or numeric formatting. The result is a body that means the same thing and hashes differently.
So reach for whatever your framework calls the raw or unparsed body: request.body as bytes rather than as a dict, file_get_contents('php://input'), io.ReadAll(r.Body). If your framework has already consumed the stream, you usually need to configure it to keep a raw copy for this route.
This is worth checking first whenever verification fails on every delivery rather than intermittently. A wrong secret and a parsed body look identical from the outside, and only one of them is likely.
What should my handler reject?
Three checks, and the first is the only one people remember.
A signature that does not match. Note that webhook-signature can hold several signatures separated by spaces, which is what a secret rotation looks like on the wire. Accept the delivery if any one of them matches, or a rotation will drop events.
A timestamp outside a five-minute window. Compare webhook-timestamp against your own clock and reject anything older, which is what stops a captured request being replayed against you later.
A webhook-id you have already processed. Bird delivers at-least-once, so the same delivery genuinely can arrive twice, and every retry of it carries the same webhook-id. Deduplicating on that value is the handler's job rather than something the signature check does for you.
What happens if I reject a delivery?
It comes back, for about a day. A rejected delivery is retried on the normal schedule rather than discarded, and that is deliberate: the usual cause of a failed verification is a secret your handler does not have yet, during a rotation or a bad deploy. The retry window is your chance to fix the secret and still receive the event.
You have about a day of retries to fix a secret in, and replay recovers the delivery after that. How failed webhooks are retried has the schedule and the replay window, which you have to set explicitly once the failures are more than a day old.
Do not read anything into the code you choose. Any response that is not a 2xx is a failure and follows the same schedule, so a 400 is a convention for your own logs rather than a signal to us.
The consequence runs the other way too. Returning 2xx is how you tell us a delivery is finished, so returning it for an event you could not verify discards that event for good. Reject what you cannot verify.
Do I have to implement this myself?
No, and mostly you should not. The Bird SDKs do the signature and timestamp checks in one call, webhooks.unwrap, which takes the raw body and the headers and returns the decoded event. Deduplication stays in your handler either way, since only you know what you have already processed.
Any Standard Webhooks reference library works too, because the scheme is the same one.
Webhooks has the full worked example in four languages, plus a hand-rolled verification function if you would rather not add a dependency.