# Payments

> Stripe payments for a Next.js 16 site — Stripe 22 with a lazy-instantiated client (no apiVersion pin; the SDK ships its own), Checkout Session creation in a server action redirecting to Stripe-hosted checkout, a webhook route handler that reads the raw body via await req.text() before constructEvent (the classic parsed-body signature failure), prices modeled in the Dashboard and allowlisted in code, strict test-mode discipline with the Stripe CLI, and success/cancel pages designed to the system — never bare. Invoke during the backend phase when the brief sells anything — one-time purchases, subscriptions, pricing-page checkout — when webhook signature verification keeps failing, or when fulfillment logic lives on the success page. Trigger phrases — "add Stripe", "checkout", "payments", "subscriptions", "billing", "buy button", "webhook signature error", "payment succeeded but nothing happened", "SEPA Lastschrift / Klarna for DACH", "Widerrufsrecht at checkout".

- Skill: `blyatiful1/payments` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/payments`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/payments/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/payments

---


# payments — Stripe without the classic footguns

**Stage:** Phase 7 — Backend - **Reads:** design/BRIEF.md, design/SYSTEM.md, pricing tiers from ultraweb:pricing - **Writes:** lib/stripe.ts, lib/prices.ts, app/actions/checkout.ts, app/api/webhooks/stripe/route.ts, /checkout/success + cancel surfaces

## Standard

- Money code is trust code: the webhook is the single source of fulfillment truth, every signature is verified, and no amount ever comes from the client.
- Stripe-hosted Checkout by default — PCI scope stays with Stripe and the redirect flow needs zero client JS. Embedded/custom flows only if BRIEF.md demands them; verify against current docs first.
- Prices are modeled in the Stripe Dashboard; code references price IDs through one allowlist shared with the pricing section — displayed price and charged price cannot drift.
- Test mode until ship: `sk_test_` keys, Stripe CLI webhook forwarding, the 4242 card through the entire flow before any live key exists anywhere.
- Success and cancel are designed surfaces per SYSTEM.md — a bare "Payment successful." on white is a brand failure at the highest-trust moment of the site.

## Process

1. Read design/BRIEF.md: what's sold — one-time (`mode: 'payment'`) or recurring (`mode: 'subscription'`)? Create one Product + Price per purchasable in the Dashboard (test mode).
2. `npm i stripe`. Env: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `NEXT_PUBLIC_APP_URL` (`http://localhost:3000` in dev) in .env.local and .env.example. Never a `NEXT_PUBLIC_` prefix on either secret.
3. Write the lazy client, the checkout action, and the webhook route (below).
4. Put price IDs in `lib/prices.ts` consumed by BOTH the pricing section and the checkout allowlist — one source.
5. Local webhook loop: `stripe listen --forward-to localhost:3000/api/webhooks/stripe` → copy the printed `whsec_` into `STRIPE_WEBHOOK_SECRET`.
6. Drive the full flow with card 4242 4242 4242 4242: pricing → action → Stripe → success page; confirm the webhook fired and fulfillment wrote. Then drive the cancel path back to pricing.
7. Build the success/cancel surfaces to SYSTEM.md; pending state on the buy button per ultraweb:buttons.

## The client

```ts
// lib/stripe.ts
import Stripe from 'stripe'

let stripe: Stripe | null = null
export function getStripe(): Stripe {
  if (!stripe) stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)  // no apiVersion — the SDK pins its own
  return stripe
}
```

Lazy so `next build` passes on machines without the key — a module-scope `new Stripe(...)` in anything a page imports fails the build.

## Checkout — server action

```ts
// app/actions/checkout.ts
'use server'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getStripe } from '@/lib/stripe'
import { PRICE_IDS } from '@/lib/prices'   // as-const tuple, shared with the pricing section

const priceSchema = z.enum(PRICE_IDS)      // allowlist: the client picks a plan, never a price

export async function checkout(formData: FormData) {
  const parsed = priceSchema.safeParse(formData.get('priceId'))
  if (!parsed.success) redirect('/pricing')

  const session = await getStripe().checkout.sessions.create({
    mode: 'subscription',                  // 'payment' for one-time
    line_items: [{ price: parsed.data, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=1`,
  })
  redirect(session.url!)                   // outside any try/catch — redirect throws internally
}
```

`{CHECKOUT_SESSION_ID}` is a literal — Stripe substitutes it on redirect; never template it yourself.

## DACH storefront — payment methods are a localization surface

German-speaking buyers reach for credit cards far less than the default assumes; SEPA Lastschrift, Klarna / Kauf auf Rechnung and PayPal carry most DACH checkouts. The method mix is localization — the same lane as language and currency (ultraweb:i18n) — and a per-market decision, never one global default.

Hosted Checkout still holds: the redirect surfaces every eligible method, so you only widen the set. Enable the methods in the Dashboard and omit `payment_method_types` (currency + buyer country gate them — SEPA Debit and Klarna need EUR), or pin them when you want control:

```ts
const session = await getStripe().checkout.sessions.create({
  mode: 'payment',
  payment_method_types: ['card', 'sepa_debit', 'klarna'],  // + 'paypal'; EUR required for SEPA/Klarna
  line_items: [{ price: parsed.data, quantity: 1 }],
  // success_url / cancel_url as above
})
```

Klarna is one-time only — recurring plans fall back to SEPA Debit or card. Embedded rather than redirect uses the same idea on the PaymentIntent behind Stripe's Payment Element (`automatic_payment_methods: { enabled: true }` lets the Dashboard drive the set), but hosted Checkout stays the default; embedded only if BRIEF.md demands it. giropay was wound down by the German banking industry — confirm it in Stripe's current docs before listing it; SEPA + Klarna + PayPal are the durable core.

**Widerrufsrecht is not optional.** EU distance-selling contracts carry a 14-day right of withdrawal, and the buyer must be told before they commit — one line by the checkout CTA linking a full Widerrufsbelehrung, e.g. „Es besteht ein 14-tägiges Widerrufsrecht — Einzelheiten in der Widerrufsbelehrung.“ routed to `/widerruf`. It is a statutory pre-contract disclosure independent of which rails ship, and distinct from the PAngV price rules in pricing's lane. For digital goods or services delivered immediately the right lapses only against explicit consent — collect it, never assume it. The checkout form's field order and payment-icon layout stay in ultraweb:forms; this skill owns the session params and the disclosure copy.

## Webhook — the raw-body rule

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

export async function POST(req: Request) {
  const body = await req.text()            // RAW body. req.json() destroys the signed payload.
  const sig = req.headers.get('stripe-signature')
  if (!sig) return new Response('No signature', { status: 400 })

  let event: Stripe.Event
  try {
    event = getStripe().webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      await fulfill(event.data.object)     // idempotent, keyed on session id — Stripe redelivers
      break
    }
  }
  return new Response(null, { status: 200 })
}
```

