CAY-N5 Revised 20 Aug 2026

The card goes through. Nothing after it was ever tested.

Payments feel finished the moment a test card clears. Every path after that one is walked by customers and never by you: a bank declines at the till, an event arrives twice, a renewal fails overnight and nobody is told.

The symptom is always one of two shapes. Somebody paid and did not get the thing, or somebody got the thing and stopped paying for it.

The short answer

Clearing a test card covers one path out of about eight, and the rest are failures. A decline your app reports as a success. An event Stripe delivers twice, granting a month of access twice. An event that never lands because your server was mid-deploy. A refund issued in the Stripe dashboard that leaves the customer holding the thing they paid for. A chargeback arriving six weeks later with a fee and a deadline. The three fixes that close most of it: verify the webhook signature, make the handler safe to run twice, and write who has paid into your own database from the webhook, never from the checkout redirect.

The paths a customer walks and you do not.

  1. 01

    The redirect back from checkout is not a receipt

    Granting access when the browser returns from Stripe means two things at once. Anyone who can reach that URL gets the thing without paying, and anyone whose browser closed on the way back pays and gets nothing.

    The webhook Stripe sends your server is what should write the row in your own database. Use the redirect to say thank you, and for nothing else.

  2. 02

    An unverified webhook is a public button that grants your product

    Stripe signs every event it sends. Where the signature is not checked, anybody who finds the endpoint can post their own event saying a session completed, and your app will believe it.

    It is one call in the Stripe library, and it is the difference between an endpoint only Stripe can use and one the whole internet can.

  3. 03

    The same event will arrive twice

    Stripe retries anything that does not answer with a 2xx, and duplicate deliveries are by design. A handler that adds a month of subscription per event will add two months for one payment.

    Record the event id the first time you see it and ignore it afterwards, or write the change so running it a second time lands on the same result. Either works, and neither happens unless you write it.

  4. 04

    The webhook that never lands at all

    If your endpoint is down, Stripe retries for up to three days with widening gaps and then gives up. Nothing tells you afterwards.

    Without something that compares payments in Stripe against entitlements in your database, the customers who paid during a deploy stay locked out until one of them writes to you, and the ones who do not write, leave.

  5. 05

    Declines are the ordinary case, not the error case

    Cards fail for reasons a customer can act on and reasons they cannot: no funds, an expired card, a bank sending a 3-D Secure challenge to an app they do not have open, an issuer that dislikes a foreign merchant. Showing the same red line for all of them turns a decline they could have fixed in ten seconds into a sale you never made.

    Stripe tells you which it was. Pass that through with the retry still in front of them and you get most of those sales back.

  6. 06

    Refunds and chargebacks run backwards through your app

    Refunding in the Stripe dashboard moves the money and touches nothing in your database. Somebody has to remove the access it paid for, and where nobody wrote that path, refunded customers keep the product.

    A chargeback is the same problem with a clock on it. It arrives weeks after the sale, takes a fee whichever way it goes, and gives you a deadline to submit evidence that nobody has gathered.

  7. 07

    Subscriptions lapse without a sound

    A card expires, the renewal fails, Stripe works through its retry schedule and eventually cancels. An app listening only for the cancellation leaves the customer in limbo for the whole retry window with no idea anything is wrong. An app listening for nothing keeps serving them for free.

    Both ends need a path: tell the customer while the card can still be fixed, and close access when it is not.

  8. 08

    Test keys in production, live keys in the repository

    A key beginning sk_test in a live deployment means real customers pay nothing and receive nothing, and it usually surfaces as a support email, because nothing errors. A key beginning sk_live committed to the repository means somebody else can charge on your account, and deleting it in a later commit does not remove it from the clone.

Seven things to run against your own integration.

Every one uses Stripe’s own test tooling on a build you already have. Stop at the first bad answer; it will be the one costing you money.

  1. 01

    A forged event is rejected

    Post an unsigned event to your webhook endpoint from a terminal.

    curl -si -X POST https://your-app/api/stripe -H "content-type: application/json" -d '{"type":"checkout.session.completed"}'

    Bad answerAnything other than a 400, and certainly anything that grants access.

  2. 02

    The same event twice grants once

    Resend a delivered event from the webhook log in the Stripe dashboard, then read the customer’s row.

    Bad answerTwo months of access, two credits, or two rows.

  3. 03

    A decline says something the customer can use

    Pay with Stripe’s generic decline card and read what your app shows.

    4000 0000 0000 0002   (generic decline)
    4000 0025 0000 3155   (3-D Secure required)

    Bad answer"Payment failed", with no reason and nothing to press.

  4. 04

    A refund removes what it paid for

    Refund a test payment in the dashboard, then reload the app signed in as that customer.

    Bad answerThey still have the thing.

  5. 05

    A lapsed subscription reaches somebody

    Advance a test clock past a failed renewal in Stripe and watch what the customer and you receive.

    Bad answerNothing happens, and access continues.

  6. 06

    You can ask who paid and did not get it

    Query your own database for customers with a successful Stripe payment and no active entitlement.

    Bad answerThe question cannot be asked, because the two live in different places with nothing joining them.

  7. 07

    No key is in the wrong place

    Check the running environment for test keys and the repository history for live ones.

    git log -p --all | grep -niE "sk_live|rk_live|whsec_"

    Bad answerAny hit in history, or sk_test answering in production.

When this sheet is the wrong one

If the thing being sold is switched on inside an iOS app, none of this is the problem you have. Apple requires that payment to go through in-app purchase, and a Stripe checkout there comes back rejected under 3.1.1 however well the webhook is written. Fix which rails you are on before you improve the ones you are not allowed to use.

Asked most often.

Why did a customer get charged twice?
Usually they were not. One payment with a handler that ran twice looks identical from the outside. Count the PaymentIntents in the Stripe dashboard before refunding anything: one means the money is right and your database is wrong, which is a different fix.
How long does Stripe retry a failed webhook?
Up to three days in live mode, with the gaps widening each time, and then it stops. After that the only record is in your Stripe event log, and nothing in your app knows a payment happened.
Does payment state need to live in your own database?
Yes. Stripe is the record of the money and your database is the record of who has what. Asking Stripe on every request is slow, costs a network call for every page load, and takes your paywall down whenever Stripe has a bad afternoon.
Is Stripe Checkout enough on its own?
For taking the money, yes, and it handles cards, wallets, 3-D Secure and the local methods your customers expect better than anything you would write yourself. What it will not do is decide who has access once the payment clears, so you still have to write that part and then test it.