My Stripe checkout works and money is arriving. What else do I actually need to handle?

The answer

Checkout is the easy part and AI tools build it well. What is usually missing is everything after: the webhook that grants access when the customer closes the tab, the duplicate guard, the failed-payment path that should revoke access, and the signature check. None of it errors. It quietly leaks money and free accounts.

By Muhammad Bilal13 min read

The short version

  • The success page after checkout is not proof of payment. Stripe's own documentation says fulfilment must not depend on the customer reaching your landing page, because they might lose their connection first. The webhook is the authoritative signal, not the redirect.
  • The most common AI-generated handler implements the successful checkout event properly and reduces the failed-payment and cancellation events to a log line or a comment — while returning success for all three. Stripe then stops retrying, and the customer keeps access forever.
  • Duplicate protection is needed in two different places for two different reasons, and having one does not give you the other. Deduplicating webhooks does not prevent a double charge; only an idempotency key on the outbound request does.
  • A dispute on a small subscription costs far more than the subscription. In the EEA the fee to receive one is €20 and it is not returned even if you win, and there is a second fee to contest it.
  • Every one of these failures is silent. Payments keep arriving, the dashboard looks healthy and revenue keeps growing, which is exactly why they are usually found months late.

An AI coding assistant will build you a Stripe checkout in about four minutes, and it will work. You will click through it with a test card, the money will appear in the dashboard, and you will reasonably conclude that payments are done.

Payments are not done. Checkout is the part where the customer's card succeeds and they do not close the tab, and that is genuinely the easy path — which is why the tools have learned it so well. Everything that makes payments reliable lives in the cases where something goes slightly wrong, and those cases produce no error, no crash and no alert. They just quietly hand out free accounts or lose orders, for months, while the dashboard looks perfectly healthy.

This is a list of what is usually missing, in the order it usually costs you money.

The one fact everything else follows from

The browser is not a reliable narrator of whether you got paid.

Stripe's own fulfilment documentation carries a boxed warning, repeated twice on the same page, saying that you must not trigger fulfilment only from your checkout landing page, because customers are not guaranteed to reach it. The example they use is the plain one: the payment succeeds, and then the customer's connection drops before the success page loads.

So the success redirect is a nice thing to show a human. It is not the record of the transaction. The record is the webhook — a message Stripe sends directly to your server, which does not care what the customer's browser did afterwards.

How you know you have this wrong: your app grants access on the success page, or in code that runs when the customer lands back on your site. If the words "success_url" or "thank you page" appear anywhere near the code that upgrades an account, that is the bug.

The handler shape that gives your product away

There is a particular pattern that turns up again and again in apps built with AI assistants, and it is worth describing precisely because you can check for it without reading any code.

The successful-checkout event is fully implemented. It creates the account, sets the plan, sends the welcome email. Someone tested it and it works.

The failed-payment event is a single line that writes to the console.

The cancellation event is a comment saying it will be done later.

And all three return success to Stripe.

That last part is what turns an omission into a leak. Returning success tells Stripe the event was handled, so Stripe stops sending it. The chain runs like this: your customer's card expires, Stripe tries to charge it, the charge fails, Stripe notifies you, your server says "got it" and does nothing, Stripe stops asking — and the customer keeps full access to your product indefinitely, with no payment and no record anywhere that anything went wrong.

The same shape applies to cancellation. Somebody cancels, Stripe tells you, your handler acknowledges and does nothing, and they keep using the product for as long as they like.

Why it survives for so long: nothing about this crosses a threshold. Payments keep arriving from everybody else. Revenue keeps growing. No error appears in any dashboard, because from Stripe's point of view every message was delivered successfully and acknowledged. The only way to see it is to compare the list of failed payments in Stripe against the list of active accounts in your own database, and neither system does that comparison for you.

The events that decide who gets to use your product

If you sell subscriptions, a handful of messages carry the entire question of who has access. Stripe documents these carefully and I will keep it to the ones that change what a customer can do.

Grant access on a paid invoice, when the subscription is also active. That is Stripe's own stated pattern, and it is more precise than granting on checkout completion — because on a subscription, checkout completing and the first payment succeeding are two different moments, and they can be hours apart on some payment methods.

Past due means keep them, but tell them. The latest invoice failed, Stripe is still generating invoices, and it will retry on the schedule you configured. This is not the moment to cut anybody off. It is the moment to email them, which most AI-built apps never do — a large share of what founders record as churn is customers whose card expired and who were never told.

Unpaid means revoke. Stripe's documentation is direct about this. By the time a subscription reaches this state the retries have already happened and failed, and no further payment will be attempted.

Cancelled means revoke. Terminal, and it does not change back.

A subscription deleted event means revoke. This is the one AI-generated handlers most often leave as a comment.

Three days before a trial ends, Stripe tells you. It is a good moment to check whether a payment method exists, and a better moment to email the customer, because a trial that ends in a surprise charge is the single most common origin of a dispute.

And one that almost nobody handles, which deserves its own paragraph because it is the purest version of the whole problem.

