Gitrex Technologies
StripePaymentsRescue

Your Stripe integration probably doesn't reconcile. Here's how to tell.

AI-built apps usually mark users as paid on the success page and never listen to Stripe again. Four failure modes, the Stripe CLI test that exposes each one, and what a reconciling integration looks like.

· Founder, Gitrex Technologies

4 min read

"Reconcile" is accounting language, so let me say what I mean by it for a web app: at any moment, the paid status in your database can be derived from Stripe alone, and if the two disagree, Stripe wins.

Most AI-generated Stripe integrations fail that test on day one. They pass the demo, because the demo is "click Subscribe, enter 4242 4242 4242 4242, see the Pro badge". They fail the month after, when the first card declines and the customer keeps their Pro badge, or the first refund goes through and nothing changes.

Failure 1: the success page is the source of truth

The pattern, almost verbatim from a dozen codebases:

// pages/success.tsx
const sessionId = searchParams.get("session_id");
if (sessionId) {
  await supabase.from("profiles").update({ is_pro: true }).eq("id", user.id);
}

Two problems. The client is deciding it has paid, so anyone who navigates to /success?session_id=anything gets upgraded. And nothing ever sets is_pro back to false, so cancellations, failed renewals and refunds don't exist as far as the app is concerned.

Test: while logged in on a free account, visit /success?session_id=cs_test_fake. If you're Pro now, this is your integration.

Failure 2: there is no webhook, or it points somewhere dead

Stripe tells you about everything that happens to a subscription by POSTing events to a URL you register. If no URL is registered, none of that reaches your app.

Open the Stripe dashboard, Developers, Webhooks. Three things I look for:

  • No endpoint at all. The app has never heard about a renewal, failure or cancellation.
  • An endpoint pointing at a preview or localhost tunnel URL from when it was built. Same effect as none.
  • An endpoint with a column of red responses. It exists, Stripe is trying, and your handler is throwing. Click through and read the response body; it's usually a signature error or a 404 because the route moved.

Failure 3: the webhook doesn't verify signatures

Anyone can POST JSON to your webhook URL. Without signature verification, anyone can POST a fake checkout.session.completed for their own user id. The generated handler often looks like this:

export async function POST(req: Request) {
  const event = await req.json();   // trusts the body
  if (event.type === "checkout.session.completed") { ... }
}

It should look like this:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature")!;
  const body = await req.text();     // raw body, not parsed JSON
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("bad signature", { status: 400 });
  }
  // ...
}

The raw-body detail matters. Frameworks that parse JSON automatically break the signature, and the "fix" people find on Stack Overflow is to skip verification.

Failure 4: only one event is handled

Even with a verified webhook, I usually find a handler for checkout.session.completed and nothing else. The events that actually change someone's paid status over the life of a subscription:

EventWhat it means for you
checkout.session.completedThey paid once. Start of the story, not the whole story.
invoice.paidA renewal went through. Extend access.
invoice.payment_failedCard declined. Start the dunning clock, don't cut off yet.
customer.subscription.updatedPlan change, trial ending, past_due, paused. Re-read the status.
customer.subscription.deletedCancelled and ended. Cut off.
charge.refundedMoney went back. Decide what that means for access.

Handling all of these by hand is where bugs breed. The approach that holds up is to treat every event as a signal to re-fetch the subscription from Stripe and write its current status to the database, rather than trying to interpret each event type separately. Stripe's status is the truth; your handler just copies it.

Also store event.id and ignore duplicates. Stripe retries, and a handler that isn't idempotent will double-extend or double-cancel.

The test that takes ten minutes

Install the Stripe CLI and forward events to your local app:

stripe listen --forward-to localhost:3000/api/stripe/webhook

Then fire the events the demo never triggered and watch your database after each one:

stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
stripe trigger charge.refunded

If the user's paid status doesn't change, or the CLI shows a 400 or 500 from your handler, you've found the gap. This is the same test I run during a rescue sprint, just against the real webhook endpoint with test-mode keys.

What "reconciles" looks like when it's done

A subscriptions table keyed by Stripe's subscription id, with status, current_period_end and customer_id, written only by the webhook handler. The app derives "is this user paid" from that table at request time, never from a boolean somebody set on the success page. And a nightly job that lists active subscriptions from Stripe and compares them to the table, because webhooks do occasionally get missed and you want to find out from a log line rather than a customer.

None of this is hard. It's just not what "add Stripe subscriptions" produces when the only test is a happy path with a test card. The gap between those two things is roughly a day of senior engineering, and it's a day worth spending before your first real renewal cycle rather than after.

Wajahat Shaw, founder of Gitrex Technologies

Wajahat Shaw

Founder of Gitrex Technologies. Senior engineer; builds and rescues software for founders, most of it React Native, Next.js and Supabase. Writes here about what actually breaks and how to check for it. More about Gitrex

Keep reading
ReplitMigrationVercel

Moving a Replit app onto your own stack without breaking it

Replit Agent apps are more portable than people fear. The order of operations we use to move one to your own repo, Postgres and hosting: what to swap, what to keep, and how to cut over with a rollback.

4 min read