Out-of-Order Stripe Webhooks: Fixing the "Active but Incomplete" Subscription Bug — Cesar Ayala
← All posts

Out-of-Order Stripe Webhooks: Fixing the "Active but Incomplete" Subscription Bug

Stripe doesn't guarantee webhook delivery order, so customer.subscription.updated can arrive before .created and leave your DB stuck at "incomplete" while Stripe says "active." Don't trust the payload or sort events. On each event: verify the signature, dedupe on event.id, re-fetch the live Subscription from the API, discard events older than your stored event.created, upsert idempotently, and reconcile on a schedule.

The bug: a subscription stuck at “incomplete” while Stripe says “active”

A subscription stuck at incomplete while Stripe shows active is a webhook-ordering bug. Stripe does not guarantee event order, so a customer.subscription.updated can land before customer.subscription.created (or a stale one last) and leave your row behind. The fix: re-fetch the live Subscription, dedupe on event.id, guard on event.created, and upsert idempotently.

Here’s the ticket that ruined a Tuesday for me: a customer paid, the Stripe dashboard shows their subscription as active, the charge cleared — and yet our app kept gating them behind the paywall like they’d never subscribed. The reason? Our local subscription row read incomplete. Stripe’s truth and our database’s truth had quietly diverged, and the customer was the one who noticed first.

The maddening part is that it’s intermittent. You re-save the customer in an admin panel, or you re-trigger the webhook, and suddenly the row flips to active and everyone moves on. “Works on retry” is the worst possible signal, because it sends your team hunting in the wrong place — checking payment logic, checking the Stripe keys, checking the network — when the actual cause is none of those.

The culprit, stated plainly: your handler trusted the order the webhooks arrived in, and Stripe never promised one. A customer.subscription.updated event landed before customer.subscription.created, or a stale updated got processed last and stomped newer state back to incomplete. This is not a niche edge case I invented — it’s documented pain. See laravel/cashier-stripe #1201 (“Order of webhooks from Stripe may not be consistent causing subscription to be in incorrect state”). It’s a state-sync correctness bug, and the good news is it’s fixable entirely in how you design your handler. If you’re still wiring up your first subscription, start from the Stripe subscription foundation and build this in from day one.

Why does this happen? Stripe never promises event order

Every webhook event is an independent HTTP POST to your endpoint. Stripe fires created, then updated, but those are two separate deliveries traveling over two separate connections — and two independent requests can and do race on the network. Whichever one your server happens to accept and finish first “wins,” and that has nothing to do with which change happened first inside Stripe.

Stripe documents this directly: it does not guarantee events are delivered in the order they were generated. So here are the two concrete ways your handler breaks.

Break #1 — update-before-create. customer.subscription.updated arrives first. Your handler runs an UPDATE against a row keyed by the subscription id… that doesn’t exist yet, because the created event hasn’t landed. The UPDATE silently affects zero rows (or throws, depending on your code). Then created arrives and inserts the row at whatever status it carried — possibly the older incomplete.

Break #2 — stale-last. A customer.subscription.updated carrying old state gets processed last and overwrites the newer active you’d already written. Your row regresses to incomplete and stays there.

On top of ordering, Stripe retries failed deliveries for up to three days in live mode with exponential backoff, and it can deliver the same event more than once. So a retry of an old event can show up long after newer events — duplicates and late arrivals compound the ordering problem.

The trap to avoid: do NOT try to “sort” webhooks back into their true order. You receive them as a stream of independent POSTs over time — you can’t buffer “the full set,” and retries arrive late by design. You cannot reconstruct true order from arrival. Design the handler to be correct under ANY order instead.

Stripe emits in ordercustomer.subscription.created, then customer.subscription.updated — milliseconds apart
Two independent POSTsEach event is a separate HTTP delivery; they race on the network
updated arrives FIRSTYour endpoint accepts the update before the create lands
Handler UPDATEs a missing rowNo row exists yet — the write no-ops (or a stale updated lands last)
DB diverges from StripeYour row is stuck at incomplete while Stripe's live truth is active

How do I reproduce it locally with the Stripe CLI?

Don’t take my word for it — make the bug land in your own database. Run the Stripe CLI to forward live events to your handler, and trigger subscription lifecycle events:

# Forward events to your local endpoint
stripe listen --forward-to localhost:3000/webhooks/stripe

