DEV Community

Pritom Mazumdar
Pritom Mazumdar

Posted on

I read ~150 public Razorpay webhook handlers. At least 9 would process the same payment twice.

Razorpay delivers webhooks "at least once". Its own docs say the same event can arrive more than once and tell you to dedupe on x-razorpay-event-id. I wanted to see how often real code actually does that.

Method. I pulled 247 public Python / JS / TS files on GitHub that reference Razorpay webhook events. A keyword scan flagged 48 candidates, and each one was then reviewed line by line. About 148 files contain a real webhook handler; the rest are helpers, schemas or client-side "verify payment" endpoints. I only counted a handler if delivering the same event twice would demonstrably run a non-idempotent side effect twice. Where idempotency could come from a database constraint I couldn't see, I marked it unclear rather than broken.

(I'm being strict here because my first keyword-only pass said "1 in 5". When I read the code, most of those were false alarms, e.g. a wallet field that just stores the payment method.)

What I found

  • At least 9 of ~148 handlers (6%) run a side effect again when the same webhook is redelivered. Another 7 are unclear. (Only the 48 flagged files were checked for this, so the real number can only be higher.)
  • 5 of those 9 change money-like values:
    • user.wallet = (user.wallet || 0) + 100 on every payment.captured. Razorpay retries, and the user gets 200 coins.
    • invoice.amount_paid_paise += amount and a customer "total recovered" counter, both incremented on every delivery.
    • A WorkerPayout row created on every payment.captured. That's a duplicate payout record, and this handler doesn't verify signatures either.
  • 9 handlers (6%) never verify the webhook signature at all. Anyone who finds the URL can POST a fake payment.captured. Several more verify only when the header or secret is present, so leaving the secret unset silently disables the check.
  • A common, subtler pattern: computing the HMAC over JSON.stringify(req.body) instead of the raw request body. It works until something re-serializes the JSON differently, and then valid webhooks start failing.

6% doesn't sound like much until you remember these are the people who publish their payment code. And it only takes one retry storm to double-pay every affected user.

Why tests don't catch it. Everyone tests the happy path once in Razorpay test mode. Nobody fires the same event three times, or sends events out of order.

What I built. carbon-layer is an open-source CLI that fires realistic, correctly signed Razorpay, Stripe, Cashfree, Juspay and Hyperswitch webhooks at your local endpoint, including retries with the same X-Razorpay-Event-Id, out-of-order delivery, and bad signatures. A non-idempotent handler still answers 200, so it also checks the side effect itself: give it any command that counts what your handler does, and it compares before and after.

pip install -U carbon-layer
carbon run dispute-spike --provider razorpay \
  --webhook-url http://localhost:8000/webhooks --webhook-secret $SECRET \
  --webhook-repeat 3 --webhook-order random \
  --verify-cmd "psql -tAc 'select count(*) from wallet_credits'"
Enter fullscreen mode Exit fullscreen mode

Against a handler written like the wallet one above:

FAIL: duplicate processing: payment.captured: 200 unique events caused 600 side effects; handler is not idempotent
Enter fullscreen mode Exit fullscreen mode

The same handler with an event-id check gives PASS: each event processed exactly once. No gateway account needed. Repo: github.com/Pritom14/carbon-layer

The fix most of these handlers need: record x-razorpay-event-id in a table with a UNIQUE constraint, in the same transaction as the side effect. If the event is already there, return 200 and do nothing:

create table processed_webhooks (event_id text primary key, received_at timestamptz default now());
Enter fullscreen mode Exit fullscreen mode
app.post('/webhooks/razorpay', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!Razorpay.validateWebhookSignature(req.body.toString(), req.get('x-razorpay-signature'), SECRET))
    return res.sendStatus(400);              // verify the RAW body, not JSON.stringify(req.body)

  const eventId = req.get('x-razorpay-event-id');
  const event = JSON.parse(req.body);
  await db.tx(async (t) => {
    const { rowCount } = await t.query(
      'insert into processed_webhooks (event_id) values ($1) on conflict do nothing', [eventId]);
    if (rowCount === 0) return;              // duplicate delivery: already handled
    if (event.event === 'payment.captured') await creditWallet(t, event.payload.payment.entity);
  });
  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

Doing the insert and the side effect in one transaction matters: if the credit fails, the event id rolls back too, and Razorpay's retry gets processed properly.

(Repo names are deliberately left out. If you want to know whether yours was in the scan, DM me and I'll send you the specific finding privately.)

Top comments (1)

Collapse
 
gautamtalksdev profile image
Gautam K •

Worth checking alongside the event id: the client side "verify payment" endpoints you mention. If an app credits the wallet there and again on payment.captured, two different requests hit the wallet and the event id check sees nothing wrong. A unique key on the effect itself, one credit row per payment id, covers both paths and redelivery too. Did any of the 7 unclear handlers look like that, with the webhook and the verify endpoint both writing?