Two different problems
A delivered webhook is what moves a purchase — a renewal, a refund or a lapse — and therefore what an entitlement resolves to on the next request. Every event and status is in the reference.
Authenticity — did this really come from the store, and is it recent? That is the signature’s job, and it is a verification strategy like any other in the kit.
Uniqueness — have we already acted on this? That is a database constraint’s job.
Conflating them is the classic webhook bug, and the kit separates them by construction: the verifier proves a delivery is authentic and fresh, never that it is new. Inside the freshness window, a captured delivery replays as many times as it is sent. Something else has to catch that.
The signature scheme
One header carries both halves of the proof:
Stripe-Signature: t=1785931200,v1=5257a8…The timestamp the delivery claims, then one hex HMAC-SHA256 per signature, each computed over <timestamp>.<body>.
That is Stripe’s format, and the kit uses it deliberately — because it is the one every other sender copied, and because it gets four things right:
The timestamp is inside the signed payload. A signature with no freshness window is valid forever: whoever captures one delivery replays it for as long as the secret lives. The window only closes that if it cannot be stepped around, and it cannot — re-dating a captured delivery invalidates its own signature. A timestamp in a second header, or beside the signature but outside it, would be a suggestion.
The window is checked in both directions. A delivery dated far in the future is a clock problem or a crafted one, and neither is worth acting on. Accepting it would also hand a forger an unbounded replay window the moment a secret leaked. The tolerance is 300 seconds.
The comparison is crypto.subtle.verify. Comparing HMACs with === leaks how many leading bytes matched, and that leak is enough to forge a signature one byte at a time. WebCrypto does the comparison in constant time, in the platform — the requirement is met by not hand-writing the compare at all.
Every listed signature is tried. A sender rotating its secret signs with each active one, and refusing a delivery whose second signature matched would drop every delivery for the length of the rotation.
What a refusal says, and does not
core/webhook_unverified, 401 — one code for every failing step.
Which step refused it goes in detail, which the HTTP codec strips. So an operator reading a log learns exactly what failed and the sender learns only that something did.
Never the secret, never a signature, never the body. A refusal is the one message a hostile caller is guaranteed to receive, so it is the last place to echo anything they supplied or anything they are trying to guess.
Then the uniqueness key
Every delivery is inserted into pithy_payments_webhook_events under UNIQUE (rail, providerEventId). Every provider delivers at-least-once and retries, so a redelivery is expected — it has to be recognized rather than reprocessed.
The row carries more than a marker:
| Column | What it records |
|---|---|
payload | The delivery, kept |
receivedAt | When it arrived |
processedAt | When it was finished with |
abandonedAt | When a repair pass gave up so the stream could advance |
error | Why the last attempt did not go through |
attempts | How many repair passes have tried |
A note and an error are opposite states
Both read as why nothing happened to a person. In the row they are opposites:
- A note is why nothing was ever going to project. The row is finished.
- An error is why this attempt did not. The row stays repairable.
Each call site says which it means, rather than letting the mere presence of a reason decide.
The purchase has its own guard
UNIQUE (rail, providerTransactionId) on the purchases table is the idempotency anchor that all three write paths rely on — the webhook, a client submitting a receipt, and a reconciliation pass. A repeat is a no-op, not an error.
Which is why a client that submits its receipt to POST /payments/purchases the moment a purchase completes is doing something safe and useful: the entitlement appears immediately instead of waiting for the webhook, and when the webhook lands it finds the row already there. A replay by its own owner is a 200 with the existing purchase. A receipt belonging to somebody else is not.
What an operator can find later
The pending index answers one question — why didn’t this renew? — as everything not yet finished with, oldest first.
Pending, failed and abandoned all appear, because all three are deliveries whose purchase has not been projected. That an abandoned event shows up here is deliberate: it is exactly the row somebody has to be able to find, and abandonedAt beside it says which of the three it is.
Doing this on your own route
The verifier is not payments-specific. signed-webhook is a first-class strategy, and any route can declare it and use the same guard — the payments rails are callers of it rather than a second copy of it.
Dedup and persistence stay yours. At-least-once delivery is a property of the sender, not of the signature, and a verifier that also owned a table could not be composed by anybody whose table differs. If your handler grants, charges, or deletes, it needs its own uniqueness key. Payments spends one unique insert on exactly this.