Use the payment provider as the authority for billing facts, and use your application database as the authority for the entitlement your code enforces. A Checkout success page can improve the customer experience, but it is not enough evidence to grant or preserve subscription access. Signed provider events and reconciliation should update a small local projection that your server can read consistently.
This distinction matters when checkout says complete, the webhook is late, and the database still says a user has no plan. It matters again when a cancellation, failed renewal, refund, or delayed payment changes the provider record after the customer has left your site. The useful goal is not to make three systems agree instantly. It is to define which system decides each question and make disagreement observable.
Give each state a narrow job
Subscription systems usually contain three different kinds of state:
- Checkout state answers whether a browser session reached a completion or return screen.
- Provider billing state describes the customer, subscription, invoice or payment, lifecycle status, and timestamps held by Stripe or Lemon Squeezy.
- Application entitlement state tells your own server whether this user can use a named paid capability right now, and why.
Do not turn one of these records into a vague universal truth. A redirect is a useful prompt to refresh a dashboard. The provider is the authoritative external record for billing. Your application still needs a local, queryable entitlement because authorization happens inside your product, often on requests that should not call the billing API.
For Stripe Checkout, a completed session and its payment status are useful signals, but Stripe explicitly recommends server-side post-payment handling through events rather than relying on the customer returning to the website. That handles delayed payment methods and the ordinary case where a customer closes the browser before the success page loads. Stripe documents the webhook-first pattern for Checkout.
The state-authority diagram
Customer redirect --> refresh UI only
Payment provider subscription
| signed lifecycle event
v
Event receipt --> verify signature --> deduplicate event ID
| |
| +-- duplicate: acknowledge, no second mutation
v
Compare event time/version with current projection
|
+-- older than stored state: audit, do not regress access
v
Local entitlement projection --> server authorization decision
|
+-- scheduled reconciliation repairs delayed or missed deliveryThis is an original decision diagram, not a claim that Accelerator currently persists this exact model. The current product repository provides Stripe and Lemon Squeezy payment adapters behind a shared PaymentAdapter, verifies provider webhook signatures, and maps checkout, subscription-update, subscription-end, and refund categories. That boundary is a useful place to keep provider payloads away from the rest of the application; the entitlement policy and durable event handling remain product-specific work.
Use provider state for billing facts
Keep provider identifiers and lifecycle facts close to the provider. For example: provider name, customer ID, subscription ID, price or variant ID, provider status, current-period or end date, and the event ID that last changed the projection. Do not replace those with a bare isPro flag if your product needs cancellation timing, plan changes, trial handling, support investigation, or reconciliation.
Stripe publishes subscription lifecycle events such as customer.subscription.updated and customer.subscription.deleted; an update can represent a plan or status change. Its event reference lists those lifecycle meanings. Lemon Squeezy similarly recommends subscription-created and subscription-updated events as a minimum, and describes lifecycle events for cancellation, expiry, pausing, failed payments, and recovery. Its event-type guide is the source of truth for the events you subscribe to.
The recommendation is not to authorize every request by making a live provider API call. That makes provider availability, rate limits, and latency part of the critical request path. Instead, use provider state to update a local projection with an explicit mapping you can test.
Make the entitlement projection explicit
Your local record should describe the decision your server must make, rather than copying every provider field. A small subscription entitlement can include the internal user ID, provider and provider subscription ID, plan key, access status, effective-through timestamp, last accepted provider event ID, last accepted provider event time or version, and an update timestamp. If the product has multiple paid capabilities, model those capabilities deliberately instead of inferring them from UI labels.
The mapping is a policy decision. For example, a product might grant access to an active subscription, keep access through an explicit cancellation end date, and move a past_due customer into a grace policy determined by the business. Lemon Squeezy's own trial guidance shows why this needs to be written down: status can move from on_trial to active, past_due, or cancelled, and its suggested access statuses are a product choice rather than a universal rule. Review the provider lifecycle before choosing your policy.
Provider status is a fact. Whether that fact permits a capability in your product is an explicit application policy.
Process events as receipts, not commands
A webhook receiver should first verify the raw-body signature. Then persist or lock an event receipt keyed by the provider event ID before applying the entitlement mutation. The event receipt gives retries one harmless answer: this event was already handled.
Next, compare the incoming event's ordering signal with the current projection. Providers can retry delivery, and independent lifecycle events can arrive in an inconvenient order. Do not assume arrival order is business order. If the new snapshot is older than the one you already accepted, keep it for audit if useful but do not overwrite current access with stale state. When the payload is too thin for a confident decision, retrieve the current provider resource and update from that verified snapshot under a bounded retry policy.
Use separate idempotency for outbound provider creation as well. Stripe supports idempotency keys on POST requests so a network retry can return the original result instead of creating a second operation. That protects the request you send to Stripe; it does not replace application-side deduplication of webhook delivery.
Test the disagreements that matter
A small test pack is more valuable than a long list of events. Test the server behavior, not only a billing settings page:
- Completed checkout with no browser return: the verified event creates the correct local entitlement.
- Browser return before webhook delivery: the UI reports pending or refreshes safely; protected server routes do not invent paid access.
- Duplicate event: the second delivery changes neither the entitlement nor downstream emails or audit counters.
- Newer cancellation followed by an older active update: the projection does not regress.
- Failed renewal, recovery, and expiry: each maps to the documented access policy.
- Webhook outage or persistent processing failure: reconciliation fetches the current provider record and repairs the projection with an audit entry.
For a worked approach to duplicate and out-of-order Stripe deliveries, see the webhook replay test guide. For the surrounding Checkout and lifecycle context, see Stripe subscriptions in Next.js.
Build for reconciliation, not perfect timing
A webhook is the fast path, not a promise that a local row can never fall behind. Keep enough provider IDs and timestamps to compare the projection with the provider during support work or a scheduled reconciliation. Reconciliation should be narrow and observable: select records that are stale, failed, or recently changed; retrieve the provider resource; apply the same entitlement policy; record what changed. It is a repair mechanism, not an excuse to skip signatures, event receipts, or normal delivery handling.
This model also keeps a provider switch more understandable. The payment adapter can normalize incoming billing data, but it cannot make status meanings, grace periods, refunds, or access rules identical. Those decisions belong in an application-owned entitlement policy with tests.
A practical decision rule
If the question is "Did the customer complete this browser flow?", use checkout state. If it is "What is the current billing lifecycle at the provider?", use the provider resource and signed events. If it is "May this authenticated user access this feature right now?", use the local entitlement projection on the server.
That gives your Next.js SaaS a clear response when the records disagree: the redirect may refresh the experience, verified lifecycle state updates the projection, and protected routes enforce a decision you can inspect. Frontend Accelerator's SaaS foundation keeps payment providers behind adapters so you can add the product-specific state policy without spreading provider SDK details through every feature.



