Wiring a Stripe endpoint to a test URL
Create a URL above, then in the Stripe Dashboard open Developers → Webhooks → Add endpoint, paste it, and pick the events you care about. Keep test mode on. Stripe sends nothing at creation time — there is no ping event — so trigger something:
stripe trigger checkout.session.completed
Or make a real test payment. Either way the delivery lands here within a second, and the request appears in the list without a refresh.
Open the captured request and look at Stripe-Signature:
t=1750000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Two fields, comma-separated. The t is a Unix timestamp; v1 is the HMAC. Both matter. The signed string is not the body — it is the timestamp, a literal dot, then the body:
1750000000.{"id":"evt_1PfakeEventIdExample","object":"event",...}
That dot is where most first attempts go wrong. The second most common mistake is signing JSON.stringify(parsedBody) instead of the original bytes; the two differ in whitespace, in key order, and sometimes in Unicode escaping, and HMAC does not forgive any of it.
The header can also carry more than one v1 — Stripe includes both the old and the new signature during a secret rotation. Compare against every v1 present and accept if any matches.
Verify the timestamp too
A signature proves the payload came from Stripe. It does not prove when. Reject deliveries whose t is more than five minutes old, or an attacker who captured one valid request can replay it indefinitely. Stripe’s official libraries do this for you with a default tolerance of 300 seconds; hand-rolled verification usually forgets it.
Reading the payload
Every Stripe event has the same envelope — id, type, created, and data.object holding the resource that changed. Switch on type and nothing else:
switch (event.type) {
case 'checkout.session.completed':
return provision(event.data.object);
case 'invoice.payment_failed':
return startDunning(event.data.object);
default:
return; // 200, quietly
}
The default: return is not laziness. Stripe sends every event type you subscribed to, plus new ones it introduces later, and an endpoint that throws on an unrecognised type will fail deliveries you never wanted in the first place.
Expand data.object in the tree above and note how little of it you need. metadata and client_reference_id are the two fields worth planning around: they are the only way to connect a Stripe object back to a row in your own database without a lookup table.
Debugging a failing endpoint
When the Dashboard shows failed attempts, the question is which of three things happened, and each has a different fingerprint:
- Nothing arrived here at all. The URL is wrong, or a firewall or WAF blocked Stripe. Compare the URL in the Dashboard against the one on this page, character for character, including the scheme.
- It arrived but your server returned 4xx. Signature verification is failing. Verify the captured request here against the same secret your server uses — if it passes here and fails there, the difference is body handling, not the secret.
- It arrived but timed out. Your handler is doing the work inline. Acknowledge with a 200 immediately, put the event on a queue, and process it after. This is also the fix for the “worked in dev, times out in production” report: dev had one event, production has a backlog.
Copy the request as cURL and replay it against your own endpoint to test the last two without waiting for Stripe. The bytes are identical, so a handler that rejects the cURL will reject Stripe.