VibeAudit

Guides · pre-launch guide · 2026-09-04

Stripe webhooks, idempotency and billing logic bugs in vibe-coded apps

Billing code generated by AI tools usually works on the happy path and breaks on the second click, the unhandled event, or the fresh Stripe account. This guide covers why that happens, what to grep for in your own repo, what it looks like in a widely copied public starter, how to fix it with one prompt, and what to verify before you take real money.

Why this keeps happening in AI-generated billing code

Most AI-generated Stripe integrations are lifted, directly or indirectly, from a small number of public starters. When the starter has a bug, every app that started from it or was trained on it has the same bug. The twelve findings in this guide all come from one such starter, vercel/nextjs-subscription-payments, and every one of them is the kind of thing an assistant reproduces without comment.

Billing bugs also hide well. The checkout flow is tested once, in test mode, by the person who built it, with one click and one subscription. The failure cases are elsewhere: a user who already has a subscription clicks again, Stripe sends an event type you did not handle, a Stripe account created this year returns a different object shape, two users share an email, or someone deletes a customer in the dashboard. None of those show up in a demo.

A third cause is trust in the client. Server Actions and API routes are plain POST endpoints, but generated code often accepts whole objects from the browser and passes them straight to Stripe. The model saw a Price type and used it; it did not ask where the data came from.

How to recognise it in your own repo

You do not need to read every file. Each of the failure modes below leaves a fingerprint you can grep for. Run these against your codebase and treat any hit as something to read carefully.

If your webhook route has a 400 for unknown event types, if your Stripe client is constructed with apiVersion: null, or if a Server Action accepts a Price object rather than a priceId, you almost certainly have the corresponding bug.

  • grep -rn "trial_period_days\|trial_end" --include=*.ts in server code. If the value comes from a function argument rather than a database row, the client controls the trial length.
  • grep -rn "status: 400" app/api/webhooks and check whether that branch is reached for event types you did not handle. Stripe treats 4xx as a failure and will eventually disable the endpoint.
  • grep -rn "apiVersion" utils/stripe and look for null or a @ts-ignore next to it. No pinned version means the response shape depends on the account's default.
  • grep -rn "current_period_start\|current_period_end" and check whether they are read from subscription directly. On API versions from 2025-03-31 those live on subscription.items.data[0].
  • grep -rn "customers.list({ email". Matching a Stripe customer by email alone can link a new signup to someone else's billing.
  • grep -rn "stripe_customer_id" schema.sql supabase/migrations and check for a unique constraint. Then grep for .single() on that column in the webhook handler.
  • grep -rn "references auth.users" schema.sql and check each FK for on delete cascade.
  • grep -rn "_LIVE" utils/stripe .env* for undocumented live/test fallbacks that can put the server and browser in different Stripe modes.
  • grep -rn "NEXT_PUBLIC_VERCEL_URL" and check whether it is used to build Stripe success_url, cancel_url or portal return_url.
  • In your pricing component, find the subscribe button and check what onClick does when the user already has a subscription. If it still calls checkout, you can double bill.
  • grep -rn "const { data, error }" utils/supabase/queries.ts and check whether error is ever used.

Real examples from vercel/nextjs-subscription-payments

These four findings are from a VibeAudit review of the public starter. If your app was scaffolded from it, or an assistant reproduced its structure, check the equivalent lines in your repo.

Client-controlled trials and prices, utils/stripe/server.ts:76. The Server Action is checkoutWithStripe(price: Price, redirectPath) (line 21), and the entire price row comes from the browser. price.trial_period_days is fed into calculateTrialEndUnixTimestamp and used as subscription_data.trial_end (line 76), while price.id and price.type decide the line item and checkout mode (lines 59, 71-84). Nothing is re-read from the database. Anyone can POST { id: 'price_xxx', type: 'recurring', trial_period_days: 3650 } and get a ten-year trial on any active price in your account, including internal or discounted prices that never appear on the pricing page.

