AS
AgSkills.dev
MARKETPLACE

web-payments

Stripe Checkout, subscriptions, webhooks, customer portal

480
37

Preview

SKILL.md
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 | Payment Element Best Practices | Building Solid Stripe Integrations | 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

# .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

# Node.js npm install stripe @stripe/stripe-js # Python pip install stripe

Integration Options

MethodBest ForComplexity
Checkout (Hosted)Quick setup, Stripe-hosted pageLow
Checkout (Embedded)Custom site, embedded formLow
Payment ElementFull customization, complex flowsMedium
Custom FormComplete control (rare)High

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


Stripe Checkout (Recommended)

Server: Create Checkout Session

Node.js / Next.js

// 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

# 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

// 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:

// 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:

// 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

// 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

# 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

EventWhenAction
checkout.session.completedPayment successfulProvision access
customer.subscription.createdNew subscriptionStore subscription ID
customer.subscription.updatedPlan changeUpdate plan in DB
customer.subscription.deletedCanceledRevoke access
invoice.payment_failedPayment failedNotify user, retry
invoice.paidRenewal successfulExtend 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

// 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:

// 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

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

// 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 NumberScenario
4242424242424242Success
4000000000000002Declined
4000002500003155Requires 3D Secure
4000000000009995Insufficient funds

Stripe CLI for Webhooks

# 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

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

// 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

// 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

// 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

# 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

EndpointPurpose
POST /api/checkoutCreate checkout session
POST /api/portalCustomer billing portal
POST /api/webhooks/stripeHandle Stripe events

Environment Variables

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
GitHub Repository
alinaqi/claude-bootstrap
Stars
480
Forks
37
Open Repository
Install Skill
Download ZIP1 files