Use a verified Stripe event to update one durable subscription projection, and make the event record and projection safe to retry. In a Next.js app backed by Firestore, that usually means three separate concerns: verify the raw webhook, record its processing state by Stripe event ID, and atomically update the subscription and entitlement documents. A Checkout success page is useful for user experience; it is not the authority that should grant paid access.
This matters as soon as a solo developer connects Stripe Checkout to a real SaaS. Stripe documents that webhook endpoints can receive the same event more than once and that delivery order is not guaranteed. A handler that simply sets isActive: true on checkout and isActive: false on cancellation can therefore process an old delivery after a newer one. The customer’s access becomes a side effect of transport timing rather than billing state.
The billing boundary to build
Keep provider-specific work at the edge. First, verify the signature against the raw request body. Then normalize only the fields your product needs: provider event ID, event type, Stripe resource ID, subscription status, customer identity, price mapping, and the provider’s update timestamp. Finally, write the result through a small billing repository rather than letting a route handler modify a user document directly.
That separation mirrors Frontend Accelerator’s current product architecture: payment-provider logic is isolated behind a payment adapter, while its Firestore adapter applies subscription, entitlement, and user-product projection changes in a transaction. The useful pattern is the boundary, not copying a provider SDK call into every feature.
What each record is for
- Billing event: an audit and retry record keyed by the Stripe event ID. It answers whether this delivery has been claimed, is being processed, completed, was intentionally ignored, or needs another attempt.
- Subscription projection: the latest known normalized state for one Stripe subscription, including a provider update timestamp.
- Entitlement projection: the small document your authorization code reads to decide whether this user may use a paid feature.
Do not make a browser redirect, an email address alone, or a client-supplied price ID the access authority. Resolve the account using a server-controlled reference and the verified Stripe customer or subscription data. If those identities disagree, stop for review rather than guessing who should receive access.
Original asset: the webhook event-state map
This compact state map is the operational asset for the guide. It gives every webhook response a reason and prevents “duplicate” from accidentally meaning “successfully processed.”
- received — create or claim a record with the Stripe event ID before a state change.
- processing — take a short processing lease and increment the attempt count. A still-valid lease means a concurrent delivery should retry later, not perform work twice.
- processed — the transaction committed the authoritative subscription and entitlement projection. A later delivery of that same event can return a successful duplicate response.
- ignored — the event is verified but deliberately does not change access, such as an unsupported event or an older state than the projection already stores.
- failed-retryable — a temporary database or Stripe retrieval failure prevented completion. Return a 5xx response so the provider can retry; retain a redacted diagnostic.
- failed-terminal — a verified event cannot safely be applied because of a missing price mapping, conflicting account ownership, or another policy failure. Record it, alert the owner, and do not silently grant or revoke access.
Stripe’s own webhook guidance recommends logging processed event IDs to handle duplicate receipts. It also notes that two distinct Event objects can describe the same logical change, so an event-ID record is necessary but not sufficient for business correctness. Your projection still needs a policy for freshness and state precedence.
Make the projection update atomic
Firestore transactions are appropriate when the new access decision depends on the current projection. Read the subscription projection inside the transaction, reject a clearly older provider timestamp, and write the subscription, entitlement, and any compatibility projection together. Firestore can retry a transaction callback, so keep that callback limited to reads and writes. Do not send email, call Stripe, enqueue work, or emit analytics from inside it.
The following is illustrative server-side TypeScript. It is not a drop-in repository excerpt; adapt document paths, entitlement policy, and timestamp parsing to your application.
await db.runTransaction(async (tx) => {
const subscriptionRef = db.collection("billingSubscriptions").doc(event.subscriptionId);
const entitlementRef = db.collection("billingEntitlements").doc(`${userId}:${entitlementKey}`);
const userRef = db.collection("users").doc(userId);
const current = await tx.get(subscriptionRef);
const currentUpdatedAt = current.data()?.providerUpdatedAt;
if (currentUpdatedAt && currentUpdatedAt > event.providerUpdatedAt) {
return { applied: false, reason: "older_delivery" };
}
tx.set(subscriptionRef, normalizedSubscription, { merge: true });
tx.set(entitlementRef, {
userId,
entitlementKey,
isActive: accessPolicy(normalizedSubscription.status),
providerUpdatedAt: event.providerUpdatedAt,
}, { merge: true });
tx.update(userRef, { products: nextProductProjection });
return { applied: true };
});
The comparison is a policy, not a universal ordering guarantee. Stripe explicitly warns that events can arrive out of order. For a state that must be canonical, retrieve the current Stripe object when your policy requires it, or make the projection update conditional on the provider timestamp you persist. Never rely on receipt time at your endpoint to decide which subscription state is newer.
Choose the webhook response deliberately
Return a 2xx response only when the verified event is already accounted for or has reached a deliberate terminal outcome. That includes a completed transaction, a known duplicate of a processed event, and an event intentionally ignored by a documented policy. An invalid signature should make no database write and receive a client-error response.
Return a 5xx response when a temporary failure leaves the intended transition incomplete: Firestore is unavailable, a required Stripe retrieval timed out, or a processing lease is still active. Stripe’s delivery system will retry failed deliveries, so a premature 2xx response can turn a transient outage into a permanent entitlement mismatch.
Recommendation: keep the event journal even after the entitlement projection is updated. The projection answers “what access is active now?”; the journal answers “why did it become that way?”
Keep the slow work outside the request boundary
A webhook handler should reach a durable, retryable outcome quickly. If a downstream job is genuinely needed after the entitlement transaction, record an outbox-style work item in the same transaction and let a separate worker perform the side effect. For an early-stage SaaS, you may not need that extra component yet. What you do need is a clear rule: never mark an event processed before the access projection is durable, and never hide a failed projection behind a successful HTTP response.
Also keep operational data proportionate. Store the provider event ID, resource ID, attempt count, outcome, and a redacted diagnostic that helps an administrator investigate. Avoid retaining raw card-related payloads or copying more customer data into an event journal than your support and reconciliation process actually requires.
Test the failure paths before you sell access
These focused tests are more valuable than a happy-path Checkout screenshot because they validate the state boundary where money and authorization meet.
- Duplicate delivery: send the same signed fixture twice. The first run should apply one transition; the second should produce no additional access mutation and a duplicate outcome.
- Late delivery: store a subscription update with a newer provider timestamp, then deliver an older cancellation or update fixture. The transaction should preserve the newer projection and record why the older delivery was ignored.
- Retryable failure: force the repository transaction to fail once. Confirm the event ends in a retryable status, the endpoint returns 5xx, and a later delivery can reclaim work after the lease.
- Ambiguous ownership: make the server-controlled user reference and verified Stripe customer resolve to different accounts. Confirm no entitlement changes and a reviewable terminal record.
- Policy mapping: send a verified event for an unknown Stripe price. Confirm the system records the configuration gap without auto-provisioning access.
Run these against the Firestore emulator as well as unit doubles. The emulator test is where transaction retries, document paths, and serialization mistakes become visible. Keep test-mode Stripe events and production events in separate environments with separate signing secrets.
What idempotency does not solve
Idempotency prevents one event delivery from doing the same work twice. It does not choose your access policy for past_due, resolve missing account links, backfill an outage automatically, or settle a billing dispute. Stripe’s subscription documentation distinguishes statuses such as active, trialing, past_due, unpaid, and canceled; your product should document which statuses grant access and why.
For a first paid SaaS, start with a narrow supported event set, an explicit price-to-entitlement map, and a manual reconciliation view. Add automatic recovery only after you can explain how it behaves during a retry, a delayed event, a bad mapping, and a support intervention. That is a much safer path than turning every webhook into an immediate mutation of a user record.
See the webhook architecture: Frontend Accelerator keeps payment-provider behavior behind adapters and makes state boundaries explicit, so a coding agent has a structure to extend rather than a billing flow to invent. Explore the Next.js SaaS foundation.