Double billing via the 'Manage' button, components/ui/Pricing/Pricing.tsx:189. The label is subscription ? 'Manage' : 'Subscribe' (line 192) but onClick always calls handleStripeCheckout(price) (line 189), which opens a new Checkout session in subscription mode for the same customer. Stripe creates a second subscription. Then getSubscription in utils/supabase/queries.ts uses .maybeSingle() on status in (trialing, active) (line 16), which returns an ignored error and null when two rows match, so the app now shows the paying user as unsubscribed and lets them do it a third time.

  • Webhook failing on every unhandled event, app/api/webhooks/route.ts:91. Events not in relevantEvents return new Response('Unsupported event type', { status: 400 }) (lines 90-93), and handler failures also return 400 (line 86). The README tells you to 'Select all events', so most deliveries (invoice.*, payment_intent.*, charge.*, customer.updated) are 4xx. Stripe retries for up to 3 days, emails you, and then disables the endpoint. After that, cancellations, payment failures and plan changes stop syncing.
  • Unpinned API version breaking subscription sync, utils/stripe/config.ts:9. apiVersion: null under a @ts-ignore means no Stripe-Version header is sent. manageSubscriptionStatusChange in utils/supabase/admin.ts reads subscription.current_period_start and current_period_end (lines 248-253) and calls toDateTime(...).toISOString(). On a fresh Stripe account those fields are undefined, toDateTime(undefined) is an Invalid Date, and .toISOString() throws a RangeError. Every customer.subscription.* and checkout.session.completed webhook fails, no subscription row is written, and paying users appear unsubscribed. You only see this in function logs.
  • Non-unique customer mapping, schema.sql:42 and utils/supabase/admin.ts:221. stripe_customer_id text has no unique constraint, and the webhook looks it up with .single(), which errors on 0 or 2+ rows. The email-based fallback in createOrRetrieveCustomer (admin.ts:142) and concurrent double-clicks on Subscribe can both create duplicates. Once two users share a Stripe customer id, every webhook for that customer throws 'Customer lookup failed' until Stripe gives up.

How to fix it

The pattern behind most of these fixes is the same: the server must be the source of truth. Accept identifiers from the client, not objects. Look up prices, subscriptions and customers from your database before calling Stripe. Return 200 for events you do not care about and 500 for events you failed to process. Pin the Stripe API version to the one your stripe-node types were generated for, and make date conversions null-safe. Add a unique constraint on stripe_customer_id so the mapping cannot fork.

The prompt below is written for the vercel/nextjs-subscription-payments file layout. If your repo has different paths, keep the reasoning and change the file names. Paste it into Cursor or Claude Code and review the diff; do not accept it blind, especially the migration.

  • Change checkoutWithStripe to take priceId: string, load the row from prices with active = true, and use the DB row for type and trial_period_days. Update Pricing.tsx to pass price.id.
  • Route the button to the Customer Portal when subscription exists, and add a server-side guard in checkoutWithStripe that rejects checkout when the user already has a trialing or active subscription. Harden getSubscription with .order('created', { ascending: false }).limit(1).maybeSingle().
  • In the webhook route, return { received: true } with status 200 for non-relevant events and status 500 when a relevant handler throws. Subscribe only to the events in relevantEvents in the Stripe dashboard.
  • Set apiVersion: '2023-10-16' (matching stripe-node 14.x types) and make manageSubscriptionStatusChange tolerate missing period fields, falling back to subscription.items.data[0].
  • Add unique (stripe_customer_id) on customers, replace .single() with .maybeSingle() plus an explicit error, and prefer matching Stripe customers on metadata.supabaseUUID over email.
Fix the billing and webhook bugs in this repo. Make each change, then show me the diff.

1. In utils/stripe/server.ts, change `checkoutWithStripe(price: Price, redirectPath)` to `checkoutWithStripe(priceId: string, redirectPath)`. After getting the user, load the price from Supabase: `const { data: price, error: priceError } = await supabase.from('prices').select('*').eq('id', priceId).eq('active', true).maybeSingle();` and throw `new Error('Invalid price.')` if missing. Use this DB row (not client input) for `price.id`, `price.type` and `price.trial_period_days`. Also query `supabase.from('subscriptions').select('id').in('status',['trialing','active']).maybeSingle()` and if a row exists throw `new Error('You already have an active subscription. Manage it from your account page.')`. Reason: the Server Action trusts a client-supplied object, letting users set arbitrary trial lengths and choose any Stripe price, and it lets subscribed users start a second subscription.

2. In components/ui/Pricing/Pricing.tsx, update `handleStripeCheckout` to call `checkoutWithStripe(price.id, currentPath)`. Import `createStripePortal` from '@/utils/stripe/server' and add `const handleStripePortal = async () => { setPriceIdLoading('portal'); const url = await createStripePortal(currentPath); router.push(url); }`. Change the plan button's onClick to `subscription ? handleStripePortal() : handleStripeCheckout(price)`.

3. In utils/supabase/queries.ts `getSubscription`, add `.order('created', { ascending: false }).limit(1)` before `.maybeSingle()`. In `getProducts` and `getSubscription`, add `if (error) { console.error(error); throw new Error(error.message); }` before returning.

4. In app/api/webhooks/route.ts, change the `else` branch for non-relevant events (currently status 400 with 'Unsupported event type') to return `new Response(JSON.stringify({ received: true }), { status: 200 })`. Change the catch block inside the relevant-events `try` to return status 500 instead of 400. Update README.md webhook instructions to select only the events listed in `relevantEvents`. Reason: Stripe disables endpoints that keep returning 4xx, which silently stops subscription sync.

