---
name: codebooks-payments
description: "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
