Test Stripe webhooks locally ngrok, localhost, webhook replay
Verify Stripe requests correctly on localhost using the exact headers and signing inputs used in production.
Then iterate faster with durable capture + replay (or avoid inbound entirely by consuming from a queue).
No credit card required. See pricing.
TL;DR
- If you must receive webhooks on localhost, use a tunnel (ngrok/cloudflared) with a stable HTTPS URL.
- Verify authenticity over the raw request body (bytes), before JSON parsing or any side effects.
- For Stripe, check `stripe-signature` and implement the exact signing input used by the strategy.
- If verification is timestamped, keep your laptop clock in sync (stale timestamps look like signature failures).
- Add a webhook replay workflow (saved fixtures → processing) so regressions are easy to reproduce.
- For faster iteration, avoid inbound entirely: receive in Hooque and pull messages locally from a consumer queue.
Anti-patterns
- Parsing or re-stringifying JSON before verification (you will verify different bytes).
- Hardcoding ephemeral tunnel URLs into provider settings.
- Logging secrets/signature headers or storing unsanitized fixtures with PII.
Want an end-to-end example? See payment webhooks.
Core concepts
Local webhook development fails for two predictable reasons: inbound traffic is fragile and signature verification is byte-sensitive.
Signed bytes
Most strategies verify a signature over the raw request body. Verifying a parsed object (or re-stringified JSON) will fail.
Exact inputs
This strategy uses HMAC-SHA256 over "t.payload" from stripe-signature (hex v1=...) + 5-minute timestamp window. Start by confirming the required headers are present.
Replay loop
When verification fails, capture one real request (raw body + headers), sanitize it, and replay deterministically until it passes.
Required headers (from the ingest strategy)
Confirm these are present before debugging signature math.
If you keep getting false negatives, see Webhook security (stripe).
Normal flow vs With Hooque flow
Two valid local workflows: accept inbound via a tunnel, or move inbound to a managed receiver and pull locally.
Normal local flow
- Provider emits an event
- Tunnel (ngrok/cloudflared) forwards to your laptop
- Local endpoint verifies auth over raw bytes (headers + signing string)
- Handler does side effects (DB/external APIs)
- Any slowness or 5xx → retries and duplicates
- If your laptop sleeps or your server is offline, deliveries fail and you miss real-time debugging
With Hooque
- Provider → Hooque ingest (verification at ingest time)
- Payload lands in a durable queue
- Developer pulls locally (REST next-message) or streams via SSE
- Developer acks/nacks/rejects explicitly
- Webhook replay/redelivery via UI after fixes
- Ingest stays online when your laptop is offline; events queue until you resume
Local dev checklist
Use this checklist when you are setting up tunnels, verifying signatures, and building a replay workflow.
- [ ] Choose approach:
- [ ] Tunnel inbound (ngrok/cloudflared) OR
- [ ] Managed receiver + local consumer (recommended for iteration)
- [ ] Use HTTPS and a stable URL (avoid frequent URL churn)
- [ ] Capture raw payload bytes + relevant headers (especially `stripe-signature`)
- [ ] Verify the signature/token over the same input as production (HMAC-SHA256 over "t.payload" from stripe-signature (hex v1=...) + 5-minute timestamp window)
- [ ] Enforce timestamp max-age (and tolerate small clock skew)
- [ ] Return 2xx/202 quickly; do side effects asynchronously
- [ ] Store a dedupe key (provider event id / delivery id if available) to tolerate retries/duplicates
- [ ] Add a webhook replay path (fixtures → processing) and run it in CI
- [ ] Keep secrets out of logs and sanitize fixtures before sharing Reference implementation
Two minimal pieces: local verification (strategy-specific) and a local consumer loop (pull + Ack/Nack/Reject).
A) Minimal local verification
Header names and signing strings are derived from the ingest strategy. Do not invent provider details.
Node
Express raw body + verification
// Minimal receiver: verify stripe-signature (t=..., v1=...) over raw body
// npm i express
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhook",
express.raw({ type: "*/*", limit: "2mb" }),
(req, res) => {
const secret = process.env.WEBHOOK_SECRET ?? "replace_me";
const sigHeader = req.header("stripe-signature") ?? "";
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
const payload = rawBody.toString("utf8");
if (!sigHeader) return res.status(401).send("missing signature header");
// Parse: t=123,v1=sig,v0=sig...
const items = sigHeader.split(",").map((p) => p.trim().split("="));
const t = items.find((i) => i[0] === "t")?.[1];
const v1 = items.find((i) => i[0] === "v1")?.[1];
if (!t || !v1) return res.status(401).send("malformed signature header");
// Timestamp max-age (replay protection)
const tolerance = 300;
const now = Math.floor(Date.now() / 1000);
const ts = Number.parseInt(t, 10);
if (!Number.isFinite(ts)) return res.status(401).send("bad timestamp");
if (Math.abs(now - ts) > tolerance) return res.status(401).send("stale timestamp");
// Expected: HMAC-SHA256(secret, \`\${t}.\${payload}\`) (hex)
const toSign = \`\${t}.\${payload}\`;
const expected = crypto.createHmac("sha256", secret).update(toSign, "utf8").digest("hex");
const a = Buffer.from(v1);
const b = Buffer.from(expected);
if (a.length !== b.length) return res.status(401).send("bad signature");
if (!crypto.timingSafeEqual(a, b)) return res.status(401).send("bad signature");
res.sendStatus(202);
}
);
app.listen(3000, () => console.log("listening on :3000")); Python
FastAPI raw body + verification
# Minimal receiver: verify stripe-signature (t=..., v1=...) over raw body
# pip install fastapi uvicorn
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Request, Response
app = FastAPI()
def parse_sig_header(value: str) -> dict:
parts = {}
for part in value.split(","):
part = part.strip()
if "=" not in part:
continue
k, v = part.split("=", 1)
parts[k] = v
return parts
@app.post("/webhook")
async def webhook(request: Request) -> Response:
secret = os.getenv("WEBHOOK_SECRET", "replace_me").encode("utf-8")
sig_header = request.headers.get("stripe-signature", "")
raw_body = await request.body()
payload = raw_body.decode("utf-8")
if not sig_header:
return Response("missing signature header", status_code=401)
parts = parse_sig_header(sig_header)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return Response("malformed signature header", status_code=401)
# Timestamp max-age (replay protection)
tolerance = 300
now = int(time.time())
try:
ts = int(t)
except Exception:
return Response("bad timestamp", status_code=401)
if abs(now - ts) > tolerance:
return Response("stale timestamp", status_code=401)
# Expected: HMAC-SHA256(secret, f"{t}.{payload}") (hex)
to_sign = f"{t}.{payload}".encode("utf-8")
expected = hmac.new(secret, to_sign, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
return Response("bad signature", status_code=401)
return Response(status_code=202)
# Run: uvicorn app:app --reload --port 3000 B) Minimal Hooque consumer loop (local)
Pull events locally, then explicitly Ack (done), Nack (retry later), or Reject (permanent failure / DLQ).
Node
GET next + X-Hooque-Meta + Ack/Nack/Reject
// Minimal local consumer loop (REST pull):
// 1) GET .../next
// 2) parse X-Hooque-Meta (ackUrl/nackUrl/rejectUrl)
// 3) POST ackUrl on success, otherwise nack/reject
//
// Node 18+ (fetch built-in)
const QUEUE_NEXT_URL =
process.env.HOOQUE_QUEUE_NEXT_URL ??
"https://app.hooque.io/queues/cons_dev_webhooks/next";
const TOKEN = process.env.HOOQUE_TOKEN ?? "hq_tok_replace_me";
const headers = { Authorization: `Bearer ${TOKEN}` };
async function processLocally(payload) {
// TODO: call your local code paths here
console.log("received event:", payload?.type ?? payload?.id ?? "event");
}
while (true) {
const resp = await fetch(QUEUE_NEXT_URL, { headers });
if (resp.status === 204) break; // queue is empty
if (!resp.ok) throw new Error(`Hooque next() failed: ${resp.status}`);
const payload = await resp.json();
const meta = JSON.parse(resp.headers.get("X-Hooque-Meta") ?? "{}");
try {
await processLocally(payload);
await fetch(meta.ackUrl, { method: "POST", headers });
} catch (err) {
const reason = String(err);
const isPermanent = err?.name === "ValidationError";
const url = isPermanent ? meta.rejectUrl : meta.nackUrl;
await fetch(url, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
}
} Python
GET next + X-Hooque-Meta + Ack/Nack/Reject
# Minimal local consumer loop (REST pull):
# 1) GET .../next
# 2) parse X-Hooque-Meta (ackUrl/nackUrl/rejectUrl)
# 3) POST ackUrl on success, otherwise nack/reject
import json
import os
import requests
QUEUE_NEXT_URL = os.getenv(
"HOOQUE_QUEUE_NEXT_URL",
"https://app.hooque.io/queues/cons_dev_webhooks/next",
)
TOKEN = os.getenv("HOOQUE_TOKEN", "hq_tok_replace_me")
headers = {"Authorization": f"Bearer {TOKEN}"}
def process_locally(payload: dict) -> None:
print("received event:", payload.get("type") or payload.get("id") or "event")
# TODO: call your local code paths here
while True:
resp = requests.get(QUEUE_NEXT_URL, headers=headers, timeout=30)
if resp.status_code == 204:
break
if resp.status_code >= 400:
raise RuntimeError(f"Hooque next() failed: {resp.status_code} {resp.text}")
payload = resp.json()
meta = json.loads(resp.headers.get("X-Hooque-Meta", "{}"))
try:
process_locally(payload)
requests.post(meta["ackUrl"], headers=headers, timeout=30)
except Exception as err:
reason = str(err)
is_permanent = err.__class__.__name__ == "ValidationError"
url = meta["rejectUrl"] if is_permanent else meta["nackUrl"]
requests.post(
url,
headers={**headers, "Content-Type": "application/json"},
json={"reason": reason},
timeout=30,
) Common failure modes
Most “it fails locally” issues are header mismatch, raw-body mismatch, or timestamp window problems.
401 / missing signature header
Likely causes
- Provider is not configured to sign requests for this endpoint.
- You are reading the wrong header name (case/typo/incorrect environment).
- A proxy/tunnel is stripping or renaming headers.
Next checks
- Log only header names (not values) and confirm `stripe-signature` are present.
- Verify your tunnel forwards headers unchanged.
- Make sure you are testing the same provider environment (test vs prod).
Timestamp expired / replay protection triggers
Likely causes
- Laptop clock skew.
- You are replaying an old payload without updating the timestamp.
- A tunnel retry delivered the same request later.
Next checks
- Sync your clock (NTP) and confirm the max-age tolerance.
- When replaying, re-sign with a fresh timestamp.
- If you need deterministic tests, use fixtures + controlled replays rather than provider retries.
Signature mismatch locally, but works elsewhere
Likely causes
- You verified parsed JSON instead of raw body bytes.
- Whitespace/newline changes (body parser differences) changed the signed bytes.
- Wrong secret/public key (environment mismatch).
Next checks
- Capture the raw body bytes and verify against that exact byte sequence.
- Ensure your handler uses raw-body middleware before any parsing.
- Rotate or re-copy the secret and confirm you are using the correct one.
How Hooque helps
Hooque changes local development from “debug inbound tunnels” to “consume verified messages and replay deterministically.”
- Verify at ingest: only verified Stripe events enter your queue.
- Local consumer loop: pull events to localhost and Ack/Nack/Reject explicitly (no inbound required for many workflows).
- Durable capture + inspection: keep the exact payload and metadata that failed, then replay after a fix.
- Strategy-specific docs: security and retries & backoff.
- See a real workflow: payment webhooks. Compare plans on pricing.
FAQ
Common questions that come up when you are trying to verify signatures and test webhook delivery on localhost.
How do I receive Stripe webhooks on localhost?
General: Use a tunnel (ngrok/cloudflared) to forward a stable HTTPS URL to your local server, then point the provider’s webhook URL at that tunnel endpoint. How Hooque helps: You can avoid inbound exposure entirely by receiving in Hooque and consuming locally via REST pull or SSE stream (great for fast iteration).
Why does verification fail only on my laptop?
General: Local dev often has clock skew, different body parsing, or different proxy behavior. Timestamped signatures will fail if your clock is off or you replay an old request without re-signing. How Hooque helps: With Hooque, verification can happen at ingest time and you can replay the exact captured payload deterministically without relying on inbound tunnel retries.
Do I need the raw request body to verify signatures?
General: Yes in most schemes. If the provider signs raw bytes, verifying a parsed object or re-stringified JSON will fail. Capture the raw bytes before any middleware mutates them. How Hooque helps: Hooque verifies signatures at ingest time and can deliver verified payloads to your consumer queue, so your local worker can focus on processing.
What headers should I log while debugging?
General: Log header names and a request ID if present, but avoid logging secrets. For this strategy, `stripe-signature` are the first place to check. How Hooque helps: Hooque stores the delivery metadata alongside the payload and gives you an inspectable history, reducing the need for verbose logging in your local tunnel.
How do I safely do webhook replay during local development?
General: Store sanitized fixtures (raw body + required headers) and replay them through your processing code. Make processing idempotent so repeated replays are safe. How Hooque helps: Hooque provides durable storage and controlled redelivery, so you can replay a specific failed delivery after a fix and track outcomes (Ack/Nack/Reject).
ngrok vs Cloudflare Tunnel: which should I use?
General: Pick the one that gives you a stable HTTPS URL and forwards headers and raw bodies reliably. The best option is the one your team can keep consistent across dev machines. How Hooque helps: If tunnels are flaky or slow, Hooque’s “receive once, consume locally” workflow removes inbound tunnels from your hot loop.
What status code should I return while debugging?
General: Return 2xx/202 quickly after successful verification, then process asynchronously. Avoid returning 5xx for known-bad payloads or you will trigger retries and duplicates. How Hooque helps: With explicit Ack/Nack/Reject, you can separate “accepted” from “processed”, and replay without relying on provider retry behavior.
Start processing webhooks reliably
Capture every webhook, persist instantly, and consume via REST or SSE with full Ack/Nack/Reject control.
No credit card required