Go to content
Web development

Verifying Webhook Signatures Without Breaking Retries

A webhook is an HTTP request a provider sends to your server when something happens: a payment succeeds, an account is created, a subscription changes. Because the request travels over the public internet, the provider signs it with a secret that only the provider and your server know. Your endpoint verifies that signature before acting. Most providers also retry deliveries that your server does not acknowledge with a 2xx response, and a retry can arrive hours or days after the original attempt.

A signature check built to reject forged requests can also reject legitimate retries if it validates the wrong value or runs at the wrong layer. A handler that returns an error for a duplicate keeps the provider’s retry loop alive. The reliable design has two layers. At the HTTP boundary, verify the signature and a timestamp window to block forgeries and replay attacks. Below that, make event processing idempotent, meaning handling the same event twice produces the same result, and return 2xx for events you have already processed so the provider stops retrying.

Stripe’s scheme is the most widely documented, so the examples here use it. The same two layers apply to any provider that signs with a shared secret.

What the signature covers and why the raw body matters

The signature is an HMAC-SHA256 digest computed from the exact bytes of the request body, a timestamp, and a shared secret that only the provider and your server hold. HMAC, short for hash-based message authentication code, is a keyed hash. The provider computes the digest and sends it in the Stripe-Signature header. Your server recomputes the digest with the same secret and compares the two. If an attacker modifies the body, the digest no longer matches. If an attacker does not know the secret, they cannot produce a valid digest for a forged body.

The header carries the timestamp and one or more signatures in this shape:

Stripe-Signature: t=1700000000,v1=abc123def456,v0=test

The t value is the Unix time, in seconds, when the provider signed that delivery. The v1 value is the signature scheme followed by the digest. During secret rotation, the header contains one v1 entry per active secret, so you may see two v1 values. Test events also include a fake v0 entry. Only v1 is a valid live scheme; ignore anything that is not v1, otherwise an attacker could downgrade the verification by sending a signature your code accepts without checking.

Because the digest covers the exact bytes of the body, you must verify before any JSON parsing. If your framework parses the body into an object and you re-serialize it, verification fails even though the payload is semantically identical, because the digest was computed over the original string. Stripe’s documentation is explicit that verification requires the raw request body. In Express, mount express.raw({ type: 'application/json' }) on the webhook route so req.body stays a Buffer, and pass req.body.toString('utf8') to the verification function. Keep express.json() for the rest of the app so the raw bytes survive on this route only.

A valid signature does not prove that an event is new, that events arrived in order, or that this is the first delivery. Stripe does not guarantee event ordering, and the created timestamp inside the payload is not a reliable ordering or deduplication key. A captured signed request can also be replayed later, because the signature still verifies; a valid webhook signature can still be a replay attack. The timestamp window and the idempotency layer handle those cases.

Check the timestamp window before the signature

The timestamp exists for replay protection. A replay attack is when someone captures a valid signed request and sends it again later to trigger the side effect a second time. The timestamp lets you bound how old a delivery may be. If the request claims to have been signed longer ago than the allowed tolerance, reject it without computing the digest.

Check the timestamp before the signature. If the timestamp is missing or outside the tolerance, return 403. If the timestamp is fine but no signature matches, return 401. That is the verification order Stripe’s spec prescribes, and it saves you the HMAC work on obviously stale requests.

The default tolerance in Stripe’s SDKs is five minutes. Use the same value for manual verification. Compute the absolute difference between the current Unix time and the t value, and reject the request when that difference exceeds the tolerance. Your server clock needs to be accurate, because the comparison runs against your local time.

Two mistakes break retries at this layer. The first is comparing against the event’s created timestamp inside the payload instead of the t value in the header. The payload timestamp is the event time, not the delivery time, and it will be far older than five minutes for most events. The second is running the timestamp check after enqueueing the request for background processing. The window measures delivery age, so verify at the moment the request arrives, before any queue delay counts against it.

