# Stripe Payments

You are working in a Next.js 16 App Router project that takes payments with Stripe through the `stripe` Node SDK 23 and TypeScript. Write code for the versions in `package.json`: the SDK pins API version `2026-09-30.endive`, and several fields and parameters you remember have moved or been removed.

## Before you write code

- Read the installed SDK's types before using a parameter: `node_modules/stripe/cjs/resources/` (`Checkout/Sessions.d.ts`, `Subscriptions.d.ts`, `Invoices.d.ts`, `BillingPortal/Sessions.d.ts`). The types describe only the pinned API version; `node_modules/stripe/CHANGELOG.md` lists what changed.
- Find the existing Stripe client (`lib/stripe.ts`), webhook route and billing tables, and extend them. Check how users map to Stripe customers before adding a column.
- Check which environment variables exist: `STRIPE_SECRET_KEY` (prefer a restricted `rk_` key with only the permissions the server uses), `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`.

## The client and the catalog

```ts
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
```

- One client, server only. `Stripe` is a class: call `new Stripe(key)`. Do not pass `apiVersion` unless the project pins one already: the default matches the types.
- Create webhook endpoints on the same API version as the SDK; events are rendered in the endpoint's version, not the SDK's.
- Products and prices live in Stripe, not in code. One Product per plan (Starter, Pro, Team); several Prices on a Product only for variants of that plan (monthly, yearly, currencies). Give each price a `lookup_key` (`pro_monthly`) and resolve it on the server: `stripe.prices.list({ lookup_keys: ["pro_monthly"], expand: ["data.product"] })`.
- The client sends a plan key or a cart of product IDs and quantities, never a price ID it chose, an amount or a currency. The server validates the key against an allowlist and looks up the price.
- Show prices on the page from Stripe (cached, and refreshed on `price.*` events) so the page and the charge cannot disagree.

## Checkout Sessions first

Use Checkout Sessions for one-time payments (`mode: "payment"`) and subscriptions (`mode: "subscription"`). Create them in a Server Function or Route Handler after authenticating the user.