5. In utils/stripe/config.ts, remove the `// @ts-ignore` and `apiVersion: null` and set `apiVersion: '2023-10-16'`. Use only `process.env.STRIPE_SECRET_KEY` (remove the `_LIVE` fallback) and throw a clear error at module load if it is missing. In utils/stripe/client.ts use only `process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`.

6. In utils/supabase/admin.ts `manageSubscriptionStatusChange`, add `const iso = (secs?: number | null) => secs ? toDateTime(secs).toISOString() : null;` and use `subscription.items.data[0]?.current_period_start ?? subscription.current_period_start` (same for end) through it. Replace `quantity: subscription.quantity` and its `@ts-ignore` with `quantity: subscription.items.data[0]?.quantity ?? null`. Replace `.single()` on the customers lookup with `.maybeSingle()` and throw `new Error(`No customer mapping for Stripe customer ${customerId}`)` when null.

7. In utils/supabase/admin.ts `createOrRetrieveCustomer`, replace `stripe.customers.list({ email })` with `const found = await stripe.customers.search({ query: `metadata['supabaseUUID']:'${uuid}'` }); stripeCustomerId = found.data[0]?.id;`. Wrap `stripe.customers.retrieve` in try/catch: on a `resource_missing` StripeError, or if the returned customer has `deleted: true`, set `stripeCustomerId = undefined` so a new customer is created and the `customers` row updated.

8. Create a new Supabase migration that runs `alter table public.customers add constraint customers_stripe_customer_id_key unique (stripe_customer_id);` and drops and re-creates the foreign keys on public.users(id), public.customers(id) and public.subscriptions(user_id) referencing auth.users(id) with `on delete cascade`. Mirror both changes in schema.sql. In `upsertCustomerToSupabase`, catch unique-violation error code '23505' and throw a clear message.

Smaller things that bite in production

Three more findings from the same starter are low severity individually but each turns into a support ticket. copyBillingDetailsToCustomer in utils/supabase/admin.ts:198 bails with if (!name || !phone || !address) return;, but Checkout never collects phone, so users.billing_address, users.payment_method and subscriptions.quantity are always empty. Change the guard to check address and read quantity from subscription.items.data[0].quantity.

getURL in utils/helpers.ts:14 falls back to NEXT_PUBLIC_VERCEL_URL, which is the per-deployment hostname, when NEXT_PUBLIC_SITE_URL is unset. That hostname ends up in Stripe success_url, cancel_url, portal return_url and auth redirectTo, and Supabase rejects it because it is not on the allow-list. Prefer NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL as the fallback and log loudly when VERCEL_ENV === 'production' and the site URL is missing.

The _LIVE env var fallbacks in utils/stripe/config.ts:4 and utils/stripe/client.ts:8 are undocumented in .env.local.example, and STRIPE_WEBHOOK_SECRET has no _LIVE variant. Setting one half gives a live Checkout session id to a test-mode Stripe.js and fails with 'No such checkout.session' the first time a real customer pays. Use one set of variables per Vercel environment.

Pre-launch checklist

Run through this in a staging project with a real Stripe test account before flipping to live keys.

  • Checkout Server Action or API route accepts a priceId string and loads the price server-side with active = true. Trial length comes from the database, never the request.
  • A user with an active or trialing subscription cannot start a new Checkout. The pricing button opens the Customer Portal for them, and the server rejects checkout anyway.
  • Webhook returns 200 for event types you do not handle and 500 when a handler throws. Only the events you process are selected in the Stripe dashboard.
  • apiVersion is pinned to the version your installed stripe-node types match. Period dates and quantity are read from subscription.items.data[0] with a null-safe fallback.
  • customers.stripe_customer_id has a unique constraint. The webhook customer lookup uses .maybeSingle() and throws a message that names the Stripe customer id.
  • Stripe customers are matched on metadata.supabaseUUID, not email alone. Email confirmation is enabled in the production Supabase project.
  • Deleting a Stripe customer in the dashboard does not permanently break checkout for that user; the code recreates and remaps.
  • getProducts and getSubscription throw on error. The public pricing page never shows 'Create them in your Stripe Dashboard' to a customer.
  • Foreign keys to auth.users have on delete cascade, and you have tested deleting a user who has a subscription.
  • NEXT_PUBLIC_SITE_URL is set in production and used for all Stripe return URLs and auth redirects.
  • STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and STRIPE_WEBHOOK_SECRET are all from the same mode in each Vercel environment, with no _LIVE fallbacks.
  • You have watched the Stripe webhook delivery log during a full subscribe, plan change and cancel, and every delivery is green.

Check your own repo for this.

Free quick scan reads your highest-risk files in under a minute. The $19 deep audit reads the whole codebase and writes a fix prompt per finding.

Scan my repo →

Written from VibeAudit audit findings. Public open-source starters are named with file and line; other apps are anonymized. Not a substitute for a professional security assessment.