Acme Store Docs
API Reference

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 from Firestore and creates a Stripe Checkout Session, returning a redirect url.
  • 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 Firestore products collection 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.

Both handlers set export const dynamic = "force-dynamic" so environment variables are 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.

FieldTypeRequiredConstraints
itemsarrayyes1–100 entries
items[].idstringyes1–128 chars; a Firestore products document id
items[].quantityintegeryespositive integer, max 10000
items[].variantIdstringno1–128 chars; selects a variant of the product
customerIdstringno1–128 chars; the Firebase uid of the signed-in customer

customerId, when present, is passed to Stripe as the session's client_reference_id so the resulting order can be attributed to the customer.

Request body
{
  "items": [
    { "id": "prod_hydraulic_pump", "quantity": 2 },
    { "id": "prod_seal_kit", "quantity": 1, "variantId": "var_viton" }
  ],
  "customerId": "aBcD1234FirebaseUid"
}

Server-side pricing and variants

For each item the handler fetches products/{id} from Firestore (public read) and passes the document to resolveCheckoutLine (see src/lib/checkout-line.ts), which is a pure, dependency-free function that treats all Firestore 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 with Math.round(price * 100).
  • The currency is always usd.
  • When an item carries a variantId, the variant is resolved on the product's variants array. Its price overrides the product price, and its stock (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 price cannot be checked out — those products are quote-only.
  • If stock is 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.

200 OK
{
  "url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3..."
}

Example request

Terminal
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" }
    ],
    "customerId": "aBcD1234FirebaseUid"
  }'

Errors

Statuserror messageWhen
400Invalid JSON bodyThe request body is not valid JSON
400Invalid request bodyThe body fails the zod schema (bad/missing fields, out-of-range values, too many items)
400Product {id} was not found.An item id has no matching products document
400Variant "{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)
400Not enough stock for "{name}": {n} available, {q} requested.Tracked stock is less than the requested quantity
429Too many requestsRate limit exceeded (see below); includes a Retry-After header
500Could not start checkout. Please try again.Stripe returned no session URL, or the Stripe call threw
503checkout not configuredSTRIPE_SECRET_KEY is not set — checkout is disabled
400 — unknown product
{ "error": "Product prod_hydraulic_pump was not found." }
503 — Stripe not configured
{ "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.

When the limit is exceeded the response is 429 with a Retry-After header (seconds until the window resets):

429 Too Many Requests
HTTP/1.1 429 Too Many Requests
Retry-After: 420
Content-Type: application/json

{ "error": "Too many requests" }

The limiter's counters live in per-instance memory, so limits are enforced per serverless instance and reset on restart. Treat it as an abuse speed bump, not a hard global guarantee. For strict limits, front the route with a shared store (e.g. Upstash/Redis).

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:

200 OK — checkout enabled
{ "enabled": true }

When STRIPE_SECRET_KEY is not configured:

200 OK — checkout disabled
{ "enabled": false }

Example request

Terminal
curl https://your-store.example.com/api/checkout/config

Enabling 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.

On this page