The customer has a receipt. Your app still shows the upgrade button.

This is not merely a support problem. The product accepted money and failed to deliver the promised state.

Do not ask the customer to pay again. Do not flip a database field without learning why the event failed. Restore access safely, preserve the evidence, and trace the payment boundary in order.

Checkout collects payment. Fulfillment grants the thing that payment bought.

Those are related events, not the same event.

Stop making the mismatch worse

If customers can complete more charges without receiving access, pause or limit the affected checkout path. Keep unrelated free or already-paid product areas working when possible.

For the customer in front of you:

  • confirm the payment in Stripe using its object or session identifier;
  • confirm the amount, currency, product, and payment status;
  • identify the application account that should receive the entitlement;
  • grant bounded access through an auditable support action when the evidence is clear;
  • record who changed what, why, and for how long;
  • explain that payment succeeded and the account update is being repaired;
  • prevent another purchase for the same missing entitlement.

Do not request full card details or ask the customer to send a secret receipt URL. Use safe identifiers and the provider dashboard.

The temporary repair should not destroy the original failure state. You still need the event, delivery attempt, application logs, and database record to prevent recurrence.

Draw the payment state machine

Many early integrations use one Boolean called paid. Reality has more states.

For a simple purchase, separate at least:

  1. Checkout session created.
  2. Customer entered the payment flow.
  3. Payment is processing, succeeded, or failed.
  4. Provider event was delivered.
  5. Event was verified and accepted.
  6. Fulfillment completed once.
  7. Entitlement became visible to the correct account.
  8. Refund, dispute, or reversal changed what happens next.

For subscriptions, also distinguish trialing, active, past due, cancelled, paused, and ended according to the commercial promise you made. Do not copy provider status names directly into product behavior without deciding what each means for access.

The useful invariant is concrete:

One successful purchase grants the intended entitlement to the intended account once, even when messages are delayed, duplicated, or reordered.

Every diagnostic step should test part of that sentence.

Do not trust the success page as payment proof

The browser returning from Checkout is useful for showing the customer what happens next. It is not durable proof that fulfillment must happen.

The customer can close the tab, lose connectivity, block the redirect, refresh the page, or share the URL. The redirect may also arrive before your asynchronous processing finishes.

Stripe's current Checkout fulfillment guide requires webhooks for reliable fulfillment because the customer is not guaranteed to reach the landing page. It also describes using the landing page to invoke the same idempotent fulfillment function when immediate customer experience matters.

That means both paths can request fulfillment, but neither path is allowed to grant it twice.

The success page should retrieve server-verified status using the Checkout Session identifier or your own safe order identifier. It should never accept paid=true, a plan name, price, user ID, or entitlement from query parameters as authority.

Find the exact Stripe objects

Start with the successful Checkout Session, PaymentIntent, invoice, or subscription in Workbench. Record identifiers without publishing them.

Then connect them to your own records:

  • application user or organization ID;
  • internal order or purchase ID;
  • intended price and product;
  • Checkout Session ID;
  • Stripe Customer ID;
  • PaymentIntent, invoice, or subscription ID;
  • relevant event ID;
  • fulfillment record and time.

Do not rely on email as the only join key. Customers change email addresses, use aliases, buy for another teammate, and sometimes share billing addresses. Attach an internal immutable identifier when creating the Checkout Session, using the provider fields intended for reconciliation, and verify it on the server before granting access.

Never let the client choose an arbitrary account ID and then grant that account whatever price appears in the returned request. Create the session on the server from an authenticated account and a server-owned price mapping.

Check the live endpoint, not the local listener

A Stripe CLI listener that worked on localhost does not register the production endpoint.

In Workbench, inspect the live event destination:

  • exact HTTPS URL;
  • enabled status;
  • live versus sandbox mode;
  • account or connected-account scope;
  • subscribed event types;
  • API version used for event payloads;
  • signing secret belonging to that endpoint;
  • recent delivery attempts and response codes.

Test and live webhook signing secrets are different. A CLI forwarding secret is different from a Dashboard endpoint secret. A preview endpoint may still be receiving the event while production waits forever.

