Skip to main content

Vibe Code Stripe Subscriptions: What the AI Gets Wrong About Billing (2026)

13 min read
Vibe Code Stripe Subscriptions: What the AI Gets Wrong About Billing (2026)

TL;DR

  • The AI writes the Checkout session correctly almost every time. It writes the webhook wrong often enough that you should assume it did.
  • Four failures account for nearly all of it: the success page grants access, the webhook never verifies the signature, the handler parses the body before Stripe can check it, and cancellation is answered with a 200 and nothing else.
  • An unverified webhook endpoint is not a code-quality problem. Anyone who finds the URL can post a fake checkout.session.completed and give themselves a paid plan.
  • Test the whole loop locally with stripe listen and a test card before any real money moves. The three lines to read before you ship are at the end.

Your app works. Someone wants to pay for it. You ask the builder for Stripe subscriptions, it writes about ninety lines, the test card goes through, and the success page says thank you.

That is the part that works. The part that does not work is quieter: a customer cancels and keeps their paid features for a year, or a payment succeeds and the account never flips, or somebody who found your webhook URL is on the Pro plan without ever paying. None of those show up in a demo. All of them show up on a bank statement.

This is the audit. It assumes you already have a working app and are adding billing to it, the way the SaaS build guide lays out at a higher level, and it deliberately stops where the authentication guide starts. That post owns "my login is broken". This one owns "my billing is wrong".

What the AI gets right, and what it gets wrong

Across the builders people actually use for this, the split is consistent.

Usually right: creating the product and price in Stripe, the Checkout session in subscription mode, the redirect, the success and cancel URLs, and the customer portal link. This is the documented happy path, it appears in thousands of tutorials, and the models have seen all of them.

Usually wrong or missing: everything that happens after the customer's browser leaves. Stripe's own agent billing documentation is blunt about where the work sits, and it publishes the subscription call itself as a few lines:

stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: priceId }],
  expand: ['latest_invoice.payment_intent'],
})

Four lines to start a subscription. The webhook that tells your database it happened is longer, less documented, and where the money goes missing.

Checkout: the easy part

Ask for it and check three things.

The session is created on the server, never in the browser, because the request carries your secret key. It uses mode: 'subscription', not 'payment', or you have charged the customer exactly once and told them it recurs. And it carries your own user id, usually in client_reference_id or metadata, because the webhook that arrives later has to know which row to update. An AI-written session that omits that identifier is the single most common reason a correct webhook updates nobody.

Test and live keys are a separate trap. sk_test_ and sk_live_ behave identically in code, so a .env left on test values takes real customers through a flow that charges nothing, and nobody notices until the first payout does not arrive.

The webhook: where it breaks

Here is the shape the models write, minus the parts that matter:

// Do not ship this.
app.post('/webhook', express.json(), async (req, res) => {
  const event = req.body
  if (event.type === 'checkout.session.completed') {
    await db.users.update({ plan: 'pro' })
  }
  res.sendStatus(200)
})

Three separate defects, and every one of them is invisible in testing.

Anyone can post to that endpoint

There is no check that the request came from Stripe. The URL is public by necessity. Curl a fake checkout.session.completed at it and it grants a paid plan, because nothing in that handler disagrees.

Stripe's guidance is direct: when processing webhook events, secure your endpoint by verifying that the event is coming from Stripe. The mechanism is the Stripe-Signature header, passed to constructEvent() along with the request body and your endpoint secret. Three parameters, one call, and the whole class of forgery is gone.

The body was already parsed

express.json() is the second defect and it breaks the first one's fix. Stripe signs the exact bytes it sent. A JSON parser turns those bytes into an object and, when you serialize it again, key order and whitespace have moved. The signature no longer matches and verification fails on legitimate events.

This is why so many people get signature verification working, then see every real event rejected while their test script passes. The fix is to hand the route the raw body before any parser touches it.

