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.

Frequently Asked Questions

What is the difference between a webhook and an API? With an API you call the other service. With a webhook the other service calls you when an event occurs. Webhooks push; APIs are pulled.
Why must I verify the webhook signature? The endpoint is publicly reachable, so anyone can send it a request claiming an event occurred. The signature proves the request genuinely came from the provider.
Why do I receive the same webhook twice? Providers retry when they do not receive a success response quickly enough, including when your handler succeeded but responded slowly. Handlers must be idempotent.
How do I test webhooks locally? Use a tunnelling tool to expose your local server on a public URL, or use the provider's test-event feature. The provider cannot reach localhost directly.
What status code should a webhook return? A 2xx as quickly as possible to acknowledge receipt. Do the real work in a background job, since a slow response is treated as a failure and triggers a retry.