Open the failed delivery. Match its timestamp and event ID to application logs. A 404 suggests the route never existed at that deployed path. A 301 or 308 may expose a hostname mismatch. A 400 often points to signature or parsing failure. A 500 says the handler ran and failed. A timeout means Stripe cannot tell whether your work completed.

The custom-domain diagnosis helps when the webhook hostname resolves inconsistently, presents the wrong certificate, or redirects away from the registered route.

Verify the signature against the raw body

The webhook endpoint is public by design. Treat every request as untrusted until its Stripe signature verifies.

Verification needs three exact inputs:

  • the unmodified request body;
  • the Stripe-Signature header;
  • the signing secret for this specific endpoint and mode.

Framework middleware often parses JSON before your handler receives it. Parsing can alter whitespace, encoding, or key order, so verification fails even though the payload looks identical as an object.

Stripe's signature troubleshooting guide identifies a mutated body and the wrong endpoint secret as common causes. Configure the webhook route to read the raw body first, verify with Stripe's official library, and only then parse or dispatch the event.

Do not log the signing secret or full payload to debug verification. Log the event ID, type, live-mode flag, endpoint identity, verification outcome, and a safe request correlation ID.

Reject invalid signatures. A webhook that accepts unsigned payment claims gives strangers a route to grant themselves access.

Return quickly, work durably

Webhook handlers should verify, record, enqueue or perform a small idempotent transition, then return a successful response quickly.

Do not wait inside the delivery request for a long model job, email campaign, report build, CRM update, or third-party synchronization. A slow dependency can cause a timeout and another delivery.

A durable pattern is:

  1. Verify the signed event.
  2. Record its ID, type, object ID, and receipt time under a uniqueness constraint.
  3. Create or wake one internal fulfillment job.
  4. Return 2xx.
  5. Process the job with bounded retries.
  6. Record fulfillment outcome and entitlement state.

Be careful with the acknowledgement boundary. Returning success before the event is durably recorded can lose the work. Doing every downstream task before returning can provoke duplicate delivery. The seam belongs after durable acceptance and before slow effects.

Stripe's webhook documentation recommends returning 2xx before complex logic and notes that live-mode deliveries may be retried with exponential backoff for up to three days.

Make fulfillment idempotent

Stripe can deliver the same event more than once. The landing page and webhook may call fulfillment concurrently. A support operator may resend a failed event while automatic retries remain scheduled.

Idempotency means repeated attempts converge on one result.

Enforce it in storage, not only with an in-memory check:

  • unique event ID for accepted deliveries;
  • unique purchase or Checkout Session ID for fulfillment;
  • one entitlement row or transition per purchased item;
  • transaction around checking and writing state;
  • safe response when another worker already completed the job.

For some duplicate situations, Stripe recommends considering both the event type and the ID of the object in data.object, because two separate Event objects can describe the same underlying change.

Do not mark an event processed before the necessary local state is durable. Do not send a second welcome email, refill credits twice, or create two licenses because the event returned twice.

Stripe's fulfillment guidance says the fulfillment function must safely handle being called multiple times, including concurrently, for the same Checkout Session. That is a product invariant, not an optional optimization.

Expect events out of order

Do not assume subscription events arrive in the order they were created.

An invoice event may reach you before the subscription event your code expects to create the local record. A cancellation or payment failure may race with a delayed success. Your queue may also process deliveries in a different order.

When an event refers to an object you do not yet have, retrieve the current authoritative object from Stripe when appropriate, then reconcile your local state. Use timestamps and state-transition rules rather than “last handler wins.”

Avoid blindly applying an old snapshot after a newer transition. Record the provider object's relevant version or update time when available, the event creation time, and the local transition you performed.

Stripe explicitly states that event order is not guaranteed. Build the handler as a reconciler, not a screenplay.

Distinguish completed Checkout from settled payment

Some payment methods complete later than cards. A Checkout Session can finish while the payment remains processing.

Decide what the product may deliver before settlement. Low-cost reversible access may be acceptable. Irreversible work, expensive fulfillment, transfer of value, or public publication may need confirmed success.

Handle the events and statuses for delayed success and delayed failure that apply to the payment methods you enable. Do not treat every checkout.session.completed event as identical without checking payment status and the product consequence.

