<!--
ROBOTS ON PAYROLL / THE VAULT
What this is: a fill-the-slots agent prompt that builds a correct Stripe Checkout and webhook integration (signature verification, idempotency, invoice.paid).
How to use it: download this file and give it to your AI (Claude, ChatGPT,
Cursor, Lovable chat) as a reference. Say: "Use this as my playbook for
building my Stripe billing."
Latest version + full guide: https://robotsonpayroll.com/resources/stripe-afternoon
-->

# The Webhook Prompt

Paste this into your agent (Claude Code, Cursor, whatever you drive) after filling the [SLOTS]. It builds the two endpoints that make Stripe subscriptions actually work: the Checkout Session creator and the webhook handler. The webhook handler is where 90% of vibe-coded Stripe integrations are broken, so the prompt is opinionated about the parts agents get wrong: raw-body signature verification, idempotency, and provisioning on `invoice.paid` instead of only `checkout.session.completed`.

Fill the slots, paste, review the diff before merging. Then run the local test loop at the bottom.

---

## The prompt

```
Build the Stripe billing integration for my app. Do NOT invent extra features.
Implement exactly what is below, then stop and list what you built.

CONTEXT
- Stack: [STACK, e.g. Next.js 15 App Router + TypeScript]
- Database + ORM: [DB, e.g. Postgres via Prisma / Supabase]
- Auth: [AUTH, e.g. Clerk / NextAuth / Supabase Auth; how I get the current user's id and email server-side]
- Product model: [PRODUCT MODEL, e.g. one SaaS subscription, two plans (Pro monthly $19, Pro yearly $190), 7-day free trial, no one-time purchases]
- Stripe SDK: use the official stripe package, latest version. Pin the API version
  explicitly in the client constructor and tell me which version you pinned.
- Env vars available: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
  NEXT_PUBLIC_APP_URL. Never hardcode keys. Never log full request bodies.

BUILD PART 1: CHECKOUT SESSION ENDPOINT
Create POST [ROUTE, e.g. /api/checkout] that:
1. Requires an authenticated user. Reject unauthenticated with 401.
2. Looks up (or creates) a Stripe Customer for this user and stores
   stripe_customer_id on my users table. One customer per user, ever.
   Reuse the stored id on every subsequent call.
3. Creates a Checkout Session with:
   - mode: "subscription"
   - customer: the stored customer id
   - line_items: the price id passed in the request body, validated against
     an allowlist of my real price ids from env/config (reject anything else)
   - success_url: {APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}
   - cancel_url: {APP_URL}/pricing
   - client_reference_id: my internal user id
   - subscription_data.metadata.user_id: my internal user id
   - allow_promotion_codes: true
   [IF TRIAL: - subscription_data.trial_period_days: 7]
4. Returns { url } and the client redirects to it. No custom card forms.

BUILD PART 2: WEBHOOK HANDLER
Create POST [WEBHOOK ROUTE, e.g. /api/webhooks/stripe] that:
1. Reads the RAW request body (bytes/string, NOT parsed JSON). If the framework
   parses JSON by default (Express json middleware, some Next.js configs),
   explicitly opt this route out. Signature verification fails on parsed bodies
   and this is the #1 integration bug. Verify with
   stripe.webhooks.constructEvent(rawBody, signatureHeader, STRIPE_WEBHOOK_SECRET)
   and return 400 on verification failure.
2. Idempotency: create a processed_stripe_events table (event_id text primary
   key, type text, processed_at timestamp). At the top of the handler, insert
   the event id; on unique-constraint conflict, return 200 immediately and do
   nothing. Stripe retries deliveries and I must not double-provision.
3. Return 200 fast. Do the minimum synchronous work (verify, dedupe, update DB
   rows); anything slow (emails, analytics) goes async or in a queue.
4. Handle EXACTLY these event types (ignore all others with a 200):

   checkout.session.completed
   -> Read client_reference_id (my user id) and session.customer,
      session.subscription. Store stripe_customer_id and
      stripe_subscription_id on the user. Set subscription_status from the
      subscription object. This event fires once at purchase; it is the
      LINK step, not the ongoing source of truth.

   customer.subscription.updated
   -> Update subscription_status, price_id, current_period_end, and
      cancel_at_period_end on my user row. This covers plan switches,
      cancellations scheduled for period end, trial-to-active transitions,
      and past_due transitions.

   customer.subscription.deleted
   -> Set subscription_status = "canceled", clear entitlements, keep the
      stripe ids for history. Do not delete the user row.

   invoice.paid
   -> The renewal event. Extend access: update current_period_end from the
      subscription. Provision on THIS event too, not only on checkout
      completion, or renewals silently stop mattering to my app.

   invoice.payment_failed
   -> Set subscription_status = "past_due". Do NOT immediately revoke access;
      Stripe retries per my dunning settings. Flag the account so the UI can
      show a "fix your payment method" banner.

5. When an event's subscription/customer doesn't match any user in my DB,
   log a warning with the ids (not the full payload) and return 200.

BUILD PART 3: DB MIGRATION
Add to my users (or a separate subscriptions) table:
   stripe_customer_id     text, unique, nullable
   stripe_subscription_id text, nullable
   subscription_status    text  (trialing | active | past_due | canceled | null)
   price_id               text, nullable
   current_period_end     timestamp, nullable
   cancel_at_period_end   boolean, default false
Plus the processed_stripe_events table from Part 2.
Entitlement rule for the app: user has access when subscription_status is
"active" or "trialing", or when status is "past_due" and I have chosen a grace
period. Write one helper function hasActiveSubscription(user) implementing this.

BUILD PART 4: CUSTOMER PORTAL ENDPOINT
Create POST [PORTAL ROUTE, e.g. /api/billing-portal] that requires auth,
creates a billing portal session (stripe.billingPortal.sessions.create) with
the user's stripe_customer_id and return_url {APP_URL}/settings/billing,
and returns { url }. All plan changes and cancellations happen there; do not
build custom cancel flows.

RULES
- TypeScript strict, no any on Stripe objects; use the SDK's types.
- No secret values in client code. The publishable key is not needed for this
  flow at all (hosted Checkout redirect).
- Do not build: custom card inputs, usage-based billing, tax logic, coupons UI.
- After building, output: (a) file list with one-line descriptions,
  (b) the exact stripe CLI commands to test locally,
  (c) the env vars I must set and where each comes from.
```

