home / skills / alinaqi / claude-bootstrap / web-payments

web-payments skill

/skills/web-payments

This skill helps you integrate Stripe payments for one-time charges, subscriptions, and webhooks with secure, framework-agnostic guidance.

npx playbooks add skill alinaqi/claude-bootstrap --skill web-payments

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
17.3 KB
---
name: web-payments
description: Stripe Checkout, subscriptions, webhooks, customer portal
---

# Web Payments Skill (Stripe)

*Load with: base.md + [framework].md*

For integrating Stripe payments into web applications - one-time payments, subscriptions, and checkout flows.

**Sources:** [Stripe Checkout](https://docs.stripe.com/payments/checkout) | [Payment Element Best Practices](https://docs.stripe.com/payments/payment-element/best-practices) | [Building Solid Stripe Integrations](https://stripe.dev/blog/building-solid-stripe-integrations-developers-guide-success) | [Subscriptions](https://docs.stripe.com/billing/subscriptions/build-subscriptions)

---

## Setup

### 1. Create Stripe Account
1. Go to https://dashboard.stripe.com/register
2. Complete business verification
3. Get API keys from https://dashboard.stripe.com/apikeys

### 2. Environment Variables
```bash
# .env
STRIPE_SECRET_KEY=sk_test_xxx          # Server-side only
STRIPE_PUBLISHABLE_KEY=pk_test_xxx     # Client-side safe
STRIPE_WEBHOOK_SECRET=whsec_xxx        # For webhook verification

# Production
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_PUBLISHABLE_KEY=pk_live_xxx
```

### 3. Install SDK
```bash
# Node.js
npm install stripe @stripe/stripe-js

# Python
pip install stripe
```

---

## Integration Options

| Method | Best For | Complexity |
|--------|----------|------------|
| **Checkout (Hosted)** | Quick setup, Stripe-hosted page | Low |
| **Checkout (Embedded)** | Custom site, embedded form | Low |
| **Payment Element** | Full customization, complex flows | Medium |
| **Custom Form** | Complete control (rare) | High |

**Recommendation**: Start with Checkout, migrate to Payment Element if needed.

---

## Stripe Checkout (Recommended)

### Server: Create Checkout Session

#### Node.js / Next.js
```typescript
// app/api/checkout/route.ts (Next.js App Router)
import Stripe from "stripe";
import { NextResponse } from "next/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const { priceId, mode = "payment" } = await request.json();

  try {
    const session = await stripe.checkout.sessions.create({
      mode: mode as "payment" | "subscription",
      payment_method_types: ["card"],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_URL}/canceled`,
      // Optional: Link to existing customer
      // customer: customerId,
      // Optional: Collect shipping
      // shipping_address_collection: { allowed_countries: ["US", "CA"] },
      // Optional: Add metadata for tracking
      metadata: {
        userId: "user_123",
        source: "pricing_page",
      },
    });

    return NextResponse.json({ sessionId: session.id, url: session.url });
  } catch (error) {
    console.error("Stripe error:", error);
    return NextResponse.json({ error: "Failed to create session" }, { status: 500 });
  }
}
```

#### Python / FastAPI
```python
# app/api/checkout.py
import stripe
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
router = APIRouter()

class CheckoutRequest(BaseModel):
    price_id: str
    mode: str = "payment"  # or "subscription"

@router.post("/api/checkout")
async def create_checkout_session(request: CheckoutRequest):
    try:
        session = stripe.checkout.Session.create(
            mode=request.mode,
            payment_method_types=["card"],
            line_items=[{
                "price": request.price_id,
                "quantity": 1,
            }],
            success_url=f"{os.environ['APP_URL']}/success?session_id={{CHECKOUT_SESSION_ID}}",
            cancel_url=f"{os.environ['APP_URL']}/canceled",
            metadata={
                "user_id": "user_123",
            },
        )
        return {"session_id": session.id, "url": session.url}
    except stripe.error.StripeError as e:
        raise HTTPException(status_code=400, detail=str(e))
```

### Client: Redirect to Checkout

```typescript
// components/CheckoutButton.tsx
"use client";

import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export function CheckoutButton({ priceId }: { priceId: string }) {
  const handleCheckout = async () => {
    const response = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });

    const { url } = await response.json();

    // Redirect to Stripe Checkout
    window.location.href = url;
  };

  return (
    <button onClick={handleCheckout}>
      Subscribe Now
    </button>
  );
}
```

---

## Embedded Checkout

For keeping users on your site:

```typescript
// components/EmbeddedCheckout.tsx
"use client";

