stripe-integration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Stripe Integration

Stripe支付集成

Production-ready Stripe integration for SaaS billing.
适用于生产环境的SaaS计费Stripe集成方案。

When to Use This Skill

适用场景

  • Adding subscription billing to your SaaS
  • Implementing usage-based pricing
  • Setting up customer self-service portal
  • Handling payment webhooks securely
  • 为你的SaaS应用添加订阅计费功能
  • 实现基于使用量的定价模式
  • 搭建客户自助服务门户
  • 安全处理支付Webhook

Architecture Overview

架构概述

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Frontend  │────▶│   Backend   │────▶│   Stripe    │
│  Checkout   │     │   Webhooks  │◀────│   Events    │
└─────────────┘     └─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │  Database   │
                    │  (sync'd)   │
                    └─────────────┘
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Frontend  │────▶│   Backend   │────▶│   Stripe    │
│  Checkout   │     │   Webhooks  │◀────│   Events    │
└─────────────┘     └─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │  Database   │
                    │  (sync'd)   │
                    └─────────────┘

Environment Setup

环境配置

bash
undefined
bash
undefined

.env

.env

STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_FREE=price_... STRIPE_PRICE_PRO=price_... STRIPE_PRICE_ENTERPRISE=price_...
undefined
STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_FREE=price_... STRIPE_PRICE_PRO=price_... STRIPE_PRICE_ENTERPRISE=price_...
undefined

TypeScript Implementation

TypeScript实现

Stripe Client Setup

Stripe客户端配置

typescript
// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true,
});

export const PRICES = {
  free: process.env.STRIPE_PRICE_FREE!,
  pro: process.env.STRIPE_PRICE_PRO!,
  enterprise: process.env.STRIPE_PRICE_ENTERPRISE!,
} as const;

export type PriceTier = keyof typeof PRICES;
typescript
// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true,
});

export const PRICES = {
  free: process.env.STRIPE_PRICE_FREE!,
  pro: process.env.STRIPE_PRICE_PRO!,
  enterprise: process.env.STRIPE_PRICE_ENTERPRISE!,
} as const;

export type PriceTier = keyof typeof PRICES;

Create Checkout Session

创建结账会话

typescript
// api/create-checkout.ts
import { stripe, PRICES, PriceTier } from '@/lib/stripe';

interface CreateCheckoutParams {
  userId: string;
  email: string;
  tier: PriceTier;
  successUrl: string;
  cancelUrl: string;
}

export async function createCheckoutSession({
  userId,
  email,
  tier,
  successUrl,
  cancelUrl,
}: CreateCheckoutParams): Promise<string> {
  // Get or create Stripe customer
  let customer = await getStripeCustomer(userId);
  
  if (!customer) {
    customer = await stripe.customers.create({
      email,
      metadata: { userId },
    });
    await saveStripeCustomerId(userId, customer.id);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customer.id,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: PRICES[tier],
        quantity: 1,
      },
    ],
    success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: cancelUrl,
    subscription_data: {
      metadata: { userId },
    },
    allow_promotion_codes: true,
  });

  return session.url!;
}
typescript
// api/create-checkout.ts
import { stripe, PRICES, PriceTier } from '@/lib/stripe';

interface CreateCheckoutParams {
  userId: string;
  email: string;
  tier: PriceTier;
  successUrl: string;
  cancelUrl: string;
}

export async function createCheckoutSession({
  userId,
  email,
  tier,
  successUrl,
  cancelUrl,
}: CreateCheckoutParams): Promise<string> {
  // 获取或创建Stripe客户
  let customer = await getStripeCustomer(userId);
  
  if (!customer) {
    customer = await stripe.customers.create({
      email,
      metadata: { userId },
    });
    await saveStripeCustomerId(userId, customer.id);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customer.id,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: PRICES[tier],
        quantity: 1,
      },
    ],
    success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: cancelUrl,
    subscription_data: {
      metadata: { userId },
    },
    allow_promotion_codes: true,
  });

  return session.url!;
}

Webhook Handler

Webhook处理器

typescript
// api/webhooks/stripe.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;

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

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object);
        break;
        
      case 'customer.subscription.updated':
        await handleSubscriptionUpdate(event.data.object);
        break;
        
      case 'customer.subscription.deleted':
        await handleSubscriptionCanceled(event.data.object);
        break;
        
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object);
        break;
        
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return new Response('OK', { status: 200 });
  } catch (err) {
    console.error('Webhook handler error:', err);
    return new Response('Webhook handler failed', { status: 500 });
  }
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const subscriptionId = session.subscription as string;
  
  if (!userId || !subscriptionId) return;

  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const priceId = subscription.items.data[0]?.price.id;
  const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

  await updateUserSubscription(userId, {
    stripeSubscriptionId: subscriptionId,
    stripeCustomerId: session.customer as string,
    tier,
    status: subscription.status,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
}

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.userId;
  if (!userId) return;

  const priceId = subscription.items.data[0]?.price.id;
  const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

  await updateUserSubscription(userId, {
    tier,
    status: subscription.status,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
  });
}