# In another terminal, fire a subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated

The catch: whether the update actually beats the create at your handler depends on how fast each forwarded POST completes, so two trigger calls won’t reliably produce the race on their own. The deterministic way to force update-before-create is to make the handler itself process them out of order — add a temporary artificial delay in the created path while testing so a later updated lands first:

// Illustrative test-only hack to force the race deterministically
if (event.type === "customer.subscription.created") {
  await new Promise((r) => setTimeout(r, 3000)); // remove after testing
}

Add temporary logging at the very top of your handler — print event.type, event.id, event.data.object.id, and event.created. You’ll watch the created timestamps come through in an order that doesn’t match arrival order. That’s the proof.

Now observe the failure: with update-before-create, your UPDATE finds no row; with stale-last, a later-arriving stale updated stomps the good state. Either way your row ends at incomplete. Confirm the divergence by retrieving the live subscription from the API and comparing its status to your DB — that comparison is also the seed of the fix. (Per-event resend-by-id is done from the Stripe Dashboard, not the CLI; confirm current CLI subcommands and flags in the Stripe CLI docs, as of 2026.)

The fix in one box: treat Stripe as the source of truth

Before the deep dive, here’s the whole pattern in one screenshot-able box. The single most important principle: never trust the event payload or its arrival order — re-fetch the live object from Stripe and act on THAT.

Source of truthRe-fetch the live Subscription from the API; act on that, not the payload
Verify signatureCheck the Stripe-Signature header on every request; reject on failure
DedupeSkip events whose event.id you've already processed (Stripe retries + resends)
Event-time guardStore last event.created per object; discard incoming events that are older
Idempotent upsertINSERT ... ON CONFLICT DO UPDATE so a late .created still converges
ReconcileScheduled sweep re-fetches and re-asserts truth to catch drops/reorders

Verifying the signature is its own topic with its own failure modes — if you hit 400s, see verify the event before you trust it. And the reconciliation sweep is exactly why you pair webhooks with reconciliation instead of treating webhooks as your only sync mechanism.

Building the handler step by step

Here’s a real, runnable Node + Express + stripe-node handler. SDK method names and constructEvent are accurate as of 2026 — confirm current signatures in the Stripe docs. App-specific DB calls are labeled illustrative.

// webhook.js — Node + Express + stripe-node (illustrative DB calls)
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();

// IMPORTANT: raw body, NOT express.json() — signature needs the raw bytes
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    // Step 1 — Verify the signature on the RAW body
    let event;
    try {
      const sig = req.headers["stripe-signature"];
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      return res.status(400).send(`Webhook signature failed: ${err.message}`);
    }

    // Step 2 — Dedupe on event.id (Stripe retries and can resend)
    // insertProcessedEvent throws on the UNIQUE(event_id) conflict (illustrative)
    try {
      await db.insertProcessedEvent(event.id);
    } catch (_dupe) {
      return res.status(200).send("already processed");
    }

    // Only re-fetch on events that mutate critical subscription state
    if (event.type.startsWith("customer.subscription.")) {
      const subId = event.data.object.id;

      // Step 3 — Re-fetch the LIVE Subscription (act on current truth)
      const sub = await stripe.subscriptions.retrieve(subId);

      // Step 4 — Event-time guard: discard events older than what we stored
      const lastEventAt = await db.getLastEventAt(subId); // illustrative
      if (lastEventAt && event.created < lastEventAt) {
        return res.status(200).send("stale event discarded");
      }

      // NOTE: as of Stripe's Basil API version (2025-03-31), the period fields
      // moved from the Subscription to the subscription ITEM. Read them from
      // the item; for multi-item subs, pick the relevant item.
      const item = sub.items.data[0];

      // Step 5 — Idempotent UPSERT keyed by Stripe subscription id
      await db.upsertSubscription({
        stripe_subscription_id: sub.id,
        customer_id: sub.customer,
        status: sub.status,                       // from the RE-FETCH, not the payload
        current_period_end: item.current_period_end, // on the item as of 2025-03-31
        last_event_at: event.created              // advance the guard
      });
    }

    // Step 6 — Return 2xx promptly so Stripe doesn't retry a success
    return res.status(200).send("ok");
  }
);