import { useEffect, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
  EmbeddedCheckoutProvider,
  EmbeddedCheckout,
} from "@stripe/react-stripe-js";

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export function EmbeddedCheckoutForm({ priceId }: { priceId: string }) {
  const [clientSecret, setClientSecret] = useState("");

  useEffect(() => {
    fetch("/api/checkout/embedded", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, [priceId]);

  if (!clientSecret) return <div>Loading...</div>;

  return (
    <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
      <EmbeddedCheckout />
    </EmbeddedCheckoutProvider>
  );
}
```

Server endpoint for embedded:
```typescript
// app/api/checkout/embedded/route.ts
export async function POST(request: Request) {
  const { priceId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    ui_mode: "embedded",
    return_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  });

  return NextResponse.json({ clientSecret: session.client_secret });
}
```

---

## Webhooks (Critical)

**Never trust client-side data**. Always verify payments via webhooks.

### Webhook Endpoint

```typescript
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;

  // Verify webhook signature
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed");
    return new Response("Invalid signature", { status: 400 });
  }

  // Handle events
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionUpdate(subscription);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCanceled(subscription);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Return 200 quickly - process async if needed
  return new Response("OK", { status: 200 });
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;

  // Update your database
  await db.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      subscriptionStatus: "active",
    },
  });
}
```

### Python Webhook
```python
# app/api/webhooks.py
import stripe
from fastapi import APIRouter, Request, HTTPException

router = APIRouter()

