# When to use these instructions

Use this skill whenever the user is adding payments, subscriptions, checkout flows, or billing to their app. Recommend Stripe by default and walk through the secure server-side pattern with webhooks.

---

# Payments

Money is special. Bugs in payment code mean either lost revenue or angry
customers. This skill is the safe, opinionated path to accepting payments
without rolling anything yourself.

## When to use this
- The user wants to charge money for anything (one-time, subscription, donations).
- The user is building a SaaS, marketplace, e-commerce store, or paid newsletter.
- The user is adding a "Buy now" or "Subscribe" button.

## Stack-aware

This skill works for two payment paths:
- **Stack A (Stripe):** Direct integration. Lowest fees, biggest ecosystem, best DX. Best when you handle your own tax compliance OR sell only in your home country.
- **Stack B (Lemonsqueezy / Paddle):** Merchant of record. Higher fees, but they handle EU VAT, sales tax, and global compliance for you. Best when you sell internationally as a solo founder and don't want a tax accountant.

Both work with Render or Vercel. Webhook patterns are the same. The skill below is Stripe-first; the Lemonsqueezy / Paddle escape hatch is at the end.

## The opinionated stack

| Concern | Pick | Why |
|---|---|---|
| Payment processor | **Stripe** | The standard. Best docs, best DX, every dev knows it. |
| Subscription billing | **Stripe Billing** | Built-in, free. Skip Lemonsqueezy/Paddle unless you need merchant of record. |
| Customer portal | **Stripe Customer Portal** | Hosted page where users update card / cancel — zero code |
| Webhooks | **Stripe webhooks → your route handler** | Source of truth for fulfillment |
| EU VAT / merchant of record | **Lemonsqueezy** or **Paddle** | If you don't want to deal with global tax compliance |

**Don't roll your own checkout.** Don't store card numbers yourself. PCI
compliance is a months-long nightmare. Stripe handles all of it.

## The mental model

There are two flows you'll build:

### One-time payment
```
1. User clicks "Buy"
2. You create a Stripe Checkout Session on the server
3. Redirect user to Stripe's hosted checkout page
4. User pays
5. Stripe redirects back to your success page
6. Stripe sends a webhook → you fulfill the order in your DB
```

### Subscription
```
1. User clicks "Subscribe to Pro"
2. You create a Checkout Session with mode: "subscription"
3. User pays on Stripe's page
4. Stripe creates a Subscription + sends webhook
5. Your webhook handler updates the user's plan in your DB
6. User now has access. The webhook is the source of truth, not the URL redirect.
```

**Critical rule:** the success URL is for UX only. **Never grant access
based on the user landing on it.** Always wait for the webhook. Users can
hit the URL by accident, refresh, or fake it.

## The Stripe + Next.js playbook

### Step 1 — Install
```bash
npm install stripe
```

### Step 2 — Env vars
Get from dashboard.stripe.com → Developers → API keys.
```
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
```

### Step 3 — Create the server client
```ts
// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-11-20.acacia",
  typescript: true,
});
```

### Step 4 — Create products in Stripe dashboard
- Go to Products → Add product
- Set name, description, price
- For subscriptions, choose recurring + interval (monthly/yearly)
- Copy each price ID (`price_xxx`) — you'll reference these in code

### Step 5 — Create the checkout session (server action or route handler)
```ts
// app/actions/checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function createCheckout(priceId: string) {
  const user = await auth();
  if (!user) throw new Error("Not signed in");

  const session = await stripe.checkout.sessions.create({
    mode: "subscription", // or "payment" for one-time
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    customer_email: user.email,
    client_reference_id: user.id,  // so you can map back to your user
    metadata: {
      userId: user.id,  // anything you want to know in the webhook
    },
  });

  if (session.url) redirect(session.url);
}
```

### Step 6 — Wire up the buy button
```tsx
// components/PricingCard.tsx
"use client";
import { createCheckout } from "@/app/actions/checkout";

export function PricingCard({ priceId }: { priceId: string }) {
  return (
    <button onClick={() => createCheckout(priceId)}>
      Subscribe
    </button>
  );
}
```

