Home/ Blog/ Tutorial

Test Stripe webhooks locally — the complete guide.

Two real options (Stripe CLI forward vs a real tunnel), three drop-in signature-verification handlers, the events that actually matter, and how to graduate to production without redoing your work. Written from shipping Stripe billing in anger.

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:

  1. 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.
  2. 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.succeeded event.

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:

  1. Parse out t (timestamp) and v1 (signature).
  2. Construct the signed payload as {t}.{body} — the timestamp, a literal dot, then the raw request body.
  3. Compute HMAC-SHA256(webhook_secret, signed_payload) and hex-encode.
  4. Constant-time compare against v1.
  5. Reject if t is 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 your organizations.plan or 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:

  1. Your handler will run the same event more than once. Design for this. Every event has an id — use it as a dedupe key.
  2. 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.
  3. 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:

  1. Create a separate webhook endpoint in live mode at your production URL (not the tunnel URL).
  2. Copy the live whsec_ into your production config.
  3. Flip your production sk_test_sk_live_ key.
  4. Run stripe trigger in live mode to verify end-to-end (a tiny $0.50 charge on a real card is the most honest test).
  5. 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.