Build1 publisher2 min readPublished
A ten-second sender timeout turns an eleven-second handler into duplicate receipts
A webhook walkthrough shows every line of a handler working correctly while the customer gets two receipts, because the sender gives up one second before the warehouse call returns and sends the event again.
The Engineer · Build desk

What happened
- In the guide's worked example, a handler that awards loyalty points, emails a receipt and calls a warehouse takes eleven seconds, and the sender gives up at ten and sends the same order event again.
- The customer ends up with two receipts and double loyalty points while the receiver's logs show two perfectly healthy 200 responses.
- Standard Webhooks signs the webhook-id, the webhook-timestamp and the raw body joined by full stops with HMAC-SHA256 and sends the base64 digest behind a v1, prefix, using a secret of 24 to 64 random bytes.
- Anything that is not a 2xx response gets retried, and that includes redirects, which Stripe also counts as delivery failures.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Handler design is bounded by someone else's clock. Any side effect that can take longer than the sender's timeout has to be safe to perform twice. That is what puts points, email and warehouse calls behind an id check or a queue.
- cost Push does not retire the poller. Because a webhook fails in whatever way the sender chooses, the reconciliation job is a permanent cost of the integration.
- exposure Event type strings are an interface: renaming order.created breaks every receiver that routes on it, so type names need the deprecation discipline of a public endpoint.
- decision Every new integration forces a payload choice, since a full snapshot spares the receiver a round trip and a thin id forces one fetch of state that is current.
The Standard Webhooks spec recommends a request timeout "somewhere between 15 and 30s", according to the guide [8]. The sender in the example sits five seconds under that floor, and the eleven-second handler would have returned with four seconds to spare at fifteen [2]. Raising the number moves the boundary, but the retries stay: a handler whose slowest dependency can outlast the sender's clock will be asked to run twice eventually [4].
The second copy arrives with the same webhook-id. The sample request carries a `webhook-id` header [19], and that id is one of the three things the signature covers [6]. A receiver that writes the id down before it does any work can recognise the second copy.
The signed timestamp is what stops a captured request being replayed tomorrow: the receiver rejects anything older than a few minutes, the spec leaves the tolerance to the receiver, and Stripe's libraries default to five minutes [11]. Five minutes is 300 seconds, ten times the spec's own ceiling for a single request [3]. So a redelivery that reuses the original signed timestamp and lands outside that window fails verification on age alone. The guide's sample shows both answers arriving together from the verifier: the signature is valid, and the timestamp is far too old to accept [12].
Verification is recomputing the HMAC and comparing, and what breaks it most often is what you compute it over, which has to be the exact bytes that arrived [13]. The sample body carries `"total": 42.50`, and a parse-and-reserialise round trip returns `"total":42.5` [14]. A handler that hashes the re-serialised body is hashing a different message from the one the sender signed.
Two status codes carry instructions. `410 Gone` asks the sender to disable the endpoint and `429 Too Many Requests` asks it to slow down [10]. On the other side of the choice, polling costs requests and rate limits when the interval is short and lag when it is long, while a webhook costs one request per event [15]. The directions of failure differ: a missed poll is repeated by the next one, and a webhook fails however the sender decides [16]. The guide keeps a slow reconciliation poll in the design to catch whatever the webhooks missed [16].
One second of overrun is what separates the working handler from the duplicate charge in this example [1]. The excerpt breaks off inside the verification section and does not name all six failures; the diagram list for the original Railhook post points at the retry ladder, duplicates, out-of-order delivery and secret rotation [20].
What to watch
- Whether senders document that a redelivery carries a fresh webhook-timestamp, since the receiver's few-minute tolerance check depends on the answer.
- Whether Standard Webhooks narrows its 15-to-30-second timeout recommendation to a single figure senders can be held to.
- The rest of the guide's six failures, including out-of-order delivery and secret rotation, and whether the published diagrams change the handling advice.