You built a dead-letter queue. Your Next.js app, Express server, or AWS Lambda crashed on a Stripe customer.subscription.updated event, your queue caught the failed payload, and you spent 30 minutes fixing a database migration bug.
You deploy the fix, trigger a replay... and your application immediately throws:
Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)
Every backend developer who attempts to build a resilient webhook ingestion pipeline runs headfirst into this wall.
Here is why it happens, why common workarounds introduce severe vulnerabilities, and how to architect a zero-loss ingress buffer that downstream handlers can verify without code changes.
## 1. Anatomy of the 300-Second Signature Window
When Stripe dispatches a webhook, it includes the Stripe-Signature header containing a Unix timestamp and an HMAC-SHA256 hash:
Stripe-Signature: t=1711234567,v1=5257a869e7eceeda32a1a243a0b7...
The signature v1 is computed across both the timestamp and the raw unparsed JSON payload:
v1 = HMAC-SHA256(webhook_secret, `${t}.${rawBody}`);
When you call stripe.webhooks.constructEvent(body, sig, secret) in your backend, the SDK performs two independent checks:
-
Cryptographic Authenticity: Does
v1match the HMAC of${t}.${rawBody}using your secret? -
Replay Protection: Is
Math.abs(Date.now() / 1000 - t) <= 300(within 5 minutes)?
### The Conflict
Replay attacks and legitimate Dead-Letter Queue (DLQ) replays look mathematically identical to Stripe's SDK. If your queue holds an event for longer than 300 seconds (5 minutes), standard signature verification will reject it every single time.
*## 2. Why Common Workarounds Fail
*
Bad Fix 1: Disabling verification on retried events
Some teams add a header like x-replayed: true and skip constructEvent() if the header is present.
Why it fails: Anyone who discovers your webhook URL can send a forged customer.subscription.created payload with x-replayed: true and grant themselves free access without ever hitting Stripe.
Bad Fix 2: Bumping the SDK tolerance window
// Dangerous: Opens a 3-day replay vulnerability
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);
Increasing tolerance to 72 hours allows retries to pass, but completely guts replay protection across your entire billing infrastructure, allowing stale or intercepted requests to be replayed.
## 3. The Solution: Ingress Verify & Outbound Re-Signing
The correct architecture decouples Ingress Verification from Internal Delivery:
[Stripe]
│
▼ (Original Stripe Signature, t=now)
[Ingress Proxy] ── verifies signature within 300s window
│
├─► Immediate 200 OK back to Stripe (stops retry timeouts)
│
▼ (Persisted to durable SQLite / WAL storage)
[Outbound Dispatcher]
│
▼ (Fresh HMAC + Fresh Timestamp t=now + Provenance Headers)
[Downstream Handler (Your App)] ── constructsEvent() passes cleanly!
### The Step-by-Step Flow:
-
At Ingress (<10ms): The proxy receives the webhook from Stripe. It uses your endpoint's signing secret to verify the original signature within the 300s tolerance window. It rejects fake requests upfront and acknowledges Stripe with an immediate
200 OK. - Durable Storage: The uncorrupted payload and metadata are written to persistent local storage (e.g. SQLite with WAL mode).
-
At Forward / Replay: When delivering the event to your backend (whether 5ms later or 3 days later from a DLQ), the proxy computes a fresh timestamp
t = Math.floor(Date.now() / 1000)and generates a validv1HMAC using your secret.
The Re-Signing Function:
const crypto = require('crypto');
function signStripeHeaders(rawBody, headers, secret) {
const freshTimestamp = Math.floor(Date.now() / 1000);
const freshSignature = crypto
.createHmac('sha256', secret)
.update(`${freshTimestamp}.${rawBody}`)
.digest('hex');
return {
...headers,
'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}`
};
}
## 4. Protecting Against Out-of-Order Overwrites
One risk with replaying delayed webhooks is that a replayed customer.subscription.deleted from 2 days ago might arrive after a fresh customer.subscription.created event from today.
To prevent stale state from overwriting fresh state downstream, the ingress proxy should attach explicit provenance metadata:
x-hookarmor-is-replay: true
x-hookarmor-original-timestamp: 1711234567
x-hookarmor-delivery-id: del_8f92b1c
x-hookarmor-original-stripe-signature: t=1711234567,v1=...
Your downstream app can check x-hookarmor-original-timestamp against your database record's updated_at before applying changes.
## 5. The Outcome: Zero Code Changes in Your App
Because the replayed request arrives with a fresh timestamp and a valid HMAC, your backend handler remains completely stock:
// In your Next.js / Express route:
export async function POST(req) {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
// Passes on initial delivery AND on replay days later:
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
await handleBilling(event);
return Response.json({ received: true });
}
Open Source Solution: HookArmor
I packaged this exact pattern into HookArmor, an open-source (MIT), single-binary / Docker reverse proxy:
- Instant
200 OKupstream + local SQLite (WAL) buffer. - Automatic exponential retry backoff.
- Embedded Dead-Letter Queue (DLQ) with a web UI for 1-click manual replays.
- Automatic transparent Stripe re-signing with provenance headers.
- Zero external database dependencies.
You can run it in front of your app with Docker Compose:
version: '3.8'
services:
hookarmor:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install -g hookarmor && hookarmor start --port 8080 --target http://my-app:3000/webhooks"
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- HOOKARMOR_TARGET_URL=http://my-app:3000/webhooks
- HOOKARMOR_UI_PASSWORD=admin
GitHub: https://github.com/pkdoddamani/hookarmor
npm: https://www.npmjs.com/package/hookarmor
How does your team currently handle delayed webhook retries and signature expiration? Would love to hear other architectural approaches in the comments!
Top comments (0)