CommerceHook

Stripe webhooks: the raw body, the signature, and why order is not on your side

Stripe's webhooks are the best documented of any payments platform, which makes the way they break slightly embarrassing: the two failures that account for most lost afternoons are both warned about in the docs, in boxes, more than once. One is a body that got parsed before it got verified. The other is an assumption about the order events arrive in.

Verify before anything touches the body

The signed string is the timestamp, a full stop, and the raw request body exactly as sent. Compute an HMAC-SHA256 over that using the endpoint's whsec_ signing secret as the key, and compare it in constant time with the value from the header. The important word is raw. Decode the JSON into an object and serialise it again and the bytes shift, so the HMAC changes and every delivery reads as tampered with, while the payload still looks perfectly correct on screen. This is why the failure is so frustrating to diagnose: nothing is wrong with the data, only with what happened to it in transit through your own framework.

In practice that means reading the raw bytes on the webhook route before any body-parsing middleware sees it, and exempting that route from CSRF protection. A framework checking every POST for a token will refuse a request that cannot carry one.

The signature header holds more than one value

Stripe-Signature is a comma-separated list, not a single signature. A timestamp arrives under t, the real signature under v1, and on test events Stripe adds a deliberately fake v0 so that code which accepts any scheme it is offered can be caught doing it. Stripe's instruction is to ignore every scheme that is not v1, which is a downgrade defence rather than tidiness.

You can also legitimately receive several v1 values at once. Rolling an endpoint's secret can leave the previous one active for up to twenty four hours, and during that overlap Stripe signs once per live secret. Verification should therefore accept a match against any of the signatures present, not just the first. Every retry is signed afresh too, with a new timestamp, so a delivery that arrives an hour late still carries a recent one.

That timestamp is what stops a captured request being replayed at you later. Stripe's libraries reject anything more than five minutes old by default. The tolerance is adjustable and setting it to zero does not tighten the check, it switches the recency test off altogether.

Delivery is at least once, in no particular order

Both halves of that matter. Duplicates are normal, so record the evt_ id of everything you process and skip anything you have seen. Order is not promised, so an integration that waits for one event before it will accept another will eventually deadlock on a sequence Stripe was never obliged to send in that shape.

The trap sitting underneath both is created. It is recorded in whole seconds, which means distinct events share a value often enough to matter, and Stripe explicitly says not to use it either for ordering or for working out whether something has already been handled. Ids do that job. When an event turns up whose prerequisite is missing, read the object's current state from the API instead of reconstructing history from the events you happen to hold.

Retries run for three days, and a 3xx counts as a failure

In live mode Stripe retries a failed delivery on an exponential backoff for up to three days. In a sandbox it tries three times over a few hours, which is worth knowing before you conclude from a test account that retries are less patient than they are. Beyond that, resending by hand is possible for fifteen days from the Dashboard and thirty from the CLI.

What counts as failure is broader than a 500. Any redirect is one, and Stripe will not follow it, so an endpoint that answers 301 to append a trailing slash fails silently and permanently. Timeouts count, which is the reason to return 2xx first and do the work afterwards. A certificate chain Stripe cannot verify counts, as does anything below TLS 1.2. An account can hold sixteen webhook endpoints, so splitting noisy event types across several is available before splitting them inside one handler.

Which API version shapes the payload

This one catches people who have reasoned it out from first principles and got it wrong. The shape of an event is decided by the API version on your account at the moment the event occurred, not by the version your code requests and not by anything set on the endpoint in live mode. Events are also immutable: upgrading your account later leaves old events exactly as they were, and fetching one back through a newer version of /v1/events returns the original shape. Test destinations are the exception and may be set to either your default version or the latest, which makes a sandbox a reasonable place to see what an upgrade would do to you.

What a delivery tells you about itself

Unlike Square or BigCommerce, Stripe puts almost nothing in the headers. There is one that matters, and the rest of what you need is in the body.

Debugging questions

Why is my Stripe signature verification failing?
Almost always because something parsed the body before you verified it. The signature covers the timestamp and the raw request body joined by a full stop, so a body that has been decoded to an object and re-serialised no longer matches, even when it is identical JSON to a human. Express with a JSON body parser, Rails, Django and most frameworks do this by default on every route. Reach for the raw bytes on your webhook route specifically, and while you are there exempt it from CSRF protection, which will otherwise reject a request that has no token and never could have one.
Why did I receive the same event twice?
Delivery is at least once, so duplicates are expected rather than a fault. Record the event id, which starts with evt_, and ignore an id you have already processed. Do not deduplicate on the created timestamp: it is recorded in whole seconds, so two unrelated events routinely share one. Stripe also warns that some situations generate two separate Event objects for the same underlying change, and there the pair to compare is the id inside data.object together with the event type.
Why are my events arriving out of order?
Because Stripe does not guarantee order and says so plainly. Creating a subscription can deliver customer.subscription.created, invoice.created, invoice.paid and charge.created in any sequence. Write the handler so it does not care: when an event arrives whose prerequisite you have not seen, fetch the current state of the object from the API rather than waiting for the event you expected first.
Stripe says my endpoint failed, but I can see it returning a redirect.
A redirect is a failure. Stripe treats any 3xx as an unsuccessful delivery and will not follow it, so an endpoint that answers 301 to add a trailing slash, or to move from a bare domain to www, fails every delivery while looking healthy in your own logs. Register the URL the redirect resolves to. The same applies to TLS: Stripe requires version 1.2 or higher and will not negotiate below it.

Seeing the deliveries

CommerceHook gives a Stripe account a stable HTTPS destination and shows every delivery in an inspector: the event type, every header including the full signature, and the payload as a JSON tree or raw, with previous_attributes readable next to the object it changed. Because Stripe returns the signing secret when an endpoint is created, deliveries you register from the dashboard verify automatically; if you built the endpoint in the Stripe Dashboard by hand you can paste its whsec_ instead. Deliveries can be replayed at any handler, and commercehook listen streams them into the one on your laptop. Free for one endpoint, no card.