When a student pays an application fee on Fifty Villagers, two things tell the server about it. The checkout window reports back the moment the payment goes through, and a little later Razorpay sends a webhook to say the same thing. If the server is slow to answer, Razorpay sends the webhook again.
So one payment can arrive two or three times, from two directions. If each one were saved as it came in, one fee would show up as two.
First, check it's really Razorpay
Every webhook carries a signature: a hash of the message, made with a secret only Razorpay and the server know. The server works out the same hash and compares the two. If they don't match, the message is dropped before it touches anything.
import crypto from 'crypto';
// Razorpay signs every event with our shared secret.
// Verify before touching the ledger; replays are dropped.
export function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}The comparison is timing safe, so nobody can guess a signature one character at a time by measuring how long a wrong one takes to fail.
Then, record it once
Before saving a payment, the server looks it up by Razorpay's payment ID. If it's already there, it answers “fine, got it” and does nothing else. Razorpay stops retrying, and the database still holds one row.
A payment only moves forward
Every payment has a status, and the code allows only certain moves between them. A paid payment can be refunded, and nothing else. A refunded one can't change at all. A failed one can be tried again.
| From | Can become |
|---|---|
| pending | attempted, paid, failed |
| attempted | paid, failed |
| failed | attempted, pending |
| paid | refunded |
| refunded | nothing |
Tests cover each of these moves, so a change that breaks the rule fails before it ships.
Try to break it
What I'd keep next time
Putting the rule in one place, as data, instead of trusting every part of the code to remember it. Once the allowed moves are written down, a retried webhook, a double tap or a slow network can’t leave a payment half paid.

Where this is from
Fifty VillagersRead the case study →