async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.userId;
  if (!userId) return;

  await updateUserSubscription(userId, {
    tier: 'free',
    status: 'canceled',
    stripeSubscriptionId: null,
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  const user = await getUserByStripeCustomerId(customerId);
  
  if (user) {
    await sendPaymentFailedEmail(user.email, {
      amount: invoice.amount_due / 100,
      nextRetry: invoice.next_payment_attempt 
        ? new Date(invoice.next_payment_attempt * 1000)
        : null,
    });
  }
}
typescript
// api/webhooks/stripe.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;

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

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object);
        break;
        
      case 'customer.subscription.updated':
        await handleSubscriptionUpdate(event.data.object);
        break;
        
      case 'customer.subscription.deleted':
        await handleSubscriptionCanceled(event.data.object);
        break;
        
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object);
        break;
        
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return new Response('OK', { status: 200 });
  } catch (err) {
    console.error('Webhook handler error:', err);
    return new Response('Webhook handler failed', { status: 500 });
  }
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const subscriptionId = session.subscription as string;
  
  if (!userId || !subscriptionId) return;

  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const priceId = subscription.items.data[0]?.price.id;
  const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

  await updateUserSubscription(userId, {
    stripeSubscriptionId: subscriptionId,
    stripeCustomerId: session.customer as string,
    tier,
    status: subscription.status,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
  });
}

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.userId;
  if (!userId) return;

  const priceId = subscription.items.data[0]?.price.id;
  const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

  await updateUserSubscription(userId, {
    tier,
    status: subscription.status,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
  });
}

async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
  const userId = subscription.metadata?.userId;
  if (!userId) return;

  await updateUserSubscription(userId, {
    tier: 'free',
    status: 'canceled',
    stripeSubscriptionId: null,
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  const user = await getUserByStripeCustomerId(customerId);
  
  if (user) {
    await sendPaymentFailedEmail(user.email, {
      amount: invoice.amount_due / 100,
      nextRetry: invoice.next_payment_attempt 
        ? new Date(invoice.next_payment_attempt * 1000)
        : null,
    });
  }
}

Customer Portal

客户门户

typescript
// api/create-portal.ts
export async function createPortalSession(userId: string): Promise<string> {
  const user = await getUser(userId);
  
  if (!user?.stripeCustomerId) {
    throw new Error('No Stripe customer found');
  }

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

  return session.url;
}
typescript
// api/create-portal.ts
export async function createPortalSession(userId: string): Promise<string> {
  const user = await getUser(userId);
  
  if (!user?.stripeCustomerId) {
    throw new Error('No Stripe customer found');
  }

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

  return session.url;
}

Python Implementation

Python实现

FastAPI Webhook Handler

FastAPI Webhook处理器

python
undefined
python
undefined

webhooks/stripe.py

webhooks/stripe.py

import stripe from fastapi import APIRouter, Request, HTTPException, Header
router = APIRouter() stripe.api_key = settings.STRIPE_SECRET_KEY
@router.post("/webhooks/stripe") async def stripe_webhook( request: Request, stripe_signature: str = Header(None), ): payload = await request.body()
try:
    event = stripe.Webhook.construct_event(
        payload,
        stripe_signature,
        settings.STRIPE_WEBHOOK_SECRET,
    )
except stripe.error.SignatureVerificationError:
    raise HTTPException(status_code=400, detail="Invalid signature")

handlers = {
    "checkout.session.completed": handle_checkout_complete,
    "customer.subscription.updated": handle_subscription_update,
    "customer.subscription.deleted": handle_subscription_canceled,
    "invoice.payment_failed": handle_payment_failed,
}

handler = handlers.get(event["type"])
if handler:
    await handler(event["data"]["object"])

return {"status": "ok"}
async def handle_checkout_complete(session: dict): user_id = session.get("metadata", {}).get("userId") subscription_id = session.get("subscription")
if not user_id or not subscription_id:
    return

subscription = stripe.Subscription.retrieve(subscription_id)
price_id = subscription["items"]["data"][0]["price"]["id"]
tier = PRICE_TO_TIER.get(price_id, "free")

await update_user_subscription(
    user_id=user_id,
    stripe_subscription_id=subscription_id,
    stripe_customer_id=session["customer"],
    tier=tier,
    status=subscription["status"],
    current_period_end=datetime.fromtimestamp(
        subscription["current_period_end"]
    ),
)
undefined
import stripe from fastapi import APIRouter, Request, HTTPException, Header
router = APIRouter() stripe.api_key = settings.STRIPE_SECRET_KEY
@router.post("/webhooks/stripe") async def stripe_webhook( request: Request, stripe_signature: str = Header(None), ): payload = await request.body()
try:
    event = stripe.Webhook.construct_event(
        payload,
        stripe_signature,
        settings.STRIPE_WEBHOOK_SECRET,
    )
except stripe.error.SignatureVerificationError:
    raise HTTPException(status_code=400, detail="Invalid signature")

handlers = {
    "checkout.session.completed": handle_checkout_complete,
    "customer.subscription.updated": handle_subscription_update,
    "customer.subscription.deleted": handle_subscription_canceled,
    "invoice.payment_failed": handle_payment_failed,
}

handler = handlers.get(event["type"])
if handler:
    await handler(event["data"]["object"])

return {"status": "ok"}
async def handle_checkout_complete(session: dict): user_id = session.get("metadata", {}).get("userId") subscription_id = session.get("subscription")
if not user_id or not subscription_id:
    return

subscription = stripe.Subscription.retrieve(subscription_id)
price_id = subscription["items"]["data"][0]["price"]["id"]
tier = PRICE_TO_TIER.get(price_id, "free")

await update_user_subscription(
    user_id=user_id,
    stripe_subscription_id=subscription_id,
    stripe_customer_id=session["customer"],
    tier=tier,
    status=subscription["status"],
    current_period_end=datetime.fromtimestamp(
        subscription["current_period_end"]
    ),
)
undefined

Frontend Checkout Button

前端结账按钮

tsx
// components/CheckoutButton.tsx
'use client';

import { useState } from 'react';

interface CheckoutButtonProps {
  tier: 'pro' | 'enterprise';
  children: React.ReactNode;
}

export function CheckoutButton({ tier, children }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ tier }),
      });
      
      const { url } = await response.json();
      window.location.href = url;
    } catch (error) {
      console.error('Checkout error:', error);
      setLoading(false);
    }
  };

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? 'Loading...' : children}
    </button>
  );
}
tsx
// components/CheckoutButton.tsx
'use client';

