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.
Stripe-Signature: the timestamp and one or more signatures, as aboveid: theevt_identity to deduplicate ontype: the event name, for examplepayment_intent.succeededorcharge.dispute.createdlivemode: true or false, so a captured event is never ambiguous about which side it came fromapi_version: the version the payload was rendered indata.object: the resource itself, as it stood when the event happeneddata.previous_attributes: on an update, only the fields that changed and what they were
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.