Why Payments Deserve Extra Scrutiny
Everything else in your app fails softly. A broken button is annoying. A payments bug can charge someone twice, give away a paid feature for free, or leave you holding a customer's money with no record of what they paid for. AI is very good at producing checkout code that looks complete — a form, a button, a "Payment successful" message — while missing the parts that only matter once real money and real attackers are involved.
You can reduce card-data exposure by using hosted checkout, but you still need to understand the integration's trust boundary. Fulfillment belongs on your server and must use verified provider events, not a browser redirect.
If an AI tool ever generates a plain <input> for a card number, expiry, or CVC that your own server code reads or stores, stop. That puts you in scope for PCI compliance rules most solo builders can't meet, and a leaked or logged card number is a serious incident. The fix below avoids this entirely by never letting card details reach your code.
The Safe Default: Hosted Checkout
Payment providers such as Stripe offer a hosted checkout page: your app sends the customer to a page Stripe controls, the customer enters their card there, and Stripe redirects back to your app afterward. Your code never sees a card number — it only ever sees a "this session paid" confirmation.
- Stripe Checkout — a hosted page you redirect to, good for one-time payments and subscriptions, works well with Bolt, Lovable, Replit, and v0.
- Stripe Payment Links — an even simpler option: a shareable checkout URL you can create without writing any code at all, useful if you just need to sell one thing.
Both keep card handling entirely on Stripe's side. Building a custom card form with Stripe's lower-level Elements API is possible and sometimes necessary for a fully embedded checkout experience, but it's more surface area to get wrong — start with hosted Checkout unless you have a specific reason not to.
The Prompt to Ask For
Be specific that you want the hosted flow, not a custom form, and ask for the webhook in the same prompt so it isn't skipped:
Add Stripe Checkout to my app for a one-time payment of $[amount] for [product/feature]. Use Stripe's hosted Checkout page — do not build a custom card input form. Include: (1) a backend endpoint that creates a Checkout Session from a server-side Price or amount, (2) a webhook endpoint that verifies Stripe's signature against the raw request body and handles checkout.session.completed plus checkout.session.async_payment_succeeded when delayed methods are enabled, (3) an idempotent fulfillment function that records processed event and Checkout Session IDs so retries cannot grant access twice, and (4) test mode using separate test keys and webhook secrets. Check the Session's payment state before fulfillment, explain where each secret goes, and confirm none is sent to the browser.
When the AI returns code, check for these three things before you consider it done:
- The secret key lives only in backend/server code (an environment variable), never in any file that ships to the browser.
- The feature unlocks from an idempotent fulfillment path called by the webhook handler, not from the redirect page the customer lands on after paying.
- Prices are set in your backend code or in Stripe itself, not read from a value the browser sends — otherwise anyone could edit the page and "buy" your product for $0.01.
Why the Success Page Isn't Proof of Payment
After Stripe Checkout completes, Stripe redirects the browser back to a URL you chose — often something like /success. It's tempting to unlock the paid feature the moment that page loads. Don't: that redirect happens in the customer's browser, which means anyone can type yoursite.com/success directly, with no payment at all, and your app would unlock the feature for free.
Stripe delivers webhook events directly to your endpoint and signs each payload. Your handler must verify that signature against the raw body, check the relevant payment state, and tolerate retries, duplicates, and out-of-order events. This is more work than trusting the landing page, but it is the provider-supported basis for reliable fulfillment.
In practice: your webhook endpoint verifies the signature, rejects irrelevant events, and calls a fulfillment function that can safely run more than once. Record the event and Checkout Session IDs before granting access, because Stripe can retry delivery and event order is not guaranteed. The success page can show a friendly message and query server-side status, but it must not be the only path that grants access.
Show me the webhook handler and fulfillment function. Confirm signature verification uses the unmodified raw request body, only required event types are accepted, delayed payment methods are handled if enabled, duplicate and out-of-order delivery cannot grant access twice, and the /success page is not the only fulfillment path. Add tests for a bad signature, the same event twice, a delayed success, and events arriving in a different order.
Test Mode Before Live Mode
Stripe separates test and live data, API keys, and webhook secrets. Test mode can simulate documented outcomes without moving real money, but it is not proof that production configuration, taxes, domains, email, or every payment method will behave identically.
- Build and test your entire checkout and webhook flow in test mode first, using Stripe's published test cards.
- Only switch to live keys once a full test-mode purchase — checkout, webhook, unlock — works end to end.
- Keep both keys out of your repository. If you're using an AI coding tool connected to your codebase, confirm it's reading the key from an environment variable or secrets manager, not a hardcoded string that could end up committed or pasted into a chat.
If you're not sure whether a key or credential is safe to share with an AI tool at all, the developer-focused Sanitizing Code and Data Before Sending to AI guide covers what to scrub before pasting anything into a conversation.
If You're Adding a Subscription
Recurring billing (monthly or annual plans) adds a few more events worth handling beyond the initial payment: renewal succeeded, renewal failed (an expired card, for example), and cancellation. Each of these should also be driven by a webhook, not by checking Stripe every time a user opens your app.
Extend the Stripe integration to a monthly subscription. Use invoice.paid to extend access, invoice.payment_failed to start the documented recovery path, and customer.subscription.updated/deleted to reflect scheduled or completed cancellation. Define how trialing, incomplete, past_due, unpaid, paused, and canceled states affect access. Verify signatures, make every state transition idempotent, retrieve current Stripe objects when event order leaves data missing, and test retries plus out-of-order events.
Pre-Launch Payments Checklist
- Hosted checkout, not a custom card form — your app should never receive a real card number.
- Secret key server-side only — never in browser-shipped code, never committed to the repo.
- Prices set on your backend or in Stripe — never trusted from a value the browser sends.
- Fulfillment uses verified provider state — the success page is not the only path that grants access.
- Webhook signature verified against the raw body — before any event is trusted.
- Retries, duplicates, delayed methods, and event ordering are handled — fulfillment is idempotent and tested.
- Full flow tested in test mode — checkout, webhook, and unlock all confirmed before switching to live keys.
Related Guides
Growing Your Vibe Coded App
Adding features, handling real users, and keeping an eye on hosting and storage costs as your app grows.
Sanitizing Code and Data Before Sending to AI
What to scrub before pasting code or credentials into an AI conversation — including API keys.