When someone pays for a CoWorkHQ booking on Razorpay’s hosted page, the server can hear about it three ways: the browser redirect back to the app, Razorpay’s webhook, and a server-side check for when neither of those gets through (an API on localhost, for one). They arrive in any order, sometimes at the same moment.
If each of them confirmed the booking, the best case is double confirmations and the worst is keeping money for a slot someone else took. So every path calls one function, and that function is idempotent.
Claim first, decide second
const claimed = await prisma.payment.updateMany({
where: { id: paymentId, status: "created" },
data: { status: "refunding", razorpayPaymentId, paidAt: new Date() },
});
if (claimed.count === 0) {
// Another report got here first: read back what it decided.
}The conditional update is the lock. Postgres guarantees only one caller moves the payment out of “created”; everyone else gets a count of zero and reads the outcome instead of acting on it.
The claimed state is named “refunding” on purpose. Until the booking is confirmed, the safe default is to give the money back. If the process dies halfway, the next report (a webhook redelivery, say) sees “refunding” and finishes the refund. Failing towards a refund beats failing towards keeping money for nothing.
When the money can’t be kept
A captured payment only stays if it confirmed its booking. It’s refunded in full if the person cancelled while paying, if the 15-minute hold lapsed and someone else booked the slot (the exclusion constraint from my other note catches that), if an earlier payment already confirmed the booking (two tabs, or a retry racing the first), or if the booking’s time has already passed. The amount is always worked out on the server from the space’s price, never taken from the client.
The takeaway
Whenever an outside system can tell you the same thing more than once, design for at-least-once delivery: one entry point, a conditional write as the claim, and a safe default for the half-finished state.