Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

How to Verify Payment Webhooks in Node.js (So You Never Ship an Order Twice)

A customer pays for their order. A second later, your server gets the same "payment completed" message twice, and your system happily sends two parcels. Or worse, someone sends your server a fake "paid" message and walks

A customer pays for their order. A second later, your server gets the same "payment completed" message twice, and your system happily sends two parcels. Or worse, someone sends your server a fake "paid" message and walks away with a free order.

If you build checkouts or payment features in Node.js, payment webhooks are where these bugs live. This guide covers the five things you need to get right, with code you can copy.

What a payment webhook is

A webhook is a message your payment provider sends to your server when something happens, like "this payment succeeded" or "this refund failed". Instead of your app asking "is it paid yet?" every few seconds, the provider tells you.

That's handy, but it means your server has to answer three questions every time a message arrives:

  • Is this message really from my payment provider?
  • Have I already handled it?
  • Did I save it before replying "got it"?

Why the same webhook can arrive twice

Webhooks are usually delivered "at least once". If your server is slow, crashes or replies with an error, the provider sends the same message again, sometimes for many hours. The open Standard Webhooks specification, which many API companies follow, expects this and recommends giving every message a unique ID so you can spot repeats.

So duplicates aren't a rare edge case. They're normal, and your code has to expect them.

Step 1: Keep the raw request body

Most payment providers sign each webhook with HMAC-SHA256. That's a way of proving the message came from them and wasn't changed on the way. The signature is worked out from the exact bytes they sent.

If you parse the JSON and turn it back into text, those bytes can change (spacing, key order) and the check fails. So read the raw body on your webhook route:

import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.WEBHOOK_SIGNING_SECRET;
const SIGNATURE_HEADER = "x-signature"; // use the header name in your provider's docs

app.post(
  "/webhooks/payments",
  express.raw({ type: "application/json" }), // keep the exact bytes
  async (req, res) => {
    if (!isValidSignature(req.body, req.get(SIGNATURE_HEADER))) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString("utf8"));
    const isNew = await saveEventOnce(event); // save first...
    res.status(200).end();                    // ...then reply

    if (isNew) await processEvent(event);     // slow work last
  }
);

Step 2: Check the signature safely

function isValidSignature(rawBody, header) {
  if (!header) return false;
  const received = header.replace(/^v1=/, ""); // some providers add a version prefix
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(received, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Two details matter here. A normal === comparison can leak tiny timing differences that an attacker can measure, so use crypto.timingSafeEqual. And timingSafeEqual throws an error if the two values are different lengths, so check the length first.

Providers format signatures slightly differently (hex or base64, with or without a version prefix), so match these lines to your provider's webhook signature verification guide.

Step 3: Save each event only once

Handling repeats safely is called making your handler "idempotent": running it twice has the same effect as running it once. The simplest way is a unique key for every event and a database rule that rejects duplicates:

async function saveEventOnce(event) {
  // Use the event ID if your provider sends one
  const key = event.id ?? [event.type, event.paymentId, event.status].join(":");
  try {
    await db.webhookEvents.insert({ key, body: event }); // unique index on "key"
    return true;
  } catch (err) {
    if (err.code === "UNIQUE_VIOLATION") return false; // seen it before
    throw err;
  }
}

Step 4: Reply fast, then do the slow work

Providers only wait a short time for your reply. If you send emails, update stock or call other services first, the provider may give up and send the message again. This matters most at an online checkout, where a customer is staring at the confirmation page. Save the event, reply with 200, then do the slow work.

Step 5: Test it before real money moves

Use your provider's sandbox (a test version that uses fake money) to send test webhooks. Try three cases: a normal payment, the same message twice, and a message with a broken signature. Your code should handle the first, ignore the second and reject the third.

Checklist before you go live

  • The webhook route reads the raw body
  • Signatures are checked with a constant-time comparison
  • Every event is saved once, using a unique key
  • You reply 200 only after the event is saved
  • Duplicates and bad signatures are tested in the sandbox
  • A back-up job checks the status of any payment you haven't heard about

Conclusion

Webhooks are how your payment provider tells your app that money has moved, and most providers send them "at least once", so the same message can arrive twice. Keep the raw body, check the signature, save each event once and reply fast, and a retry will never turn into a second order. Run the checklist above in your sandbox before you switch on live payments.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.