Building · October 8, 2026 · 5 min read

Stripe webhooks done right in AI-built apps

Most AI-built apps get Stripe Checkout working in an afternoon. The payment goes through, the user lands on a success page, and the app unlocks the paid features. It feels done.

The problem is that the success page proves nothing. Anyone can visit that URL by hand, and a user who pays but closes the tab before the redirect gets nothing. The reliable signal is the webhook: a message Stripe sends to your server when something happens. Here's how to handle it properly.

Why the success page can't unlock anything

A redirect happens in the user's browser, which you don't control. Treat the success page as a friendly message only. It can say "thanks, we're setting up your account" and poll your own database for the result, but it should never be the thing that grants access.

Payments also change after checkout. Subscriptions renew, cards fail, customers cancel, disputes get opened. None of that involves your success page. Only webhooks tell you.

Step 1: verify the signature

Your webhook endpoint is a public URL. Without verification, anyone can send it a fake "payment succeeded" event. Stripe signs every event with your endpoint's signing secret, and the official libraries check it for you.

// Next.js route handler: app/api/stripe/webhook/route.ts
import Stripe from "stripe";

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

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

Two details trip people up. The signature is checked against the raw request body, so parsing it as JSON first breaks verification. And the signing secret (it starts with whsec_) is different for each endpoint and for the Stripe CLI, so make sure production uses the one from your live endpoint.

Step 2: make the handler idempotent

Stripe may deliver the same event more than once, and it retries when your server doesn't respond with a success status in time. Events can also arrive out of order. Your handler has to produce the same result whether it runs once or five times.

  1. Create a table of processed events with the Stripe event ID as a unique key.
  2. When an event arrives, try to insert its ID. If it already exists, return 200 and stop.
  3. Do the actual work, like updating the subscription, in the same database transaction where possible.
  4. Prefer setting state over incrementing it. "Set plan to pro" is safe to repeat. "Add 100 credits" is not, unless it's guarded by the event ID.

For out-of-order events, don't rely on the event payload alone. When a subscription event arrives, fetch the current subscription from the Stripe API and store that. The latest state from Stripe always wins.

Step 3: store entitlements in your own database

Your app shouldn't ask Stripe on every page load whether a user has paid. Keep a simple record in your database and update it from webhooks. A minimal version:

  • user_id, linking to your own users table
  • stripe_customer_id, so you can match incoming events to the right user
  • plan, the product or price the user is on
  • status, copied from Stripe, such as active, trialing, past_due or canceled
  • current_period_end, so you know when access should lapse

Your app and your database rules then check this table, which users must not be able to write. In Supabase, that means RLS with no insert or update policy for normal users, and the webhook writing with the service role key on the server.

To link a checkout to a user, pass your user ID when you create the Checkout Session, using client_reference_id or metadata. Create the session on your server, where you know who is signed in.

Step 4: handle the events that matter

You don't need to listen to everything. For most subscription apps, these cover the lifecycle:

  • checkout.session.completed, to link the new customer and subscription to your user
  • customer.subscription.created, updated and deleted, to keep plan and status in sync
  • invoice.paid, to confirm renewals
  • invoice.payment_failed, to warn the user and decide on a grace period

For one-time payments, checkout.session.completed is usually enough, but check that payment_status is paid. Some payment methods complete the session before the money actually arrives.

Step 5: test it like it will fail

  1. Use the Stripe CLI to forward events to your local server and trigger test events with stripe trigger.
  2. Send the same event twice and confirm nothing is granted twice.
  3. Send a request with a bad signature and confirm it returns 400.
  4. Cancel a test subscription and confirm access is removed at the right time.
  5. Check the webhook section of the Stripe dashboard for failed deliveries after each deploy.

Keep the handler fast. Return 200 quickly and move slow work, like sending emails, to a background job, so Stripe doesn't time out and retry.

Payments are where small mistakes cost real money, so a Deeraf Tech Check always traces the full path from checkout to entitlement. If the webhook needs rebuilding, that fits neatly into a Build Sprint.

Keep reading

Want a second pair of eyes on your app?

Book a Tech Check