Stripe's fulfillment guide discusses checkout.session.async_payment_succeeded and checkout.session.async_payment_failed for delayed methods. Keep this behavior visible to the customer: “Payment processing” is better than “Payment failed” or a permanent spinner when the outcome is still unknown.

Separate payment from entitlement

Keep a local entitlement model that describes what the customer can use now and why.

Useful fields include:

  • user or organization owner;
  • product or capability;
  • source purchase, subscription, or invoice;
  • current entitlement state;
  • effective and expiry times;
  • quantity, seats, or remaining usage where relevant;
  • last reconciled provider state;
  • manual override with actor, reason, and expiry.

Do not query Stripe on every page view to decide whether a button should appear. Provider state and product access are related but not identical, and network failure should not randomly log a paying customer out.

Define policy for failed renewal, cancellation at period end, refund, chargeback, plan change, and grace period. Billing APIs should manage recurring billing rather than a homemade loop of raw charges. The product still owns the access policy that follows each state.

Reconcile what webhooks missed

Webhooks are durable delivery, not magic certainty.

Build a reconciliation job that can compare recent provider objects with local fulfillment and entitlement records. Look for:

  • successful payments without fulfillment;
  • active subscriptions without the expected entitlement;
  • local paid accounts tied to ended or missing subscriptions;
  • refunded purchases whose irreversible work needs review;
  • received events stuck in processing;
  • repeated failures for one event type;
  • objects that cannot map to an internal account.

Run it after an incident and on a modest schedule appropriate to the product. Make repairs idempotent and auditable.

Manual event resend is useful after the handler is fixed, but Stripe notes that a manual resend does not cancel already-scheduled automatic retries. Fix idempotency before pressing resend.

Give the customer a truthful pending state

The success page should not oscillate between “unpaid” and “paid” based on timing.

Use explicit states:

  • confirming payment;
  • payment processing;
  • access being prepared;
  • ready;
  • payment failed;
  • needs support.

Poll your own fulfillment record or use a bounded live update. Do not recreate Checkout or charge again when confirmation takes longer than expected.

Show a receipt reference safe for support, the product purchased, and what the customer can expect next. Send a confirmation after durable fulfillment, not merely after button submission.

If access remains pending beyond the normal window, alert the team before the customer has to explain the purchase.

Test the ugly payment paths

Run these in a sandbox, then make one controlled live purchase and refund when appropriate:

  1. Successful instant payment.
  2. Customer closes the tab before the success page.
  3. Success page loads before webhook processing finishes.
  4. Success page refreshes several times.
  5. Same event is delivered twice.
  6. Two fulfillment attempts run concurrently.
  7. Events arrive out of order.
  8. Signature is invalid.
  9. Handler records the event, then the worker fails.
  10. Provider delivery times out after local work succeeds.
  11. Payment method reports delayed success.
  12. Delayed payment fails.
  13. Subscription renews.
  14. Renewal payment fails.
  15. Customer cancels at the end of the period.
  16. Purchase is refunded or disputed.
  17. User changes their application email.
  18. Production receives a sandbox event or uses the wrong signing secret.

For every case, inspect provider state, local order, entitlement, email, logs, and the customer-visible screen.

Use a restricted API key with only the permissions the service needs where possible, keep keys server-side, and maintain separate credentials for each environment. These requirements come from the Stripe best-practices security model, not from the convenience of the demo.

Add the results to the AI-built app production checklist and the launch-day runbook. Payment mismatch should have an owner, alert, containment action, and reconciliation query before launch traffic arrives.

Keep the payment incident record

For each mismatch, write:

Customer and internal account: Purchase and intended entitlement: Provider object IDs: Payment state: Event and delivery attempts: Signature verification result: Application request ID: Local fulfillment state: Customer-visible state: Temporary repair: Root cause: Permanent boundary added: Reconciliation result:

The customer should not need to understand webhooks. They paid for a result.

Build the boundary so that browser redirects can fail, messages can repeat, events can arrive late, and workers can restart without changing that result. One successful payment. One intended entitlement. One auditable path between them.