Ecommerce Boilerplate
API Reference

Stripe Webhook

POST /api/webhooks/stripe — records paid orders and decrements stock.

The Stripe webhook is the server-side half of the optional Stripe payments flow. When a shopper completes a Stripe Checkout session, Stripe calls this endpoint; the handler verifies the event, records it as a paid order in Firestore, and best-effort decrements tracked inventory.

Payments are optional. By default the store is quote-based (browse → cart → submit a quote request, no payment). This endpoint only does anything when Stripe is configured. Without STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET it returns 503 and records nothing. See cart & quotes for the default flow.

Endpoint

POST /api/webhooks/stripe

Defined in src/app/api/webhooks/stripe/route.ts, which pins export const runtime = "nodejs" because firebase-admin, the pg pool, and the Supabase client cannot run on the Edge runtime. The signature covers the raw bytes, so the handler reads await request.text() and never a parsed body.

Only the checkout.session.completed event type is acted on. Every other verified event is acknowledged with 200 and ignored.

What it does

Verify configuration. Loads the Stripe client (getStripe()) and reads STRIPE_WEBHOOK_SECRET. If either is missing, responds 503.

Verify the signature. Reads the stripe-signature header and the raw body, then calls stripe.webhooks.constructEvent(payload, signature, webhookSecret). A missing header or a failed verification responds 400.

Filter the event. If event.type is not checkout.session.completed, responds 200 with { "received": true } and stops.

Resolve the backend. Calls getServerCommerce(), which returns the adapter named by COMMERCE_BACKEND — Prisma, Supabase, or the Firebase Admin surface.

Record the order and the stock it consumed, idempotently on the session id. Any failure is logged and the endpoint still responds 200 (recorded: false) so Stripe does not retry forever.

Signature verification

The handler never trusts the request body until Stripe's signature checks out:

src/app/api/webhooks/stripe/route.ts
const signature = request.headers.get("stripe-signature");
if (!signature) {
  return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}

const payload = await request.text();
let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (error) {
  console.error("Stripe webhook signature verification failed:", error);
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

STRIPE_WEBHOOK_SECRET is the signing secret for this specific endpoint (it starts with whsec_). The Stripe CLI prints one value when you run stripe listen; the Stripe Dashboard shows a different value per registered endpoint. Use the one that matches how the event is being delivered, or every request fails signature verification with 400.

Recording the order

The route does no database work itself. It converts the verified session into a provider-neutral PaidSession and hands it to the active backend:

src/app/api/webhooks/stripe/route.ts
const commerce = await getServerCommerce();
const { orderNumber, created } = await commerce.orders.recordPaidSession(
  toPaidSession(session)
);

getServerCommerce() resolves the adapter from COMMERCE_BACKEND, so a Postgres store records the order in Postgres, a Supabase store in Supabase, and a Firebase store in Firestore. recordPaidSession is deliberately absent from the commerce RPC whitelist — it mints a paid order with no payment check of its own, and the only caller allowed to make that claim is this signature-verified route.

src/lib/commerce/types.ts
export interface PaidSession {
  sessionId: string;                    // idempotency key
  customerId: string | null;            // null for a guest checkout
  customer: { firstName: string; lastName: string; email: string; phone: string };
  items: PaidSessionItem[];             // { id, quantity, variantId? }
  total: number;                        // what the provider charged
}

Idempotency

Stripe retries deliveries, so recording the same session twice must produce one order. Each adapter enforces that with a real constraint rather than a check:

BackendMechanismOn a retry
Postgres, Supabaseorders.payment_session_id is UNIQUEThe INSERT raises (P2002 / 23505); the adapter returns the existing order with created: false. On Postgres the whole transaction rolls back with it.
FirebaseThe order document id is the session idThe transaction sees the document and returns early. A legacy auto-id order carrying stripeSessionId is recognised too.

A pre-flight SELECT would not be enough: two deliveries arriving together can both pass it. The constraint is what makes only one win.

Line items from metadata

The purchased lines are not read back from Stripe's line-item API — they are decoded from the session metadata that /api/checkout wrote when creating the session, using decodeItemsMetadata() from src/lib/checkout-metadata.ts.

Each entry is a compact JSON tuple, ["id", quantity] or, for a variant line, ["id", quantity, "variantId"]. Because Stripe caps each metadata value at 500 characters (STRIPE_METADATA_VALUE_LIMIT), long carts spill losslessly across items, items_2, items_3, … keys, which the decoder reassembles in order:

src/lib/checkout-metadata.ts
export interface CheckoutItemRef {
  id: string;
  quantity: number;
  variantId?: string;
}

buildPaidOrderItems (src/lib/orders/paid-session.ts, shared by every adapter) then enriches each ref from your catalog with productName, brand, price, subtotal, and primaryImageURL — plus variantId / variantName and the variant-resolved price on variant lines — so the order matches the shape the admin dashboard expects. A product deleted between checkout and delivery degrades to its id with a null price rather than failing the order.

total is Stripe's authoritative amount_total (in dollars, i.e. cents ÷ 100) — net of any promotion code. With a Stripe discount applied, the per-item subtotal values may sum higher than total.

Where it's written

The order is written in two places, mirroring the quote-conversion dual-write:

LocationKeyed byNotes
orders (table or collection)the payment session id on Firestore; a generated id plus a unique payment_session_id on SQLMain copy.
The per-customer copy (customers/{uid}/orders/{orderNumber} on Firestore, customer_orders on SQL)human-readable orderNumberWritten only when session.client_reference_id is present.

customerId comes from session.client_reference_id; orderNumber is generated in the form O-YYYYMMDD-NNNNXXXX (e.g. O-20260703-4821K3XW). The order is stored with status: "Processing" and paymentStatus: "paid".

The recorded order looks like:

orders/{sessionId}
{
  "orderNumber": "O-20260703-4821K3XW",
  "customerId": "firebase-uid-or-null",
  "customer": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com",
    "phone": ""
  },
  "items": [
    {
      "id": "prod_123",
      "quantity": 2,
      "productName": "Widget",
      "brand": "Wavecrest",
      "price": 19.99,
      "subtotal": 39.98,
      "primaryImageURL": "https://…"
    }
  ],
  "total": 39.98,
  "status": "Processing",
  "created": "2026-07-03T12:00:00.000Z",
  "paymentStatus": "paid",
  "stripeSessionId": "cs_test_…"
}

On the SQL backends the last field is the payment_session_id column instead of a stripeSessionId property — same value, same purpose.

The customer name comes from Stripe's single customer_details.name, split into firstName (first token) and lastName (the rest).

Inventory decrement

The stock the order consumed is decremented in the same transaction as the order, so a retried or concurrent delivery cannot oversell:

  • Postgres locks every product the session touches with SELECT … FOR UPDATE (ordered by id, so concurrent sessions cannot deadlock) before computing anything, then writes inside one $transaction.
  • Firestore reads the products inside runTransaction, so a transaction whose reads were touched concurrently retries and sees the other decrement.

Supabase is the exception. PostgREST exposes no interactive transaction and no row locks, so its decrement runs after the insert, best-effort: 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 is unaffected — that rests on the unique constraint. Point Prisma at the same database if you need the stricter guarantee.

Decrements are grouped per product first (groupStockDecrements) so several lines touching the same product produce a single update, then computeStockUpdate decides what to write. Untracked stock (non-numeric), unknown ids, and unknown variants are skipped silently. Variant lines decrement the variant's own stock when it tracks it, falling back to the product-level stock otherwise — the same math the quote flow uses. See inventory & variants for the shared logic.

Requirements

This endpoint writes with your backend's own server credentials — the ones the commerce layer already needs. Never expose any of them to the client.

Env varPurpose
STRIPE_SECRET_KEYEnables the Stripe client. Without it, the endpoint returns 503.
STRIPE_WEBHOOK_SECRETSigning secret used to verify the event. Without it, 503.
DATABASE_URLPostgres backend: the connection the Prisma adapter records through.
SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEYSupabase backend: same role.
FIREBASE_SERVICE_ACCOUNT_JSONFirebase backend: service-account JSON contents (Vercel-friendly: paste the JSON in).
GOOGLE_APPLICATION_CREDENTIALSFirebase backend: path to a service-account JSON file (application-default creds).
FIREBASE_SERVICE_ACCOUNTFirebase backend: path to a service-account JSON file.

On Firebase, provide one of the three credential options; when none is set, getAdminDb() returns null, the webhook logs that the payment completed but the order could not be recorded, and still returns 200. See environment variables for the full list.

SQL backends need one migration. payment_session_id and payment_status are new columns on orders. Run npm run prisma:push, or re-run supabase/schema.sql, before the first paid event arrives — otherwise recording fails on an unknown column and the payment goes unrecorded.

Responses

StatusBodyWhen
503{ "error": "webhook not configured" }STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing.
400{ "error": "Missing signature" }No stripe-signature header.
400{ "error": "Invalid signature" }Signature verification failed.
200{ "received": true }Verified event that is not checkout.session.completed.
200{ "received": true, "recorded": false }Recording threw: missing credentials, an unmigrated schema, or an unknown COMMERCE_BACKEND.
200{ "received": true, "recorded": true, "orderNumber": "…", "created": true }Order recorded.
200{ "received": true, "recorded": true, "created": false }A retried delivery; the existing order is returned unchanged.

Verified events always resolve to 200 even when the order cannot be recorded, so Stripe does not retry indefinitely. Diagnose recording failures from the server logs (console.error), not from the HTTP status.

Testing locally with the Stripe CLI

The Stripe CLI forwards live webhook events to your dev server and prints a signing secret to use.

Start the dev server so http://localhost:3000 is reachable.

Forward events to the endpoint:

Terminal
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a signing secret like whsec_….

Set that value as STRIPE_WEBHOOK_SECRET (alongside STRIPE_SECRET_KEY and your backend's server credentials), then restart the dev server so it picks up the env vars.

Complete a test checkout, or trigger an event directly:

Terminal
stripe trigger checkout.session.completed

stripe trigger sends a synthetic session with no items metadata and no client_reference_id, so the order records with an empty items array and no customer copy. To exercise the full path (line items, stock decrement, customer copy), complete a real test-mode checkout started from your store's checkout endpoint.

On this page