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. The route is marked export const dynamic = "force-dynamic" because the raw request body must be read per request for signature verification — it must not be cached or pre-parsed.
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.
Require Admin credentials. Calls getAdminDb(). If Firebase Admin is not configured, the payment is logged as unrecorded and the endpoint still responds 200 (recorded: false) so Stripe does not retry forever.
Record the order (idempotently), then best-effort decrement stock.
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
recordOrder() mirrors the quote → order conversion (dbService.createOrderFromQuote) using the Firebase Admin SDK.
Idempotency
Stripe retries deliveries, so the handler first checks whether an order already exists for this session id and returns early if so:
const existing = await adminDb
.collection("orders")
.where("stripeSessionId", "==", session.id)
.limit(1)
.get();
if (!existing.empty) return;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;
}For each decoded ref the handler reads the catalog document from the products collection to enrich the line with productName, brand, price, subtotal, and primaryImageURL (and, for variant lines, variantId / variantName with the variant-resolved price) so the order matches the shape the admin dashboard expects.
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 (collection) | auto id (.add) | Main copy. Carries stripeSessionId for idempotency. |
customers/{customerId}/orders/{orderNumber} | 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-NNNN (e.g. O-20260703-4821). The order is stored with status: "Processing" and paymentStatus: "paid".
The recorded order document looks like:
{
"orderNumber": "O-20260703-4821",
"customerId": "firebase-uid-or-null",
"customer": {
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"phone": ""
},
"items": [
{
"id": "prod_123",
"quantity": 2,
"productName": "Widget",
"brand": "Acme",
"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_…"
}The customer name comes from Stripe's single customer_details.name, split into firstName (first token) and lastName (the rest).
Inventory decrement
After the order is saved, tracked stock is decremented best-effort: it is wrapped in try/catch and any failure is logged but never fails the already-recorded order.
try {
const groups = groupStockDecrements(itemRefs);
const batch = adminDb.batch();
// …read each product, computeStockUpdate(), batch.update()…
if (hasStockUpdates) await batch.commit();
} catch (error) {
console.error(`Failed to decrement stock for order ${orderNumber}:`, error);
}Decrements are grouped per product first (groupStockDecrements) so several lines touching the same product document produce a single batched 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 needs server-side Firebase Admin credentials to write orders. The browser SDK is not enough. Configure one of the following on the server (never expose these publicly).
| 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. |
FIREBASE_SERVICE_ACCOUNT_JSON | Service-account JSON contents (Vercel-friendly: paste the JSON in). |
GOOGLE_APPLICATION_CREDENTIALS | Path to a service-account JSON file (application-default creds). |
FIREBASE_SERVICE_ACCOUNT | Path to a service-account JSON file. |
Provide one of the three Firebase 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.
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 } | Admin credentials missing, or recording threw. |
200 | { "received": true, "recorded": true } | Order recorded successfully. |
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 Firebase Admin 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.