Research · Deep dive · Webhooks

Payment webhook security for stablecoin integrations

The webhook handler is the function that decides money has arrived. Here is how it gets forged, replayed and confused, and the rules that make it correct under all of it.

In a stablecoin integration, the webhook handler is the function that decides money has arrived. It flips orders to paid, credits balances and triggers payouts. It is also, in almost every codebase I review, the endpoint with the least scrutiny: written once during integration, tested with the provider’s happy-path events, and never looked at again.

This guide covers what can go wrong with payment webhooks and the handful of rules that close nearly all of it. The examples use Stripe because its SDK is widely known; the same rules apply to Circle, Coinbase, Bridge and any provider that pushes events to you.

No. 01What can go wrong

Treat the webhook endpoint as a public, unauthenticated URL, because that is what it is. Anyone who learns it can send it requests. Six things can happen to the events that reach it:

  • Forged. An attacker sends a hand-written “payment succeeded” event.
  • Replayed. A genuine, signed event is captured and sent again.
  • Duplicated. The provider itself retries an event you already processed: this is normal, documented behaviour.
  • Reordered. “Succeeded” arrives before “processing”, or a refund before the payment.
  • Delayed. An event arrives hours late, after the object has changed.
  • Mis-environment. A test-mode event reaches the production handler, or the other way round.

A safe handler is correct under all six. The rules below get you there.

No. 02Verify the signature over the raw bytes

Providers sign the exact bytes they send. Your framework almost certainly parses JSON before your handler runs, and re-serialising the parsed object does not reproduce those bytes: key order, whitespace and number formatting all change. Verification then fails intermittently, and under deadline pressure someone comments it out.

Capture the raw body for the webhook route only, and verify it with the provider’s SDK:

// Express: raw body on this route only, before any JSON middleware.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler);

// Next.js App Router (Node runtime): read the body as text, never as JSON.
// On the Edge runtime, use stripe.webhooks.constructEventAsync instead.
export async function POST(req) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  // Reject events from the wrong environment (see No. 06).
  if (event.livemode !== (process.env.STRIPE_MODE === "live")) {
    return new Response("wrong environment", { status: 400 });
  }
  // ...
}

If a provider has no SDK helper, implement verification exactly as its documentation specifies and compare in constant time:

import crypto from "node:crypto";

// Generic HMAC-SHA256 scheme: signature = HMAC(secret, `${timestamp}.${rawBody}`)
// signatures: every candidate from the header. There can be several during secret rotation.
function verifyHmac(rawBody, timestamp, signatures, secret, toleranceSec = 300) {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)                 // a Buffer: hash the bytes, don't decode them
    .digest();
  if (!Array.isArray(signatures)) return false;
  return signatures.some((sig) => {
    if (typeof sig !== "string" || !/^[0-9a-f]{64}$/i.test(sig)) return false;  // missing or malformed
    return crypto.timingSafeEqual(Buffer.from(sig, "hex"), expected);
  });
}

Signature schemes differ between providers. Coinbase CDP, for example, also signs selected headers, and Bridge signs with RSA rather than HMAC. Follow each provider’s specification exactly.

Some providers sign with an asymmetric key instead. Circle signs with ECDSA and sends the key ID in the X-Circle-Key-Id header, and you fetch the matching public key from its API. Bridge gives you a per-endpoint public key when you create the webhook. Fetch keys only from the provider’s documented HTTPS endpoint, cache them by key ID, and never accept a key URL supplied in the request itself.

No. 03Reject stale events and replays

A valid signature proves the provider sent the event once. It doesn’t prove the provider sent it now. Two controls stop replays:

  • Timestamp tolerance. Reject signed timestamps older than the provider’s window, typically five to ten minutes. Bridge recommends about ten and sends its timestamp in milliseconds. Stripe’s SDK applies a five-minute tolerance by default. Never set it to 0, which disables the check, and don’t raise it to “fix” clock problems. Fix the clock with NTP.
  • Event ID deduplication. Record every event ID under a unique constraint and mark it processed in the same database transaction as the side effect. If it is already marked, the event was handled and you do nothing. Event IDs alone are not enough: Stripe can emit two distinct events for one change, so also make the state change itself idempotent (see No. 05).

The receiving endpoint only verifies the event, stores it with INSERT INTO webhook_inbox … ON CONFLICT (event_id) DO NOTHING and returns 2xx. A worker then processes each inbox row like this:

// Worker: claim the inbox row and apply the event in one transaction.
await db.transaction(async (tx) => {
  const claimed = await tx.query(
    "UPDATE webhook_inbox SET processed_at = now() WHERE event_id = $1 AND processed_at IS NULL",
    [event.id]
  );
  if (claimed.rowCount === 0) return;       // already processed: no-op
  await applyEvent(tx, event);               // commits or rolls back together
});

Deduplicating outside the transaction, for example in Redis before the database write, leaves a window where a crash marks the event done without applying it, or applies it twice.

No. 04Treat the event as a hint and re-fetch

The most robust pattern is to trust the webhook only as a notification that something changed, then ask the provider’s API for the current state of the object before acting:

if (!event.type.startsWith("payment_intent.")) return;  // other objects need their own re-fetch

// Re-fetch before opening the transaction, so no lock is held during the API call.
const intent = await stripe.paymentIntents.retrieve(event.data.object.id);

await db.transaction(async (tx) => {
  const order = await findOrderByPaymentIntent(tx, intent.id);
  if (
    order &&
    intent.status === "succeeded" &&
    intent.metadata.order_id === order.id &&      // this payment belongs to this order
    intent.currency === order.currency &&
    intent.amount_received >= order.amountDue
  ) {
    // Conditional write (No. 05): only a pending order can become paid.
    const paid = await tx.query(
      "UPDATE orders SET status = 'paid' WHERE id = $1 AND status = 'pending'",
      [order.id]
    );
    if (paid.rowCount === 0) return;               // already paid: don't credit twice
  }
});

Re-fetching solves several problems at once. Delayed and reordered events stop mattering, because you always act on the latest state. A forged event that somehow passed verification can’t invent a payment the API doesn’t know about. And the amount you act on comes from the API, so you can check it against what the order actually required: currency, amount and, for stablecoins, the network.

Stripe’s newer thin events carry no data.object. Parse them with parseEventNotification and load the object with fetchRelatedObject().

No. 05Make state transitions monotonic

Even with re-fetching, your own records need rules about which transitions are allowed. Model payments and orders as state machines and reject moves backwards:

  • pending → succeeded is allowed; succeeded → pending never is.
  • A refund event for a payment you have no record of goes to an investigation queue, not into the ledger.
  • Updates use conditional writes: UPDATE … WHERE status = 'pending': so two concurrent deliveries can’t both win.

No. 06Keep test and live apart

A surprisingly common production bug: the live handler accepts test-mode events. Stripe issues a separate secret per endpoint and per mode, so there the risk is a handler shared across environments or crossed configuration. With providers that let test and live share a secret, the risk is the same: anyone with test-mode access can create “payments” that production honours.

  • Use a different signing secret per environment and per endpoint.
  • Check the event’s environment flag explicitly (Stripe events carry livemode) and reject mismatches.
  • Support two active secrets during rotation so you can rotate without downtime. Stripe can keep the old secret valid for up to 24 hours and sends one signature per active secret. Rotate after any suspected leak.

No. 07Respond fast, process safely, watch it

  • Acknowledge quickly. Verify, persist the event, return 2xx, and do slow work in a queue. Handlers that time out trigger retries, which multiply load at the worst moment.
  • Fail closed. Any exception before the side effect commits returns an error so the provider retries; never return 200 from a catch block that swallowed a failure.
  • Don’t leak. Return generic errors. Stack traces from a webhook endpoint tell an attacker which checks exist.
  • IP allowlists are defence in depth, not a substitute. Stripe recommends them alongside signature checks and publishes its ranges. Ranges change, so automate the updates, and never skip the signature check.
  • Alert on anomalies: spikes in signature failures, events for objects you don’t know, and any gap where expected events stop arriving.
  • Reconcile anyway. Webhooks can be missed entirely. A scheduled job that lists recent payments from the API and compares them to your records catches what webhooks don’t.

Test planTen tests to run against your handler

  • An unsigned “payment succeeded” event is rejected with no side effects.
  • An event with a valid signature but a tampered body is rejected.
  • A valid event with a timestamp older than the tolerance is rejected.
  • The same valid event delivered twice changes state exactly once.
  • Two concurrent deliveries of the same event change state exactly once.
  • “Succeeded” delivered before “processing” leaves the order paid.
  • An event whose amount is lower than the order total does not mark it paid.
  • A test-mode event sent to the production endpoint is rejected.
  • A crash after receiving but before committing results in the event being processed again: by a provider retry if it was not acknowledged, or by a queue retry if it was.
  • A payment whose webhook never arrives is caught by the reconciliation job.

Want a second pair of eyes?

I run this review on your code, before launch.

Fixed scope, fixed fee, a written report with fixes, and a re-test once you’ve shipped them. Most reviews take five to ten working days.