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 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 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.
| Field | Type | Required | Constraints |
|---|---|---|---|
items | array | yes | 1–100 entries |
items[].id | string | yes | 1–128 chars; a Firestore products document id |
items[].quantity | integer | yes | positive integer, max 10000 |
items[].variantId | string | no | 1–128 chars; selects a variant of the product |
customerId | string | no | 1–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.
{
"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 withMath.round(price * 100). - The currency is always
usd. - 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" }
],
"customerId": "aBcD1234FirebaseUid"
}'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 products document |
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.
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" }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:
{ "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.