import express from 'express'
import Stripe from 'stripe'

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

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      )
    } catch (err) {
      return res.status(400).send(`Webhook signature failed: ${err.message}`)
    }

    // Same event, twice, is normal. Refuse the second one.
    if (await db.processedEvents.exists(event.id)) return res.sendStatus(200)

    switch (event.type) {
      case 'checkout.session.completed':
        await grantAccess(event.data.object)
        break
      case 'customer.subscription.updated':
        await syncStatus(event.data.object)
        break
      case 'customer.subscription.deleted':
        await revokeAccess(event.data.object)
        break
    }

    await db.processedEvents.insert(event.id)
    res.sendStatus(200)
  }
)

On Supabase the shape is the same inside an Edge Function, and on Next.js it is the same inside a route handler that reads the request as text. The framework changes, the three requirements do not: raw bytes, verified signature, event id remembered.

The same event will arrive twice

Stripe retries when your endpoint is slow, errors, or looks like it errored. The event arriving twice is normal operation, not a fault. If your handler adds a month of access each time it sees checkout.session.completed, a retry gives someone two months.

The defence has two halves. For requests you send to Stripe, its documentation says to use idempotency keys for all POST requests to the API, sent in the Idempotency-Key header; Stripe removes keys automatically once they are at least 24 hours old. For events Stripe sends you, the equivalent is the processedEvents check above: store event.id, and return 200 without acting if you have seen it.

The write that actually flips the plan

Granting access is one row, and it is worth being pedantic about which one.

Resolve the user from the identifier you put into the Checkout session, not from the email on the Stripe customer. Emails change, people pay with a work address and log in with a personal one, and matching on email is how one person's payment upgrades another person's account.

Store the subscription id and the current status alongside the plan. Your app should gate features on a status you copied from Stripe, not on a boolean you set once. active and trialing mean access. past_due means Stripe is retrying the card and you decide whether to keep access on for a grace period. canceled and unpaid mean access is off.

Cancellation, failure, and refunds

This is the half the AI most often omits entirely, because nothing in the happy path requires it.

Cancellation. customer.subscription.deleted fires when the subscription actually ends, which for a cancel-at-period-end is weeks after the customer clicked cancel. If you revoke on the click instead, you have taken away paid time they already bought.

Failed payment. invoice.payment_failed is the one that costs the most when it is handled with a bare 200. Stripe will retry the card on a schedule. Your app has to decide what the customer sees in the meantime, and it has to actually revoke when the retries run out rather than leaving a past_due account fully paid forever.

Refunds. A refund is a charge.refunded event, not a subscription event. Refunding an invoice in the Stripe dashboard does not cancel anything by itself, so unless your handler covers it, you have given the money back and kept the customer on the plan.

The re-prompt that gets this written properly

Ask for the audit rather than the feature. Paste this at the builder after it has written the first version:

Rewrite the Stripe webhook handler with these requirements, and do not skip any of them. Read the raw request body, not a parsed JSON body, and verify the Stripe-Signature header with constructEvent and the endpoint secret from an environment variable; reject with 400 if verification fails. Store every processed event.id and return 200 without acting if the id has been seen, because Stripe retries. Handle checkout.session.completed, customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed in separate branches, and say in a comment what each one does to my user record. Resolve the user from the identifier I set on the Checkout session, never from the customer's email. Do not grant any access in the success-page route. Show me the database schema the handler expects.

The last sentence matters more than it looks. If the model cannot state the schema it is writing against, it is guessing at your column names, and the handler will run cleanly while updating nothing.

If you are doing this in Lovable, the handler belongs in a Supabase Edge Function rather than the frontend project. In Claude Code, point it at the real file and ask it to explain the existing handler before it rewrites it, so the diff is small enough to read.

Test the loop before real money moves

You can run the entire flow locally with the Stripe CLI. Nothing here touches live keys.

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

stripe listen prints a webhook signing secret starting whsec_. That is the secret your local environment needs, and it is different from the one in the dashboard for your deployed endpoint. Mixing those two up is the other reason verification fails for people who did everything else right.