import { useState } from 'react';

interface CheckoutButtonProps {
  tier: 'pro' | 'enterprise';
  children: React.ReactNode;
}

export function CheckoutButton({ tier, children }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ tier }),
      });
      
      const { url } = await response.json();
      window.location.href = url;
    } catch (error) {
      console.error('Checkout error:', error);
      setLoading(false);
    }
  };

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? '加载中...' : children}
    </button>
  );
}

Database Schema

数据库表结构

sql
-- users table additions
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT;
ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active';
ALTER TABLE users ADD COLUMN current_period_end TIMESTAMP;
ALTER TABLE users ADD COLUMN cancel_at_period_end BOOLEAN DEFAULT FALSE;

CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);
sql
-- users表新增字段
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT;
ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active';
ALTER TABLE users ADD COLUMN current_period_end TIMESTAMP;
ALTER TABLE users ADD COLUMN cancel_at_period_end BOOLEAN DEFAULT FALSE;

CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);

Testing Webhooks Locally

本地测试Webhook

bash
undefined
bash
undefined

Install Stripe CLI

安装Stripe CLI

brew install stripe/stripe-cli/stripe
brew install stripe/stripe-cli/stripe

Login

登录

stripe login
stripe login

Forward webhooks to local server

将Webhook转发到本地服务器

stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Trigger test events

触发测试事件

stripe trigger checkout.session.completed stripe trigger customer.subscription.updated
undefined
stripe trigger checkout.session.completed stripe trigger customer.subscription.updated
undefined

Best Practices

最佳实践

  1. Always verify webhook signatures: Never trust unverified payloads
  2. Make webhooks idempotent: Same event may be delivered multiple times
  3. Store Stripe IDs: Keep customer/subscription IDs in your database
  4. Use metadata: Pass your user IDs in metadata for easy lookup
  5. Handle all subscription states: active, past_due, canceled, etc.
  1. 始终验证Webhook签名:绝不信任未验证的请求体
  2. 确保Webhook的幂等性:同一事件可能会被多次推送
  3. 存储Stripe ID:在数据库中保存客户/订阅ID
  4. 使用元数据:在元数据中传递用户ID以便快速查找
  5. 处理所有订阅状态:包括active、past_due、canceled等

Common Mistakes

常见错误

  • Not handling
    invoice.payment_failed
    (users don't know payment failed)
  • Trusting client-side tier selection (always verify server-side)
  • Not setting up customer portal (users can't self-manage)
  • Forgetting to sync subscription status on webhook
  • Not testing with Stripe CLI locally
  • 未处理
    invoice.payment_failed
    事件(用户无法知晓支付失败)
  • 信任客户端的层级选择(始终在服务端验证)
  • 未搭建客户门户(用户无法自助管理)
  • 忘记通过Webhook同步订阅状态
  • 未使用Stripe CLI进行本地测试

Security Checklist

安全检查清单

  • Webhook signature verification
  • HTTPS only for webhook endpoints
  • Stripe keys in environment variables
  • Customer portal configured
  • Test mode vs live mode separation
  • PCI compliance (use Stripe Elements/Checkout)
  • Webhook签名验证
  • Webhook端点仅使用HTTPS
  • Stripe密钥存储在环境变量中
  • 已配置客户门户
  • 测试环境与生产环境分离
  • PCI合规(使用Stripe Elements/Checkout)