---
title: "Payments checkout reference"
description: "Hosted card fields, one charge per order and keys in KMS, in SAQ A scope for PCI DSS 4.0.1."
section: "guides"
status: "Planned for 0.7"
spec: "reference-architectures"
canonical: "https://mainsdk.dev/docs/guides/reference-payments-checkout"
updated: "2026-10-28"
---

# Payments checkout reference

> Hosted card fields, one charge per order and keys in KMS, in SAQ A scope for PCI DSS 4.0.1.

Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-payments-checkout · Updated: 2026-10-28

<!-- generated by tools/build_reference.py from packages/cli/templates/mainsdk/payments-checkout; edit the template, not this page -->

Start from it: `npx mainsdk init --template mainsdk/payments-checkout`

The reference architecture for taking payments: a page that mounts the provider's hosted fields, a checkout route that charges a stored total once per order, and a verified webhook. Pack: Payments (PCI DSS 4.0.1, SAQ A).

Card data goes from the buyer's browser to Stripe and never reaches this app, so the page has no card field and the app stays in SAQ A scope. Stripe's keys are `secret:` references read from AWS Secrets Manager in production, and record keys are wrapped by AWS KMS (`main.env.prod.yaml`).

Runs with no account and no key: `npm run dev` uses the local test-card driver. `npm test` runs the tests. Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk assure --standard pci-dss-4.0.1`, `npm run bench` and `npm run chaos` (Stripe down, slow or answering nonsense must never charge an order twice). `npm run verify-ci` fails when a job is removed.

Moving off Stripe called directly? `mainsdk migrate stripe` ends here.

## Performance

The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`).

## main.yaml

```yaml
schema: 1
project: prj_template_payments_checkout
baseline: main27
industry: [payments]
auth:
  driver: local
  methods: [passkeys, link]
  admin: { require: phishing-resistant }
db:
  driver: local
pay:
  driver: local
keys:
  driver: local
webhooks:
  inbound:
    stripe: { preset: stripe, secret: secret:STRIPE_WEBHOOK_SECRET, path: /webhooks/stripe }
logging:
  audit: true
  retention: 12m
  redact: [personal, sensitive]
network:
  # js.stripe.com is loaded by the buyer's browser from the checkout page, never fetched by this server; it is listed
  # because the page source names it, and the PCI script inventory below records why.
  allow: [js.stripe.com]
  default: deny
  mode: observe
compliance:
  pages: [checkout-page.ts]
  scripts:
    - { host: js.stripe.com, reason: Stripe hosted fields, owner: payments-team }
```

## app.ts

```ts
// The payments checkout reference architecture: a page that mounts the provider's hosted fields (card data goes from
// the buyer's browser to Stripe and never touches this app, so the scope is PCI DSS 4.0.1 SAQ A), a checkout route
// that charges a server-side total once per order, and a verified webhook that marks the order paid.
import { app, auth, MainError, pay } from 'mainsdk'
import { CHECKOUT_PAGE } from './checkout-page.ts'

export { CHECKOUT_PAGE }

type Order = {
  id: string
  user: string
  amountMinor: number
  currency: string
  status: 'open' | 'paid'
}

const orders = new Map<string, Order>()
const byCheckout = new Map<string, string>()
const sessions = new Map<string, unknown>()
const paidEarly = new Set<string>()
const attempts = new Map<string, number>()
const wired = new WeakSet<object>()

/** Register the payment handlers once per running app. */
function wire(): void {
  const current = app()
  if (wired.has(current)) return
  wired.add(current)
  pay.on('payment.succeeded', (event) => {
    const sid = String((event.data as { id?: string }).id)
    const order = orders.get(byCheckout.get(sid) ?? '')
    // A test driver can confirm before checkout() returns; remember it until the session is known.
    if (order) order.status = 'paid'
    else paidEarly.add(sid)
  })
}

const json = (status: number, body: unknown): Response => Response.json(body, { status })

/** The service: every refusal from an interface is answered as a problem and never a crash. */
export default async function fetch(req: Request): Promise<Response> {
  try {
    wire()
    const { pathname, origin } = new URL(req.url)
    if (req.method === 'GET' && pathname === '/')
      return new Response(CHECKOUT_PAGE, {
        headers: { 'content-type': 'text/html; charset=utf-8' },
      })
    if (req.method === 'GET' && pathname === '/health') return json(200, { ok: true })
    if (req.method === 'POST' && pathname === '/webhooks/stripe') {
      const { status, body } = await pay.handleWebhook(await req.text(), req.headers)
      return new Response(body, { status })
    }
    const { user } = await auth.require(req)
    if (req.method === 'POST' && pathname === '/orders') {
      const body = (await req.json()) as { amountMinor?: number; currency?: string }
      const amountMinor = Number(body.amountMinor)
      if (!Number.isInteger(amountMinor) || amountMinor <= 0)
        throw new MainError('MAIN-APP-0003', {
          detail: 'The amount must be a positive whole number of minor units',
          status: 400,
        })
      const order: Order = {
        id: crypto.randomUUID(),
        user,
        amountMinor,
        currency: body.currency ?? 'USD',
        status: 'open',
      }
      orders.set(order.id, order)
      return json(201, order)
    }
    const m = /^\/orders\/([^/]+)(\/checkout)?$/.exec(pathname)
    const order = m ? orders.get(m[1] as string) : undefined
    if (m && (!order || order.user !== user)) return json(404, { error: 'not found' })
    if (order && !m?.[2] && req.method === 'GET') return json(200, order)
    if (order && m?.[2] && req.method === 'POST') {
      // One session per order: a second click returns the first, so the order is charged once.
      const known = sessions.get(order.id)
      if (known) return json(200, known)
      // The amount is the stored total, never a number from the browser; the order id is the idempotency key.
      const session = await pay.checkout({
        order: order.id,
        amount: order.amountMinor,
        currency: order.currency,
        successUrl: `${origin}/orders/${order.id}`,
        cancelUrl: `${origin}/`,
        idempotencyKey: `checkout:${order.id}:${attempts.get(order.id) ?? 0}`,
      })
      attempts.set(order.id, (attempts.get(order.id) ?? 0) + 1) // a bad answer must not be replayed on retry
      const sid = (session as { id?: unknown }).id
      if (typeof sid !== 'string' || !/^cs_/.test(sid))
        throw new MainError('MAIN-APP-0003', {
          detail: 'The payment provider answered without a checkout session',
          status: 502,
        })
      byCheckout.set(sid, order.id)
      if (paidEarly.delete(sid)) order.status = 'paid'
      sessions.set(order.id, session)
      return json(200, session)
    }
    return json(404, { error: 'not found' })
  } catch (e) {
    if (e instanceof MainError) return json(e.status, { code: e.code, title: e.title, fix: e.fix })
    throw e
  }
}
```

## checkout-page.ts

```ts title="checkout-page.ts"
// The payment page, the one file the PCI DSS script inventory and page integrity controls watch (compliance.pages).
/** The page. It holds no card field: the provider's script puts its own iframe into the empty container. */
export const CHECKOUT_PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Pay</title></head>
<body>
  <h1>Pay</h1>
  <div id="payment-element"></div>
  <button id="pay" type="button">Pay now</button>
  <script src="https://js.stripe.com/v3/"></script>
  <script>
    // fetch the client secret from POST /checkout, then mount Stripe's hosted fields into #payment-element
  </script>
</body></html>`
```
