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 Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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
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
// 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
// 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>`Was this page helpful?