How to Add Payments to an AI-Built App

A checkout that visibly works can still let anyone buy your product for one cent. The two server-side controls that separate a demo from something you can charge money with.

Steve Jefferson
Steve Jefferson
Developer Advocate
3 August 20261 min read

Ask an AI builder to add checkout to your app and you will get something that works. Card form renders, test card goes through, success page appears, money moves in the dashboard. It genuinely works.

It may also let anyone buy your 99 dollar plan for one cent, and pay you twice for the same order, and neither shows up in testing.

Payments are the part of an app where the difference between "works" and "correct" costs actual money. Two controls close most of that gap, and AI-generated checkout code commonly ships without either.

Flaw 1: The price came from the browser

Generated checkout code very often looks something like this:

// Client sends the amount to charge
fetch("/api/create-checkout", {
  method: "POST",
  body: JSON.stringify({ priceId: "pro", amount: 9900 })
})

And the server obligingly charges what it was told:

const session = await stripe.checkout.sessions.create({
  line_items: [{ price_data: { unit_amount: req.body.amount, ... } }]
})

This works perfectly in every test you will run, because your browser always sends the right number. It works differently for someone who opens developer tools and changes 9900 to 1.

The rule is unconditional: the server decides the price. The client is allowed to say which product, never how much it costs.

// Server-side price table. The client never sends an amount.
const PLANS = {
  pro:   { priceId: "price_1abc...", },
  basic: { priceId: "price_1xyz...", },
}

const plan = PLANS[req.body.plan]
if (!plan) return res.status(400).json({ error: "Unknown plan" })

const session = await stripe.checkout.sessions.create({
  line_items: [{ price: plan.priceId, quantity: 1 }],
  mode: "subscription",
  customer: await getOrCreateCustomer(req.user),
})

The client now sends "pro" and nothing else that matters. Using a price ID defined in your payment provider's dashboard is stronger still, since the amount never appears in your code at all.

This is the same class of mistake as trusting a client-supplied user ID, and it comes from the same place. A model generating code from a prompt has no model of an adversary. It writes the path where everyone behaves, which is a recurring reason AI-generated code fails in the real world rather than a payments-specific quirk.

Quantity deserves the same treatment. If the client sends quantity, validate it is a positive integer within a sane bound, or someone will send negative three and discover what your code does with a refund it never authorized.

Flaw 2: Fulfillment on the success page

The second pattern looks reasonable and is worse:

// pages/success.js  <- do not do this
useEffect(() => {
  fetch("/api/grant-access", { method: "POST" })
}, [])

Granting access when the browser lands on the success URL means access is granted to anyone who visits that URL. It also means a customer whose payment succeeded but whose browser crashed, or who closed the tab during the redirect, pays and receives nothing.

Fulfillment belongs in a webhook. Your payment provider calls your server directly when the payment actually completes, independent of what the customer's browser is doing.

app.post("/webhooks/stripe",
  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("Invalid signature")
    }

    if (event.type === "checkout.session.completed") {
      await grantAccess(event.data.object)
    }
    res.json({ received: true })
  })

Two details in there are load-bearing.

Signature verification is not optional. Your webhook URL is a public endpoint that grants access when called. Stripe's webhook documentation is blunt about it: without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records. Stripe signs every event and recommends verifying with the official libraries, alongside IP allowlisting as a second layer. The signature includes a timestamp specifically to defeat replay attacks, with a default tolerance of five minutes.

The raw body matters. Signature verification runs over the exact bytes received. If your framework parses JSON before your handler sees it, verification fails on a legitimate request. This is the single most common reason a correctly-written webhook rejects everything, and the fix is to exempt the webhook route from body parsing, as express.raw does above.

Flaw 3: Processing the same event twice

Webhooks are delivered at least once, not exactly once. Retries after a timeout, and occasional duplicate events, are normal operation rather than edge cases.

Stripe's guidance is specific: webhook endpoints might occasionally receive the same event more than once, and you guard against it by logging the event IDs you have processed and not processing already-logged events. The docs also note that in some cases two separate event objects are generated, and recommend identifying those duplicates using the ID of the object in data.object together with the event type.

Without this, a retry means a second month credited, a second license issued, or a second physical item shipped.

const { error } = await db.from("processed_events")
  .insert({ event_id: event.id })      // event_id is a unique column
if (error?.code === "23505") {         // already processed
  return res.json({ received: true })
}
await grantAccess(event.data.object)

A unique constraint doing the work is better than a check-then-act, which has a race condition of its own when two deliveries arrive at once.

Return a 2xx quickly. If your handler does slow work before responding, the provider times out and retries, which is how one purchase becomes three.

What to hand the AI, and what to keep

You do not need to write payment code by hand. You need to specify it correctly, which is a short list:

Add Stripe Checkout with these requirements:
- Client sends only a plan key, never an amount. Server maps the key
  to a price ID from a server-side table.
- Fulfillment happens only in the webhook handler, never on the success page.
- Verify the webhook signature using the raw request body.
- Store processed event IDs with a unique constraint and skip duplicates.
- Return 200 immediately; queue any slow work.
- Never log full card data or the webhook secret.

Then verify each line in the output rather than assuming it landed. Generated code frequently satisfies four of six requirements convincingly, which is precisely why reading AI-generated code properly before shipping it matters more here than in most features.

You also need somewhere for the webhook to arrive, which means a real server endpoint. If your app is currently a static frontend, this is one of the cases that answers whether you need a backend: you do.

Before you accept a real card

Run these in test mode. Each one takes a minute and each maps to a failure above.

  1. Tamper with the request. Intercept the checkout call and change the amount or plan to something cheap. You should get a rejection, not a discount.

  2. Replay the webhook. Send the same test event twice with the CLI. The customer should be granted access once.

  3. Send an unsigned webhook. Post a handcrafted event to your endpoint with no signature. Expect a 400.

  4. Abandon the redirect. Complete a payment and close the tab before the success page loads. Access should still be granted, because the webhook did it.

  5. Fail a card. Use a declining test card and confirm nothing is granted and the customer sees a clear message.

  6. Check what you log. Grep your logs for the webhook secret and for anything resembling card data.

Payments also raise the stakes on everything around them. Access control has to be right before charging for access is meaningful, which is why user accounts are worth getting solid first, and secrets have to be real environment variables in your hosting platform rather than committed values, which is part of deploying an AI-built app properly.

Frequently asked questions

Can I use Stripe Checkout without a backend?

Payment links and client-only checkout can collect a payment with no server. Fulfillment is the problem: without a webhook endpoint you have no trustworthy signal that payment succeeded, so it only suits cases where nothing needs unlocking automatically.

How do I test webhooks locally?

The Stripe CLI forwards live test events to a local URL and can trigger specific event types on demand, which also makes the duplicate-delivery test easy to run deliberately.

What happens if my webhook endpoint is down?

Providers retry with backoff over a period of hours, so brief outages recover on their own. This is another reason fulfillment must be idempotent, since the retry will arrive eventually and may overlap with a recovered original.

Do I need PCI compliance?

Using a hosted checkout where card details are entered on the provider's page and never touch your server keeps you in the simplest compliance tier. Building your own card form changes that substantially, which is a strong argument for not building your own card form.

Should subscription state live in my database or the payment provider?

The provider is the source of truth for billing status. Mirror what you need for fast access checks, update the mirror from webhooks, and reconcile periodically rather than trusting a cached value indefinitely.

Related: adding rate limiting to an AI-built app

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.