@router.post("/api/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Handle events
    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        await handle_checkout_complete(session)
    elif event["type"] == "customer.subscription.deleted":
        subscription = event["data"]["object"]
        await handle_subscription_canceled(subscription)

    return {"status": "success"}
```

### Key Webhook Events

| Event | When | Action |
|-------|------|--------|
| `checkout.session.completed` | Payment successful | Provision access |
| `customer.subscription.created` | New subscription | Store subscription ID |
| `customer.subscription.updated` | Plan change | Update plan in DB |
| `customer.subscription.deleted` | Canceled | Revoke access |
| `invoice.payment_failed` | Payment failed | Notify user, retry |
| `invoice.paid` | Renewal successful | Extend access |

---

## Products & Prices

### Create via Dashboard (Recommended)
1. Go to https://dashboard.stripe.com/products
2. Create product with name, description
3. Add price(s) - one-time or recurring
4. Copy Price ID (`price_xxx`)

### Create via API
```typescript
// One-time product
const product = await stripe.products.create({
  name: "Pro Plan",
  description: "Full access to all features",
});

const price = await stripe.prices.create({
  product: product.id,
  unit_amount: 2999, // $29.99 in cents
  currency: "usd",
});

// Subscription product
const subscriptionPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 999, // $9.99/month
  currency: "usd",
  recurring: {
    interval: "month",
  },
});
```

---

## Customer Portal

Let users manage their subscriptions:

```typescript
// app/api/portal/route.ts
export async function POST(request: Request) {
  const { customerId } = await request.json();

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/settings`,
  });

  return NextResponse.json({ url: session.url });
}
```

Configure portal at: https://dashboard.stripe.com/settings/billing/portal

---

## Subscriptions

### Create Subscription with Trial
```typescript
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: {
    trial_period_days: 14,
    // Cancel if no payment method after trial
    trial_settings: {
      end_behavior: { missing_payment_method: "cancel" },
    },
  },
  success_url: successUrl,
  cancel_url: cancelUrl,
});
```

### Check Subscription Status
```typescript
// lib/subscription.ts
export async function getSubscriptionStatus(customerId: string) {
  const subscriptions = await stripe.subscriptions.list({
    customer: customerId,
    status: "all",
    limit: 1,
  });

  if (subscriptions.data.length === 0) {
    return { status: "none", plan: null };
  }

  const subscription = subscriptions.data[0];
  return {
    status: subscription.status,
    plan: subscription.items.data[0].price.id,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
  };
}
```

---

## Testing

### Test Cards
| Card Number | Scenario |
|-------------|----------|
| `4242424242424242` | Success |
| `4000000000000002` | Declined |
| `4000002500003155` | Requires 3D Secure |
| `4000000000009995` | Insufficient funds |

### Stripe CLI for Webhooks
```bash
# Install CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
```

---

## Project Structure

```
project/
├── app/
│   ├── api/
│   │   ├── checkout/
│   │   │   └── route.ts          # Create checkout session
│   │   ├── portal/
│   │   │   └── route.ts          # Customer portal
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts      # Webhook handler
│   ├── pricing/
│   │   └── page.tsx              # Pricing page
│   ├── success/
│   │   └── page.tsx              # Post-checkout success
│   └── settings/
│       └── page.tsx              # Manage subscription
├── lib/
│   ├── stripe.ts                 # Stripe client
│   └── subscription.ts           # Subscription helpers
└── .env.local
```

---

## Security Best Practices

### Non-Negotiable Rules
1. **Server-side only for secrets** - Never expose `STRIPE_SECRET_KEY`
2. **Always verify webhooks** - Check signature before processing
3. **Idempotency** - Store webhook event IDs, skip duplicates
4. **Use metadata** - Track user IDs, sources for debugging
5. **Handle all states** - Success, failure, pending, canceled

### Idempotent Webhook Handler
```typescript
const processedEvents = new Set<string>(); // Use Redis in production

export async function POST(request: Request) {
  // ... verify signature ...

  // Skip duplicate events
  if (processedEvents.has(event.id)) {
    return new Response("Already processed", { status: 200 });
  }
  processedEvents.add(event.id);

  // Process event...
}
```

### Amount Handling
```typescript
// Always use cents (smallest currency unit)
const priceInCents = 2999; // $29.99

// Helper functions
const toCents = (dollars: number) => Math.round(dollars * 100);
const toDollars = (cents: number) => cents / 100;

// Display
const displayPrice = (cents: number) =>
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(toDollars(cents));
```

---

## Common Patterns

### Pricing Page
```typescript
// app/pricing/page.tsx
const plans = [
  {
    name: "Starter",
    price: "$9/mo",
    priceId: "price_starter_monthly",
    features: ["Feature 1", "Feature 2"],
  },
  {
    name: "Pro",
    price: "$29/mo",
    priceId: "price_pro_monthly",
    features: ["Everything in Starter", "Feature 3", "Feature 4"],
    popular: true,
  },
];

export default function PricingPage() {
  return (
    <div className="grid md:grid-cols-2 gap-8">
      {plans.map((plan) => (
        <div key={plan.name} className={plan.popular ? "border-blue-500" : ""}>
          <h3>{plan.name}</h3>
          <p>{plan.price}</p>
          <ul>
            {plan.features.map((f) => <li key={f}>{f}</li>)}
          </ul>
          <CheckoutButton priceId={plan.priceId} />
        </div>
      ))}
    </div>
  );
}
```

### Protect Routes by Subscription
```typescript
// middleware.ts
import { getSubscriptionStatus } from "@/lib/subscription";

export async function middleware(request: NextRequest) {
  const session = await getSession();

  if (request.nextUrl.pathname.startsWith("/pro")) {
    const { status } = await getSubscriptionStatus(session.stripeCustomerId);

    if (status !== "active" && status !== "trialing") {
      return NextResponse.redirect(new URL("/pricing", request.url));
    }
  }
}
```

---

## Anti-Patterns

- **Hardcoding API keys** - Use environment variables
- **Client-side payment creation** - Always create PaymentIntent/Session server-side
- **Skipping webhook verification** - Always verify signatures
- **Processing duplicate webhooks** - Implement idempotency
- **Floating-point currency math** - Use integers (cents)
- **Trusting client data** - Verify everything server-side
- **Ignoring failed payments** - Handle `invoice.payment_failed`
- **No error handling** - Catch and handle Stripe errors

---

## Quick Reference

```bash
# Install
npm install stripe @stripe/stripe-js @stripe/react-stripe-js

# Stripe CLI
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed

# Test mode prefix
sk_test_xxx  # Secret key
pk_test_xxx  # Publishable key

# Live mode prefix
sk_live_xxx
pk_live_xxx
```

### Key Endpoints
| Endpoint | Purpose |
|----------|---------|
| `POST /api/checkout` | Create checkout session |
| `POST /api/portal` | Customer billing portal |
| `POST /api/webhooks/stripe` | Handle Stripe events |

### Environment Variables
```bash
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
```

Overview

This skill integrates Stripe Checkout, subscriptions, webhooks, and the customer portal into web applications with a security-first, opinionated setup. It provides server and client examples for Checkout (hosted and embedded), webhook verification, subscription management, and testing tips. The goal is a reliable, spec-driven payment baseline you can drop into Python or Node projects.

How this skill works

The skill shows how to create Checkout sessions on the server, redirect or embed Stripe Checkout on the client, and handle critical webhook events to verify payments and update your database. It includes patterns for creating products and prices, provisioning access on checkout.session.completed, managing subscriptions, and opening the Stripe billing portal for customers. Security rules include keeping secret keys server-side, verifying webhook signatures, and ensuring idempotent webhook handling.

When to use it

  • You need a quick, secure flow for one-time payments or subscriptions using Stripe Checkout.
  • You want an embedded payment experience with client-side Payment Element while keeping server verification.
  • You must reliably handle recurring billing events and update user access via webhooks.
  • You want a reference for creating products/prices and exposing a customer portal for self-service updates.

Best practices

  • Keep STRIPE_SECRET_KEY server-side and use STRIPE_PUBLISHABLE_KEY only in the browser.
  • Always verify webhook signatures with STRIPE_WEBHOOK_SECRET before processing events.
  • Record webhook event IDs and implement idempotency to avoid duplicate processing.
  • Use metadata on sessions to link payments to internal user IDs and sources.
  • Start with Stripe Checkout for speed, migrate to Payment Element for full customization.

Example use cases

  • Create a Checkout session on the server and redirect users to Stripe for payment or subscription.
  • Embed an embedded Checkout or Payment Element to keep customers on your site while using Stripe-hosted UIs.
  • Listen for checkout.session.completed, invoice.paid, and invoice.payment_failed to provision access and notify users.
  • Create and manage products/prices via the dashboard or API and surface price IDs in your pricing UI.
  • Open the Stripe billing portal for customers to update payment methods and cancel or change plans.

FAQ

Do I need webhooks if I redirect to Stripe Checkout?

Yes. Webhooks provide a trustworthy server-side confirmation of payment and subscription lifecycle events independent of client redirects.

Which integration should I start with?

Start with hosted Stripe Checkout for fastest, lowest-risk setup. Move to Payment Element if you need advanced UI control or complex payment flows.