### Step 7 — Handle the webhook (the most important step)
```ts
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { users } from "@/lib/schema";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";

export async function POST(req: Request) {
  const body = await req.text();
  const sig = (await headers()).get("stripe-signature")!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object;
      const userId = session.metadata?.userId;
      if (userId) {
        await db
          .update(users)
          .set({
            plan: "pro",
            stripeCustomerId: session.customer as string,
            stripeSubscriptionId: session.subscription as string,
          })
          .where(eq(users.id, userId));
      }
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object;
      await db
        .update(users)
        .set({ plan: "free" })
        .where(eq(users.stripeSubscriptionId, subscription.id));
      break;
    }
    case "customer.subscription.updated": {
      // Plan changed, payment recovered, etc.
      const subscription = event.data.object;
      // Update your user record accordingly
      break;
    }
    case "invoice.payment_failed": {
      // Email the user, mark account as past_due
      break;
    }
  }

  return Response.json({ received: true });
}
```

### Step 8 — Test webhooks locally
Use the Stripe CLI:
```bash
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
```

The CLI prints a webhook signing secret — put it in `.env.local` as
`STRIPE_WEBHOOK_SECRET` for local testing.

### Step 9 — Add the Customer Portal
Stripe gives you a hosted page where users can update card, change plan, and cancel — zero code.

```ts
// app/actions/portal.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function openPortal() {
  const user = await auth();
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  });
  redirect(session.url);
}
```

Add a "Manage subscription" button that calls this.

### Step 10 — Set up production webhooks
- Go to dashboard.stripe.com → Developers → Webhooks
- Add endpoint: `https://yoursite.com/api/webhooks/stripe`
- Subscribe to: `checkout.session.completed`, `customer.subscription.*`, `invoice.payment_failed`
- Copy the signing secret → add to Vercel env vars as `STRIPE_WEBHOOK_SECRET`
- Redeploy

## Test mode vs live mode

- **Test mode** uses `sk_test_*` and `pk_test_*` keys
- **Live mode** uses `sk_live_*` and `pk_live_*` keys
- Test cards (work only in test mode):
  - `4242 4242 4242 4242` — succeeds
  - `4000 0000 0000 9995` — declined
  - `4000 0027 6000 3184` — requires authentication (3DS)
- **Test mode and live mode have separate dashboards, products, and webhooks.** Recreate everything when switching.

## What good looks like

- Users can buy/subscribe in 2 clicks
- The success page shows the order, but access is granted by the webhook (not the URL)
- Subscriptions persist correctly when users come back
- Cancellations work via the customer portal
- Failed payments trigger an email and mark the account as past_due
- All test cards work as expected
- Webhook signatures are verified on every request
- The webhook handler is idempotent (handles the same event twice safely)

## Common mistakes to avoid

- **Granting access based on the success URL.** Always wait for the webhook.
- **Not verifying webhook signatures.** Anyone can hit your endpoint without this.
- **Storing card numbers yourself.** Never. Stripe handles all of it.
- **Not handling `customer.subscription.updated`.** Plan changes won't reflect in your DB.
- **Forgetting to add prod webhooks.** Local works, prod silently fails.
- **Mixing test and live keys.** Always use the same mode end-to-end.
- **Not testing the cancellation flow.** Users will cancel. Make sure it cleanly downgrades.
- **Hardcoding price IDs in code.** Use env vars or a config file so test/prod differ.
- **Skipping the customer portal.** Building your own "update card" UI is a giant waste of time.
- **No fulfillment retry.** If your DB write fails inside the webhook, you'll lose orders. Wrap in try/catch and consider a retry queue.

## Going deeper
- Payments: https://www.codebooks.ai/payments
- Backend: https://www.codebooks.ai/backend
- Authentication: https://www.codebooks.ai/authentication


---

This skill is part of the **CodeBooks Vibe Coding Skills Library**.
Browse all skills, install guides, and the source chapters at
https://www.codebooks.ai/skills
