Stripe Webhooks Out of Order: Why created Timestamps Fail and What to Do Instead
Stripe may deliver events late, twice, or out of order. A lab replays 40,000 events through five handlers; only fetching the current object kept every subscription correct.
Stripe tells your application about subscription changes, payments, and failed charges by sending webhook events. Building a handler that works in a demo takes an afternoon. Building one that keeps your subscription table correct when events arrive twice, late, or in the wrong order takes a decision most tutorials get wrong: how to tell which event is newest.
Stripe's documentation answers that question directly, and the answer is not the event's created timestamp. This guide explains what Stripe does and does not guarantee, how to accept events safely, and how to apply them so order does not matter. A lab replays 40,000 simulated events through five handler strategies and counts how many subscriptions end up wrong.
What Stripe promises about delivery
From Stripe's webhook documentation, checked on November 3, 2026:
- Retries. In live mode, Stripe retries a failed delivery for up to three days with exponential backoff. In a sandbox it retries three times over a few hours.
- Duplicates. An endpoint "might occasionally receive the same event more than once". Track event IDs to detect them.
- Order. Stripe "doesn't guarantee the delivery of events in the order that they're generated".
- Timestamps. The documentation says not to use the event's
createdfield to determine event order or whether you have already processed an event. - Signatures. Verification needs the raw request body; any change to the body makes it fail.
A handler therefore needs three properties: it verifies the signature before trusting anything, it records each event ID at most once, and the final state it writes does not depend on arrival order.
Accepting events: verify, record, acknowledge
Keep the HTTP handler small. Read the raw body with a size limit, verify the Stripe-Signature header with your endpoint secret, insert the event into a table keyed by event ID, and return 200. Do the business work afterwards, in a worker. Stripe's documentation recommends handling events asynchronously for the same reason: a slow handler times out, Stripe retries, and you end up with more concurrent work, not less.
CREATE TABLE stripe_events (
event_id text PRIMARY KEY, -- evt_...
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
If the insert fails, return a 5xx. Stripe will retry, and because the event ID is the primary key, a retry that arrives after a successful insert is ignored. Return 200 only after the row is committed.
Why ordering by created fails
The tempting fix for out-of-order delivery is a guard such as UPDATE subscriptions ... WHERE last_event_created < $created. It has three problems:
createdhas one-second resolution. Two changes in the same second have the same value, so a strict<ignores the second change and a<=lets the older one overwrite the newer.- A
NULLstarting value never compares true. In SQL,NULL < xis unknown, so a new row whose column starts asNULLis never updated by the guarded statement. - It compares event creation, not object state. An event can describe a change that a later event already superseded for reasons the guard cannot see.
Lab: five ways to apply out-of-order events
The program simulates 10,000 subscriptions, each changing plan two to six times with zero to two seconds between changes. Each change creates an event with a one-second created value and a snapshot of the plan. Each delivery gets a random delay, and 5% are delivered twice. The provider's current plan is the truth. After all 42,126 deliveries, the program counts subscriptions whose local plan differs from the truth.
The fifth strategy treats each event only as a signal that something changed, reads the subscription's current state from the provider at processing time, and saves that. Run it with go run .; it uses only the standard library.
// Lab: can a webhook handler order Stripe-style events by their `created` time?
//
// 10,000 simulated subscriptions each change plan 2-6 times. Changes happen
// close together (a user clicking upgrade, then changing seats). Every
// change produces an event whose `created` field has 1-second resolution,
// like Stripe's. Delivery is at-least-once and unordered: each delivery gets
// a random delay, 5% are delivered twice. The provider's current object is
// the truth. We count subscriptions whose local copy disagrees with it after
// every delivery has been processed.
package main
import (
"fmt"
"math/rand/v2"
"sort"
)
type event struct {
id int
sub int
created int64 // seconds
plan string // snapshot of the object in the event payload
arrive int64 // ms
}
type local struct {
plan string
lastCreated *int64 // NULL until the first guarded update
}
func main() {
rng := rand.New(rand.NewPCG(2026, 11))
plans := []string{"starter", "pro", "business", "enterprise"}
const subs = 10_000
truth := make([]string, subs)
var deliveries []event
id := 0
for s := 0; s < subs; s++ {
t := int64(1_790_000_000) + int64(rng.IntN(86_400))
changes := 2 + rng.IntN(5)
for c := 0; c < changes; c++ {
t += int64(rng.IntN(3)) // 0-2 s apart: several changes can share one second
p := plans[rng.IntN(len(plans))]
truth[s] = p
e := event{id: id, sub: s, created: t, plan: p}
id++
for copies := 1 + boolInt(rng.Float64() < 0.05); copies > 0; copies-- {
e.arrive = t*1000 + int64(rng.ExpFloat64()*1500) // retries and network delay
deliveries = append(deliveries, e)
}
}
}
sort.Slice(deliveries, func(i, j int) bool { return deliveries[i].arrive < deliveries[j].arrive })
type handler struct {
name string
apply func(st *local, e event, current string)
}
handlers := []handler{
{"apply each payload in arrival order", func(st *local, e event, _ string) { st.plan = e.plan }},
{"guard: last_created < created, column starts NULL", func(st *local, e event, _ string) {
if st.lastCreated != nil && *st.lastCreated < e.created { // NULL < x is NULL in SQL
st.plan, st.lastCreated = e.plan, &e.created
}
}},
{"guard: last_created < created, column starts at 0", func(st *local, e event, _ string) {
if st.lastCreated == nil || *st.lastCreated < e.created {
st.plan, st.lastCreated = e.plan, &e.created
}
}},
{"guard: last_created <= created", func(st *local, e event, _ string) {
if st.lastCreated == nil || *st.lastCreated <= e.created {
st.plan, st.lastCreated = e.plan, &e.created
}
}},
{"event as a trigger: fetch current object, then save", func(st *local, _ event, current string) {
st.plan = current
}},
}
fmt.Printf("%d subscriptions, %d deliveries (%d events)\n\n", subs, len(deliveries), id)
for _, h := range handlers {
state := make([]local, subs)
current := make([]string, subs) // provider-side object as deliveries are processed
// replay creation order to know the provider's state at each delivery time
var created []event
seen := map[int]bool{}
for _, d := range deliveries {
if !seen[d.id] {
seen[d.id] = true
created = append(created, d)
}
}
sort.SliceStable(created, func(i, j int) bool { // provider timeline: time, then creation order
if created[i].created != created[j].created {
return created[i].created < created[j].created
}
return created[i].id < created[j].id
})
ci := 0
for _, d := range deliveries {
for ci < len(created) && created[ci].created*1000 <= d.arrive {
current[created[ci].sub] = created[ci].plan
ci++
}
h.apply(&state[d.sub], d, current[d.sub])
}
wrong := 0
for s := range state {
if state[s].plan != truth[s] {
wrong++
}
}
fmt.Printf("%-52s %5d wrong (%.2f%%)\n", h.name, wrong, 100*float64(wrong)/subs)
}
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64):
10000 subscriptions, 42126 deliveries (40048 events)
apply each payload in arrival order 3050 wrong (30.50%)
guard: last_created < created, column starts NULL 10000 wrong (100.00%)
guard: last_created < created, column starts at 0 1379 wrong (13.79%)
guard: last_created <= created 1407 wrong (14.07%)
event as a trigger: fetch current object, then save 0 wrong (0.00%)
What the numbers mean
- Applying each payload as it arrives left 30.5% of subscriptions wrong. Any reordering of the final two events leaves the older state in place.
- The
createdguard with aNULLstart left every subscription wrong, because no update ever matched. This is the version in many tutorials, and it fails silently: no errors, just a table that never changes. - The same guard with a non-null start still left 13.8% wrong, all from changes that shared a second. Switching to
<=did not help (14.1%); it trades one same-second error for another. - Fetching the current object left none wrong, because it never uses the event to decide state.
The simulation is denser than most real traffic: many subscriptions change several times within seconds. Your error rate with a created guard will usually be lower, but it will not be zero, and the errors land on exactly the customers who were actively changing their plan.
Applying events by reading current state
The pattern is: for each stored event, take the object ID from the payload, retrieve that object from the Stripe API, and write its current state. An old event, a duplicate, and a new event all lead to the same result. Stripe's documentation describes this approach for its "thin" events, which contain only the object reference, and it works the same way with full event payloads.
Two costs come with it:
- API calls. Each processed event costs a retrieve. Group pending events by object ID and fetch each object once per batch, as the worker below does, and you call the API once per changed subscription rather than once per event.
- Latency. The local state follows Stripe's state within the worker's polling interval. For access decisions that must be immediate, such as granting a feature right after checkout, read from Stripe in the request that needs it and then let the worker catch up.
The package below implements the receiving handler and the worker. It compiles against github.com/stripe/stripe-go/v86 (v86.4.2) with Go 1.26. It was not run against Stripe or a database as part of the lab; adapt the SQL to your schema and test it with the Stripe CLI's event forwarding.
// Package billing receives Stripe webhooks, stores them durably, and applies
// them by reading the current object from Stripe instead of trusting the
// order in which events arrive.
package billing
import (
"context"
"database/sql"
"errors"
"io"
"log"
"net/http"
stripe "github.com/stripe/stripe-go/v86"
"github.com/stripe/stripe-go/v86/webhook"
)
type Server struct {
DB *sql.DB
Stripe *stripe.Client
WebhookSecret string
}
// HandleWebhook verifies the signature over the raw body, records the event
// once, and acknowledges. Business changes happen later in ProcessPending.
func (s *Server) HandleWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), s.WebhookSecret)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
_, err = s.DB.ExecContext(r.Context(), `
INSERT INTO stripe_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING`, event.ID, string(event.Type), payload)
if err != nil {
log.Printf("store stripe event %s: %v", event.ID, err)
http.Error(w, "try again", http.StatusInternalServerError) // Stripe retries
return
}
w.WriteHeader(http.StatusOK)
}
// ProcessPending applies stored subscription events. For each one it fetches
// the subscription's current state from Stripe, so an old or duplicate event
// can never overwrite newer state. The local update and an outbox row for
// downstream effects (cache invalidation, emails) commit together.
func (s *Server) ProcessPending(ctx context.Context) error {
rows, err := s.DB.QueryContext(ctx, `
SELECT event_id, payload->'data'->'object'->>'id'
FROM stripe_events
WHERE processed_at IS NULL AND event_type LIKE 'customer.subscription.%'
ORDER BY received_at
LIMIT 100`)
if err != nil {
return err
}
type pending struct{ eventID, subID string }
var batch []pending
for rows.Next() {
var p pending
if err := rows.Scan(&p.eventID, &p.subID); err != nil {
rows.Close()
return err
}
batch = append(batch, p)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
fetched := map[string]*stripe.Subscription{} // one API call per subscription per batch
for _, p := range batch {
sub, ok := fetched[p.subID]
if !ok {
sub, err = s.Stripe.V1Subscriptions.Retrieve(ctx, p.subID, nil)
var se *stripe.Error
if errors.As(err, &se) && se.HTTPStatusCode == http.StatusNotFound {
sub = nil // deleted in Stripe; apply as canceled below
} else if err != nil {
return err // leave the event pending; retry later
}
fetched[p.subID] = sub
}
if err := s.apply(ctx, p.eventID, p.subID, sub); err != nil {
return err
}
}
return nil
}
func (s *Server) apply(ctx context.Context, eventID, subID string, sub *stripe.Subscription) error {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
status := "canceled"
if sub != nil {
status = string(sub.Status)
}
if _, err := tx.ExecContext(ctx, `
UPDATE subscriptions SET status = $2, synced_at = now()
WHERE stripe_subscription_id = $1`, subID, status); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO outbox (topic, key, payload)
VALUES ('subscription.synced', $1, json_build_object('status', $2::text))`, subID, status); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stripe_events SET processed_at = now() WHERE event_id = $1`, eventID); err != nil {
return err
}
return tx.Commit()
}
Side effects belong in an outbox
When a subscription changes, other things usually follow: invalidating cached entitlements, sending a confirmation email, updating a CRM. If the worker updates the database and then calls those systems directly, a crash between the two leaves them out of sync, and a retry can send the email twice.
The worker above writes an outbox row in the same transaction as the subscription update. A separate publisher reads unsent outbox rows, delivers them, and marks them sent. The transactional outbox pattern describes the trade-offs; the important property is that the state change and the record of what must happen next commit together. Consumers must still tolerate duplicates, because the publisher can crash after delivering and before marking the row sent.
Reconciliation catches what webhooks miss
An endpoint can be misconfigured, a deploy can reject signatures for an hour, or a bug can mark events processed without applying them. Run a periodic reconciliation that does not depend on webhooks:
- List recent events with the Events API, which returns events from up to the last 30 days, and insert any whose IDs you have not recorded.
- For subscriptions that changed recently, retrieve them and compare status, price, and quantity with your table.
- Fix differences through the same
applypath, so reconciliation and webhooks cannot disagree about what "correct" means, and count every fix. A rising count is a bug report.
Where to start
Look at the WHERE clause your handler uses to apply subscription events. If it compares created timestamps or relies on arrival order, switch the subscription path to "fetch current object, then save", keep the event-ID table for deduplication, and move side effects into an outbox. Then run the reconciliation once to find out how many subscriptions were already wrong. The entitlements guide covers how those subscription changes should reach feature checks, and the dunning guide covers the failed-payment events that usually arrive alongside them.
Frequently Asked Questions
Does fetching the object for every event hit Stripe's rate limits?
It can if you fetch per event. Group pending events by object ID and fetch each object once per batch; most bursts are several events about the same subscription. Monitor 429 responses from Stripe and slow the worker down if they appear.
Can I still use the payload in the event?
Yes, for things that are fixed at the time of the event, such as which invoice was paid or the amount of a charge. Use the current object for anything that changes over time, such as subscription status, plan, or quantity.
Should the webhook handler return an error if processing fails?
Return an error only if you could not store the event, so Stripe retries. Once the event is stored, return 200 and let your worker retry processing; otherwise a bug in business logic turns into three days of redeliveries.
How do I test out-of-order delivery locally?
Use the Stripe CLI to trigger events and forward them, and add a test mode in your handler that stores events without processing. Then process them in shuffled order, including duplicates, and compare the final table with the objects retrieved from Stripe.
Is the event ID enough to deduplicate business actions?
It deduplicates deliveries of the same event. Two different events can still describe related changes, so make each downstream action idempotent on its own key, such as the invoice ID for a receipt email.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
Receive Stripe events in your webhook endpoint
(Stripe)
• Link checked 2026-11-03
Retry schedule, duplicate events, no ordering guarantee, not using created for ordering, raw-body signature verification.
-
List all events
(Stripe)
• Link checked 2026-11-03
Events can be listed going back up to 30 days, which bounds API-based reconciliation.
-
Pattern: Transactional outbox
(Chris Richardson, microservices.io)
• Link checked 2026-11-03
Committing a state change and the message describing it in one transaction.
Revision history
- — Rewrote the guide around Stripe's documented delivery behavior and a reproducible lab. Removed the created-timestamp ordering guard that Stripe's documentation advises against, which also never matched rows starting as NULL. Added a compiled handler and worker that fetch the current object, and corrected the reconciliation step.
- — Clarified acknowledgement and transaction boundaries.