Skip to content
DERKONLINE

Verify Payment Webhooks Before They Move Money

Signature checks, amount matching, and idempotency guards that stop forged Paystack and Stripe events from charging users twice.

Derrick S. K. Siawor7 min read

A payment webhook is your payment provider telling your server "this customer just paid." It is also a URL on the public internet that anyone can send a POST request to. If your code trusts the body of that request without verifying it came from the provider, then anyone who finds the endpoint can forge a "payment succeeded" event and unlock a paid plan, a downloaded product, or an account upgrade without ever paying a cent.

This is not a hypothetical. Webhook endpoints are discoverable, the payloads are documented, and the only thing standing between a forged event and free product is signature verification. Getting it right is not hard, but there are three or four specific places where teams get it subtly wrong in ways that pass testing and fail in production, and those are exactly the places that cost real money.

The signature is the whole point

Every serious payment provider signs its webhooks. The provider computes a cryptographic signature over the request body using a secret only you and the provider know, and includes that signature in a header. You recompute the signature on your end and compare. If they match, the event genuinely came from the provider and was not altered. If they do not, you reject it, full stop, before your code does anything with the contents.

The two providers behave slightly differently, and the differences matter:

Stripe sends a Stripe-Signature header containing a timestamp and an HMAC-SHA256 hash of the payload, signed with your endpoint secret. You verify it with the official SDK call, stripe.webhooks.constructEvent(rawBody, signature, endpointSecret), which parses the header, recomputes the hash, and throws if it does not match.

Paystack sends an x-paystack-signature header containing an HMAC-SHA512 signature of the event payload, signed with your secret key. You verify it by computing the same HMAC-SHA512 over the raw body with your key and comparing.

// Paystack verification
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyPaystack(rawBody, signature, secretKey) {
  const expected = createHmac("sha512", secretKey).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

The mistake that breaks verification: parsing the body first

This is the single most common webhook bug, and it is maddening because it makes verification fail for valid events. The signature is computed over the exact raw bytes the provider sent. If your web framework parses the JSON body before you verify, it normalises whitespace and can reorder keys, producing bytes that no longer match what was signed. Your verification then fails on perfectly legitimate events, and you start "fixing" it by loosening checks, which is how endpoints end up unverified.

The fix is to capture the raw body before any JSON middleware touches it, and pass those exact bytes to the verifier. In most frameworks this means configuring the webhook route specifically to receive the raw body, separate from your normal JSON-parsing routes. Verify against the raw bytes, and only after verification passes do you parse the JSON to read the event.

Use the SDK, do not hand-roll the comparison

For Stripe specifically, use stripe.webhooks.constructEvent rather than computing the HMAC yourself. The SDK handles three things correctly that a hand-rolled version usually gets wrong: constant-time comparison so the check does not leak timing information, the timestamp tolerance that blocks replay attacks, and the exact header parsing format. Reimplementing those by hand is how you introduce a subtle vulnerability while believing you secured the endpoint.

Where you do compare signatures yourself, as with Paystack, use a constant-time comparison like crypto.timingSafeEqual, not ===. A normal string comparison can leak, through timing, how many leading characters matched, which over many attempts helps an attacker. Constant-time comparison removes that channel, the same reason it matters when hashing passwords with scrypt and timing-safe comparison.

Verifying the sender is necessary but not sufficient

A valid signature proves the event came from the provider. It does not prove the event means what you assume, and it does not protect you from the provider sending the same event twice. Two more checks turn a verified webhook into a safe one.

Match the amount

A signed charge.success event tells you a charge succeeded. It does not, by itself, tell you the charge was for the right amount. Always check that the amount in the event matches the amount you expected for that order. Without this, a customer who is supposed to pay $100 for a plan, but whose charge came through for $1, can end up provisioned for the $100 plan if your code only checks "did a payment succeed" and not "for how much." Read the amount from the verified event, compare it to the order's expected total, and only fulfil if they match.

Guard against duplicate processing with idempotency

Payment providers can and do deliver the same webhook more than once. This is by design, because they retry delivery if your endpoint is slow or briefly down, and a retry can arrive after your first handler already succeeded. If your handler is not idempotent, that second delivery credits the customer twice, ships the product twice, or sends two receipts.

The fix is an idempotency check keyed on the event's unique ID. Before processing, record that you have seen this event ID, in a way that is atomic, and skip if you have seen it before. A unique constraint on the event ID in your database is a clean way to do it: the first insert succeeds and you process, the duplicate insert fails the constraint and you safely no-op. The rule is that processing the same event twice produces the same result as processing it once, the same idempotency discipline that keeps agent tool calls from double-charging a customer on a retry.

The full chain that protects money

Payment webhook verification chain: raw body, signature, replay, idempotency, amount match, then fulfil

A webhook handler that can be trusted with payments does all of this, in order:

  1. Read the raw, unparsed request body.
  2. Verify the signature against that raw body, using the SDK where one exists and a constant-time comparison otherwise. Reject on failure.
  3. Reject events that are too old (Stripe's timestamp tolerance handles this; for others, reject events older than a few minutes) to block replays of a captured event.
  4. Check the event has not already been processed, using its unique ID, and stop if it has.
  5. Verify the amount and currency match the order you are about to fulfil.
  6. Only then fulfil, and record the event as processed atomically so a retry is a no-op.
  7. Exempt the webhook from CSRF protection, because it has its own signature verification, but exempt nothing else, and make sure your real CSRF defence still survives OAuth callbacks on every other mutation.

A webhook endpoint is also a public POST target an attacker can flood, so stopping brute force and POST floods with nginx rate limit zones keeps a retry storm from becoming an outage.

The webhook secret itself is a credential, so plan to rotate it without downtime the same way you would any signing key.

Miss step two and anyone can forge payments. Miss the raw-body detail and your verification fails on real events and you are tempted to weaken it. Miss the amount check and customers underpay for full access. Miss idempotency and retries double-charge or double-ship. Each of these is a money bug, and each is invisible until someone, friendly or hostile, finds it.

This is core to any system that takes payments, and it is one of the first things we check when we build web apps that handle money or run a security audit on one that already does. Payment integration is the part of an app where a quiet mistake has a direct financial cost, which is exactly why it deserves the verification chain done in full rather than the happy-path version that works in the demo. If you are validating a payment path as part of scoping an MVP that ships in six weeks, this is the one corner you do not cut. If you are taking payments and have not confirmed your webhooks verify signatures, match amounts, and dedupe retries, it is worth checking before someone else does.