---

## The local test loop (run this before deploying)

```bash
# 1. Install and log in
stripe login

# 2. Forward events to your local handler; copy the whsec_... it prints
#    into STRIPE_WEBHOOK_SECRET for local dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# 3. In another terminal, fire the events your handler claims to handle
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
stripe trigger invoice.paid

# 4. Real end-to-end: hit your own /api/checkout, pay with 4242 4242 4242 4242
#    (any future expiry, any CVC), and watch the listen terminal + your DB row.
```

Pass condition: after the 4242 checkout, your user row has a customer id, a subscription id, status "active" (or "trialing"), and a correct current_period_end. Then trigger the same event twice and confirm the second delivery is a no-op (idempotency table did its job).

## Review checklist for the agent's output

- [ ] Webhook route reads the raw body (search the diff for constructEvent and check what's passed in)
- [ ] express.json() / auto body parsing disabled on the webhook route specifically
- [ ] processed_stripe_events insert happens BEFORE side effects, conflict returns 200
- [ ] invoice.paid updates current_period_end (renewals provision)
- [ ] invoice.payment_failed does not instantly revoke access
- [ ] Price ids validated against an allowlist, not taken raw from the client
- [ ] No secret key or webhook secret anywhere in client-side code
- [ ] Handler returns 200 for unhandled event types instead of erroring
