Checkout API
Create a Stripe Checkout Session and query whether checkout is enabled.
The Checkout API turns a cart into a hosted Stripe Checkout payment page. It is an optional, env-gated feature: this boilerplate is quote-based by default (browse → cart → submit a quote request, no payment). When STRIPE_SECRET_KEY is not set, the checkout endpoint responds 503 and the storefront falls back to the quote flow only.
There are two endpoints:
POST /api/checkout— re-prices the cart against the catalog and creates a Stripe Checkout Session, returning a redirecturl.GET /api/checkout/config— reports whether checkout is enabled, so the cart UI can decide whether to render the Checkout button.
Prices are never trusted from the client. Every line item is re-fetched from the catalog and re-priced server-side. The client only sends product ids, quantities, and an optional variant id.
POST /api/checkout
Creates a Stripe Checkout Session for the given cart items and returns the hosted payment URL to redirect the shopper to.
Neither handler declares a caching mode. Route handlers are dynamic by default, and that default is what keeps STRIPE_SECRET_KEY read per request rather than baked in at build time.
Request body
Content-Type: application/json. The body is validated with zod; anything that fails validation returns 400.
| Field | Type | Required | Constraints |
|---|---|---|---|
items | array | yes | 1–100 entries |
items[].id | string | yes | 1–128 chars; a catalog product id |
items[].quantity | integer | yes | positive integer, max 10000 |
items[].variantId | string | no | 1–128 chars; selects a variant of the product |
items is the entire schema — there is no field naming who is buying. The
customer is resolved from the request's own credential by getAuthUser() and
passed to Stripe as the session's client_reference_id, so the order attaches
to that account's history. Reading it from the body instead would let a payer
file their order under someone else's uid. A guest, an invalid credential, and
an unconfigured identity provider all resolve to the same thing: an
unattributed order.
{
"items": [
{ "id": "prod_hydraulic_pump", "quantity": 2 },
{ "id": "prod_seal_kit", "quantity": 1, "variantId": "var_viton" }
]
}Server-side pricing and variants
For each item the handler reads the product through the catalog seam (getCatalogProduct), so a store on Postgres or Supabase prices from the database it actually runs on rather than from Firestore. The record goes to resolveCheckoutLine (see src/lib/checkout-line.ts), a pure, dependency-free function that treats stored product data as untrusted:
- The unit price comes from the product's stored
price(in major currency units, e.g. dollars). It is converted to Stripe's minor units withMath.round(price * 100). - The currency is the store's base currency,
siteConfig.currency.code, lowercased for Stripe's ISO 4217 expectation. Prices are stored in that currency and the storefront's currency switcher is a display conversion only, so the amount Stripe charges always matches the stored price. See currency. - When an item carries a
variantId, the variant is resolved on the product'svariantsarray. Itspriceoverrides the product price, and itsstock(when the variant tracks its own) is checked instead of the product-level stock. The line is named"Product Name (Variant Name)". - Two variants of the same product are separate line items.
- A product (or variant) with no valid, positive numeric
pricecannot be checked out — those products are quote-only. - If
stockis a number and is less than the requested quantity, the line is rejected.
The created session uses mode: "payment", enables allow_promotion_codes, and sets:
success_url→{origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}cancel_url→{origin}/cart
where origin is the request's Origin header (falling back to the request URL's origin). The cart items are also encoded into the session metadata so the Stripe webhook can reconstruct the order after payment.
Success response
200 OK — returns the hosted Stripe Checkout URL. Redirect the browser to it.
{
"url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3..."
}Example request
curl -X POST https://your-store.example.com/api/checkout \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "id": "prod_hydraulic_pump", "quantity": 2 },
{ "id": "prod_seal_kit", "quantity": 1, "variantId": "var_viton" }
]
}'Errors
| Status | error message | When |
|---|---|---|
400 | Invalid JSON body | The request body is not valid JSON |
400 | Invalid request body | The body fails the zod schema (bad/missing fields, out-of-range values, too many items) |
400 | Product {id} was not found. | An item id has no matching catalog product |
400 | Variant "{variantId}" of "{name}" was not found. | The variantId is not present on the product's variants |
400 | "{name}" has no price — request a quote for it instead. | The product/variant has no valid positive price (quote-only) |
400 | Not enough stock for "{name}": {n} available, {q} requested. | Tracked stock is less than the requested quantity |
429 | Too many requests | Rate limit exceeded (see below); includes a Retry-After header |
500 | Could not start checkout. Please try again. | Stripe returned no session URL, or the Stripe call threw |
503 | checkout not configured | STRIPE_SECRET_KEY is not set — checkout is disabled |
{ "error": "Product prod_hydraulic_pump was not found." }{ "error": "checkout not configured" }Rate limiting
POST /api/checkout is rate limited per client IP: 10 requests per 10 minutes. The client IP is derived from platform-set headers (x-vercel-forwarded-for, cf-connecting-ip, x-real-ip), falling back to x-forwarded-for.
On serverless the default limiter is a speed bump, not a limit. Counters
live in the process, so each concurrent instance keeps its own and a cold start
resets them — "N per window" becomes "N per instance per window", with the
instance count set by the attacker's own traffic. Set
UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN and the counters move
to a shared store, which makes the limit hold across instances. No package to
install: it speaks Upstash's REST API with fetch. If the store is
unreachable the request is allowed and [rate-limit] is logged — a limiter
outage must not take checkout down.
When the limit is exceeded the response is 429 with a Retry-After header (seconds until the window resets):
HTTP/1.1 429 Too Many Requests
Retry-After: 420
Content-Type: application/json
{ "error": "Too many requests" }GET /api/checkout/config
Reports whether Stripe checkout is available for this deployment. The cart uses it to decide whether to render the Checkout button (versus quote-only). It never exposes any secret — the response is a single boolean derived from isStripeEnabled(), which just checks whether STRIPE_SECRET_KEY is set.
Response
200 OK:
{ "enabled": true }When STRIPE_SECRET_KEY is not configured:
{ "enabled": false }Example request
curl https://your-store.example.com/api/checkout/configEnabling checkout
Set STRIPE_SECRET_KEY in your environment (see environment variables) and redeploy. With no key set, both endpoints keep the store safe: config reports { "enabled": false } and POST /api/checkout returns 503.