A customer pays, sees your success page, and lands in a dashboard that still says "Free plan". Another customer's card failed weeks ago and they still have every paid feature. Neither bug appears in a happy-path demo. Both appear in support tickets and quiet revenue leakage.
The usual cause: the app treats "Stripe said it succeeded" as one fact, when it is four.
Four records that are easy to confuse
Payment. Money moved, or is moving, through a PaymentIntent. It can succeed, fail, wait for customer authentication, or stay in processing for days with bank debits.
Invoice. What is owed for a period. A card charge can pay it, but so can credit balance, a zero amount, or marking it paid outside Stripe.
Subscription. Stripe's record of the commercial relationship: status, subscription items and their billing periods, scheduled cancellation.
Entitlement. What your application lets this account do right now. It lives in your database. Stripe informs it, and offers an optional Entitlements feature, but the rules for trials, grace periods, downgrades and refunds are your business decisions.
No single Stripe event proves access. Derive access from current Stripe state plus your written policy, and treat events as signals to recompute it.
What Stripe's documentation says
A success page is not evidence of payment. Stripe's fulfillment guide warns that customers may pay and never load your landing page, and requires webhooks for subscriptions and delayed payment methods. It also notes fulfillment may run several times, even concurrently, for one Checkout Session.
invoice.paid does not mean a card was charged. The invoicing overview lists paid invoices with no successful PaymentIntent: trials and free subscriptions, amounts covered by credit balance or below the minimum charge, and invoices marked paid out of band.
Status alone does not prove collection. Stripe's subscription lifecycle documentation explains that some asynchronous payment methods can activate a subscription before payment settles; the subscription can remain active even if that payment later fails. Trial-end behavior and the result of exhausted retries also depend on your configuration. Check the relevant invoice and payment outcome, not just active.
Delivery is unordered and repeatable. The webhooks guide says events can arrive out of order and more than once, so a local sort of notifications is not a substitute for reconciliation. The subscription webhook guide also documents how a failed response to invoice.created can delay automatic invoice finalization.
Write the access policy first
A test needs an expected result, and "whatever Stripe does" is not one. Decide:
- What a trial unlocks, and what happens if it ends without a payment method.
- Grace period length and feature level while
past_due. - Whether upgrades unlock immediately or after the prorated amount is paid.
- Whether downgrades apply now or at period end.
- Whether refunds and disputes affect access, and who decides.
The scenarios below use an example policy. Replace the expected access decisions with yours.
Twelve acceptance scenarios
1. First paid purchase
- Money and subscription: Charge succeeds;
active. - Expected access: Paid plan, granted once.
- Relevant notifications:
checkout.session.completed,invoice.paid. - Acceptance evidence: One entitlement; latest invoice paid.
2. 3DS required, abandoned
- Money and subscription: None;
incomplete, thenincomplete_expired. - Expected access: No paid access; "finish payment" prompt.
- Relevant notifications:
invoice.payment_action_required. - Acceptance evidence: Success-page visit grants nothing.
3. Delayed payment method
- Money and subscription: Processing, then success or failure; Can already be
activebefore settlement. - Expected access: Pending or explicitly provisional access; apply outcome policy.
- Relevant notifications:
checkout.session.async_payment_succeeded,checkout.session.async_payment_failed. - Acceptance evidence:
activealone never proves collection.
4. Trial starts
- Money and subscription: None ($0 invoice);
trialing. - Expected access: Trial tier.
- Relevant notifications:
customer.subscription.created,invoice.paid. - Acceptance evidence: $0 invoice doesn't mark account as paying.
5. Trial ends
- Money and subscription: First charge, or none; Depends on payment and missing-payment-method settings; may be
active,past_due,pausedorcanceled. - Expected access: Paid, grace, or trial-ended screen.
- Relevant notifications:
customer.subscription.trial_will_end,customer.subscription.updated,customer.subscription.deleted. - Acceptance evidence: Test the actual configured outcome.
6. Renewal succeeds
- Money and subscription: Charged;
active, renewed item period. - Expected access: Extended.
- Relevant notifications:
invoice.paid. - Acceptance evidence: Relevant item period matches Stripe.
7. Renewal fails
- Money and subscription: None yet;
past_due. - Expected access: Access plus banner; grace deadline stored.
- Relevant notifications:
invoice.payment_failed. - Acceptance evidence: Not cut at first failure.
8. Retries exhausted
- Money and subscription: None;
canceled,unpaidor stillpast_due, depending on settings. - Expected access: Revoke or restrict under the explicit terminal/grace policy.
- Relevant notifications:
customer.subscription.deleted,customer.subscription.updated. - Acceptance evidence: A retained
past_duestatus cannot silently grant access forever.
9. Mid-cycle upgrade
- Money and subscription: Per proration settings; may bill next cycle;
active, new items. - Expected access: Higher tier now or after payment.
- Relevant notifications:
customer.subscription.updated. - Acceptance evidence: Tier read from subscription items.
10. Downgrade
- Money and subscription: Credit may go to balance; New items or scheduled change.
- Expected access: Lower tier now or at period end.
- Relevant notifications:
customer.subscription.updated,subscription_schedule.*. - Acceptance evidence: Limits enforced at right moment.
11. Cancel at period end
- Money and subscription: No new charge;
activewithcancel_at_period_end, later deleted. - Expected access: Full access until period end.
- Relevant notifications:
customer.subscription.updated, thencustomer.subscription.deleted. - Acceptance evidence: No early revocation.
12. Refund vs cancellation
- Money and subscription: Returned, or not; A refund doesn't cancel; cancelling doesn't refund by default.
- Expected access: Per policy, applied deliberately.
- Relevant notifications:
charge.refunded,customer.subscription.deleted. - Acceptance evidence: Refund never changes access silently.
Webhooks: accept durably, then act
- Verify on the server. Use Stripe's library with the raw body, the
Stripe-Signatureheader and that endpoint's secret. Keep the library's timestamp checks. Verify the Stripe account and test/live mode, then map the customer to the correct application tenant using a server-owned relationship, not a supplied email address. Verification failures get a 4xx and no side effects. - Persist before acknowledging. Store the verified event durably, with a unique constraint on event ID, then return 2xx. If storage fails, return an error so Stripe retries. A fast response means storing now and processing later, not acknowledging and hoping.
- Process from a queue. A worker re-fetches the current subscription and invoice from Stripe and recomputes the entitlement under a versioned policy. Serialize work for the same subscription, or use locking and conditional writes, so a slower worker cannot overwrite a newer decision. Apply the entitlement and processing record atomically; a fresh fetch alone does not prevent concurrent-worker races.
- Deduplicate the business operation. Event-ID dedupe stops redelivery, but the same outcome can arrive via Checkout,
invoice.paid, the success page and a manual resend. Key grants on what is granted, such as "subscription X, invoice Y". - Make side effects idempotent. Emails, credits, seats and Stripe API writes need keys derived from the business operation, so a retried worker can't double-grant or double-bill.
Test in a sandbox, never with real money
- Use a dedicated Stripe sandbox with its own keys and webhook secret, plus a separate app database with obviously fake users. Nothing in this setup should reach live keys or real customers.
- Drive real test subscriptions. Stripe notes that CLI-triggered events carry fake data unrelated to your subscriptions.
- Use Stripe's published test payment methods for successful collection, failing renewals and customer authentication. Never use real card details in the sandbox.
- Use test clocks for trials, renewals and prorations. Check the documented restrictions for the scenario and payment method; a clock does not simulate every real-world collection or delivery behavior.
- Break things on purpose: resend events, stop the worker and let events pile up, return 500 and watch retries.
A green run proves your logic against Stripe's state transitions. It doesn't prove production delivery timing or behaviour at renewal peaks.
Reconcile Stripe against your app
Events will eventually be missed: a bad deploy, a rotated secret, a stuck worker. A scheduled job using a read-only restricted key lists subscriptions and compares status, tier, relevant subscription-item billing periods and scheduled cancellation with your entitlements. Report four buckets: qualifying paid periods without access, unexplained access, wrong tier and wrong period. Compare against the policy: legitimate trials, grace periods, credits and approved out-of-band payments are not automatically errors. Start report-only. Any repair path needs separate, scoped write authority and an auditable policy decision; a read-only audit key must not silently become a correction credential.
Route ambiguous cases to a person: out-of-band payments, partial refunds, open disputes, customers with two subscriptions, or a success-page screenshot while Stripe shows incomplete. Give them an owner, a runbook, and an audit log of overrides with reason and expiry.
Scope
This is our recommended acceptance framework, built from Stripe's public documentation. It is not an official Stripe checklist, and it is not legal or tax advice on refunds or invoicing. For the wider production review, see our AI-built app production audit.
If your product is live and billing, webhooks and the surrounding code need to stay correct as Stripe, pricing and features change, that is the work of our application maintenance service.