When an invoice cannot be finalised, the subscription stays active. Stripe says so explicitly. If something is wrong with the invoice — most commonly a missing or unrecognisable customer address once automatic tax is switched on — the invoice never gets to the point of being charged, and yet the customer's subscription continues in good standing. They keep the product, you never bill them, and the only sign is an event nobody is listening to. Indefinite free service caused by an address field.

Duplicates: two problems that get confused

Almost everyone who learns about this learns half of it, so it is worth separating.

Problem one: Stripe sends you the same message twice. Stripe documents that an endpoint may occasionally receive the same event more than once, and that delivery order is not guaranteed at all — the messages generated when a subscription starts can arrive in any sequence. The defence is to record the id of every event you have processed and ignore anything you have seen before. There is a wrinkle worth knowing: in some cases Stripe generates two genuinely different event records for one underlying change, so deduplicating on the event id alone will not catch those. Keying on the id of the object inside the event, together with the event type, does.

Problem two: you accidentally charge the customer twice. This one has nothing to do with webhooks and is not prevented by any of the above. Your server sends a request to create a payment, Stripe creates it and charges the card, and then the response never reaches you — a timeout, a container restart, a worker killed mid-job. Your retry logic sends the request again. Stripe has no way of knowing it is the same purchase, so it charges the card a second time. No duplicate webhook was ever involved, so your carefully built deduplication table never fires.

The fix is a header Stripe provides for exactly this, carrying a key you send with the request. Stripe stores the outcome of the first request under that key and replays it rather than doing the work again. The detail that matters, and that gets missed: the key has to be derived from something stable, like the order it belongs to. A key generated fresh inside the retry loop is not protection, it is decoration — every retry gets a new key and every key gets a new charge.

One limit to plan around: these keys can be pruned after twenty-four hours. Anything that retries days later, such as a dunning process or a batch job, needs its own record of what it has already done.

The signature check, and why it specifically breaks in Next.js

Every webhook Stripe sends carries a signature, and verifying it is the only thing standing between your endpoint and anybody on the internet who guesses its address. Stripe's own description of the risk is blunt: without verification, an attacker can send fabricated events to trigger fulfilment, grant access or modify records. Given the endpoint is usually at a predictable address and grants paid accounts, that is not a theoretical concern.

Verification works by computing a hash over the exact bytes Stripe sent. Which brings us to the problem that catches almost everyone building on a modern JavaScript framework: most frameworks parse the request body into an object before your code ever sees it, and once that has happened the original bytes are gone. Adding or removing a space, reordering keys, converting to an object and back — any of it breaks the signature.

Stripe maintains a list of frameworks known to do this, and Next.js appears on it twice, once for each routing style, with a working example for each. Express has a rule of its own: the JSON parsing middleware has to be registered after the webhook route, because middleware order decides whether the raw body still exists by the time you need it.

If you see an error about no signatures matching the expected signature, that is this. And before you go looking at body parsing, check the simpler cause first — Stripe names the wrong signing secret as the single most common reason, particularly using the secret from the command-line testing tool against events from a real dashboard endpoint, or the reverse.

The 200 that means nothing

A developer wrote this up in June 2026 and it is the best cautionary story in the subject.

A customer reported a failed payment. Stripe's dashboard showed the webhook sent and answered with a clean success. The application logs showed nothing. The database showed nothing. The error tracker showed nothing. No handler had run.

Three hours of tracing found it: the webhook endpoint was still pointed at a route that had been removed in a deploy about two weeks earlier, and the success Stripe recorded had come from a load balancer health check on a path that still answered but no longer reached any of the payment code.

The lesson worth taking: Stripe's view of your webhook health is a view of whether something answered, not of whether anything happened. The person who wrote this up drew the right conclusion — store the incoming request somewhere in front of your application, so that when this happens you still have the payload and can replay it, rather than discovering that the only copy of a customer's payment record was a message you dropped.

When it goes wrong anyway

It will, at some point, and the recovery options have deadlines on them.

In live mode Stripe retries a failed delivery for up to three days with increasing gaps. After that it stops. From the dashboard you can resend an event for up to fifteen days after it was created; the command-line tool extends that to thirty. Beyond thirty days the full payload is gone and you have summaries only.

If you had an outage, there is a proper way to catch up rather than clicking resend a hundred times: list events from just before the outage, filtered to the types you care about and to those that failed delivery. One caveat that bites people — Stripe still considers those events undelivered and will keep retrying them, so your handler has to recognise something it has already processed and acknowledge it, or you will spend a week fighting your own backlog.

There is also a specific and expensive consequence of failing the invoice-created event that almost nobody knows about. If Stripe does not get a successful response to it, finalisation of every invoice on the account using automatic collection is delayed for up to seventy-two hours, and during that time Stripe will not attempt to charge anybody. A broken handler does not just fail to record payments. It stops them.

Disputes, where small numbers become large ones

If you sell a subscription for ten or fifteen a month, this is the section that matters most, because the arithmetic is not intuitive.

When a customer disputes a charge, the money is pulled back immediately, before you have said anything. Stripe then charges a fee for receiving the dispute, and that fee is not returned even if you win. In the United States it is fifteen dollars; across most of the EEA it is twenty euros. Since June 2025 there is a second fee for contesting one, which is returned if you win. So a ten-euro subscription that gets disputed costs you the ten euros plus twenty. Fight it and lose, and it is fifty.