One open question is whether a provider signs each delivery attempt or resends the original signature on retries. The timestamp is part of the signed message, so a retry carries either the original t with the original signature, or a fresh t with a newly computed signature. A provider that ships an SDK applying a five-minute window must sign each attempt freshly. Otherwise, its own retries would fail its recommended verification. Confirm the behavior in staging before you enforce the window in production, because the cost of being wrong is rejected retries.

Here is the verification function in Node.js:

const crypto = require('crypto');

// Stripe-Signature header: t=1700000000,v1=abc123,v1=def456,v0=test
function parseSignatureHeader(header) {
  const parts = {};
  for (const item of header.split(',')) {
    const separator = item.indexOf('=');
    const key = item.slice(0, separator);
    const value = item.slice(separator + 1);
    (parts[key] = parts[key] || []).push(value);
  }
  return parts;
}

function verifyStripeSignature(rawBody, header, secrets, toleranceSeconds) {
  const parts = parseSignatureHeader(header);

  if (!parts.t || parts.t.length !== 1) {
    return { valid: false, status: 403, reason: 'missing timestamp' };
  }

  const timestamp = Number(parts.t[0]);
  if (!Number.isFinite(timestamp)) {
    return { valid: false, status: 403, reason: 'invalid timestamp' };
  }

  const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
  if (ageSeconds > toleranceSeconds) {
    return { valid: false, status: 403, reason: 'timestamp outside tolerance' };
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const v1Signatures = parts.v1 || [];

  for (const secret of secrets) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');

    for (const candidate of v1Signatures) {
      const expectedBuffer = Buffer.from(expected);
      const candidateBuffer = Buffer.from(candidate);
      if (
        expectedBuffer.length === candidateBuffer.length &&
        crypto.timingSafeEqual(expectedBuffer, candidateBuffer)
      ) {
        return { valid: true, timestamp };
      }
    }
  }

  return { valid: false, status: 401, reason: 'signature mismatch' };
}

The function computes HMAC-SHA256 over ${timestamp}.${rawBody} and compares it against every v1 signature with timingSafeEqual, which runs in constant time so the comparison does not leak information about the digest. The secrets array handles rotation. Pass the current secret and the previous one, and a delivery signed with either validates.

Stripe recommends its official libraries for this exact flow, and they exist for most languages. The manual version above shows what those libraries do internally, which is what you need when you write your own or audit an existing implementation.

Make retries harmless: idempotent processing and 2xx responses

Providers decide whether a delivery succeeded by the HTTP status code your endpoint returns. A 2xx response means the event was handled; anything else means the provider will try again on its schedule. Stripe retries failed deliveries with exponential backoff for up to three days in live mode, and test-mode events are retried three times within a few hours. You can also resend an event manually from the dashboard for up to 15 days after creation, or up to 30 days via the CLI, and a manual resend does not stop automatic retries. The practical consequence is that the same event can arrive again long after you last saw it.

Duplicates are a normal part of webhook delivery. Exactly-once delivery is impossible over a network; providers deliver at least once, so your endpoint must make repeated deliveries harmless. The standard term is effectively-once processing, and you achieve it with idempotency. The same event ID processed twice produces the same result, and the second processing is a no-op.

The clean way to make a webhook handler idempotent is to claim the event ID in the database inside the same transaction as the business logic. A processed_events table in Postgres with event_id as the primary key gives you the claim. The first insert succeeds, a duplicate insert violates the primary key. If the business logic throws, the rollback removes the claim, so the provider’s retry re-runs the logic. If a duplicate arrives while the first request is still running, the insert waits for the first transaction to finish. If the first committed, the insert reports zero rows and the handler returns 200. If the first rolled back, the insert proceeds and this request runs the logic.

