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 both checkout routes declare
export const dynamic = "force-dynamic" so env vars are read at request time,
not at build time. 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 simply 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 | Firestore 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-fetches the product from Firestore and
resolves the price and stock itself (src/lib/checkout-line.ts):
const snapshot = await getDoc(doc(db, "products", item.id));
if (!snapshot.exists()) {
return NextResponse.json(
{ error: `Product ${item.id} was not found.` },
{ status: 400 }
);
}
const resolved = resolveCheckoutLine(snapshot.data(), item);resolveCheckoutLine treats every Firestore 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 (currency is usd), so Stripe only ever sees an amount
the server computed:
price_data: {
currency: "usd",
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" }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
For a completed session the handler (recordOrder) writes a paid order using
the Firebase Admin SDK:
- Idempotency — it first queries
ordersfor an existing document with the samestripeSessionIdand returns early if found, so Stripe's retries don't create duplicates. - Rebuild line items —
decodeItemsMetadatareconstructs the id/quantity/variantId refs from the session metadata, then each product is re-fetched from Firestore to enrich the order item withproductName,brand,price,subtotal,primaryImageURL, and (for variants)variantId/variantName. - Write the order — a document is added to the top-level
orderscollection withstatus: "Processing",paymentStatus: "paid", the Stripe-authoritativetotal(session.amount_total / 100, net of any promotion code), and customer details split from Stripe'scustomer_details. Whenclient_reference_idis present, a mirror copy is written tocustomers/{uid}/orders/{orderNumber}. - Decrement stock — a best-effort, batched inventory decrement runs last.
Lines are grouped per product, variant lines decrement the variant's own
stock(falling back to product-level stock when a variant doesn't track its own), and untracked/unknown items are skipped. A failure here is logged but never fails the already-recorded order.
Order writes need Admin credentials. If the event verifies but no
firebase-admin credentials 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 } | Order recorded. |
{ "received": true, "recorded": false } | Verified, but no Admin credentials or recording failed (logged). |
{ "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 server-side write access
Recording orders uses the Firebase Admin SDK, which needs service-account
credentials. Set FIREBASE_SERVICE_ACCOUNT_JSON to the contents of your
service-account JSON key (Vercel-friendly); alternatively provide
FIREBASE_SERVICE_ACCOUNT as a filesystem path to the key file.
FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"...","private_key":"..."}This is a private key — never commit it or expose it to the client. Without it,
payments still succeed but orders are not recorded (recorded: false).
See environment variables for the
full list, including how FIREBASE_SERVICE_ACCOUNT_JSON and
FIREBASE_SERVICE_ACCOUNT relate.