# Pathrule Pattern: Stripe Billing (1.0.0)
# ::pathrule:package:stripe-billing

### [RULE] Verify the webhook signature against the raw request body  (path: /api/stripe)
<!-- scope: folder | priority: high | strict -->

Verify the `Stripe-Signature` header with the endpoint's signing secret using the official SDK (`stripe.webhooks.constructEvent`, or `constructEventAsync` on edge/Workers runtimes) before reading any field of the event.

- Pass the EXACT raw bytes of the request body. Any re-serialization, key reordering, whitespace change, or Unicode normalization makes verification fail. In Express, mount `express.raw({ type: 'application/json' })` on the webhook route only, never a global `express.json()` ahead of it. In Next.js route handlers read `await req.text()`; in Supabase Edge Functions read `await req.text()` and never `await req.json()` first.
- Read the signing secret from server config, never from the request.
- Return `400` on verification failure and do not run any handler logic.
- During a signing-secret rotation Stripe signs with both the old and new secret over an overlap window, so deploy the new secret before retiring the old one.

---

### [RULE] Make webhook handlers idempotent and return 2xx fast  (path: /api/stripe)
<!-- scope: folder | priority: high | advisory -->

Stripe delivers events at least once, so the same event (and occasionally two distinct Event objects for one change) can arrive more than once.

- Dedupe on `event.id`: record processed IDs and skip a repeat, or make the side effect idempotent (upsert by a stable key such as the subscription or invoice ID). For the rare two-Events case, key on `event.type` plus `data.object.id`.
- Return a `2xx` BEFORE any logic that could time out. Offload slow work (emails, accounting sync, provisioning) to a queue or background job and acknowledge the delivery immediately, otherwise Stripe retries a delivery you already handled.
- A non-2xx response triggers Stripe's retry schedule, so only return 4xx/5xx when you actually want a retry.

---

### [RULE] Send an idempotency key on every Stripe write request  (path: /)
<!-- scope: project | priority: high | advisory -->

This is a SEPARATE concern from webhook deduplication. It protects outbound calls TO Stripe, not events you receive.

- Every Stripe POST (create/update of PaymentIntents, Subscriptions, Refunds, etc.) accepts an idempotency key. Pass one whenever a network error or your own retry could resend the request: `stripe.paymentIntents.create(params, { idempotencyKey })`.
- Derive the key from your own stable identity (for example `order:<id>` or a UUID you persist with the order), not a fresh random value per attempt, so a retry reuses the same key.
- Stripe remembers a key's result for 24 hours; reusing it returns the original response instead of performing the operation twice.
- The SDK auto-retries some transient failures with its own idempotency handling, but supply your own key for any operation you initiate or retry at the application layer.

---

### [RULE] Keep Stripe secrets server-side and use restricted keys  (path: /)
<!-- scope: project | priority: high | strict -->

The secret key (`sk_...`) and the webhook signing secret (`whsec_...`) are server-side only. Only the publishable key (`pk_...`) may ship to the client.

- Store both secrets in server/function environment variables, never in client bundles, repos, or logs.
- For server-side code use a Restricted API Key (RAK) scoped to exactly the resources the integration needs, so a leaked key limits blast radius. Reserve the full secret key for operations a RAK cannot perform.
- In edge or serverless functions, authenticate the caller (or verify the webhook signature for webhook routes) before any billing action; an unauthenticated function with a secret key is a charge-anyone hole.

---

### [MEMORY] Webhooks grant entitlements, not the success redirect  (path: /api/stripe)

The redirect to your success URL is only a suggestion that the user came back; it can be skipped, replayed, or forged. The verified webhook is the source of truth for what actually happened.

- Grant or revoke access from events such as `checkout.session.completed`, `customer.subscription.created/updated/deleted`, and `invoice.paid`, not from the success-page query params.
- On the success page, show a neutral "finishing up" state and read entitlement from your own database (which the webhook updates), rather than unlocking features directly from the redirect.

---

### [MEMORY] DB mirrors Stripe; one checker reads active/trialing  (path: /api/stripe)

Do not call the Stripe API on every request to check billing; that adds latency and rate-limit risk. Keep a local mirror and treat Stripe as the source of truth that flows in through webhooks.

- Maintain a `subscriptions` table mirroring Stripe: subscription ID, customer ID, `status`, price ID, `current_period_end`, and `cancel_at_period_end`. Update it on every relevant webhook.
- Gate access through ONE shared function so the rule lives in a single place. Only `active` and `trialing` grant access; `past_due`, `canceled`, `incomplete`, `unpaid`, etc. do not.
- Store the Stripe `customer` ID on the user as soon as it exists so later events map back to the right account.

---

### [MEMORY] Checkout Sessions vs PaymentIntents  (path: /api/stripe)

Stripe recommends the Checkout Sessions API for most integrations, and it remains the default choice in 2026.

- Use Checkout with `mode: 'subscription'` for recurring billing: it handles SCA/3DS, tax, currency conversion, trials, Smart Retries, dunning, and proration with far less code.
- Use Checkout with `mode: 'payment'` for standard one-time payments.
- Reach for PaymentIntents with the Payment Element only when you must own every part of the in-app flow and are prepared to rebuild discount, tax, and currency logic yourself.
- Either way, drive entitlements from webhook events (see the entitlements memory), not the API response on the redirect.

---

### [MEMORY] Pin the API version on the webhook endpoint  (path: /api/stripe)

Stripe pins your account to an API version on first request and releases new versions monthly (non-breaking) with breaking releases roughly twice a year. Event payload shapes follow the version the webhook endpoint is configured with.

- Set an explicit API version on the endpoint and in the SDK client (`new Stripe(key, { apiVersion: '...' })`) rather than relying on the account default, so an account-level upgrade does not silently change the JSON you parse.
- With statically typed SDKs (Go, Java, .NET), the webhook endpoint's API version must match the version the SDK was generated for, or event deserialization fails.
- To upgrade: send the new `Stripe-Version` from staging, diff the responses and events, fix your code, then move the webhook endpoint and pinned version forward.

---

### [SKILL] stripe-billing-review  (path: /)

---
name: stripe-billing-review
description: Review a Stripe billing change for security and correctness before merging.
---

# Stripe billing review

## Webhooks
- [ ] Signature verified with constructEvent/constructEventAsync against the RAW body before any logic
- [ ] No global JSON body parser runs ahead of the webhook route (Express raw, Next req.text(), Edge req.text())
- [ ] Returns 400 on verification failure with no side effects
- [ ] Handler is idempotent: dedupes on event.id or upserts by a stable key
- [ ] Returns 2xx fast; slow work is offloaded to a queue/background job
- [ ] Webhook endpoint has an explicit pinned API version

## Entitlements and state
- [ ] Access is granted from webhook events, not the success-page redirect
- [ ] Local subscription mirror is updated by webhooks; one shared function gates access on active/trialing only
- [ ] Stripe customer ID is stored on the user

## Outbound calls and keys
- [ ] POST/create/update calls send a stable Idempotency-Key for safe retries
- [ ] Secret key and webhook signing secret are server-side only; only pk_ ships to the client
- [ ] Server uses a restricted API key scoped to what it needs

## Money
- [ ] Amounts are integers in the currency's minor unit (and account for zero-decimal currencies); no float math
- [ ] Correct API chosen: Checkout for standard/subscription, PaymentIntents only for fully custom flows