CREATE TABLE processed_events (
  event_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
const { Pool } = require('pg');
const pool = new Pool();

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.headers['stripe-signature'];
  const secrets = [
    process.env.STRIPE_WEBHOOK_SECRET,
    process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
  ].filter(Boolean);

  const result = verifyStripeSignature(rawBody, header, secrets, Number(process.env.WEBHOOK_TOLERANCE_SECONDS));
  if (!result.valid) {
    return res.status(result.status).end();
  }

  const event = JSON.parse(rawBody);
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const claim = await client.query(
      `INSERT INTO processed_events (event_id)
       VALUES ($1)
       ON CONFLICT DO NOTHING`,
      [event.id]
    );

    if (claim.rowCount === 0) {
      // Another request already handled this event.
      await client.query('ROLLBACK');
      return res.status(200).end();
    }

    await applyBusinessLogic(event, client);

    await client.query('COMMIT');
    return res.status(200).end();
  } catch (err) {
    await client.query('ROLLBACK');
    // The claim is gone; the provider retry will run the logic.
    return res.status(500).end();
  } finally {
    client.release();
  }
});

applyBusinessLogic must use the passed client for its writes so they commit or roll back together with the claim. The two details that prevent double billing are the ON CONFLICT DO NOTHING insert and the single transaction. The common buggy pattern is to check whether the event ID exists, run the business logic, then record the event ID. Two concurrent requests can both pass the existence check before either records the ID, and both run the side effect. The database-level unique constraint is the pattern recommended for production because the database, not your application code, decides which request wins.

Returning 200 for an already-processed event is what stops the retry loop. If you return an error for a duplicate, the provider keeps retrying, which adds load and can resurface long after you thought the event was done. A duplicate that you skip should still get a 2xx response so the provider considers the delivery complete.

Two refinements matter at this layer. First, if your business action must run once per underlying object rather than once per event, know that some providers emit two event objects for one operation, such as invoice.paid and charge.succeeded for the same payment. Identify those duplicates by the object ID from data.object and the event type, and key your dedup on that pair. Second, if you return 200 before processing and hand the event to a queue, the provider will not retry on processing failure, so the queue must be durable and have its own retry and dead-letter policy for messages that keep failing. Processing webhooks asynchronously and acknowledging quickly is the right call at high volume, but only when the queue owns retries from that point. If the business logic calls a downstream API that supports idempotency keys, derive the key from the event ID and pass the same key on every retry. A fresh random key on a retry makes the downstream call non-idempotent and can double the side effect.

Logging follows the same boundary. Log the event ID, event type, and the status code you returned. Do not log full headers or body content, which can contain card data and tokens. Keeping webhook logs limited to non-sensitive metadata keeps debugging possible without turning your logs into a data breach.

Rotation, test signatures, and provider differences

When you rotate the signing secret, the old secret remains valid for up to 24 hours, and during that overlap the header contains one v1 signature per active secret. If the verification code checks only the newest secret, deliveries signed with the old secret fail for up to a day. Pass both secrets in the secrets array, as the example above does, and the loop accepts either signature. Keep the previous secret in the array for a day, then remove it.

The fake v0 signature in test events exists so test-mode payloads do not accidentally pass a live verification. The parse function above collects only v1 entries, so a v0 value never reaches the comparison. Do not add fallback handling for other schemes. Accepting them creates a downgrade path.

If your framework applies CSRF protection, such as Rails or Django, exempt the webhook endpoint. CSRF tokens do not exist in webhook requests, and the framework’s check will reject legitimate events before your signature verification runs. The signature header is the authentication mechanism for this endpoint.

PayPal works differently. It does not sign with a shared-secret HMAC that you recompute locally. Instead, PayPal requires you to verify every webhook notification by calling its verify-webhook-signature endpoint and passing the values from the incoming headers and the raw event body: auth_algo, cert_url, transmission_id, transmission_sig, transmission_time, webhook_id, and webhook_event. The endpoint returns verification_status=SUCCESS with HTTP 200 when the signature checks out. This is a synchronous outbound call from your handler, so it adds latency and a runtime dependency on PayPal. It also verifies only the signature. It does not give you a timestamp window, so track transmission_id yourself and reject a transmission ID you have already seen. Otherwise, a captured request can be replayed.

A few providers offer mutual TLS as an alternative to signatures. It is stronger because the transport itself authenticates the client, but it adds certificate provisioning and renewal on both sides, and most integrations still rely on signature verification as the baseline.

More in Web development Back to top ↑