Payments you don't hand-roll: a production path for AI-coded SaaS
Payments you don't hand-roll are not about avoiding code. They are about refusing to make your application responsible for card storage, recurring invoice collection, or payment retry logic when a payment provider already operates those systems. Your code should own product access and business rules. The provider should own the payment lifecycle.
For an AI-coded SaaS, the practical boundary is: create a checkout or subscription on the server, listen for signed events, store the provider identifiers, and grant access only from a verified payment state. The browser can start the flow. It cannot decide that a customer has paid.
Start with the lifecycle, not the checkout button
A checkout button is the visible part of billing, not the system. Before asking an AI coding agent to build it, write down the states your product needs:
type BillingState =
| "none"
| "checkout_started"
| "active"
| "past_due"
| "cancelled"
| "expired"
type AccountBilling = {
accountId: string
providerCustomerId: string | null
providerSubscriptionId: string | null
state: BillingState
currentPeriodEnd: string | null
}Then define what each state means for access. active might allow paid features. past_due might allow a short grace period or read-only access. cancelled might remain active until the current period ends. The correct choice depends on your product, but the choice must be explicit.
Stripe’s subscription lifecycle documentation describes recurring payments, invoices, payment collection, status changes, and entitlement-based access. Use those provider states as inputs to your product state; do not copy every provider state into every screen.
The first useful artifact is a state table, not a generated form:
| Provider event or state | Product action | Safe to repeat? |
|---|---|---|
| Checkout completed | Save customer and subscription identifiers | Yes, upsert by provider ID |
| Invoice paid | Keep or grant the paid entitlement | Yes |
| Payment failed | Mark account for recovery | Yes |
| Subscription cancelled | Set an end date and stop renewal | Yes |
| Unknown event | Record it for review | Yes |
Keep price and account selection on the server
The client should send an internal plan choice, not an arbitrary provider price identifier. The server maps that choice to a configured price and checks that the account is allowed to start a subscription.
type Plan = "starter" | "team"
const priceByPlan: Record<Plan, string> = {
starter: process.env.STRIPE_STARTER_PRICE_ID!,
team: process.env.STRIPE_TEAM_PRICE_ID!,
}
export async function createCheckout(request: Request) {
const actor = await requireAuthenticatedActor(request)
const input = await request.json()
const plan = planSchema.parse(input.plan) as Plan
const account = await requireAccountMembership(actor, input.accountId)
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: await findOrCreateCustomer(account),
line_items: [{ price: priceByPlan[plan], quantity: 1 }],
metadata: { accountId: account.id },
})
return Response.json({ checkoutId: session.id })
}The example keeps provider configuration on the server and ties the checkout to an authenticated account. In a real implementation, also validate return paths, prevent unauthorized account switching, and decide whether an existing active subscription can be replaced.
Do not put a secret key or signing secret in a client bundle. Do not accept a price from the browser and forward it without checking it. An AI coding agent will happily connect these pieces if you give it a vague request; the boundary has to be part of the request and the tests.
11 production screens. Login, database, payments — all wired.
The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.
Treat webhooks as the source of payment truth
The user may close the tab after paying. A redirect may fail. A webhook may arrive before the browser returns. Your server needs an event handler that verifies the signature using the untouched request body, identifies the event, and records it before applying the business change.
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get("stripe-signature")
if (!signature) return new Response("Missing signature", { status: 400 })
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
)
if (await events.exists(event.id)) {
return Response.json({ received: true })
}
await events.insert({ id: event.id, type: event.type })
await applyBillingEvent(event)
return Response.json({ received: true })
}The event ID is the first idempotency key. A retry of the same delivery should produce the same product state, not a second entitlement or duplicate email. You can make the event record and state update transactional when your database supports it. If processing is slow, store the verified event and hand it to a worker.
Stripe’s webhook documentation explains that events are delivered to an HTTPS endpoint and can represent asynchronous changes such as successful recurring payments, disputes, and failed payments. The important application decision is yours: which event changes access, and which events only need recording or review.
Separate checkout completion from access provisioning
A checkout success page is useful for navigation. It is not proof that access should be granted. Provision access from a verified event or a server-side provider lookup, then make the UI poll your own account endpoint.
async function hasPaidAccess(accountId: string) {
const billing = await db.accountBilling.findUnique({ where: { accountId } })
if (!billing) return false
return billing.state === "active" ||
(billing.state === "past_due" && billing.currentPeriodEnd > new Date())
}This keeps the authorization decision inside your application. It also lets a mobile client, background job, and browser share the same rule. Do not scatter checks such as “the user returned from checkout” across routes and screens.
For a broader look at boundaries around generated features, read the AI app security checklist. For slow billing notifications or reconciliation work, background jobs for AI features covers retry-safe workers.
Make customer recovery a product flow
Payment failure is not a single error page. Give the account owner a clear state, the next action, and a way to reach the billing portal or update payment details. Keep paid data available according to your policy, but stop new paid actions when the entitlement ends.
Test these transitions with provider test modes and recorded fixtures:
- An initial payment succeeds and the event is delivered twice.
- The browser closes before it reaches the success page.
- A renewal fails, then succeeds after the payment method changes.
- A subscription is cancelled at period end.
- An event arrives for an account that was deleted locally.
- Two administrators start checkout at nearly the same time.
The desired result is not that every event is accepted. It is that every accepted or rejected event leaves evidence: event ID, account ID, type, processing status, and a safe error category. Never log full payment details or secret values.
Give an AI coding agent a bounded billing brief
A good implementation prompt names the boundaries and acceptance checks. It does not ask the agent to “add Stripe billing” and hope it discovers your access model.
Implement subscriptions for the existing account model.
Constraints:
- The browser sends only an internal plan name.
- The server maps plan names to server-only price configuration.
- Access changes only from verified webhook events.
- Webhook delivery is idempotent by provider event ID.
- Store provider customer and subscription IDs on the account billing record.
- Do not log payment details, signing secrets, or raw request bodies.
Acceptance checks:
- Duplicate event delivery does not duplicate an entitlement.
- A user cannot create checkout for another account.
- A failed renewal changes access according to the state table.
- The success redirect alone does not grant access.
- Tests cover active, past_due, cancelled, and unknown-event states.Ask for a plan and a diff before asking for implementation. Review the migration, server route, webhook handler, and access check as separate pieces. Run the tests against a clean database and a replayed event fixture. That makes billing code easier to inspect than a large generated patch with one happy-path test.
Buy back repetition without giving up ownership
Some teams should build the billing boundary themselves because their pricing, marketplace rules, or tax requirements are unusual. Others need a working SaaS base so they can spend their time on the product that makes the subscription worth buying. OTF’s paid SaaS kit is one option in that second category: the product facts are auth, billing, database, and Stripe wiring in code you own, with AI-tool configuration for extending the project. Check the current OTF templates and kit options before deciding.
The standard remains the same either way. A kit does not remove the need to review access rules, event handling, test coverage, and recovery. It removes repeated setup work; your team still owns the product decisions.
Payments you don't hand-roll should still be payments you understand. Keep the provider lifecycle outside your UI, keep access decisions inside your server, make every event safe to replay, and document the recovery path. That is enough structure for an AI coding agent to extend billing without turning a successful checkout into a production incident.
Sources
- Stripe subscription lifecycle
- Stripe webhooks
- Stripe payments overview
- OTF templates and kit options
Ship the product, not the setup.
- 11 production screens — auth, billing, team, analytics, settings
- Real database, payments, and login — all wired on day 1
- AI configs pre-tuned so your agent extends instead of regenerates