You get exactly one submission of evidence, with a combined file size limit and, on one network, a page limit. Banks will not follow links or watch videos. The issuing bank's decision can take up to three months.

And then there is the part that ends businesses rather than merely costing them, which is the card networks' monitoring programmes. Two things about them are worth knowing because they contradict what most advice says. Refunding a customer does not remove their chargeback from the count, and the outcome of a dispute is irrelevant to it, because the networks are not going to wait months to find out who won. And the thresholds are lower than the folklore — most writing repeats one per cent as the danger line, which corresponds to nothing in the current rules. One network's programme flags non-compliance at half a per cent, with a floor as low as five events, and it counts early fraud warnings alongside disputes, with a transaction that appears in both counted twice. On a small subscription business, five is not a large number.

Stripe's own list of ways to prevent subscription disputes reads like a product specification, and it is the cheapest work in this entire article: a cancellation button inside the app, so nobody has to call their bank to make it stop; the billing terms shown before the card is entered; a reminder before a trial converts; and a renewal reminder, roughly a week before an annual charge and a couple of days before a monthly one.

Test mode and live mode

Briefly, because it is the source of a whole category of launch-day panic.

The two modes are completely isolated and objects do not cross between them. A price you created while testing does not exist in live mode. Stripe's own advice when you recreate them is not to make your code cleverer but to use the same identifiers, so the code keeps working unchanged.

Webhook endpoints are separate too, and each has its own signing secret, including when the address is identical. Recreating an endpoint gives you a new secret, which has to be deployed before you can trust anything it sends.

The ten-minute check

Open Stripe, go to your webhook endpoint, and look at recent deliveries. Filter to the failed-payment event and pick any customer in the list. Now look that customer up in your own database.

If they show as active and paid, you have the leak, and you have found it in less time than it took to read this section.

While you are there, check three more things. Does an endpoint exist at all for the cancellation event. Does your code record which event ids it has already processed. And does your outbound payment creation send an idempotency key derived from the order rather than generated at random.

If you would rather not go through this alone

None of this was ever explained to you, and none of it announces itself. If you have found something above and are not sure how far it goes, or if you would rather have somebody walk the whole path once and tell you plainly what is missing, that is a defined piece of work rather than a hire.

I do a Production-Ready Audit that covers exactly this ground: every payment path walked, the webhook handler read against what Stripe actually sends, duplicate protection checked in both of the places it needs to exist, and a reconciliation of who currently has access against who is actually paying. From $499, back in five to seven days. If the checks above came back clean, I will tell you that and you keep your money.

You are also welcome to send me a screenshot of your webhook deliveries page. I will tell you what I see in it, at no charge and with nothing attached.

The repair work is described on the AI SaaS rescue page. Payments are one of ten things that tend to be missing together, and the rest of that list is in the ten problems every AI-built app has in production. If the symptom you actually have is that everything worked until real customers arrived, that is a different article — why your app breaks when real users show up — and if something is broken right now, start with my app is down and I do not know why.

Follow-up questions

What people ask next

How can I tell in a couple of minutes whether I have the free-access leak?

Open your Stripe dashboard, go to your webhook endpoint, and look at recent deliveries filtered to the failed-payment event. Note a customer from that list, then look them up in your own database. If their account still shows as active and paid, you have it. That one lookup answers the question faster than reading any code.

Do I really need webhooks if I only sell one-off purchases?

Yes, though the case is less severe than for subscriptions. For one-off payments the risk is that a customer pays and never gets what they bought, because they closed the tab before your success page loaded. For subscriptions webhooks are not optional at all — the entire lifecycle after the first payment happens asynchronously and there is no other way to learn about it.

My webhook shows 200 in Stripe. Doesn't that mean it worked?

It means something at that address answered. It does not mean your handler ran. There is a documented case of a developer spending three hours on this: Stripe recorded a clean 200, but the endpoint pointed at a route removed in a deploy two weeks earlier, and the 200 came from a load balancer health check on a path that still responded. Check your own logs, not Stripe's.

Is this something I can fix myself?

The individual pieces are each small. Adding an idempotency key, verifying the signature properly, handling the failed-payment event — none of these is more than an hour of work for someone who knows the shape of the fix. What takes the time is working out which ones you are missing and reconciling the customers whose access is already wrong, because that part is not a code change, it is a data cleanup with real money attached.

Related reading

Production-Ready Audit

Every table's row-level security reviewed, keys checked and rotated, auth and payments tested — back as a written fix list in priority order.

From $499 · 5–7 days

Muhammad Bilal, Full Stack AI Developer

Muhammad Bilal

Full Stack AI Developer · Faisalabad, Pakistan

I build and rescue production AI SaaS products with Next.js, Supabase, Stripe and Claude. Most of my work is finishing apps that were started with Lovable, Bolt, Cursor or Replit and stalled somewhere between working and shippable.

SF
MS
BK
AS

5.0★ · 100% job success · 35+ projects delivered

All articles · RSS