Index Ai Academy
Phase 8 · APIs · Security & reliability

Webhooks and events

Push events to other systems: signed, retried and deduplicated.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Push instead of poll
  2. At-least-once delivery
  3. Rotating a webhook secret
  4. The sending side
  5. Verify the bytes you received
  6. 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:

  1. The provider takes the current time t and the raw body bytes.
  2. It computes HMAC-SHA256(secret, f"{t}.{body}") and sends t and the signature in a header.
  3. 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]))
It runs right here in your browser, with nothing to install.
Quick check
Your framework parses the JSON body for you. Why should you verify the signature against the raw bytes, not json.dumps(parsed)?
  1. Signatures only work on text, not dicts
  2. Raw bytes are encrypted
  3. Re-encoding can change spacing or key order, so the bytes and the signature no longer match
  4. json.dumps is too slow
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 200 without 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.

Quick check
What does this print?
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)
  1. 2
  2. 0
  3. 3
  4. 1
Show the answer

2

The repeat of e1 still gets a 200, but the work is only done once.

Try it now

Delivery is at least once. Handling the same event twice must do no harm.

Quick check
Your handler receives event evt_8Qz a second time, after already processing it. What should it answer?
  1. 200, without processing it again
  2. 409 Conflict, so the provider knows
  3. 500, so the provider retries
  4. Process it again and answer 200
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.

Worked example

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")
In the lesson you can change this and run it.

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/ or http://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.

Quick check
You send webhooks. One customer's endpoint has been down for an hour. What should your sender do?
  1. Retry with growing delays for a while, then mark the event failed and allow a replay, without holding up other customers
  2. Drop the event at once
  3. Stop sending webhooks to every customer
  4. Retry every second forever
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

Create a free account

Try a quiz question

Why do webhook signatures include a timestamp?

  1. So an attacker can't record a real webhook and replay it later
  2. So the receiver knows the time zone the event came from
  3. To make the signature longer, and harder to guess by chance
  4. Because HMAC needs a number to work with, as well as the text

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson