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/stripeDefined 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:
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:
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.
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:
| Backend | Mechanism | On a retry |
|---|---|---|
| Postgres, Supabase | orders.payment_session_id is UNIQUE | The INSERT raises (P2002 / 23505); the adapter returns the existing order with created: false. On Postgres the whole transaction rolls back with it. |
| Firebase | The order document id is the session id | The 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:
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:
| Location | Keyed by | Notes |
|---|---|---|
orders (table or collection) | the payment session id on Firestore; a generated id plus a unique payment_session_id on SQL | Main copy. |
The per-customer copy (customers/{uid}/orders/{orderNumber} on Firestore, customer_orders on SQL) | human-readable orderNumber | Written 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:
{
"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 var | Purpose |
|---|---|
STRIPE_SECRET_KEY | Enables the Stripe client. Without it, the endpoint returns 503. |
STRIPE_WEBHOOK_SECRET | Signing secret used to verify the event. Without it, 503. |
DATABASE_URL | Postgres backend: the connection the Prisma adapter records through. |
SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY | Supabase backend: same role. |
FIREBASE_SERVICE_ACCOUNT_JSON | Firebase backend: service-account JSON contents (Vercel-friendly: paste the JSON in). |
GOOGLE_APPLICATION_CREDENTIALS | Firebase backend: path to a service-account JSON file (application-default creds). |
FIREBASE_SERVICE_ACCOUNT | Firebase 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
| Status | Body | When |
|---|---|---|
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:
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe 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:
stripe trigger checkout.session.completedstripe 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.