```ts
"use server";
import { redirect } from "next/navigation";

export async function startCheckout(plan: string, attemptId: string) {
  const user = await requireUser();
  const origin = process.env.APP_URL; // configured, never the Host header
  const price = await priceForPlan(plan); // lookup key -> Stripe.Price, or throws
  const customer = await customerIdFor(user); // created once, stored on the user
  const session = await stripe.checkout.sessions.create(
    {
      mode: "subscription",
      customer,
      client_reference_id: user.id,
      line_items: [{ price: price.id, quantity: 1 }],
      automatic_tax: { enabled: true },
      customer_update: { address: "auto" },
      success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${origin}/pricing`,
    },
    { idempotencyKey: `checkout-${user.id}-${attemptId}` }
  );
  redirect(session.url as string);
}
```

- `ui_mode` is `"hosted_page"` by default (redirect to `session.url`). For checkout inside your page use `ui_mode: "embedded_page"` with a `return_url` instead of `success_url`, return `session.client_secret` to the client, and mount Stripe's embedded Checkout from `@stripe/react-stripe-js`. For a fully custom form use the Payment Element backed by a Checkout Session (`ui_mode: "elements"`), not a hand-built card form.
- Do not pass `payment_method_types`: it is removed from Checkout Sessions in this API version. Payment methods come from the Dashboard (dynamic payment methods); narrow them with `excluded_payment_method_types`, or `allowed_payment_method_types` only when you must.
- Load Stripe.js once per page with `loadStripe(publishableKey)` from `@stripe/stripe-js` at module scope, never inside a render.
- The success page is for showing a receipt, not for fulfilling. It may read the session by `session_id`, check it belongs to the signed-in user, and show its state.
- Use PaymentIntents directly only for off-session charges or flows Checkout cannot model. Never use the Charges API, Sources, Tokens or the Card Element.

## Customers and subscriptions

- Map one user (or one organization, for team billing) to one Stripe Customer. Create the customer before the first checkout, store `stripeCustomerId` with a unique index, and pass `customer` on every session so payment methods and invoices stay on one record.
- Resolve events to your records through Stripe's objects: customer ID to user, subscription ID to subscription row. Use `metadata` and `client_reference_id` only as a fallback.
- Store per subscription: Stripe subscription ID, customer ID, status, price ID or lookup key, `cancel_at_period_end`, and the period end. The period end is on the item: `subscription.items.data[0].current_period_end` (seconds), not on the subscription.
- Grant access from the stored status: `active` and `trialing` have access; decide deliberately whether `past_due` keeps a grace period; `incomplete`, `incomplete_expired`, `unpaid`, `canceled` and `paused` do not.
- On an invoice, the subscription is at `invoice.parent.subscription_details.subscription`, not `invoice.subscription`.
- Upgrades, downgrades, cancellations and card changes go through the Billing (customer) Portal: `stripe.billingPortal.sessions.create({ customer, return_url })`, then redirect to its `url`. Configure allowed plan changes in the Dashboard. Build your own flow only for what the portal cannot do, with `stripe.subscriptions.update` and proration settings you choose.
- Never build renewals with PaymentIntents in a cron job: Billing handles renewals, retries and dunning.

## Webhooks are the source of truth

```ts
// app/api/webhooks/stripe/route.ts
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature") ?? "";
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET as string);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  await handleEvent(event); // throws on failure, so Stripe retries
  return new Response(null, { status: 200 });
}
```

- Read the raw body with `await request.text()` and verify it before parsing. Never call `request.json()` first or re-serialize the body: the signature covers the exact bytes. On an edge or worker runtime, use `constructEventAsync`.
- Keep the webhook route public: exclude it from `proxy.ts` auth redirects and never require a session on it. The signature is its authentication.
- Handle the events you act on and acknowledge the rest with 200:
  - `checkout.session.completed` and `checkout.session.async_payment_succeeded`: fulfill when `payment_status` is not `"unpaid"`. `checkout.session.async_payment_failed`: tell the customer.
  - `customer.subscription.created`, `.updated`, `.deleted`: sync the subscription row.
  - `invoice.paid`: extend access for the new period. `invoice.payment_failed`: flag the account and notify. `invoice.payment_action_required`: send the customer to the hosted invoice to authenticate.
  - `charge.refunded` and `charge.dispute.created` when access or records depend on them.
- Make every handler idempotent. Record `event.id` in a table with a unique constraint inside the same transaction as the write; a duplicate insert means the event was handled, so return 200.
- Events arrive late, twice and out of order. For subscriptions, retrieve the current object (`stripe.subscriptions.retrieve(id)`) and write that state, rather than applying the event's snapshot over newer data.
- Respond within seconds. Move slow work (emails, provisioning) after the write. A non-2xx response makes Stripe retry for days, so return 500 only when a retry can succeed.
- Fulfillment code is one function called by the webhook (and optionally by the success page), safe to run twice.

## Money safety

- Pass an `idempotencyKey` (second argument, request options) on create calls that charge or create objects, derived from the attempt (a form token or order ID), so a double click or retried request returns the first result instead of a second charge. Reusing a key with different parameters is an error.
- 3D Secure and other authentication are handled by Checkout and the Payment Element. Do not try to detect or skip them. For renewals that need authentication, handle `invoice.payment_action_required` and keep Stripe's customer emails for failed and incomplete payments on.
- Delayed methods (bank debits, vouchers) complete hours or days later: a subscription can sit in `incomplete` and a session can be `complete` while `unpaid`.
- Stripe Tax: `automatic_tax: { enabled: true }` collects nothing until the account has an active registration in the customer's jurisdiction and products have tax codes. Say so when you enable it. It cannot be combined with manual `tax_rates` on the same object. For returning customers, `customer_update: { address: "auto" }` saves the address entered at checkout.
- PCI: card data never touches your server. Collect it only in Checkout or Stripe Elements; never log, store or proxy card numbers, and never put a secret or restricted key in client code or a `NEXT_PUBLIC_` variable.
- Catch `Stripe.errors.StripeError` (and subclasses like `StripeCardError`) around API calls and return a message the user can act on; log the request ID, not the payload.

## Do not

- Do not trust a price, amount, currency, plan or quantity limit sent by the browser.
- Do not fulfill orders or grant access from the success URL alone, or skip signature verification "for now".
- Do not pass `payment_method_types`, read `subscription.current_period_end` or `invoice.subscription`, or call `Stripe(key)` without `new`: those belong to older API versions and SDKs.
- Do not use the Pages Router `bodyParser: false` config, Charges, Sources, Tokens, the Card Element or the deprecated `plan` object.
- Do not create a new Stripe Customer on every checkout, or a Product per price.

## Before you finish

- Run the type checker and linter; the SDK's types catch removed parameters.
- Test in a sandbox with the Stripe CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe` (use the `whsec_` it prints locally), then `stripe trigger checkout.session.completed` and the subscription events. Send each event twice and confirm one write.
- Pay with test cards: `4242 4242 4242 4242` succeeds, `4000 0025 0000 3155` requires 3D Secure, `4000 0000 0000 0341` attaches but fails later charges. Use test clocks to advance a subscription through renewal and failure.
- List the Dashboard settings the change depends on (webhook events and API version, portal configuration, tax registrations, payment methods) for the reviewer.
