Payments (Stripe)
Optional, env-gated Stripe Checkout with server-side re-pricing and a webhook.
The boilerplate is quote-first: shoppers browse, add to cart, and submit a
quote request — no payment required. Stripe
Checkout is an optional, env-gated layer on top. Set STRIPE_SECRET_KEY and
a real "Checkout" button appears next to the quote flow; leave it unset and the
store behaves exactly as before.
Nothing about Stripe is baked into the build. The Stripe client is created
lazily per request (src/lib/stripe.ts), and no /api route is ever
statically generated, so STRIPE_SECRET_KEY is read at request time rather
than frozen at build time. That used to be declared with
export const dynamic = "force-dynamic"; the declarations were redundant and
are gone, and tests/api-routes-dynamic.test.ts now checks the built route
table instead — the outcome rather than the intent. A deployment without
STRIPE_SECRET_KEY builds and runs fine.
How the store decides whether to show Checkout
The client never assumes Stripe is on. The cart asks the server first, via
GET /api/checkout/config, which returns a single boolean:
{ "enabled": true }enabled is Boolean(process.env.STRIPE_SECRET_KEY) — it never exposes
the key or any other secret:
export function isStripeEnabled(): boolean {
return Boolean(process.env.STRIPE_SECRET_KEY);
}When enabled is false, the cart renders only the quote flow and the Checkout
button does not appear.
Creating a Checkout Session
POST /api/checkout turns a cart into a Stripe Checkout Session and returns the
hosted-page URL to redirect to.
Request
{
"items": [
{ "id": "prod_abc", "quantity": 2 },
{ "id": "prod_xyz", "quantity": 1, "variantId": "var_red_m" }
],
"customerId": "firebase-uid-optional"
}| Field | Type | Required | Notes |
|---|---|---|---|
items | array | Yes | 1–100 lines. |
items[].id | string | Yes | Catalog product id, 1–128 chars. |
items[].quantity | integer | Yes | Positive, up to 10,000. |
items[].variantId | string | No | Selected variant id, 1–128 chars. |
customerId | string | No | Firebase uid of the signed-in shopper, 1–128 chars. Stored as the session's client_reference_id. |
The body is validated with Zod; anything outside these bounds returns
400 Invalid request body, and a non-JSON body returns 400 Invalid JSON body.
Prices are never trusted from the client
The request carries only ids, quantities, and an optional variant id — no
prices. For each line the server re-reads the product through the catalog seam,
so it queries whichever backend the store runs on, and resolves the price and
stock itself (src/lib/checkout-line.ts):
const product = await getCatalogProduct(item.id);
if (!product) {
return NextResponse.json(
{ error: `Product ${item.id} was not found.` },
{ status: 400 }
);
}
const resolved = resolveCheckoutLine(product, item);resolveCheckoutLine treats every stored field as untrusted and applies
these rules:
- Price comes from the product's stored
price. When avariantIdis present the variant's ownpriceoverrides it (falling back to the product price when the variant has none). - Missing/invalid price (not a finite number
> 0) resolves to an error:"<name>" has no price — request a quote for it instead.Such products can only go through the quote flow. - Variant not found returns
Variant "<id>" of "<product>" was not found. - Stock is checked server-side. A variant that tracks its own numeric
stockis validated against that; otherwise the product-levelstockapplies. Ifstock < quantitythe line fails withNot enough stock for "<name>": <n> available, <q> requested. - Line name is the product name, or
"Product Name (Variant Name)"for a variant line. Two variants of one product become two separate line items.
The resolved price is then converted to the smallest currency unit and sent to
Stripe as price_data, in the store's own base currency
(siteConfig.currency.code, not a hardcoded "usd"), so Stripe only ever sees
an amount the server computed:
price_data: {
currency: baseCurrency,
unit_amount: Math.round(resolved.price * 100),
product_data: {
name: resolved.name,
metadata: {
productId: item.id,
...(item.variantId ? { variantId: item.variantId } : {}),
},
},
},Session options
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: lineItems,
// Let shoppers enter Stripe promotion codes on the payment page.
allow_promotion_codes: true,
success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
metadata: encodeItemsMetadata(items),
client_reference_id: customerId,
});allow_promotion_codes: true— shoppers can enter Stripe promotion codes on the payment page.success_url—/checkout/success?session_id={CHECKOUT_SESSION_ID}on the request origin.cancel_url— back to/cart.metadata— the cart's[id, quantity]/[id, quantity, variantId]entries are encoded as compact JSON (encodeItemsMetadata) so the webhook can rebuild the order later. Long carts spill losslessly intoitems_2,items_3, … keys to stay under Stripe's 500-char metadata limit.client_reference_id— the optionalcustomerId, used to attach the resulting order to a customer.
Response
{ "url": "https://checkout.stripe.com/c/pay/cs_test_..." }Redirect the shopper to url. Error responses:
| Status | When |
|---|---|
400 | Invalid JSON, invalid body, missing product, no price, missing variant, or insufficient stock. |
429 | Rate limit exceeded (see below). |
503 | { "error": "checkout not configured" } — STRIPE_SECRET_KEY is not set. |
500 | Stripe returned no session URL, or session creation threw. |
Rate limiting
POST /api/checkout is rate-limited per client IP to 10 requests per
10 minutes. Over the limit it returns 429 with a Retry-After header:
{ "error": "Too many requests" }The default limiter counts in-process, which on serverless means "N per
instance per window" rather than a real limit. Set UPSTASH_REDIS_REST_URL
and UPSTASH_REDIS_REST_TOKEN to make it hold across instances — see
rate limiting for why, and for what
happens when the store is unreachable.
Example
curl -X POST https://your-store.example.com/api/checkout \
-H "Content-Type: application/json" \
-d '{
"items": [{ "id": "prod_abc", "quantity": 2 }],
"customerId": "firebase-uid-123"
}'See the checkout API reference for the full route contract.
The webhook: turning a payment into an order
POST /api/webhooks/stripe is where a paid session becomes a real order in the
fulfillment pipeline. Point your Stripe webhook (or stripe listen) at it.
Signature verification
The endpoint requires both a Stripe client and STRIPE_WEBHOOK_SECRET;
missing either returns 503 { "error": "webhook not configured" }. The raw
request body is read as text and verified against the stripe-signature
header:
const payload = await request.text();
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);- A missing
stripe-signatureheader →400 Missing signature. - A failed verification →
400 Invalid signature. - Only
checkout.session.completedevents are acted on; any other verified event type is acknowledged with{ "received": true }.
What it records
The route itself does no database work. It turns the verified Stripe session
into a provider-neutral PaidSession — session id, buyer, line refs decoded
from the metadata, and the amount Stripe actually charged — and hands it to
commerce.orders.recordPaidSession() on the active backend, resolved by
getServerCommerce(). A Postgres store records the order in Postgres, a
Supabase store in Supabase, a Firebase store in Firestore.
const commerce = await getServerCommerce();
const { orderNumber, created } = await commerce.orders.recordPaidSession(
toPaidSession(session)
);Every adapter owes the same four things:
- Idempotency, keyed by the session id. Stripe retries deliveries, so a
second delivery must return the first order rather than creating another.
The SQL adapters get this from a UNIQUE
orders.payment_session_id(a real constraint, so two concurrent deliveries cannot both win it); Firestore gets it from keying the order document by the session id. - Line items rebuilt from your catalog.
decodeItemsMetadatareconstructs the id/quantity/variantId refs, thenbuildPaidOrderItemsenriches each one withproductName,brand,price,subtotal,primaryImageURLand, for variant lines,variantId/variantName. A product deleted between checkout and delivery degrades to its id and a null price — the money has moved, so a thin record beats no record. - The order.
status: "Processing",paymentStatus: "paid", and the Stripe-authoritativetotal(amount_total / 100, net of any promotion code — which is why the line subtotals can sum higher). With aclient_reference_id, a per-customer copy is written too. - Stock, consumed atomically with the order. Lines are grouped per
product; a variant line decrements the variant's own
stock, falling back to product-level stock; untracked and unknown items are skipped. Prisma runs the whole thing in one transaction and takesSELECT … FOR UPDATElocks first, so two deliveries cannot both spend the last unit. Firestore reads inside its transaction, which gives the same guarantee.
Supabase is the exception on point 4. PostgREST offers no interactive transaction and no row locks, so its decrement is best-effort and separate from the insert: a failure is logged and never fails the recorded order, and two sessions decrementing the same product at the same instant can lose one update. Idempotency still holds — that rests on the unique constraint. A store that needs the stricter guarantee should point Prisma at the same database.
Existing SQL deployments must migrate. The payment_session_id /
payment_status columns are new. Run npm run prisma:push (Postgres) or
re-run supabase/schema.sql (Supabase) — both are idempotent — before the
first paid webhook arrives, or recording fails with an unknown-column error.
On the Firebase backend, order writes need Admin credentials. If the event
verifies but none are configured, the handler still returns 200 with
{ "received": true, "recorded": false } and logs that the payment completed
but the order could not be recorded. Verified events always get a 200 so
Stripe doesn't retry indefinitely — check your logs if orders aren't appearing.
Responses
| Body | Meaning |
|---|---|
{ "received": true, "recorded": true, "orderNumber": "…", "created": true } | Order recorded. |
{ "received": true, "recorded": true, "created": false } | A retried delivery; the existing order is returned. |
{ "received": true, "recorded": false } | Verified, but recording failed (logged) — missing credentials, an unmigrated schema, or an unknown COMMERCE_BACKEND. |
{ "received": true } | Verified event of a type other than checkout.session.completed. |
Full contract: webhooks API reference.
Setup
Set your Stripe secret key
Add your key to .env.local. This alone flips
GET /api/checkout/config to { "enabled": true } and makes the Checkout
button appear.
STRIPE_SECRET_KEY=sk_test_...Forward webhooks and grab the signing secret
In development, use the Stripe CLI to forward checkout.session.completed
events to your local route. The command prints a signing secret
(whsec_...) — copy it into STRIPE_WEBHOOK_SECRET.
stripe listen --forward-to localhost:3000/api/webhooks/stripeSTRIPE_WEBHOOK_SECRET=whsec_...In production, create the webhook endpoint in the Stripe Dashboard pointing at
https://your-store.example.com/api/webhooks/stripe and use the endpoint's
signing secret instead.
Give the webhook write access to your backend
The webhook writes with the same server credentials the rest of the server uses, so if your backend already works there is nothing new to add.
DATABASE_URL (or SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY) is all the
recording needs. One migration is required the first time, because the payment
columns are new:
npm run prisma:push # Postgres
# or re-run supabase/schema.sql in the Supabase SQL editorSee environment variables for the
full list, including how FIREBASE_SERVICE_ACCOUNT_JSON and
FIREBASE_SERVICE_ACCOUNT relate.