A few notes on why each step is shaped this way. Step 1 must use the raw bytes — if Express has already parsed the JSON, the signature won’t match. Step 2’s dedupe leans on a UNIQUE constraint on event_id; let the database reject the duplicate rather than checking-then-inserting (that check-then-act is its own race). Step 4 returns 200, not an error — a stale event isn’t a failure, you’re deliberately ignoring it, and a non-2xx would just make Stripe retry it. Step 5’s current_period_end is read off the subscription item, not the Subscription — that field moved to items as of API version 2025-03-31, so reading sub.current_period_end directly comes back undefined and silently writes a null. Confirm where your pinned API version exposes period fields.

Honest cost note: the re-fetch in Step 3 adds one API call per critical event. For high volume, apply the cheap event-time guard to ALL events and re-fetch only on the events that actually mutate critical state — which is exactly what the if gate above does.

  1. 1. Verify signatureconstructEvent on the raw body; reject with 400 on failure
  2. 2. Dedupe on event.idUNIQUE constraint rejects repeats; return 200 and stop
  3. 3. Re-fetch live Subscriptionsubscriptions.retrieve(id) — act on current state, not the payload
  4. 4. Event-time guardIf event.created is older than stored last_event_at, discard (200)
  5. 5. Idempotent upsertON CONFLICT DO UPDATE; period fields live on the item as of 2025-03-31
  6. 6. Return 2xx + reconcileAck promptly; a scheduled sweep re-asserts truth

Trusting event order vs. designing for unordered

Put the two approaches side by side and the tradeoff is impossible to miss. The naive handler saves you an API call and ships a correctness bug. The robust handler spends one retrieve per critical event and is right under any order.

Trust event order

  • Processes the payload in arrival order
  • UPDATEs the row from payload fields
  • Breaks on update-before-create: no row to update
  • Stuck-incomplete when a stale updated lands last
  • Retries/duplicates double-apply or re-stomp state
  • Cheaper, but the correctness bug generates tickets

Design for unordered

  • Re-fetches the live Subscription from Stripe
  • Guards on event.created vs stored last_event_at
  • Idempotent upsert — late .created still converges
  • Correct under any arrival order and under retries
  • Self-healing: drops caught by next event or reconcile
  • Costs one retrieve per critical event — cheap insurance

The mental shift is everything here. Stop asking “what order did these arrive in?” — you can’t answer that reliably. Start asking “what is the live truth right now, and is this event newer than what I last saw?” That second question you CAN answer, and it’s correct under chaos. This is also the pattern that keeps your subscription states honest enough that dunning and failed-payment recovery actually fire on the right customers.

FAQ

Can’t I just sort events by event.created before processing? No. You receive them as independent POSTs spread over time — you can’t buffer “the full set” to sort, and retries arrive late by design. The event-time guard plus re-fetch is the correct pattern, not sorting.

Does re-fetching always cost an extra API call? Yes — one retrieve per re-fetched event. Mitigate by re-fetching only on events that mutate critical state and applying the cheap event-time guard to everything.

How long does Stripe retry a failed webhook? Per Stripe’s docs, up to three days in live mode with exponential backoff; in test mode, three retries over a few hours. Confirm current windows in their docs, as of 2026.

What should my endpoint return so Stripe stops retrying? Return a 2xx quickly once you’ve safely recorded the event. Return non-2xx only when you genuinely want a retry.

Where do I store last_event_at and processed event ids? In your own DB — a per-subscription last_event_at column, plus a processed_events table with a UNIQUE constraint on event.id.

Is this just a Stripe problem? No. The ordering reality applies to most webhook providers. The same re-fetch + idempotency + time-guard pattern generalizes to Mercado Pago and Conekta — just confirm each provider’s specific event names and retry behavior in their docs.

Closing: design for chaos, not for order

The one durable lesson: webhooks are unordered and retried by design. Don’t fight that reality by trying to reorder events — make the handler correct under any order, and the whole class of ghost bug disappears.

Stripe is the source of truth. Your database is a cache that must converge to it — through re-fetch, idempotent upsert, and a reconciliation sweep that catches anything dropped or reordered. That’s it. This isn’t a Stripe-only trick; it’s a state-sync correctness pattern that’ll save you the same contracargo-adjacent (chargeback-adjacent) ghost bug on every payment provider you ever integrate. Build it once, build it right, and stop debugging “works on retry” tickets. For more on getting subscription billing right end to end, see more Stripe and SaaS billing guides.