Writing · 15 September 2026 · 3 min read

The webhook that routed every payment to the refund handler

A one-line guard, a whole-flow outage, and the four rules for payment webhooks I follow now.

  • backend
  • payments
  • CampusCritique

On 15 June 2026, every payment webhook reaching CampusCritique was handled as a refund. The commit that fixed it is one line. The commit message reads “payment webhook broken by refund guard: all webhooks were being routed to the refund handler”. This is the story of that line, and of what a paid booking flow actually has to survive.

What a booking touches

Connect lets a student book a paid session with a verified mentor. From the outside it is one button. From the inside it is a slot hold, a gateway order, a webhook that confirms payment, a refund path, a reschedule path, reminders, and notifications to two people. Each step can fail, and the gateway can call you back in any order it likes.

We had switched gateways a week earlier, from Razorpay to Cashfree, to align the refund policy with the gateway’s compliance rules. That cost a week and it is the first lesson: pick the gateway before you build the UI on top of it.

The guard

Cashfree sends one webhook endpoint several kinds of event. Payment succeeded, payment failed, refund processed, refund failed. Refunds needed an “already processed” guard, because refund events can arrive twice and a second run must not touch the booking again. The guard was added at the top of the handler, before the branch on event type. Its condition was wrong in a way that was true for every event. So every event, including every successful payment, took the refund branch.

Nothing crashed. The endpoint returned 200. That is the worst kind of bug in a webhook: the gateway is satisfied, the logs look normal, and the product is silently wrong. It was caught and fixed the same day. A second commit that day fixed three more payment-flow bugs, a phone-number fallback, a race in the refund guard, and the atomicity of the confirmation step; that one was reverted and reapplied after verification.

The four rules

The fix pattern that came out of that day is what I follow now for any webhook that moves money.

Verify, then key, then guard, then act. Verify the signature first; nothing else matters if the request is not from the gateway. Derive an idempotency key from the gateway’s event id, falling back to a hash of the body, and record it before doing work; a second delivery of the same event returns early. Guard state transitions on the current state, not on the event alone, so a refund event for a booking that is not paid is ignored, and a second refund event for a booking already refunded is ignored. Only then act, and make the confirmation atomic: hold, order and confirmation succeed together or not at all.

Side effects leave the request. The handler emits an event to Notify and returns. Booking confirmed, reminder, refund processed: all of them are Notify’s job. A slow email can no longer fail a payment, and a retried webhook cannot send a second confirmation, because Notify keys on the same idempotency key.

Scheduled work leaves the request too. Reminders moved to QStash, then to a single reminder two hours before the session after the 24-hour and 10-minute variants proved noisy. Two schedules, 576 runs a day, instead of cron on the web server.

Test the contract, not the happy path. The refund guard bug would have been caught by a test that replays each event type through the handler and asserts the branch it lands in. That suite did not exist on 15 June. It is the item I would build first if I did it again.

What the numbers say now

Since launch, 43 sessions have been created, 15 paid, and all 15 completed, with no refunds needed and two reschedules handled. Thirteen verified mentors, 1.1K users, and organic search at 53% of sessions in the week before the snapshot. The webhook route has not misrouted an event since the fix. Those numbers are from Supabase on 14 September 2026, and the case study with its sources is at /work/campuscritique.

The thing I keep from that day is not the one-line fix. It is that a webhook handler is a state machine with a hostile input stream, and it deserves the same care as the payment page everyone looks at.

keysKeyboard
j / k
next / previous row
Enter
open the focused row
⌘K or /
search, or ask this site
g then h w a c
go home, work, about, contact
t
toggle light and dark
Esc
close the palette or this map
?
this map