Stripe webhook ordering in Better Auth: how a retry can drop onTrialEnd

Refetching the subscription is the right fix for Stripe snapshot events. Deriving lifecycle callbacks from the event while guarding them with the stored row meant a retried event could skip onTrialEnd, onTrialExpired and onSubscriptionCancel.

Category
Code review
Published
Reading time
3 min read

The problem

A Stripe event's data.object is a snapshot rendered in the webhook endpoint's API version. The plugin's types follow the Stripe SDK's API version, which can be newer, so fields like billing periods could be read from a differently shaped object. Stripe's guidance for snapshot events is to fetch the current object before acting on it.

The proposed fix

Both subscription handlers now retrieve the subscription with the SDK, and a failed retrieval is deliberately not caught, so the webhook returns an error and Stripe retries the event:

packages/stripe/src/hooks.ts
// The payload follows the webhook endpoint's API version, so refetch to get the SDK's shape.
// A failed fetch is not caught here, so the webhook fails and Stripe retries the event.
const stripeSubscriptionUpdated = await options.stripeClient.subscriptions.retrieve(eventSubscription.id);

Because a refetched object always shows the latest state, lifecycle callbacks were moved to the event's own previous_attributes, and guarded with the stored row so a redelivered event does not fire twice:

TypeScript
const isTrialEnded = previousSubscription.status === "trialing" && eventSubscription.status === "active";
// ...
if (isTrialEnded && subscription.status === "trialing") { /* onTrialEnd */ }

What I found in review

The refetch is right. The guard is where it breaks. It combines two different points in time: isTrialEnded describes this event's transition, while subscription is the stored row, which earlier events already wrote from the refetched, latest state.

Stripe does not guarantee delivery order, and this pull request makes reordering more likely, because a transient refetch failure now sends an event to a retry. The sequence:

  1. E1: status trialing to active, with previous_attributes: { status: "trialing" }.
  2. E2, shortly after: an unrelated change, such as metadata or quantity.
  3. E1's refetch fails transiently; the route returns an error and Stripe schedules a retry.
  4. E2 is processed: no transition in its payload, but the refetch says active, so the stored row goes from trialing to active and no callback runs.
  5. E1 is retried: isTrialEnded is true, but the stored row is already active, so onTrialEnd is skipped.

onTrialEnd never runs. Before the pull request, step 4 would have fired it, because the old check compared the stored row with the current state. The same sequence drops onSubscriptionCancel and onTrialExpired.

The fix is to fire on whichever event first sees the change, while the stored row still shows the old state:

TypeScript
const shouldFireTrialEnd =
  subscription.status === "trialing" &&
  (isTrialEnded || stripeSubscriptionUpdated.status === "active");
const shouldFireCancel =
  !isPendingCancel(subscription) &&
  (isNewCancellation ||
    (stripeSubscriptionUpdated.status === "active" && isStripePendingCancel(stripeSubscriptionUpdated)));

And a test that reproduces the order: a stored trialing row, retrieve mocked to return active, E2 delivered before E1, and onTrialEnd expected exactly once. This does not cover duplicate callbacks on redelivery, which earlier automated reviews raised and which need deduplication by event id; it covers the dropped ones.

What happened next

Takeaways

  • "What changed in this event" and "what the object looks like now" are different truths. Use each for what it is good at.
  • Every new retry path creates new orderings. Re-check any logic that compares an event with stored state.
  • With at-least-once, unordered delivery, the safe trigger for a side effect is a stored state transition, not an event.

References

Code and history behind this post

More

Theme