Webhooks and events
Push events to other systems: signed, retried and deduplicated.
Create a free account Try part of it here
Why it matters
Payments succeed, forms get submitted, automated test runs finish, a model batch completes. The systems that know first tell yours by webhook: an HTTP request to a URL you gave them. Webhooks drive most real automations, including AI ones that react to a new email or a closed deal. They're also an open door, because your endpoint is public and anyone can POST to it. This lesson makes it safe and correct.
What you'll be able to do
- Explain how webhooks flip the usual direction: the provider calls your server
- Sign webhook payloads and verify them, with protection against replayed messages
- Handle at-least-once delivery: duplicates, retries and out-of-order events
- Design the sending side: event objects, retries, secrets and safe destinations
What it covers
- Push instead of poll
- At-least-once delivery
- Rotating a webhook secret
- The sending side
- Verify the bytes you received
- Test with replays
Push instead of poll
On a long car trip, a child keeps asking "are we there yet?" Your code does the same with an ordinary API: "has the payment finished yet?" Asking every few seconds is called polling. It's slow to notice changes, and wasteful when nothing has changed.
A webhook reverses it, like leaving your number so a restaurant can text you when your table is ready. You register a URL with the provider, and when something happens, the provider sends an HTTP POST there:
POST /webhooks/payments HTTP/1.1
Host: shop.example
Content-Type: application/json
Shop-Signature: t=1793001234,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"id": "evt_8Qz", "type": "payment.succeeded", "created": 1793001234, "data": {"order": "ord_51", "amount": 4900}}
Each event has an id, a type, a time, and the data. Your endpoint answers 200 to say "got it".
Anyone can POST to a URL
Your webhook endpoint is on the public internet. An attacker who finds it can send a fake payment.succeeded and get goods for free. So providers sign each webhook with a secret that only you and they know. Think of a wax seal: only the holder of the stamp can make it, and any tampering shows. A common scheme uses HMAC, a fingerprint that takes a secret to make:
- The provider takes the current time
tand the raw body bytes. - It computes
HMAC-SHA256(secret, f"{t}.{body}")and sendstand the signature in a header. - You recompute the same HMAC and compare with
hmac.compare_digest. A mismatch means you reject.
Including the time in what's signed blocks replay attacks. An attacker who copies one real webhook could otherwise resend it forever, like photocopying a valid ticket. You reject any message whose t is more than a few minutes from now.
In plain words: the signature proves the message really came from the provider and wasn't changed. The time proves it's recent.
import hashlib, hmac secret = b"whsec_test_secret" body = b'{"id": "evt_8Qz", "type": "payment.succeeded"}' def signature(t, body): return hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest() t = 1793001234 header = f"t={t},v1={signature(t, body)}" print(header) # Receiver: recompute from the raw bytes it received, then compare. received_t = int(header.split(",")[0][2:]) print(hmac.compare_digest(signature(received_t, body), header.split("v1=")[1])) print(hmac.compare_digest(signature(received_t, body.replace(b"8Qz", b"FAKE")), header.split("v1=")[1]))
json.dumps(parsed)?Show the answer
Re-encoding can change spacing or key order, so the bytes and the signature no longer match
The signature covers exact bytes. Parsing and re-serializing rarely reproduces them, so valid webhooks would fail (or tampered ones could slip through a sloppy comparison).
At-least-once delivery
A courier who gets no signature comes back with the parcel the next day. Providers do the same. They retry when they don't get a 2xx: after a timeout, a 500, or a network error, often with backoff over hours or days. So delivery is at least once. The same event can arrive two or more times, for example when your 200 was lost on the way back. Events can also arrive out of order, like two letters crossing in the mail: payment.refunded before payment.succeeded.
The receiving rules follow from that:
- Verify first. No valid signature, no processing: answer 400 (or 401).
- Deduplicate by event id. Record each id you've handled. A repeat gets
200without doing the work again. Answering 200 matters, because it tells the sender to stop retrying. - Answer fast. Providers time out in seconds. Save the event and do the slow work (emails, model calls) in a background job, then reply.
- Don't trust order. Check the current state (for example, fetch the payment from the API) rather than assuming events arrive in sequence.
Combined, these make your handler idempotent: processing the same event twice has the same effect as once.
handled = set() work = 0 for event_id in ["e1", "e1", "e2"]: if event_id in handled: continue handled.add(event_id) work += 1 print(work)
Show the answer
2
The repeat of e1 still gets a 200, but the work is only done once.
Delivery is at least once. Handling the same event twice must do no harm.
evt_8Qz a second time, after already processing it. What should it answer?Show the answer
200, without processing it again
A 2xx tells the provider delivery succeeded, so it stops retrying. Recording handled ids keeps the work from happening twice.
Rotating a webhook secret
During a secret rotation, the provider signs with both the old and the new secret, sending two v1 values. The receiver accepts the message if any of them matches.
import hashlib, hmac def sig(secret, t, body): return hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest() old, new = b"whsec_old", b"whsec_new" body, t = b'{"id": "evt_1"}', 1793001234 header = f"t={t},v1={sig(old, t, body)},v1={sig(new, t, body)}" parts = [p.split("=", 1) for p in header.split(",")] candidates = [v for k, v in parts if k == "v1"] for mine in (old, new, b"whsec_wrong"): ok = any(hmac.compare_digest(sig(mine, t, body), c) for c in candidates) print(mine.decode(), "->", "accepted" if ok else "rejected")
Receivers that already switched to the new secret and ones still on the old secret both accept. That's how a secret is changed without dropping a single event. Afterwards the provider signs with the new one only.
The sending side
If your product sends webhooks, you're the provider:
- Event objects have a stable shape:
id,type(order.created),created,data. Document every type. - Retry with backoff for a day or more, then mark the endpoint as failing and tell its owner.
- One secret per endpoint, shown once, with a way to rotate it.
- Guard against SSRF (server-side request forgery). Picture a stranger asking your assistant to fetch a parcel, and the address they give is your own locked office. Customers choose the URL, so someone will enter
http://169.254.169.254/orhttp://localhost:6379, hoping your server calls your own internal systems. Allow only public HTTPS addresses, and check which address the name points to before each delivery. - Send a "thin" payload when data is sensitive: only the id and type, and let the receiver fetch details from your authenticated API.
A published standard, Standard Webhooks, uses webhook-id, webhook-timestamp and webhook-signature headers. Following it means receivers can use ready-made libraries.
Show the answer
Retry with growing delays for a while, then mark the event failed and allow a replay, without holding up other customers
Back off, keep each customer's queue separate, and give up gracefully with a way to replay.
Verify the bytes you received
The signature covers the exact bytes the provider sent, the way a seal covers a letter exactly as it was folded. If you parse the JSON and then re-serialize it to check the signature, the result will often differ, since spacing and key order can change. The check fails, or worse, you end up checking something else. Read the raw body first, verify that, and only then parse it.
import json raw = b'{"id": "evt_1", "amount": 500}' # note the two spaces print(raw == json.dumps(json.loads(raw)).encode())
Same data, different bytes. So the signature would no longer match.
Answer fast, work later
A receptionist signs for a parcel and opens it later. They don't keep the courier waiting while they unpack it. Providers wait only a few seconds for your reply. If you send an email, update three systems and then answer, you'll sometimes time out. The provider then thinks it failed and sends the event again, while your work is still going. So save the event, reply 200 straight away, and do the slow work in the background.
Don't punish duplicates
When you see an event id again, answer 200. A 4xx or 5xx tells the provider that delivery failed, so it keeps retrying for days, perhaps until it disables your endpoint.
And don't count on order. payment.refunded can arrive before payment.succeeded. Rather than assuming a sequence, treat each event as a hint to look at the current state: fetch the payment from the provider's API if you need to. That way, order stops mattering at all.
Test with replays
Save a few real events, signatures included, and replay them in your tests. You'll catch mistakes without waiting for the provider to send anything.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why do webhook signatures include a timestamp?