Why this checklist exists
Stripe publishes excellent documentation. It's not the documentation that's missing — it's the implementation discipline. AI builders scaffold a webhook route that works once and breaks on Stripe's first retry. The Stripe benchmark on AI-built integrations documented this plateau directly: repeated prompting converges on broken-but-superficially-working patterns. The checklist below is what we use on every integration fix; ticking every item is the bar for “ready to take money.”
The 14-point checklist
- Secret key is server-side only. `process.env.STRIPE_SECRET_KEY` read from a server route. Never imported by a client component. Grep the repo for the key name; it should appear in `/api/` or `/app/api/` paths only.
- Publishable key is the only Stripe key in client code.It's public-safe by design; the secret key isn't.
- Test mode and live mode keys are separate env vars. Never swap them with a feature flag. Use `STRIPE_SECRET_KEY_TEST` and `STRIPE_SECRET_KEY_LIVE`, or environment-scoped `STRIPE_SECRET_KEY` in Vercel preview vs production.
- Checkout is Stripe Checkout, not Elements — at least until a developer is on the team. Checkout is a hosted page; Stripe handles PCI, 3DS, SCA, and input validation for you.
- Webhook endpoint verifies the signature. Every Stripe request includes a `Stripe-Signature` header. Verify it with your webhook secret using `stripe.webhooks.constructEvent`. Reject any request where verification fails.
- Webhook endpoint is idempotent.Maintain an events table with a unique constraint on `event.id`. On each webhook, insert the event ID first — if it's a duplicate, return 200 and don't re-process. This is the single most important fix.
- Handler processes `checkout.session.completed`. On this event, create or update the subscription in your database. Mark the user as paid. Send the welcome email.
- Handler processes `invoice.payment_failed`. On this event, notify the user, update subscription state to `past_due`, and gate access after a grace period.
- Handler processes `customer.subscription.updated`. On upgrades, downgrades, and plan changes, sync the new state to your database.
- Handler processes `customer.subscription.deleted`. On cancellations or end-of-billing-period terminations, mark the subscription as cancelled.
- Handler returns 2xx only on success; 5xx on failure.Stripe retries on any non-2xx or timeout. Never return 200 if the event didn't process correctly; return 5xx and let Stripe retry.
- Failed webhook events go to a dead-letter log.Sentry, PostHog, or an explicit `failed_webhook_events` table. You need to know which events didn't process.
- Daily reconciliation cron.Query Stripe's API for active subscriptions and compare against your database. Any drift is logged and alerted. This is the check that catches the bugs the webhook misses.
- `stripe trigger` tests in CI. Use the Stripe CLI to simulate every event the handler processes. Assert your endpoint returns 200 and your database reflects the expected state.
The anatomy of an idempotent webhook handler
The pattern, described in plain English:
- Request arrives. Read the raw body and the `Stripe-Signature` header.
- Verify the signature. If invalid, return 400.
- Parse the event. Extract `event.id` and `event.type`.
- Insert `event.id` into the `stripe_events` table with a unique constraint. If insert fails (duplicate), return 200 — you've already processed this event.
- Dispatch to the handler for the event type.
- Handler runs in a database transaction. Either everything succeeds or nothing does.
- On success, return 200. On failure, return 500 so Stripe retries.
This is the shape of the handler. Most AI-generated versions skip steps 2, 4, and 6 — which is why they break.
What to test before going live
| Scenario | How to simulate | Expected outcome |
|---|---|---|
| Happy-path checkout | Test card 4242 4242 4242 4242 | Subscription active, welcome email sent |
| Card decline | Test card 4000 0000 0000 0002 | No subscription, user stays unpaid |
| 3DS challenge | Test card 4000 0025 0000 3155 | Checkout completes after challenge |
| Webhook retry | `stripe trigger` twice | Handler processes once (idempotent) |
| Subscription upgrade | Change plan in dashboard | Database reflects new plan |
| Cancellation | Cancel in Stripe dashboard | Access gated at period end |
| Failed payment retry | `stripe trigger invoice.payment_failed` | User notified, grace period starts |
The most common Lovable Stripe bug
A webhook handler that writes to the database outside a transaction. The event inserts a subscription row, then crashes before updating the user row. Stripe retries. The duplicate subscription row inserts again; the user still isn't updated. By the end of the day there are three subscription rows for one user, all pointing at different states, and the app bills them three times. This is the pattern that caused the $8,000 double-charge incident in the Ledgerlark — fintech MVP rescued from Lovable.
The fix is two lines of code: wrap every handler in a transaction, and return 5xx on any error so Stripe retries cleanly. The 14 checks above cover both.
When to bring in an engineer
If any of the 14 checks above reads as jargon, you're not going to ship a working Stripe integration by prompting. Book our Integration Fix. It's a fixed-fee engagement that closes all 14 checks in 5–10 working days for $1,500–$2,500. Cheaper than a single bad charge.