With that running, take the checkout yourself with test card 4242 4242 4242 4242, any future expiry, any CVC. Then watch what the terminal prints. You are checking that the event arrived, that your handler returned 200, and that the row in your database actually changed.

Then break it on purpose, which is the part people skip:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Card 4000 0000 0000 0341 attaches to a customer but fails when charged, which is how you see a real failed renewal rather than a triggered one. If any of those leaves the account in a state you would not want to explain to the customer, the handler is not finished.

The three lines to read before you ship

Not a checklist, three greps. Everything above is a consequence of one of them.

  1. Does the webhook route read the raw body, and does the handler call constructEvent before it reads any event data? If express.json() or an equivalent parser sits in front of the route, or if the code branches on req.body.type anywhere, stop there.
  2. Does anything grant access outside the webhook? Search the success route and the client for the place your plan flag gets set. If the success page writes it, anyone who visits that URL is on the paid plan.
  3. Does customer.subscription.deleted do something? Open the handler and read the branch. An empty case, a log line, or a bare 200 means nobody who cancels ever loses access.

Going live

  • Switch the keys, then confirm it. sk_live_ in the environment, the endpoint registered in the live dashboard, and the live whsec_ in your deployed environment rather than the CLI one.
  • Subscribe yourself with a real card and cancel it. It costs one month of your own money and it is the only test that exercises live keys, the live webhook and your real database together.
  • Watch the Stripe dashboard's webhook log for the first week. Failed deliveries are listed there with the response your endpoint gave, which is the fastest way to find a handler that is erroring in production.
  • Know the cost before you price. Stripe's Billing pricing page, read on 15 September 2026, lists 2.9% + 30¢ per successful card charge for US cards, plus 0.7% of Billing volume on the pay-as-you-go plan for subscriptions themselves. Rates differ by country, so read the page for yours.

FAQ

Should the success page mark the user as paid? No. The success URL is a page the browser gets sent to, and anyone can visit it directly. Access follows a webhook event whose signature you verified.

Why does signature verification fail after the AI writes Express code? The body was already parsed. Stripe signs the exact bytes it sent, so constructEvent needs the raw request body. A JSON parser in front of the route rewrites those bytes and the signature stops matching.

Which events does a subscription app need first? checkout.session.completed to grant access, customer.subscription.updated to follow status changes, customer.subscription.deleted to revoke. Add invoice.payment_failed once those three are right.

Do I need an idempotency key? For requests you send to Stripe, yes: its documentation says to use them for all POST requests to the API, in the Idempotency-Key header, and it drops keys once they are at least 24 hours old. For events Stripe sends you, store the event id instead and ignore one you have already handled.

Is a Payment Link enough? Only if nothing in your database changes when someone pays. The moment access depends on payment status, you are writing a webhook handler either way.

Can Claude or an MCP server set the subscription up for me? Stripe's agent billing documentation says the workflow can be implemented in code or in an application such as Claude or an MCP server. That changes who types the code, not what the code has to do: the four cases above still need to be in the handler.

How much does Stripe take? Its Billing pricing page, read on 15 September 2026, lists 2.9% + 30¢ per successful card charge for US cards, and 0.7% of Billing volume on the pay-as-you-go plan. Rates differ by country.

Where this leaves you

Every failure mode above is silent. A broken login generates support mail within a day. A webhook that never revokes access generates nothing at all, because the only person who would complain is being given something for free.

That is why the audit is worth more than the feature. The builder will write the Checkout session faster than you can, and it will keep writing the webhook in a way that works in the demo and leaks money in production, until you ask it for the four cases above by name.

If you are still choosing the builder for all of this, the tool directory has the current pricing and free tiers for each of them.

Zane

Written by

Zane

AI Tools Editor

AI editorial avatar for the Vibe Coding team. Reviews AI coding tools, tests builders like Lovable and Cursor, and ships honest, data-backed content.

Related Articles