Avanish JhaStart a project

WritingPayments

How I stop a payment from being recorded twice

Razorpay tells you about a payment more than once, on purpose. Here's how the Fifty Villagers platform makes sure it only ever counts once.

Avanish Jha2 min read

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.

razorpay-webhook.js
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.

FromCan become
pendingattempted, paid, failed
attemptedpaid, failed
failedattempted, pending
paidrefunded
refundednothing

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 →

Need payments that add up?

A few lines is enough. I'll reply with questions or a time to talk.