DEV Community

Divyakush Punjabi
Divyakush Punjabi

Posted on

Never trust a payment webhook on its first word

A payment webhook is, structurally, a stranger sending your server a POST that says "this person paid." If your handler believes it, anyone who can reach that URL can mark orders as paid. In Saturdays, where PhonePe settles real money for food orders, the webhook handler is the most defensive code in the system — and every step in it exists because the step before it isn't enough.

The handler most tutorials give you

def payment_webhook(request):
    data = json.loads(request.body)
    order = Order.objects.get(id=data["order_id"])
    if data["status"] == "SUCCESS":
        order.mark_paid()
Enter fullscreen mode Exit fullscreen mode

Count the assumptions: that the request came from the gateway, that the body wasn't altered, that SUCCESS means the money actually arrived, that the amount matches the order, that this event hasn't already been processed, and that nothing else is changing this order at the same moment. Every one of those is a separate way to lose money.

Five steps, in this order

1. Log the raw body before you parse it. Not the parsed dict — the bytes. When a gateway disputes what it sent, or a parser throws on a malformed payload, the raw body is the only evidence you'll have. It costs one write and saves every investigation.

2. Verify authentication. Confirm the request really came from the gateway, using whatever the provider signs or authenticates with. A request that fails this isn't "a failed payment." It isn't a payment event at all.

3. Ask the gateway yourself. Even an authentic webhook is a notification, not a ledger. Saturdays independently re-reads the payment status from the gateway's status API — and does it outside any database transaction, because a network call can take seconds, and holding a row lock across it turns one slow response into a queue of blocked requests.

4. Lock, then re-check. Only after that independent confirmation does the handler lock the order row and look again under the lock. Webhooks retry and can arrive concurrently, so the state you read before locking may already be stale. The re-check is what turns a duplicate delivery into a harmless no-op instead of a second fulfilment.

5. Compare the amount — and fail loudly. The confirmed amount is checked against the order's own stored total. A mismatch doesn't quietly succeed, and it doesn't silently fail either: it's flagged for manual review, because a payment that doesn't match its order is exactly the case a human needs to see.

The rules underneath the sequence

Two principles hold those steps together:

  • The client never supplies the amount. The endpoint that starts a payment takes an order id and computes the figure from the order's immutable total. The webhook then verifies against that same number. Nowhere in the flow is a price accepted from outside.
  • No lock is ever held across a network call. Confirm first, lock second. The same discipline covers refunds, which commit their pending state before the gateway call goes out — so a crash mid-refund leaves a record that something was attempted, not a mystery.

The takeaway

Treat every inbound payment event as a claim to verify, not a fact to record. Keep the evidence, authenticate the sender, confirm with the source of truth, serialize the write, and make the mismatch case visible. None of it is clever. All of it is the difference between a checkout that works in a demo and one you can leave running with real money in it.

The full payment path, and the order state machine it gates, are in the case study.

👉 See the build: www.divyakush.com/projects/saturdays


Divyakush Punjabi — Full-Stack & AI Systems Engineer

🌐 https://www.divyakush.com · 💼 LinkedIn · 💻 GitHub

Top comments (2)

Collapse
 
raknaos profile image
Raknaos

"The client never supplies the amount" removes most of the attack surface before authentication even matters. "Confirm first, lock second" is the part that gets inverted under load — I have watched a handler hold a row lock across the gateway HTTP call and turn a 300 ms gateway hiccup into a queue of blocked workers, protecting nothing that the post-confirm re-read did not already protect.

One addition from experience: the re-check is only a no-op if the fulfilment write is idempotent on the event id, not on the order id. A second delivery of the same event can arrive after the order already moved to paid by another path — manual reconcile, a partial refund — and then "order is already paid" is a different question from "have I processed this delivery". A unique constraint on the gateway event id answers the second one cheaply.

Flagging amount mismatches for a human instead of failing or silently succeeding is the right default. A mismatch is usually a coupon applied out of band, a partial capture, or a price edited in a dashboard, and all three want a person looking at them rather than a retry loop.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.