stripe-integration
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseStripe 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
undefinedbash
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_...
undefinedSTRIPE_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_...
undefinedTypeScript 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
undefinedpython
undefinedwebhooks/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"]
),
)undefinedimport 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"]
),
)undefinedFrontend 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
undefinedbash
undefinedInstall 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
undefinedstripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
undefinedBest Practices
最佳实践
- Always verify webhook signatures: Never trust unverified payloads
- Make webhooks idempotent: Same event may be delivered multiple times
- Store Stripe IDs: Keep customer/subscription IDs in your database
- Use metadata: Pass your user IDs in metadata for easy lookup
- Handle all subscription states: active, past_due, canceled, etc.
- 始终验证Webhook签名:绝不信任未验证的请求体
- 确保Webhook的幂等性:同一事件可能会被多次推送
- 存储Stripe ID:在数据库中保存客户/订阅ID
- 使用元数据:在元数据中传递用户ID以便快速查找
- 处理所有订阅状态:包括active、past_due、canceled等
Common Mistakes
常见错误
- Not handling (users don't know payment failed)
invoice.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)