Webhook Studio
/Stripe webhooks
Webhooks / Stripe

Stripe webhooks

Inspect, verify, and replay Stripe webhooks without deploying anything.

Get a test URL

Get a live Stripe webhook URL
No signup, no install. The URL works the moment you click, and every request Stripe sends is stored with its full headers and raw body.
Free, anonymous, and expires after 3 days.

Point Stripe at this URL

  1. Create a test endpoint below and copy its URL.
  2. In the Stripe Dashboard, open Developers → Webhooks → Add endpoint.
  3. Paste the Webhook Studio URL as the endpoint URL.
  4. Select the events you want to receive, then click Add endpoint.
  5. Trigger an event (Stripe CLI: stripe trigger payment_intent.succeeded) and watch it land in the Inbox.
Nothing to install. Webhook Studio sits between Stripe and your app. Every delivery is stored and inspectable, and routing rules forward the ones you care about to your real backend — with retries, and without losing the request history when your backend is down.

What a Stripe delivery looks like

A representative body, using Stripe’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
  "id": "evt_3PqR2s2eZvKYlo2C1a2b3c4d",
  "object": "event",
  "api_version": "2024-06-20",
  "created": 1721558400,
  "type": "payment_intent.succeeded",
  "livemode": false,
  "pending_webhooks": 1,
  "request": {
    "id": "req_9Kd2LmQpXn4t",
    "idempotency_key": "b1e2c3d4-5f60-4a7b-8c9d-0e1f2a3b4c5d"
  },
  "data": {
    "object": {
      "id": "pi_3PqR2s2eZvKYlo2C1a2b3c4d",
      "object": "payment_intent",
      "amount": 2400,
      "amount_received": 2400,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_QZ1a2b3c4d5e6f",
      "description": "Pro plan — monthly",
      "latest_charge": "ch_3PqR2s2eZvKYlo2C1a2b3c4d",
      "payment_method_types": [
        "card"
      ],
      "metadata": {
        "user_id": "usr_1a2b3c"
      },
      "created": 1721558397
    }
  }
}

Delivery headers

HeaderWhat it carries
Stripe-SignatureTimestamp and signatures, comma-separated: t=<unix seconds>,v1=<hex HMAC>. v1 is the only valid live scheme; a v0 signature is also sent for test events and must be ignored.
Content-TypeAlways application/json; charset=utf-8.
User-AgentStripe/1.0 (+https://stripe.com/docs/webhooks).

Event types

The events worth handling first. See Stripe’s own documentation for the complete list.

EventFires when
payment_intent.succeededA payment completes successfully. This is the event to fulfil orders on.
payment_intent.payment_failedA payment attempt fails — card declined, insufficient funds, or failed authentication.
checkout.session.completedA customer finishes a Checkout session. Arrives before the payment may have settled for async methods.
customer.subscription.createdA subscription starts, including at the end of a trial signup.
customer.subscription.updatedA subscription changes plan, quantity, or status. Also fires when a trial converts.
customer.subscription.deletedA subscription is cancelled and has reached the end of its billing period.
invoice.paidAn invoice is paid in full — the recurring-billing equivalent of payment_intent.succeeded.
invoice.payment_failedA recurring charge fails. Usually the trigger for dunning email.
charge.refundedA charge is refunded in whole or in part.
charge.dispute.createdA customer disputes a charge. Clocks start immediately, so alert on this one.

Verify the Stripe-Signature header

Stripe signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.

Node.js (Express)
import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// express.raw() matters: constructEvent hashes the exact bytes Stripe sent.
// A global express.json() upstream would parse and discard them, and every
// signature check would fail with no obvious cause.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                          // Buffer, not a parsed object
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET  // whsec_... , per endpoint
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Acknowledge first, then do the work. Stripe retries anything that is
  // slow enough to time out, and you do not want the retry to be your
  // accounting system running twice.
  res.status(200).json({ received: true });

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object;
    // fulfil(intent) — idempotently, keyed on event.id
  }
});

Retries and delivery guarantees

Stripe attempts delivery for up to three days in live mode, with exponential backoff. Events created in a sandbox are retried three times over the course of a few hours. Any 2xx counts as success; everything else — including a timeout — is a failure and will be retried, so your handler must be idempotent on event.id.

Common pitfalls

Parsing the body before verifying it

The signature is computed over the raw request bytes. Any JSON body-parser mounted ahead of the webhook route rewrites those bytes, and verification then fails for every single delivery. Mount the raw parser on the webhook path specifically, above the global one.

Doing the work before returning 200

Stripe treats a slow handler as a failed delivery and retries it. If your fulfilment runs before the response, a slow database write turns into duplicate fulfilment. Return 2xx first, then process — ideally on a queue.

Assuming events arrive in order

Stripe does not guarantee ordering. customer.subscription.updated can land before the created event it follows. Read the current state from the API when order matters, rather than reconstructing it from the event stream.

Reusing one signing secret across endpoints

Each endpoint you add in the Dashboard gets its own whsec_ secret. Pointing a second endpoint at the same handler without updating the secret produces failures that look identical to a signature bug.

Trusting the v0 signature

Stripe sends a fake v0 scheme alongside v1 for test events. Only v1 is a real signature. Hand-rolled verifiers that grab the first signature in the header can end up validating against nothing.

FAQ

How do I test Stripe webhooks without deploying?

Create a test endpoint on this page, paste its URL into the Stripe Dashboard as a webhook destination, and trigger an event. The full request — headers, raw body, and signature — appears in the Inbox immediately, with no code deployed and no account required.

Why does my Stripe signature verification keep failing?

Almost always because the body was parsed before it was verified. Stripe signs the exact bytes it sent, so a JSON body-parser running first invalidates every signature. The second most common cause is using the wrong whsec_ secret — each Dashboard endpoint has its own.

What is the tolerance on the Stripe-Signature timestamp?

Stripe's official libraries default to a 5-minute tolerance between the timestamp in the header and the current time, to blunt replay attacks. Do not set it to 0 — that disables the recency check entirely rather than tightening it.

How long does Stripe retry a failed webhook?

Up to three days in live mode with exponential backoff. Sandbox events are retried three times over a few hours. Because retries are expected, your handler must be idempotent — deduplicate on event.id.

Can I forward Stripe webhooks to localhost?

Yes. Add a routing rule on your endpoint pointing at your local tunnel, and matching events are forwarded with retries while still being stored here. You keep the full request history even when your local server was down.