I wrote the Stripe webhook handler for 21tunnel two weeks ago. The handler is 120 lines of Rust; everything around it — local testing, signature verification, idempotency, swapping test mode for live — ate more time than the handler itself. This post is the guide I wish I'd had.
The two problems
Testing Stripe webhooks locally combines two unrelated problems that people conflate:
- Delivery. Stripe's servers are on the public internet; your laptop isn't. You need a way to get the HTTP POST from them to your dev server.
- Verification. Once the POST arrives, you
need to prove it really came from Stripe and the body
hasn't been tampered with. Otherwise anyone who can
reach your public URL can forge a
payment_intent.succeededevent.
Different tools solve different halves. The Stripe CLI solves delivery. A tunneling service also solves delivery. Neither of them solves verification — you do that yourself with the Stripe library for your language, no matter how the request got to you.
Option A — Stripe CLI forward
Stripe ships an official CLI that reaches out to Stripe's API, subscribes to events, and forwards them to a local URL. No tunnel required.
# one-time install
brew install stripe/stripe-cli/stripe
stripe login
# start forwarding events to your dev server
stripe listen --forward-to localhost:3000/webhooks/stripe
# output includes a webhook signing secret, ONLY valid for this session:
# > Ready! Your webhook signing secret is whsec_abc123... The CLI polls Stripe, pulls events down, and POSTs them to your localhost — so your webhook handler runs against real Stripe events without your laptop ever being publicly reachable.
Good: zero infrastructure. Every request
has a valid Stripe signature (signed with the session-only
whsec_ the CLI hands you). Works on corporate
Wi-Fi, airplane Wi-Fi, anywhere you have outbound HTTPS.
You can trigger events on demand with
stripe trigger payment_intent.succeeded.
Limits: only you can forward — a teammate can't hit your URL because the URL is localhost. Doesn't help if you need Stripe and other webhooks (GitHub, Shopify) to reach the same service. The signing secret rotates every session, so you can't test the long-term storage of webhook events.
Option B — a real tunnel
Run a tunneling service so your localhost has a public URL. Give Stripe that URL in the dashboard (or via the API). Now Stripe's production infrastructure POSTs directly to your laptop, signed with your real webhook secret.
# Start a tunnel (any of these work):
tunnel21 http 3000 # 21tunnel
ngrok http 3000 # ngrok
cloudflared tunnel --url http://localhost:3000 # Cloudflare Tunnel
# Copy the HTTPS URL the agent prints.
# In Stripe: Dashboard → Developers → Webhooks → Add endpoint
# URL: https://yoursubdomain.21tunnel.app/webhooks/stripe
# Events: select the ones you care about (we list them below)
# Save, reveal "Signing secret", copy the whsec_... Good: identical code path to production.
The whsec_ you test against is the same shape
as the one you'll use in prod (though scoped to test
mode). Teammates can share the URL, multiple services can
post to it, retries work end-to-end.
Limits: the URL changes every time your tunnel restarts — unless you pay for a reserved subdomain or self-host. Stripe will keep firing at the old URL until you update it, collecting errors in the dashboard.
Which to pick
Plain rules:
- Solo dev, just getting signatures to verify → Stripe CLI. Fastest, most ergonomic.
- Shared dev environment → tunnel with a reserved subdomain. Multiple teammates can point Stripe at the same URL.
- Testing lots of SaaS webhooks at once (Stripe + GitHub + Shopify + Twilio) → tunnel. One URL receives them all.
- CI preview deploys → tunnel, because CI doesn't have a Stripe CLI session.
I usually run both at once — Stripe CLI for my own quick iteration, tunnel for end-to-end tests against the exact URL pattern I'll use in staging and prod.
Verify the signature — always
Stripe signs every webhook with HMAC-SHA256 over a canonical string. The signature shows up as a header like:
Stripe-Signature: t=1712345678,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=... Verification recipe:
- Parse out
t(timestamp) andv1(signature). -
Construct the signed payload as
{t}.{body}— the timestamp, a literal dot, then the raw request body. - Compute
HMAC-SHA256(webhook_secret, signed_payload)and hex-encode. - Constant-time compare against
v1. - Reject if
tis more than ~5 minutes old (replay protection).
Use the library. Every official Stripe SDK implements this correctly and in constant time. Don't write this yourself — the one time I tried, I forgot the timestamp freshness check and shipped a replay vulnerability for three days.
Drop-in handlers
Node.js / Express
The crucial trick: Stripe signs the raw body.
Express's default JSON parser consumes the body and
re-serializes it, which breaks the signature. Use
express.raw() just for the webhook route:
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const whsec = process.env.STRIPE_WEBHOOK_SECRET;
// NOTE: raw body, not json. Only for this route.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, raw bytes
req.headers["stripe-signature"],
whsec
);
} catch (err) {
return res.status(400).send("bad signature: " + err.message);
}
switch (event.type) {
case "checkout.session.completed":
// TODO: mark order paid. Use event.data.object.id as dedupe key.
break;
case "invoice.payment_failed":
// TODO: email the customer, flip subscription to past_due.
break;
}
res.json({ received: true });
}
); Python / Flask
Flask has the opposite problem — by default,
request.data gives you the raw body, which is
what you want. No middleware gotchas.
import stripe
from flask import Flask, request, jsonify
app = Flask(__name__)
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
whsec = os.environ["STRIPE_WEBHOOK_SECRET"]
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
payload = request.data # raw bytes
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, whsec)
except ValueError:
return "bad payload", 400
except stripe.error.SignatureVerificationError:
return "bad signature", 400
if event["type"] == "customer.subscription.updated":
sub = event["data"]["object"]
# TODO: update local DB with sub["status"], sub["current_period_end"]
pass
return jsonify(received=True) Rust / axum
No official Stripe SDK for Rust covers webhooks completely, so we hand-roll HMAC verification. This is essentially what ships in the 21tunnel codebase:
use axum::{body::Bytes, http::HeaderMap, response::IntoResponse};
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub async fn webhook(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
let whsec = std::env::var("STRIPE_WEBHOOK_SECRET").expect("whsec");
let sig = headers.get("stripe-signature")
.and_then(|v| v.to_str().ok()).unwrap_or("");
// Parse t= and v1= out of the header.
let (mut ts, mut v1) = ("", "");
for kv in sig.split(',') {
if let Some(v) = kv.strip_prefix("t=") { ts = v; }
if let Some(v) = kv.strip_prefix("v1=") { v1 = v; }
}
// HMAC-SHA256 over `t.payload`.
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(whsec.as_bytes()).unwrap();
mac.update(ts.as_bytes());
mac.update(b".");
mac.update(&body);
let expected: String = mac.finalize().into_bytes()
.iter().map(|b| format!("{:02x}", b)).collect();
// Constant-time compare.
if expected.len() != v1.len() { return (401, "bad sig").into_response(); }
let mut diff = 0u8;
for (a, b) in expected.as_bytes().iter().zip(v1.as_bytes()) { diff |= a ^ b; }
if diff != 0 { return (401, "bad sig").into_response(); }
// Parse the event + dispatch...
let event: serde_json::Value = serde_json::from_slice(&body).unwrap();
// match on event["type"], update your DB, etc.
(200, "ok").into_response()
} Events you actually care about
Stripe emits 200+ event types. You usually need 5-10. The ones that cover most billing flows:
checkout.session.completed— the customer finished Checkout. This is where you provision access to your product.customer.subscription.created/.updated/.deleted— subscription lifecycle. Update yourorganizations.planor equivalent here.invoice.payment_succeeded— the customer paid an invoice. Useful for receipts, not strictly required if you rely on subscription status.invoice.payment_failed— retry logic. Usually flip the account to past_due with a grace period.customer.subscription.trial_will_end— 3 days before trial ends. Email the customer.charge.refunded— money went back. Revoke what you provisioned.
Subscribe to exactly these in the webhook endpoint settings. More events = more noise = more rate-limiting pressure on your handler for no benefit.
Idempotency + retries
Stripe retries a webhook for up to 3 days if you don't return a 2xx. That means:
- Your handler will run the same event more than once.
Design for this. Every event has an
id— use it as a dedupe key. - Slow handlers get retried. If your handler takes 20 seconds because it's waiting on Postgres, Stripe's 30-second timeout fires and you get a duplicate. Acknowledge fast, do work async.
- Failed handlers stay broken quietly. Stripe's dashboard shows a “webhook attempts” page. Check it. Silent failures in production are the worst.
The dedupe pattern we use (same as the 21tunnel billing table):
CREATE TABLE billing_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stripe_event_id VARCHAR(255) UNIQUE NOT NULL, -- dedupe key
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- on webhook:
INSERT INTO billing_events (stripe_event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (stripe_event_id) DO NOTHING;
If the INSERT returned zero rows, it's a
duplicate — ack with 200 and skip the rest of the handler.
If it inserted, you're the first; process the event
and return 200 when done. First-write-wins, guaranteed
by Postgres.
Graduating to production
The single mistake people make here is switching from
test-mode to live-mode keys without switching the webhook
signing secret. Test-mode and live-mode have
different whsec_ values; a live-mode
event signed with the live secret will fail verification
against your test-mode secret, and you'll see a wall
of 400s in the Stripe dashboard.
The production checklist:
- Create a separate webhook endpoint in live mode at your production URL (not the tunnel URL).
- Copy the live
whsec_into your production config. - Flip your production
sk_test_→sk_live_key. - Run
stripe triggerin live mode to verify end-to-end (a tiny $0.50 charge on a real card is the most honest test). - Set up alerting on the Stripe dashboard's webhook failure count.
On our end, 21tunnel self-host
uses exactly this pattern — test vs live keys live at
different /etc/qnt/stripe.key paths, the
webhook secret at /etc/qnt/stripe-webhook.key,
and the server config switches them with one line in
server.toml. The build log
walks through how it hooks together.
One-paragraph summary. Use Stripe CLI
for 80% of dev work. Use a tunnel when you need a real
URL, a shared endpoint, or end-to-end tests against
production-shape infrastructure. Always verify the
signature with the official library; always dedupe on
stripe_event_id at the database level;
always ack fast and process async if the handler is
slow. That's most of what's hard.
If you're reaching for a tunnel now: 21tunnel has a free Hobby tier (3 tunnels, custom domain on signup, no rate cap) — handy for Stripe testing because the reserved subdomain means your webhook URL stays stable across restarts. Or compare tunneling options in our roundup.