Acme Store Docs
Features

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:

GET /api/checkout/config
{ "enabled": true }

enabled is simply Boolean(process.env.STRIPE_SECRET_KEY) — it never exposes the key or any other secret:

src/lib/stripe.ts
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

POST /api/checkout
{
  "items": [
    { "id": "prod_abc", "quantity": 2 },
    { "id": "prod_xyz", "quantity": 1, "variantId": "var_red_m" }
  ],
  "customerId": "firebase-uid-optional"
}
FieldTypeRequiredNotes
itemsarrayYes1–100 lines.
items[].idstringYesFirestore product id, 1–128 chars.
items[].quantityintegerYesPositive, up to 10,000.
items[].variantIdstringNoSelected variant id, 1–128 chars.
customerIdstringNoFirebase 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):

src/app/api/checkout/route.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 a variantId is present the variant's own price overrides 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 stock is validated against that; otherwise the product-level stock applies. If stock < quantity the line fails with Not 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:

src/app/api/checkout/route.ts
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

src/app/api/checkout/route.ts
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 into items_2, items_3, … keys to stay under Stripe's 500-char metadata limit.
  • client_reference_id — the optional customerId, used to attach the resulting order to a customer.

Response

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

Redirect the shopper to url. Error responses:

StatusWhen
400Invalid JSON, invalid body, missing product, no price, missing variant, or insufficient stock.
429Rate limit exceeded (see below).
503{ "error": "checkout not configured" }STRIPE_SECRET_KEY is not set.
500Stripe 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:

429 Too Many Requests
{ "error": "Too many requests" }

Example

Terminal
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:

src/app/api/webhooks/stripe/route.ts
const payload = await request.text();
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
  • A missing stripe-signature header → 400 Missing signature.
  • A failed verification → 400 Invalid signature.
  • Only checkout.session.completed events 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:

  1. Idempotency — it first queries orders for an existing document with the same stripeSessionId and returns early if found, so Stripe's retries don't create duplicates.
  2. Rebuild line itemsdecodeItemsMetadata reconstructs the id/quantity/variantId refs from the session metadata, then each product is re-fetched from Firestore to enrich the order item with productName, brand, price, subtotal, primaryImageURL, and (for variants) variantId / variantName.
  3. Write the order — a document is added to the top-level orders collection with status: "Processing", paymentStatus: "paid", the Stripe-authoritative total (session.amount_total / 100, net of any promotion code), and customer details split from Stripe's customer_details. When client_reference_id is present, a mirror copy is written to customers/{uid}/orders/{orderNumber}.
  4. 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

BodyMeaning
{ "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.

.env.local
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.

Terminal
stripe listen --forward-to localhost:3000/api/webhooks/stripe
.env.local
STRIPE_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.

.env.local
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.

On this page