What you'll learn
Quick Answer
A webhook is an HTTP request another service sends to your URL when an event occurs. You must verify the signature, respond quickly, and handle duplicate deliveries, because retries mean the same event can arrive more than once.
The problem with polling
You have taken a payment and need to know when it clears. Without webhooks you poll: ask the payment provider every few seconds whether the status changed.
That is bad in three ways at once. It is wasteful — hundreds of requests that answer "still pending". It is slow — you learn about the change up to one polling interval late. And it does not scale: a thousand pending payments means a thousand repeated checks.
A webhook inverts the direction. You register a URL, and the provider sends an HTTP POST to it the moment the payment clears. One request, no delay, no waste.
The mental model: polling is refreshing your inbox; a webhook is a notification.
What a webhook actually is
Nothing exotic — it is an ordinary HTTP endpoint on your server that someone else calls.
POST /webhooks/payment HTTP/1.1
Content-Type: application/json
X-Signature: sha256=a3f9...
{
"event": "payment.captured",
"id": "evt_12345",
"data": { "order_id": "ord_99", "amount": 19900 }
}
Your handler reads the event, does something, and returns a status code. A 2xx means received; anything else usually triggers a retry.
The difference from a normal API endpoint is that you do not control who calls it or when. It is publicly reachable, it will be called by a machine, and anyone on the internet can send a request to it. That last point drives everything below.
Verify the signature. Always.
Your webhook URL is public. Anyone can POST to it, including a JSON body claiming a payment of ₹50,000 succeeded.
Providers therefore sign each request with a shared secret, typically as an HMAC of the raw body in a header. You recompute it and compare:
import hmac, hashlib
def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_sig)
Three details that matter. Use the raw body bytes, not a re-serialised parsed object — reformatting changes the bytes and the signature will never match. Use compare_digest rather than ==, which leaks timing information. And reject the request before doing any work if verification fails.
An unverified webhook endpoint that credits accounts is not a bug; it is a way for anyone to give themselves money.
The same event will arrive twice
Providers retry when they do not get a 2xx. If your handler processed the event successfully but timed out before responding, the provider retries — and you process it again.
So handlers must be idempotent: processing the same event twice must have the same effect as once. Sending a duplicate confirmation email is embarrassing; crediting a wallet twice is a financial loss.
The standard approach is to record the event ID:
if already_processed(event["id"]):
return 200
mark_processed(event["id"])
handle(event)
Store IDs in a table with a unique constraint so concurrent duplicates cannot both pass the check. This is the same requirement as consuming from a message queue, and for the same reason — at-least-once delivery.
Also do not assume ordering. payment.captured can arrive before order.created, so handlers should tolerate events out of sequence.
Respond fast, work later
Providers expect a response within a few seconds and treat a slow reply as a failure — which means a retry, which means duplicate processing on an endpoint that was working correctly but slowly.
So the handler should do the minimum: verify, record, enqueue, return 200. Any real work — sending emails, generating invoices, calling other services — belongs in a background job.
@app.post("/webhooks/payment")
def handler():
if not verify(request.data, request.headers["X-Signature"], SECRET):
return "", 401
enqueue(process_payment, request.get_json())
return "", 200
Two practical notes. Testing locally needs a public URL, since the provider cannot reach localhost — tunnelling tools exist for exactly this, and most providers offer a test-event button. And log every received event including rejected ones, because webhook problems are almost impossible to debug retrospectively without a record of what actually arrived.
