A webhook is a server-to-server callback: when an event happens, the source server proactively sends an HTTP request to notify another server. Unlike polling, where a client repeatedly asks 'anything new?', the side where the event occurs (the provider) pushes a POST to a pre-registered consumer endpoint. Events like a completed payment, a successful deploy, or a repository push arrive in near real time, and the consumer never has to hold a connection open waiting.
The shape of event delivery
A provider typically POSTs a JSON body containing the event type, a unique event ID, a timestamp, and a payload (data). The consumer must accept the request and return a 2xx quickly to acknowledge (ack) it. A standard webhook delivery looks like this:
POST /webhooks/payments HTTP/1.1
Host: consumer.example.com
Content-Type: application/json
Webhook-Id: evt_1Qk2m9
Webhook-Timestamp: 1737100800
Webhook-Signature: v1,3Qb1f8y2Y0p9d7c6...
{"type":"payment.succeeded","id":"evt_1Qk2m9","created":1737100800,"data":{"amount":5000,"currency":"krw"}}
Signing and verification (HMAC)
A consumer endpoint is exposed to the public internet, so anyone could forge a request. The provider therefore computes an HMAC signature over the request body using a shared signing secret and sends it in a header like Webhook-Signature; the consumer recomputes the signature with the same secret and checks that it matches. The signature is usually computed over the timestamp concatenated with the body.
signed_payload = webhook_id + "." + timestamp + "." + raw_body
expected = base64(HMAC_SHA256(signing_secret, signed_payload))
# Compare the header signature against expected with a constant-time compare
Two mistakes are common in verification. First, feed the raw received bytes into the HMAC, not JSON that your framework parsed and re-serialized — re-serialization changes key order and whitespace and breaks the signature. Second, use a constant-time comparison function for the strings to avoid timing attacks.
Replay protection — To stop a replay attack — where an attacker captures a valid request and re-sends it later — include a timestamp in the signed payload and have the consumer check it. Even with a valid signature, reject the request if the timestamp is outside a tolerance window (say, five minutes) from now. A request captured in the past then falls outside the window and cannot be replayed.
Retries and idempotency
Networks fail, so if the consumer doesn't return a 2xx (timeout, 5xx, or connection error) the provider retries with exponential backoff. The catch: when 'the consumer processed it fine but the response was lost,' the same event may arrive twice. Consumer handling must therefore be idempotent.
- De-duplicate by event ID: Store the
Webhook-Id(or the body'sid) and, if you've already processed it, just return a 2xx without reprocessing. - Ack fast, work slow: Enqueue heavy work and return 2xx immediately. Holding the request while you process risks a provider timeout and needless retries.
- Retry budget: Providers usually retry over several days, then give up. An endpoint that keeps failing may be auto-disabled.
- Ack with 200: To signal failure, deliberately return a 5xx to trigger a retry; on success, always return a 2xx.
Ordering — Webhooks do not guarantee order by default. Retries and parallel delivery can make the created order diverge from the arrival order, so you must tolerate a 'deleted' event arriving before its 'created' event. In practice, check the event's timestamp or the resource's version number and defend against overwriting newer state with older. If ordering truly matters, it's safer to reconcile by re-fetching the current state from the API for each event.
Debugging — Because a consumer must be reachable from the internet, local development is awkward. During development, expose your local server via a tunneling tool that gives it a temporary public URL, or use the provider dashboard's delivery log and replay button. To narrow a problem, check in order: (1) did the request arrive, (2) does signature verification pass, and (3) did you return a 2xx in time. A signature mismatch is almost always a raw-body issue, and a retry storm is usually caused by slow acking.