跨国收单的自建成本,最终把选择推向了 Stripe Checkout 托管支付 + Webhook 验证。原因很简单:目标用户横跨国内和海外,自建收单要面对银行卡合规、3D 验证、风控一堆事,而 Checkout 把这些全托管了,我们不碰卡号,PCI 合规负担直接降到最低。下面的方案覆盖订阅、按次付费、退款、对账,都是线上跑通过的。
一、为什么选 Stripe
支付平台的选择直接决定海外用户能不能付上钱。我对比过 Stripe、支付宝、微信支付、PayPal 四家,重点看覆盖范围和接入成本:
| 支付平台 | 覆盖 | 优势 | 劣势 |
|---|---|---|---|
| Stripe | 全球 195 国 | API 优秀、文档好 | 国内访问偶尔慢 |
| 支付宝 | 国内 | 国内普及 | 海外用户付不了 |
| 微信支付 | 国内 | 国内普及 | 海外用户付不了 |
| PayPal | 全球 | 老牌 | 费率比 Stripe 高 |
结论是 Stripe 覆盖面够广、API 体验好,国内用户偶尔慢的问题用备用支付渠道兜底,PayPal 费率偏高只作备选。平台目标用户有海外华人 + 国内用户,选 Stripe(PayPal 备选)。
选型时我还拉过一张更细的评分表,把文档质量、测试环境完备度、退款自动化、汇率支持逐项打分,Stripe 在每一项上都比自建收单省事。真正打动我的是它把 3D 验证、风控、拒付申诉这些脏活全部挡在托管页里,后端只负责在回调里改会员状态。最初我也想过直接对接卡组织收单,算完合规改造和人力成本就放弃了,托管方案省下的时间足够我们把产品体验打磨好几轮。
二、Stripe 核心概念
刚接触 Stripe 时最容易被一堆名词绕晕,理清楚之后其实是一条链:Customer 是用户,Product 是商品,Price 是定价,Subscription 把三者串起来,Webhook 负责把结果推回给我们。
| 概念 | 说明 |
|---|---|
| Customer | 客户 |
| Product | 商品(订阅产品) |
| Price | 定价(月付/年付) |
| Subscription | 订阅(关联 Customer + Price) |
| Checkout Session | 托管支付页 |
| Webhook | 事件回调(支付成功/失败) |
| Customer Portal | 用户自助管理订阅 |
记住一个原则:下单时后端只认 Price ID,金额由 Price 定义,绝不从客户端接收金额。这个原则后面踩坑部分还会展开。
三、订阅商品配置
商品在 Dashboard 里创建,这一步不需要写代码,但 Price ID 要记牢,后面下单全凭它。
Product: AI 视频下载总结器 VIP
├─ Price 1: ¥29 / 月 (recurring, monthly)
├─ Price 2: ¥299 / 年 (recurring, yearly)
└─ Price 3: ¥99 / 季度 (recurring, quarterly)
记录 Price ID:
price_monthly = 'price_xxx'
price_yearly = 'price_yyy'
我把三个 Price ID 放进环境变量而不是写死,这样 staging 和生产可以各自配置不同价格做测试。
四、创建 Checkout Session
下单接口的核心是创建一个 Checkout Session,把用户、商品、跳转地址都交给 Stripe:
import stripe
from fastapi import APIRouter
stripe.api_key = settings.STRIPE_SECRET_KEY
router = APIRouter()
@router.post('/api/payment/create-session')
async def create_session(
plan: str, # 'monthly' | 'yearly' | 'quarterly'
user: User = Depends(get_current_user)
):
price_id = {
'monthly': settings.STRIPE_PRICE_MONTHLY,
'yearly': settings.STRIPE_PRICE_YEARLY,
'quarterly': settings.STRIPE_PRICE_QUARTERLY
}[plan]
# 已有 customer 则复用
customer_id = await get_or_create_customer(user)
try:
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=['card'],
line_items=[{
'price': price_id,
'quantity': 1
}],
mode='subscription',
success_url=f'{settings.FRONTEND_URL}/payment/success'
f'?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{settings.FRONTEND_URL}/pricing',
metadata={
'user_id': str(user.id),
'plan': plan
}
)
return {'session_id': session.id,
'url': session.url}
except stripe.error.StripeError as e:
raise HTTPException(400, str(e))
session.url 直接返回给前端跳转,整个支付页、卡号输入、3D 验证都在 Stripe 托管页里完成,我们的服务器全程不接触卡号。
五、前端跳转支付
前端拿到 url 后整页跳转即可:
async function subscribe(plan) {
const { data } = await api.post('/api/payment/create-session', { plan })
// 跳转到 Stripe 托管支付页
window.location.href = data.url
}
Stripe Checkout 托管卡号输入、3D 验证、风控,前端不接触敏感数据。注意别用内嵌框架的方式打开支付页,直接整页跳转最省心。
六、支付成功回调
支付完成后 Stripe 通过 Webhook 通知我们,这是真正激活 VIP 的地方。核心是先验签再处理:
@router.post('/api/payment/webhook')
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, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(400, "Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Invalid signature")
# 处理事件
if event['type'] == 'checkout.session.completed':
await handle_checkout_completed(event['data']['object'])
elif event['type'] == 'customer.subscription.updated':
await handle_subscription_updated(event['data']['object'])
elif event['type'] == 'customer.subscription.deleted':
await handle_subscription_deleted(event['data']['object'])
elif event['type'] == 'invoice.payment_failed':
await handle_payment_failed(event['data']['object'])
return {'received': True}
async def handle_checkout_completed(session):
"""支付成功,激活 VIP"""
user_id = int(session['metadata']['user_id'])
subscription_id = session['subscription']
customer_id = session['customer']
# 查询订阅详情
subscription = stripe.Subscription.retrieve(subscription_id)
user = await user_service.get(user_id)
user.is_vip = True
user.vip_expires_at = datetime.fromtimestamp(
subscription.current_period_end
)
user.stripe_customer_id = customer_id
user.stripe_subscription_id = subscription_id
await db.commit()
# 发邮件通知
await email_service.send_vip_active(user)
验签必须用原始报文,先解析 JSON 再验会失败,这一步我栽过跟头,安全章节还会细讲。收到事件先回 200,业务处理放在验签通过之后,避免 Stripe 一直重试。
七、Webhook 幂等
Stripe 的投递是”至少一次”,同一个事件可能收到两遍,所以幂等去重是必须的:
async def handle_event(event):
event_id = event['id']
# 检查是否处理过
if await redis.exists(f'stripe_event:{event_id}'):
return # 已处理,跳过
# 标记为已处理
await redis.setex(f'stripe_event:{event_id}', 86400, '1')
# 业务逻辑
if event['type'] == '...':
await do_something(event)
去重键用事件 id,处理前先查、处理后写入,重复投递直接跳过。这一招避免了”支付两次、VIP 激活两次”的事故。
八、用户订阅查询
用户在前端看自己的会员状态,不用维护本地状态,直接以 Stripe 为事实源查询:
@router.get('/api/payment/subscription')
async def get_subscription(user: User = Depends(get_current_user)):
if not user.stripe_subscription_id:
return {'is_vip': False}
try:
sub = stripe.Subscription.retrieve(user.stripe_subscription_id)
return {
'is_vip': sub.status == 'active',
'plan': sub.items.data[0].price.nickname,
'current_period_end': sub.current_period_end,
'cancel_at_period_end': sub.cancel_at_period_end
}
except stripe.error.InvalidRequestError:
return {'is_vip': False, 'error': 'Subscription not found'}
以 Stripe 的订阅状态为唯一事实源,本地数据库只做展示缓存,状态不一致时靠后台每日对账兜底。
九、用户自助管理
改卡、退订、查账单这类操作,自己写一套既费劲又容易出错,Stripe Customer Portal 直接托管。后端只需创建一个 portal session:
@router.post('/api/payment/customer-portal')
async def create_portal_session(user: User = Depends(get_current_user)):
"""创建 Stripe Customer Portal session"""
if not user.stripe_customer_id:
raise HTTPException(400, "No subscription")
session = stripe.billing_portal.Session.create(
customer=user.stripe_customer_id,
return_url=f'{settings.FRONTEND_URL}/account'
)
return {'url': session.url}
前端按钮同样只要跳转 url:
async function manageSubscription() {
const { data } = await api.post('/api/payment/customer-portal')
window.location.href = data.url
}
Stripe Portal 让用户自己改卡、退订、查账单,后端零代码。
十、按次付费
除了订阅,还要支持不常来的用户按次付费。区别只在 mode 从 subscription 换成 payment:
@router.post('/api/payment/one-time')
async def create_one_time_payment(
user: User = Depends(get_current_user)
):
customer_id = await get_or_create_customer(user)
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=['card'],
line_items=[{
'price_data': {
'currency': 'cny',
'unit_amount': 100, # ¥1.00
'product_data': {'name': '单次视频总结'}
},
'quantity': 1
}],
mode='payment', # 注意:不是 subscription
success_url=f'{settings.FRONTEND_URL}/payment/success'
f'?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{settings.FRONTEND_URL}/pricing',
metadata={'user_id': str(user.id), 'type': 'one_time'}
)
return {'url': session.url}
Webhook checkout.session.completed 加积分:
if session.metadata.get('type') == 'one_time':
user.credits += 10 # 10 次
await db.commit()
加积分同样走事件去重,积分归额度系统管,和订阅是两条独立链路。
十一、退款处理
退款是客服场景的高频需求,我按四步走:
- 用 PaymentIntent.retrieve 查出这笔支付;
- 校验 customer 是否属于当前用户;
- 校验是否还在 7 天退款窗口内;
- 执行 Refund.create 并撤销 VIP。
对应代码如下:
@router.post('/api/payment/refund')
async def request_refund(
payment_id: str,
user: User = Depends(get_current_user)
):
"""用户申请退款(仅 VIP 订阅 7 天内)"""
# 1) 查 payment
payment = stripe.PaymentIntent.retrieve(payment_id)
# 2) 校验是否是本人的支付
if payment.customer != user.stripe_customer_id:
raise HTTPException(403, "Not your payment")
# 3) 校验时间(7 天内)
if datetime.now() - datetime.fromtimestamp(payment.created) > timedelta(days=7):
raise HTTPException(400, "Refund window expired")
# 4) 退款
refund = stripe.Refund.create(payment_intent=payment_id)
# 5) 撤销 VIP
user.is_vip = False
await db.commit()
return {'refund_id': refund.id}
退款后同步撤销 VIP,避免用户既拿到钱又继续用会员。
退款流程里我额外加了两道闸:第一步校验这笔支付是不是当前用户的,线上确实碰到过拿着别人订单号来申请退款的请求,customer 不匹配直接 403 挡回去;第二步卡 7 天窗口,这个期限是产品侧定的,和 Stripe 无关,哪天想放宽改一处常量就行。退款接口上线前我在测试模式用真实卡号退了一遍,确认金额原路退回、会员状态同步关闭,才敢放给客服用。
十二、税务与发票
Stripe 自动处理:
- 各国税率(VAT/GST/销售税);
- 客户发票(用户在 Stripe Portal 下载);
- 中国大陆暂不支持自动开票,需手动。
税务这块的边界在于:各国税率 Stripe 能自动代收,客户发票能在 Portal 自助下载,但中国大陆的自动开票还没铺开,需要手动对账,所以我们把月度交易导出来给财务:
# 每月导出交易给财务
def export_monthly_transactions(year: int, month: int):
"""导出月度交易给财务"""
since = datetime(year, month, 1).timestamp()
until = datetime(year, month + 1, 1).timestamp() if month < 12 else \
datetime(year + 1, 1, 1).timestamp()
charges = stripe.Charge.list(
created={'gte': since, 'lt': until},
limit=100
)
df = pd.DataFrame([{
'date': datetime.fromtimestamp(c.created),
'amount': c.amount / 100,
'currency': c.currency,
'user_email': c.billing_details.email
} for c in charges.data])
df.to_excel(f'transactions_{year}_{month}.xlsx')
字段保持最简,够财务对账用就行,别在报表里塞一堆没人看的列。
十三、安全最佳实践
支付安全没有侥幸,下面几条是从线上事故和代码审计里沉淀出来的。
四条里有两条来自线上事故,两条来自接入初期的自查。Webhook 验签那条最不能省:webhook 端点对公网开放,攻击者只要照着文档拼一个 checkout.session.completed 事件,把 type 字段填对,再伪造一个合法用户 id,就能在完全不付钱的情况下把会员刷出来。验签是挡在这条路中间的唯一一道门,签名过不了直接 400,不带任何业务处理。
1. Webhook 验签
# 关键!必须验签防伪造
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
签名验不过直接 400,不让攻击者拿伪造事件刷 VIP。
2. 不用前端传 amount
# ❌ 前端传 amount
amount = request.json['amount']
# ✅ 后端根据 plan 查价格
amount = PLAN_PRICES[plan]
金额永远以服务端 Price 为准,前端传来的数字一个字都不能信。
3. HTTPS 强制
支付页面强制 HTTPS,Token 不在 HTTP 环境传输。
4. PCI 合规
用 Stripe Checkout 后,平台不接触卡号,SAQ A 级别合规(最简)。
Checkout 模式下我们全程不接触卡号,走的是 PCI 里最轻的一档;真要自建卡号采集,合规成本完全不是一个量级。
十四、监控
支付链路挂了用户直接付不了钱,我埋了几组关键指标:
metrics.counter('stripe.checkout.create',
'plan', plan, 'result', 'success').increment()
metrics.counter('stripe.webhook',
'event', event_type, 'result', 'success').increment()
metrics.counter('stripe.webhook',
'event', event_type, 'result', 'fail', 'reason', 'invalid_signature').increment()
监控异常事件、签名失败(可能是攻击)。
签名失败次数飙高多半是有人试伪造事件,这类告警我给的优先级比较高。
十五、踩过的坑
这些坑基本是每接一个就踩一个,整理如下:
- Webhook 接收地址必须 HTTPS:开发时用
stripe listen --forward-to localhost:8000/api/payment/webhook。 - Webhook 重放:同一事件可能多次发送,幂等去重必须。
- 货币单位:Stripe 用最小单位(分),不要传 29.00 而要 2900。
- Customer Portal 需要配置:Stripe Dashboard → Settings → Billing → Customer Portal 启用。
- 中国大陆访问 Stripe:偶尔慢。需要备用支付(支付宝)。
- 失败支付恢复:
invoice.payment_failed时给用户宽限期(3 天),期间还能用。 - 订阅升级/降级:用户从月付改年付,Stripe 自动按比例结算。
- 税务:B2C 销售平台通常要代扣税。Stripe Tax 自动化但要开通。
这份清单是按踩坑成本排的序,代价大的案例是货币单位那次:staging 里把 ¥29 写成了 29 而不是 2900,账单金额直接错了两个数量级,好在是测试模式没有真实扣款。现在所有涉及金额的断言在测试里都锁死最小单位,谁再拿元当分传,测试直接报红,这类事故从此没再出现过。
十六、测试模式
开发期全程用测试模式,密钥 sk_test 开头,配测试卡号模拟各种结果:
# 测试密钥
stripe.api_key = 'sk_test_xxx'
# 测试卡号
# 4242 4242 4242 4242 -- 成功
# 4000 0000 0000 9995 -- 余额不足
# 4000 0000 0000 0069 -- 过期
平台在 staging 用测试模式,生产用正式密钥。两套环境彻底隔离,防止测试数据混进真实账目。
测试模式里我习惯把关键事件主动触发一遍再上生产:用成功卡号走完整条订阅链路,再用余额不足卡号验证 invoice.payment_failed 分支,确认宽限期逻辑真的生效。开发时用 stripe CLI 的 listen 命令把事件直接打到本地,配合日志逐条看处理路径,比拍脑袋猜逻辑高效得多。测试卡号对应的场景在文档里有清单,每个分支都过一遍,心里才有底。
十七、未来优化
付费能力按需演进,后续打算按这个顺序加:
- 多种支付方式:加 Apple Pay / Google Pay / 支付宝;
- 优惠券系统:用 Stripe Coupon;
- 按用量计费:用 Stripe Meters(API 调用次数);
- 自动对账:每日拉取交易,邮件给财务。
每加一项都先在测试模式验证再上生产,这是接入 Stripe 后我给自己定的规矩。
加新支付方式成本大头不在代码,而在每种方式的测试流程和退款差异,所以每一项我都先灰度,让一小撮用户先走新通道,确认成功率稳定再全量放开。这条顺序执行下来,付费功能没有因为上线新通道出过事故,用户侧的支付成功率也一直稳定在预期范围内。
常见问题(FAQ)
Q1:Stripe 收手续费多少?
国内卡 2.9% + 0.3 USD/笔,海外卡 3.5% + 0.3 USD/笔。
Q2:能不用 Webhook 吗?
不能。Webhook 是支付成功的可靠通知,redirect URL 不靠谱(用户可能关掉)。
Q3:怎么防止 webhook 漏单?
用幂等表 + 每日对账脚本(拉所有成功 charge 对比订单)。