Your payment webhook is wrong, and you will only find out when someone who just subscribed loses access to the Pro plan.

Webhooks do not arrive in order. A "subscription canceled" event can show up after "subscription renewed", and a handler that applies each event in the order it arrives will take the plan away from someone who just paid. Providers say so in their docs, but it is easy to read that sentence and keep writing if (event.type === "canceled") downgrade(user).

This is how I handled it in a side project: a time-tracking app with a Free and a Pro plan, where the payment provider is the source of truth for who is Pro and my database keeps a local copy. The stack is TypeScript, Drizzle and Postgres, but the idea fits any of them.

The three ways it goes wrong

Three different problems hide behind "out of order":

  • Stale events. An old event is delivered after a newer one, or the same event is delivered twice. Applying it rolls the user back to a state they already left.
  • Events from a different subscription. A user cancels their old subscription and buys a new one. The provider then sends the late "ended" notice for the old one. If you only look at the user, that notice removes the plan the user just paid for.
  • Final states that are not final. An order is refunded, then a delayed "paid" event arrives. Without a guard, a refunded order becomes paid again.

Ordering with a timestamp from the provider

Every subscription event carries the time the provider last modified the subscription. I store that time next to the plan, in an event_at column, and only apply an event that is not older than the one I already saved.

The check and the write have to be one statement. If you read the row, compare in application code and then write, two events arriving together can both pass the check. Postgres can do it in a single conditional upsert:

await db .insert(userPlans) .values({ ...values, userId }) .onConflictDoUpdate({ target: userPlans.userId, set: values, setWhere: sql` (excluded.event_at is null or user_plans.event_at is null or excluded.event_at >= user_plans.event_at) and (excluded.plan <> 'free' or user_plans.subscription_id is null or user_plans.subscription_id = excluded.subscription_id)`, }) .returning({ userId: userPlans.userId }); 

When the setWhere condition is false, Postgres leaves the row alone and returning comes back empty. That tells me the event was ignored, which I use to decide whether to run side effects such as pausing paid features.

When two events for the same user run at the same time, Postgres locks the conflicting row for the ON CONFLICT DO UPDATE and checks the condition against the latest version of it. So they are applied one after the other, not on top of each other.

Only the right subscription can take the plan away

The second half of the condition is about subscriptions. A change to free is accepted only if it names the subscription stored for that user, or if none is stored yet.

That covers the case from earlier. The user cancels sub_old, buys sub_new, and then a late "ended" notice for sub_old arrives. Its subscription id does not match the stored one, so nothing happens and the user keeps the plan. A change that grants a plan is always allowed, because a purchase is how a new subscription id gets in.

A refund stays a refund

Payments use the same idea at the column level. The order id is unique, so recording the same payment twice is one row. The status update refuses to leave refunded:

.onConflictDoUpdate({ target: payments.providerOrderId, set: { status: sql`case when payments.status = 'refunded' then 'refunded' else excluded.status end`, updatedAt: now, }, }); 

Test it with the ugly order

The tests that matter replay events in the wrong order against a real database. This is the shape of the one for subscriptions:

await active("sub_old", "2026-01-01"); // Pro await revoked("sub_old", "2026-02-01"); // Free await active("sub_old", "2026-01-01"); // stale re-delivery: ignored await active("sub_new", "2026-03-01"); // bought again: Pro await revoked("sub_old", "2026-04-01"); // late notice for the old one: ignored // the user is still Pro on sub_new await revoked("sub_new", "2026-02-15"); // older than what we have: ignored await revoked("sub_new", "2026-05-01"); // the current subscription ends: Free 

Each line asserts both the resulting plan and whether the event was applied. These tests run against Postgres, not a mock, because the behaviour I care about lives in the database.

What this costs

Be clear about the trade-offs:

  • Old events are dropped, not queued. I rely on the provider's timestamp being trustworthy. If a provider reuses or rounds timestamps, two different changes can look identical and the later one can be ignored.
  • It protects the plan, not your whole system. Side effects that already ran, such as an e-mail, are not undone. The applied flag exists so they only run when the write took effect.
  • You still need monitoring. An ignored event is the correct outcome, but a stream of ignored events is worth a look. Log them.

Takeaway

If your billing code has a handler per event type that writes straight to the user, try this: replay your events backwards and twice, and see who ends up with which plan. It is a cheap test, and the failure is the kind that shows up as a support ticket from someone who paid.

Have you been bitten by out-of-order webhooks? I would like to hear how you handled it.