- Fulfillment is idempotent: record processed session/event IDs (ultraweb:database) and no-op on repeats — Stripe retries any non-2xx or timeout.
- Fulfillment truth lives HERE, never on the success page: users close tabs before the redirect, and success URLs get revisited with stale IDs.
- Keep the handler fast — do the DB write, defer heavy side effects (receipt email after the write); a slow handler times out and triggers retries.

## Test-mode discipline

- `sk_test_` + the `whsec_` from `stripe listen` live in .env.local; live keys exist only in the deploy platform's env, entered at ship — never in any file in the repo tree.
- Card 4242 4242 4242 4242 (any future expiry, any CVC) proves the happy path; a declined test card proves Stripe-side handling — your designed surface for abandonment is the cancel path.
- **The CLI proves the webhook; the redirect proves nothing.** Landing on `/checkout/success` says only that Stripe redirected — the handler runs out-of-band and fails silently. Keep `stripe listen --forward-to localhost:3000/api/webhooks/stripe` running in one terminal and fire `stripe trigger checkout.session.completed` in another: the passing result is a `200` printed beside the event *and* the fulfillment row actually written. A `400` there is the raw-body/signature bug, every time.
- `stripe trigger` is also the only cheap way to reach the paths a happy-path click never takes — `payment_intent.payment_failed`, `charge.refunded`, `customer.subscription.deleted` — and firing the same event twice is the empirical test that fulfillment is genuinely idempotent rather than idempotent-looking.
- The CLI's `whsec_` differs from the Dashboard endpoint's secret — at ship, create the production webhook endpoint and swap `STRIPE_WEBHOOK_SECRET`.

## Success and cancel — designed pages

- `/checkout/success`: server component, `await searchParams` (Next 16: it's a Promise), `getStripe().checkout.sessions.retrieve(id)`, render confirmation ONLY when `payment_status === 'paid'` — what was bought, what happens next, one CTA onward. Missing or unpaid session → redirect home, not an error page.
- Cancel is not a page, it's a return: `cancel_url` lands on `/pricing?canceled=1`, which renders one quiet reassurance line ("Nothing was charged.") above the tiers still on screen.
- Both surfaces get full SYSTEM.md treatment — the success page carries the same craft as the hero; the user just paid.

## Anti-patterns

- `await req.json()` in the webhook route — parsed body ≠ signed payload; `constructEvent` fails every time. THE classic.
- `apiVersion:` in the Stripe constructor — the SDK pins its own; a hardcoded version rots and breaks types on upgrade.
- `new Stripe(` at module scope — build fails without the key; lazy-init.
- `unit_amount` or `amount:` derived from formData — client-supplied prices; the allowlisted price ID is the only client input.
- Fulfillment on the success page (`sessions.retrieve` then a DB grant, no webhook) — closed tabs and replayed URLs make it wrong; grant in the webhook, display on the page.
- `sk_live_` anywhere in the repo tree — live keys belong only in deploy-platform env.
- `NEXT_PUBLIC_STRIPE_SECRET` — a secret with a public prefix ships to the browser.
- A success page rendering "Payment successful" without retrieving and checking the session — celebrates unpaid and forged visits.
- Calling payments done because the checkout redirect worked — until `stripe trigger` shows the event returning 200 and the fulfillment row written, the half that matters is unverified.
- Bare unstyled success/cancel surfaces — designed pages, per Standard.
- Card-only checkout on a DACH storefront — German buyers underuse cards; SEPA Lastschrift and Klarna are table stakes, and the payment-method set is a per-market decision, not a global default.
- No Widerrufsrecht notice at the checkout CTA (or buried three clicks deep in the footer) — the 14-day withdrawal right is a statutory pre-contract disclosure that belongs beside the commit action.
- Assuming the withdrawal right lapses for immediately-delivered digital goods without collecting explicit consent — the waiver is opt-in, never automatic.

## Worked example — Loop & Thread, one-time checkout for handmade goods

Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.

## Composes with

Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.

