-
-
[原创]Stripe 协议支付自动化深度拆解:从 HAR 抓包到纯 API 全链路实现
-
发表于: 1天前 553
-
⚠️ 免责声明:本文仅供安全研究与技术学习。文中描述的技术手段仅用于分析 Stripe 支付协议的工作原理,帮助支付系统开发者理解安全边界。请勿将相关技术用于任何未授权的操作。
前言
做过 SaaS 订阅的人都知道,Stripe 是全球最主流的支付处理商。几乎所有主流 AI 平台——从对话类到图像类——都用 Stripe 处理信用卡支付。
但很少有人去研究 Stripe Checkout 背后的协议细节。大多数人的认知停留在"调一下 API、跳个 Checkout 页面、信用卡扣完款就行了"。
实际上,Stripe 的 Checkout Session 协议远比你想象的复杂。从 checkout_ui_mode 的 custom 与 hosted 两种模式,到 ConfirmationToken 的创建机制,再到 PaymentIntent 的 requires_action 状态下 3DS 验证挑战的处理——每一步都有大量的协议细节、反欺诈检测和时序要求。
今天我把这些全拆开。从 HAR 抓包出发,逐层剖析 Stripe 协议支付的完整链路,并提供 5 套不同技术栈的实现源码。
零、方法论:如何逆向 Stripe 支付协议
在开始之前,先讲方法论。所有协议支付的起点都是 HAR 抓包。
0.1 什么是 HAR 文件
HAR(HTTP Archive)是浏览器开发者工具导出的完整 HTTP 交互记录。包含每个请求的 URL、Headers、Body、Response,以及精确的时序信息。
打开 Chrome DevTools → Network → 完成一次真实支付 → 右键 → Save all as HAR with content。
这个 HAR 文件就是你的 ground truth。所有协议支付的字段、值、顺序、编码方式,都以 HAR 为准。代码实现、第三方文档、安全审计报告——全部都可能过时或有误,只有 HAR 是当时浏览器真实发出的请求。
0.2 HAR 分析流程
- 筛选关键域名:
api.stripe.com、m.stripe.com、checkout.stripe.com、平台 API 域名 - 按时间排序:找出完整的请求链路
- 对比成功和失败:同一流程的成功 HAR 和失败 HAR,diff 出关键差异
- 提取稳定字段:跨多个 HAR 都一致的值可以硬编码
- 标记动态字段:每次不同的值(Session ID、Token、时间戳)需要运行时获取
0.3 经过验证的 HAR 请求链路
通过分析多个成功支付的 HAR 文件,提取出标准的浏览器 Checkout 请求序列:
1. POST /api/v1/payments/checkout → 创建 session (oaics_xxx)
2. GET stripe/v1/elements/sessions → 获取 Elements 配置 (deferred_intent)
3. POST /api/v1/payments/checkout/taxes → 税务重算 (US免税地址可消除VAT)
4. GET stripe/v1/elements/sessions → 更新后的 amount
5. POST stripe/v1/confirmation_tokens → 卡号 → ctoken_xxx
6. POST /api/v1/payments/checkout/confirm → 平台确认 → pi_xxx client_secret
7. POST stripe/v1/payment_intents/{pi}/confirm → Stripe 侧扣款
8. GET /checkout/verify → redirect_status=succeeded
每一步都有大量细节。下面逐一拆解。
一、为什么要做"协议支付"
先搞清楚一个概念:什么是协议支付(Protocol Payment)?
浏览器支付:用户打开 Stripe Checkout 页面 → 手动填写信用卡号 → 点击支付 → 完成。这是正常流程。
协议支付:用纯 HTTP API 请求模拟浏览器的完整操作链路,包括 Stripe 的内部 API(不是官方 SDK 暴露的那些),直接在协议层完成支付,不需要浏览器。
为什么要这么做?
- 自动化需求:批量管理订阅时,手动操作不现实
- 性能需求:浏览器(Puppeteer/Playwright)启动慢、占内存大、不稳定;协议支付 <5s 完成
- 环境隔离:服务端运行,不需要 GUI 环境
- 学习价值:深入理解支付系统的工作原理
二、Stripe Checkout Session 的两种模式
Stripe Checkout 有两种 checkout_ui_mode,这是整个协议支付的第一个分叉点:
2.1 Custom 模式 (oaics_ 前缀)
POST /payments/checkout
{
"checkout_ui_mode": "custom",
"currency": "PHP",
"plan_type": "plus"
}
→ {"checkout_session_id": "oaics_xxx", "stripe_publishable_key": "pk_live_..."}
Custom 模式返回的 Session ID 以 oaics_ 开头。这种模式下:
- 前端需要自己挂载 Stripe Elements
- 通过 Stripe.js 创建
ConfirmationToken - 调用平台的
confirm接口完成支付 - 直接在 Stripe API 上确认 PaymentIntent
2.2 Hosted 模式 (cs_live_ 前缀)
POST /payments/checkout
{
"checkout_ui_mode": "hosted",
"currency": "PHP",
"plan_type": "plus"
}
→ {"checkout_session_id": "cs_live_xxx", "url": "3ddK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0K9r3g2U0K9$3!0#2N6q4)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8g2)9J5c8W2)9J5k6g2)9J5k6g2)9J5k6b7`.`."}
Hosted 模式返回 cs_live_ 前缀的 Session。这种模式下:
- Stripe 托管整个 Checkout 页面
- 走 Stripe Payment Page 的内部协议
- 流程更复杂,涉及
ppage_初始化、elementsSession、inline_confirm - 需要额外处理平台侧的
approve操作
两种模式的选择取决于平台的 billing 配置和你的需求。一般来说:
- 如果平台的计费货币与你的信用卡货币一致 → Custom 模式更简单
- 如果存在货币不匹配(比如用美元卡付菲律宾比索订阅)→ 可能只有 Hosted 模式可用
2.3 决定性参数:checkout_ui_mode
通过大量测试验证,决定返回 oaics_ 还是 cs_live_ 的唯一参数就是 checkout_ui_mode。
// 返回 oaics_ 的请求
{
"entry_point": "all_plans_pricing_modal",
"plan_name": "basic_plan",
"billing_details": {"country": "PH", "currency": "PHP"},
"checkout_ui_mode": "custom"
}
// 返回 cs_live_ 的请求
{
"plan_name": "basic_plan",
"billing_details": {"country": "PH", "currency": "PHP"},
"checkout_ui_mode": "hosted",
"cancel_url": "702K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3L8r3q4@1k6X3!0J5L8g2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6Q4x3U0y4H3M7X3W2U0K9h3&6Y4",
"locale": "zh-CN"
}
注意差异:
entry_point:Custom 模式有,Hosted 模式没有cancel_url:Hosted 模式需要(Stripe 托管页面的返回按钮用)locale:Hosted 模式建议传(影响 Stripe 页面语言)
Hosted 模式的响应多一个 url 字段——这是 Stripe 托管页面的完整 URL,可以直接在浏览器中打开。
2.4 _stripe_version 的版本差异
这是一个极其容易忽略的细节。两种模式使用不同的 _stripe_version:
| 模式 | _stripe_version 值 |
|---|---|
| Custom (oaics_) | 2025-03-31.basil |
| Hosted (cs_live_) | 2025-03-31.basil; checkout_server_update_beta=v1; checkout_manual_approval_preview=v1 |
Hosted 模式的版本串后面带了两个 beta flag。如果在 cs_live_ 请求中用了 Custom 的短版本串,Stripe 会返回字段缺失或行为不一致。
2.5 两种模式的完整字段对比
| 字段 | Custom (oaics_) | Hosted (cs_live_) |
|---|---|---|
| 确认端点 | payment_intents/{pi}/confirm |
payment_pages/{cs_live}/confirm |
顶层 source |
elements |
checkout |
顶层 version |
2021 |
custom |
selection_flow |
merchant_specified |
automatic |
elements_session_client |
无 | 有(8 个子字段) |
elements_options_client |
无 | 有(2 个子字段) |
link_brand |
无 | link |
checkout_config_id |
无 | 有(两层不同值) |
| hCaptcha 位置 | pmd[radar_options][hcaptcha_token] |
passive_captcha_token(顶层) |
init_checksum |
无 | 有 |
js_checksum |
无 | 有 |
rv_timestamp |
无 | 有 |
expected_amount |
无 | 有 |
这两套字段集完全不能混用。这也是很多人协议支付做不通的原因——用了 oaics_ 的字段去调 cs_live_ 的接口,反过来也一样。
三、Custom 模式协议流程详解
Custom 模式是最干净的协议支付路径。完整流程 8 步:
┌──────────────────────────────────────┐
│ 1. 刷新 Session(获取最新 Token) │
│ 2. 查询账号信息(确认当前订阅状态) │
│ 3. 获取定价信息(币种+金额) │
│ 4. 创建 Checkout Session │
│ 5. 更新税务信息(Billing 地址) │
│ 6. 创建 ConfirmationToken(卡号→Token)│
│ 7. 确认支付(Confirm Checkout) │
│ 8. 确认 PaymentIntent(Stripe 侧) │
└──────────────────────────────────────┘
3.1 创建 ConfirmationToken
这是协议支付最核心的一步。浏览器里,Stripe.js 会在 iframe 中收集卡号,然后调用 Stripe 的内部 API 创建一个 ConfirmationToken(ctoken_ 前缀)。
协议支付需要直接模拟这个请求:
POST a4aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8X3y4G2L8X3k6A6M7X3#2S2N6r3W2G2L8W2)9#2k6Y4c8G2K9$3g2F1M7H3`.`.
Content-Type: application/x-www-form-urlencoded
type=card
&payment_method_data[type]=card
&payment_method_data[card][number]=4242424242424242
&payment_method_data[card][exp_month]=12
&payment_method_data[card][exp_year]=2029
&payment_method_data[card][cvc]=123
&payment_method_data[billing_details][name]=John Doe
&payment_method_data[billing_details][address][country]=US
&payment_method_data[billing_details][address][postal_code]=97201
&key=pk_live_xxx
&_stripe_version=2025-03-31.basil
几个关键细节:
key必须是平台的 Stripe Publishable Key,不是你自己的_stripe_version必须匹配当前 Stripe.js 的 API 版本- 需要传 Stripe.js 的指纹参数:
guid、muid、sid、time_on_page - Referer 必须是平台域名,Stripe 会校验
卡号格式细节
HAR 验证发现,浏览器发送的卡号带空格(4004 1641 0185 9397),而不是纯数字。不过 Stripe 接受两种格式。
guid/muid/sid 格式
这三个指纹 ID 的格式比很多人以为的更复杂。HAR 验证的正确格式是 42 字符:
guid = randomUUID() + randomHex(6)
= xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + 6位hex
= 36字符UUID + 6字符 = 42字符
例如:a1b2c3d4-e5f6-4789-abcd-ef0123456789abc123
有些实现用 32 位纯 hex 也能工作,但 HAR 中浏览器发送的是 42 字符格式。
Attribution 元数据(两层结构)
confirmation_tokens 请求中有一组 client_attribution_metadata 字段,这些是 Stripe 用来追踪支付集成来源的元数据:
payment_user_agent = stripe.js/...
client_session_id = <UUID> # 每 session 唯一
merchant_integration_source = elements # ← oaics_ 固定 "elements"
merchant_integration_version = 2021
merchant_integration_subtype = payment-element
payment_intent_creation_flow = deferred
payment_method_selection_flow = merchant_specified # ← oaics_ 固定
additional_elements = [expressCheckout, payment, address]
这些字段在 confirmation_tokens 请求中出现两次——一次在 payment_method_data 内部,一次在顶层。两层的值大部分相同,但 merchant_integration_source 在 confirm 请求中变成 l1。这个细节从 HAR 中才能发现。
请求头
HAR 中 Stripe API 请求的完整请求头:
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...
Accept: application/json
Content-Type: application/x-www-form-urlencoded
Origin: eefK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7M7#2)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8b7`.`.
Referer: 3c3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7M7#2)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8g2)9J5c8R3`.`.
Sec-CH-UA: "Not=A?Brand";v="99", "Chromium";v="151"
Sec-CH-UA-Mobile: ?0
Sec-CH-UA-Platform: "Windows"
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Language: en-US,en;q=0.9
注意 Accept-Language 必须存在。这是纯协议支付跑通的关键 bug 之一——缺少这个 header 会导致 Stripe 返回非预期的响应格式。
返回值:
{
"id": "ctoken_1xxx",
"type": "card",
"payment_method_preview": {
"card": {
"brand": "visa",
"last4": "4242"
}
}
}
3.2 确认 Checkout
拿到 ConfirmationToken 后,调用平台的 confirm 接口:
POST /api/v1/payments/checkout/confirm
{
"checkout_session_id": "oaics_xxx",
"confirmation_token": "ctoken_1xxx"
}
这一步平台会拿着你的 token 去 Stripe 创建 PaymentIntent 并尝试扣款。
如果一切顺利(卡余额充足、无 3DS 要求),PaymentIntent 直接变成 succeeded,订阅激活。
如果遇到 3DS 验证,流程会更复杂——我们在第五节讲。
3.3 Elements Session — 获取支付配置
在创建 ConfirmationToken 之前,需要先调用 Stripe 的 elements/sessions API 获取支付配置信息。这一步在 HAR 中被调用两次——一次在税务更新前,一次在税务更新后。
GET 234K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8X3g2D9k6h3#2W2L8Y4c8K6i4K6u0r3M7$3g2K6M7$3W2G2L8Y4y4Q4x3@1k6V1k6h3k6W2M7Y4u0W2k6q4)9#2k6X3W2F1N6r3g2F1N6q4)9#2b7X3#2G2k6r3g2Q4y4f1c8Q4x3@1c8K6N6h3u0K6j5%4u0A6M7s2c8A6L8$3^5`.
&deferred_intent[amount]=98214
&deferred_intent[currency]=php
&deferred_intent[setup_future_usage]=off_session
&deferred_intent[payment_method_types][0]=card
&type=deferred_intent
&key=pk_live_xxx
&_stripe_version=2025-03-31.basil
关键返回值:
elements_session_id:elements_session_xxx,后续 confirm 请求需要customer_session_client_secret:cuss_xxx,客户会话密钥payment_method_configuration:pmc_xxx,支付方式配置 ID
3.4 税务地址优化 — US 免税地址消除 VAT
这是一个实际操作中极其重要的技巧。
很多 SaaS 平台默认按账号注册地收税。比如菲律宾区的订阅,标价 ₱982.14 但实际会加 12% VAT 变成 ₱1,100+。
解决方案:在 taxes API 调用中使用美国免税州地址。
POST /api/v1/payments/checkout/taxes
{
"checkout_session_id": "oaics_xxx",
"billing_details": {
"name": "John Doe",
"address": {
"country": "US",
"state": "OR",
"city": "Portland",
"postal_code": "97201",
"line1": "123 Main St"
}
}
}
美国有 5 个无销售税的州:
- Oregon (OR) — 无销售税
- Montana (MT) — 无销售税
- Delaware (DE) — 无销售税
- New Hampshire (NH) — 无销售税
- Alaska (AK) — 大部分地区无销售税
HAR 验证:使用 US 地址后 tax=0,最终金额保持 ₱982.14。
重要:taxes 和后续 confirmation_tokens 中的地址必须一致。同一个 session 内选择一个地址后全程复用。
3.5 PaymentIntent 确认 — Stripe 侧扣款
平台的 /confirm 返回 client_secret(格式:pi_xxx_secret_xxx)后,需要调用 Stripe 的 payment_intents/confirm 完成实际扣款:
POST 196K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8Y4m8S2P5h3#2W2L8Y4c8Q4y4h3k6A6L8Y4c8W2L8Y4c8K6i4K6u0r3i4K6N6n7M7r3W2Q4y4h3k6A6k6q4)9%4c8q4)9J5c8X3y4G2L8X3k6A6M7X3@1`.
return_url = d3dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3L8r3q4@1k6X3!0J5L8g2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6U0K9r3g2U0K9$3!0#2N6q4)9J5c8Y4k6W2M7X3W2X3P5g2)9K6c8Y4y4@1M7X3W2H3k6g2)9#2k6Y4y4W2M7%4y4A6L8$3&6Q4y4h3k6A6k6q4)9K6c8r3!0S2K9h3y4K6i4K6g2X3P5s2S2^5
confirmation_token = ctoken_xxx
key = pk_live_xxx
_stripe_version = 2025-03-31.basil
client_secret = pi_xxx_secret_xxx
client_attribution_metadata[client_session_id] = <UUID>
client_attribution_metadata[merchant_integration_source] = l1
注意这里 merchant_integration_source 变成了 l1,不再是 elements。
PI 拒卡重试
HAR 验证了一个重要特性:同一个 PaymentIntent 被拒卡后可以用新的 ConfirmationToken 重试。
流程:
- 第一张卡 →
generic_decline→ PI 状态requires_payment_method - 创建新的
confirmation_token(用另一张卡) - 再次调用
payment_intents/confirm - 第二张卡 →
succeeded
这意味着拒卡不需要重建整个 Checkout Session,只需要换卡重建 token。
3.6 Cookie 隔离架构
协议支付需要严格的 Cookie 隔离。一个常见的 bug 是平台的 Cookie 被发送到 Stripe API,或反过来。
最佳实践:使用两个独立的 HTTP 客户端:
# 平台客户端 — 带 Cookie(session token)
platform_http = HttpClient(cookies_enabled=True)
platform_http.set_cookie("__Secure-platform-auth", session_token)
# Stripe 客户端 — 不带 Cookie
stripe_http = HttpClient(cookies_enabled=False)
绝不让 Stripe 客户端发送平台 Cookie,也不要让平台客户端发送 Stripe 的 Cookie。
四、Hosted 模式协议流程详解
Hosted 模式(cs_live_)的流程更复杂,因为需要模拟 Stripe Payment Page 的完整内部协议。
┌────────────────────────────────────────────────┐
│ 1. 创建 Checkout (hosted) → cs_live_ session │
│ 2. 更新税务 │
│ 3. 分配信用卡 │
│ 4. 初始化 Payment Page → ppage_ token │
│ 5. 获取 Elements Session │
│ 6. 创建 ConfirmationToken │
│ 7. 确认 Checkout (内部 confirm) │
│ 8. 平台 Approve 操作 │
│ 9. 轮询 PaymentIntent 状态 │
│ 10. 处理 3DS Challenge(如果需要) │
│ 11. 验证最终状态 │
└────────────────────────────────────────────────┘
4.1 Payment Page 初始化
GET e8bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0K9r3g2U0K9$3!0#2N6q4)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8g2)9J5c8X3q4H3K9g2)9J5c8Y4m8S2P5h3#2W2L8Y4c8Q4x3X3c8H3j5h3N6W2i4K6u0r3i4K6N6n7j5%4y4Q4y4h3k6D9K9i4k6W2i4K6g2X3P5s2S2^5i4K6N6p5i4K6u0r3K9h3&6A6N6l9`.`.
→ {"ppage_token": "ppage_xxx", "merchant_name": "..."}
这是 Hosted 模式独有的。返回一个 ppage_ token,后续所有 Stripe Payment Page API 都需要这个 token。
4.2 Elements Session(Hosted 模式特殊字段)
Hosted 模式的 elements/sessions 请求比 Custom 模式多出很多字段:
POST b92K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8X3g2D9k6h3#2W2L8Y4c8K6i4K6u0r3M7$3g2K6M7$3W2G2L8Y4x3`.
deferred_intent[mode] = subscription
deferred_intent[amount] = 98214
deferred_intent[currency] = php
deferred_intent[setup_future_usage] = off_session
deferred_intent[payment_method_types][0] = card
deferred_intent[payment_method_configuration][id] = pmc_xxx
currency = php
elements_init_source = custom_checkout
referrer_host = platform.example.com
stripe_js_id = <UUID>
locale = zh
type = deferred_intent
checkout_session_id = cs_live_xxx
_stripe_version = 2025-03-31.basil; checkout_server_update_beta=v1; ...
关键差异:
- 需要传
checkout_session_id(Custom 模式不需要) elements_init_source是custom_checkout(不是elements)- 需要传
referrer_host、stripe_js_id、locale - 使用长版本的
_stripe_version
返回值中有一个重要字段 passive_captcha.rqdata——这是 hCaptcha Enterprise 的 rqdata,后续 confirm 如果触发 challenge 需要用到。
4.3 Hosted 模式的 Confirm 请求
cs_live_ 的 confirm 走的是 payment_pages/confirm,不是 payment_intents/confirm:
POST 63dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8Y4m8S2P5h3#2W2L8Y4c8Q4y4h3k6H3j5h3N6W2M7#2)9J5c8W2)9%4b7X3y4K6i4K6g2X3L8r3W2$3k6g2)9#2k6Y4S2^5P5q4)9%4c8q4)9J5c8X3y4G2L8X3k6A6M7X3@1`.
这个请求的 body 字段比 Custom 模式多很多:
elements_session_client[client_betas][0] = custom_checkout_server_updates_1
elements_session_client[client_betas][1] = custom_checkout_manual_approval_1
elements_session_client[elements_init_source] = custom_checkout
elements_session_client[referrer_host] = platform.example.com
elements_session_client[session_id] = elements_session_xxx
elements_session_client[stripe_js_id] = <UUID>
elements_session_client[locale] = zh
elements_session_client[is_aggregation_expected] = false
elements_options_client[saved_payment_method][enable_save] = auto
elements_options_client[saved_payment_method][enable_redisplay] = auto
init_checksum = <checksum>
js_checksum = <checksum>
rv_timestamp = <timestamp>
expected_amount = 98214
expected_payment_method_type = card
version = <build_hash>
js_checksum 和 rv_timestamp 是从 Stripe.js 部署状态动态提取的,init_checksum 来自 init 响应。
4.4 Approve 机制与 Sentinel Token
Hosted 模式下,payment_pages/confirm 成功后,状态不会直接变成 succeeded,而是进入 requires_approval(HAR 中 confirm 返回 status=open)。这时需要调用平台的 approve 接口:
POST /api/v1/payments/checkout/approve
Authorization: Bearer {access_token}
X-Vendor-Challenge-Token: {sentinel_token}
Sentinel Token 结构
这里的 challenge_token 是一个编码后的 JSON 对象,内部包含多个通过算力证明(PoW)和 Turnstile 验证生成的字段:
{
"p": "gAAAAAB...", // 主负载(加密后的 PoW + session 信息)
"c": "...", // enforcement_token(基于 FNV-1a 的算力证明结果)
"id": "<UUID>", // 一次性标识
"flow": "platform_checkout", // 标记来源流程
"t": "<turnstile_dx_token>" // 4000+ 字符的 Turnstile 验证 token
}
t 字段是核心——如果为空字符串,approve 立即返回 blocked。它来自 Cloudflare Turnstile 的隐式挑战验证(不是肉眼可见的验证码)。
Sentinel Token 生成流程
完整的 Sentinel 生成需要两个组件协作:
1. 生成 requirements_token(本地 seed + difficulty "0")
2. POST sentinel.platform.com/backend-api/sentinel/req
→ 返回 {turnstile: {dx: "..."}, ...}
3. 用 server seed 生成 enforcement_token(FNV-1a 算力证明)
4. 在 Node.js VM 中执行 Turnstile SDK(需要 jsdom 模拟 DOM 环境)
→ 返回 turnstile token(~4388 字符)
5. 组装 JSON → Base64 → 设为请求头
关键点:
- PoW 使用 FNV-1a 哈希算法,difficulty 对 checkout 流程通常是 "0"(即几乎不需要算力)
- Turnstile SDK 必须在真实的 DOM 环境中执行——简单的字符串拼接无法通过验证
- 实际生产中需要 Node.js + jsdom 来执行官方 Turnstile VM
- SDK 代码需要定期更新(随平台版本迭代)
Approve 的正确时机
关键发现:Approve 不应该主动调用。正确的流程是 fall-through:
confirm → status=open, requires_approval
↓
等待 Stripe 回调
↓
平台自动完成 approve(不需要客户端调)
↓
poll payment_page → PI 出现
↓
可能 requires_action → verify_challenge
↓
poll 到 succeeded → 完成
但某些边缘情况下(平台回调未触发),客户端需要手动 approve。此时 Sentinel token 是必须的。
- Approve 是一次性操作,同一 session 不可重复
- 如果
t字段为空或格式错误 → 返回blocked→ session 作废 - 成功的 approve 返回
{"result":"approved"}
五、3DS 验证挑战
当 Stripe 判断交易需要额外验证时,PaymentIntent 会进入 requires_action 状态,附带 3DS challenge 信息。
5.1 Challenge 类型
Stripe 使用 hCaptcha Enterprise 作为 3DS 验证手段:
{
"status": "requires_action",
"next_action": {
"type": "verify_with_challenge",
"verify_with_challenge": {
"url": "40dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0K9r3q4D9L8r3g2F1k6$3g2K6i4K6u0W2M7%4c8J5K9i4m8W2i4K6u0W2j5$3!0E0i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2",
"challenge_type": "hcaptcha"
}
}
}
5.2 verify_challenge 完整请求格式
HAR 中验证的 verify_challenge 完整请求:
POST 300K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8Y4m8S2P5h3#2W2L8Y4c8Q4y4h3k6A6L8Y4c8W2L8Y4c8K6i4K6u0r3i4K6N6n7M7r3W2Q4y4h3k6A6k6q4)9%4c8q4)9J5c8Y4k6W2M7X3W2X3P5g2)9#2k6X3y4Z5j5h3I4D9k6h3&6Y4k6b7`.`.
captcha_response = {hcaptcha_token} # P0开头的长字符串
captcha_vendor_name = hcaptcha # 固定值
captcha_sitekey = xxxxxxxx-xxxx-xxxx # Stripe 的 hCaptcha sitekey
expected_amount = 98214 # 交易金额
expected_currency = php # 币种
key = pk_live_xxx # Publishable Key
_stripe_version = 2025-03-31.basil; checkout_server_update_beta=v1; ...
client_secret = pi_xxx_secret_xxx # PI 的 client_secret
注意:
- 字段名是
captcha_response而不是challenge_response - 需要同时传
captcha_vendor_name和captcha_sitekey - 需要传
expected_amount/expected_currency做一致性校验 - 使用长版本
_stripe_version(与 Hosted 模式一致)
5.3 hCaptcha Enterprise 的特殊性
Stripe 使用的不是普通 hCaptcha,而是 hCaptcha Enterprise。区别:
- 有
rqdata参数:普通 hCaptcha 不需要 rqdata,Enterprise 必须传 - sitekey 是 Stripe 专有的:从
elements/sessions响应的passive_captcha字段中提取 - invisible 模式:浏览器里看不到验证码图片,但后台在做风控评估
# 从 elements_session 响应中提取
rqdata = elements_session["passive_captcha"]["rqdata"]
sitekey = elements_session["passive_captcha"]["sitekey"]
5.4 时效性问题
hCaptcha token 有时效性——浏览器里 ~4 秒解出即有效,但远程打码服务可能需要 30-120 秒。如果超时:
verify_challenge返回payment_intent_authentication_failure- 但 PI 状态仍然是
requires_action——可以重新 verify_challenge
解决方案分级:
- 最优:本地 headless 浏览器解 hCaptcha(<5s,成功率 95%+)
- 次优:支持 Enterprise rqdata 的快速打码 API(如 YesCaptcha,~15s)
- 兜底:如果 PI 已经进入
requires_action,可以循环重试 verify_challenge(每次用新 captcha token)
5.5 3DS vs hCaptcha 的区别
重要澄清:Stripe 的 verify_with_challenge 并非传统银行 3DS 验证(3D Secure)。它是 Stripe 自有的风控挑战,使用 hCaptcha 作为人机验证手段。
真正的银行 3DS(如 Visa Secure)会打开银行页面要求输入 OTP/密码,这个在纯协议支付中无法绕过——但使用虚拟信用卡(VCC)通常不会触发银行 3DS,只会触发 Stripe 自身的 hCaptcha challenge。
六、TLS 指纹对抗
纯 HTTP 请求最大的挑战不是协议逻辑,而是 TLS 指纹。
Stripe 和 Cloudflare 都会检查 TLS Client Hello 的指纹(JA3/JA4),如果发现你的 TLS 握手特征不像浏览器,直接返回 403/429。
6.1 指纹伪装
Python 项目使用 curl_cffi 库,支持模拟多种浏览器的 TLS 指纹:
PROFILES = [
("chrome131", "Mozilla/5.0 ... Chrome/131.0"),
("chrome124", "Mozilla/5.0 ... Chrome/124.0"),
("safari18", "Mozilla/5.0 ... Safari/605.1"),
("firefox117", "Mozilla/5.0 ... Firefox/117.0"),
]
Node.js 项目使用 CycleTLS:
import initCycleTLS from 'cycletls';
const cycleTLS = await initCycleTLS();
const response = await cycleTLS(url, {
ja3: '771,4865-4866-4867...',
userAgent: 'Mozilla/5.0 ...'
});
6.2 指纹轮换策略
当遇到 403/429 时,自动切换到下一个 TLS 指纹配置:
class TLSFingerprintManager:
def rotate_on_failure(self):
self.exclude_current()
return self.pick_next()
最多重试 3 次,每次使用不同的浏览器指纹。
6.3 TLS 指纹与 User-Agent 一致性
一个常见错误是 TLS 指纹和 User-Agent 不匹配。比如用 Chrome 131 的 TLS 指纹配置但 User-Agent 写的是 Chrome 124,Cloudflare 的 bot detection 会立即发现这种不一致。
正确做法:TLS profile 和 UA 必须成对配置:
PROFILES = [
{
"tls": "chrome131",
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/131.0.0.0 Safari/537.36",
"sec_ch_ua": '"Not=A?Brand";v="99", "Chromium";v="131"'
},
{
"tls": "safari18",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) ... Safari/605.1.15",
"sec_ch_ua": None # Safari 不发 sec-ch-ua
}
]
6.4 HTTP 协议版本
另一个细节:Stripe 的 API 请求在浏览器中走 HTTP/2,但 payment_intents/confirm 在某些实现中用 HTTP/1.1 反而更稳定。
HAR 中观察到:
confirmation_tokens:HTTP/2payment_intents/confirm:可以 HTTP/1.1payment_pages/confirm:HTTP/2
使用 curl_cffi 时默认走 HTTP/2(如果服务端支持),CycleTLS 需要在选项中显式指定。
七、Stripe.js 指纹参数
Stripe 的 confirmation_tokens 和 payment_intents/confirm 接口都需要一组指纹参数。这些参数由 Stripe.js 在前端收集,协议支付需要正确模拟:
| 参数 | 含义 | 生成方式 |
|---|---|---|
guid |
全局唯一 ID | UUID + 6位 hex = 42 字符(见 3.1 节) |
muid |
设备 ID | 同 guid 格式,42 字符 |
sid |
会话 ID | 同 guid 格式,42 字符 |
time_on_page |
页面停留时间 | 随机 8-30 秒(模拟真实用户) |
key |
Publishable Key | 从 Checkout Session 获取 |
_stripe_version |
API 版本 | 从 Stripe.js 的 basil 部署中提取 |
7.1 m.stripe.com/6 — 指纹注册
在所有业务请求之前,Stripe.js 会向 m.stripe.com/6 发送 4 次 POST 请求注册设备指纹。HAR 中的完整调用链:
POST 68cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6E0i4K6u0W2M7%4c8J5K9i4m8W2i4K6u0W2j5$3!0E0i4K6u0r36 (×4)
每次请求的 body 是 application/x-www-form-urlencoded,包含:
v2 = 1
id = <stripe_js_id> # 与 confirmation_tokens 中的 guid 不同
tag = <event_tag>
src = js
a = <JSON编码的分析数据>
tag 的值依次为:
stripejs-init-startedstripejs-init-completeadyen-component-loaded(如果有 Adyen)payment-element-mounted
a 字段包含浏览器环境的详细指纹数据(屏幕尺寸、时区、语言、plugin 列表等)。
实际影响:不发这些请求也能支付成功,但长期使用同一个 guid/muid 而从未注册过指纹可能触发 Stripe Radar 的异常检测。建议至少发一次 stripejs-init-complete。
7.2 r.stripe.com/b — 遥测上报
Stripe.js 还会持续向 r.stripe.com/b 发送遥测数据:
POST 9faK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6J5i4K6u0W2M7%4c8J5K9i4m8W2i4K6u0W2j5$3!0E0i4K6u0r3j5R3`.`.
Content-Type: text/plain
{JSON payload}
这个请求的 payload 是 JSON(不是 form-urlencoded),包含支付流程中的各种事件指标(加载时间、渲染时间、错误数)。
{
"type": "measurement",
"event_name": "paymentElement.mount",
"metrics": {"duration": 342},
"client_session_id": "<UUID>",
"publish_key": "pk_live_xxx"
}
实际影响:纯可选。不发不影响支付成功。但如果你的实现在生产中运行,偶尔发几次遥测可以让你的流量看起来更像真实浏览器。
7.3 动态提取 Stripe.js 版本
Stripe.js 的版本(_stripe_version)和构建哈希(rv/sv)不是固定的,需要从 Stripe 的部署状态接口动态提取:
// 获取当前 Stripe.js 部署信息
const status = await fetch('03fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7M7#2)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8W2M7r3I4G2P5g2)9#2k6Y4y4@1j5i4c8#2M7#2)9#2k6X3S2W2L8Y4y4G2L8W2)9J5k6h3A6K6L8$3&6Q4x3U0M7`.);
const { basil } = await status.json();
// basil.rv = "2025-03-31" → _stripe_version = "2025-03-31.basil"
Custom 模式只需要基础版本:2025-03-31.basil
Hosted 模式需要追加 beta flags:
2025-03-31.basil; checkout_server_update_beta=v1; custom_checkout_beta=v1; ...
这些 beta flags 从 deploy_status_henson.json 的 metadata 或 checkout 页面的初始化脚本中提取。Stripe 大约每 1-2 周更新一次部署版本。
7.4 js_checksum 和 rv_timestamp
Hosted 模式的 payment_pages/confirm 需要两个动态校验值:
js_checksum:当前 Stripe.js bundle 的 SHA 校验和,从 deploy status 提取rv_timestamp:当前 Stripe.js 的部署时间戳(Unix 毫秒),也从 deploy status 提取
这两个值随 Stripe.js 版本更新而变化。如果使用过期的 checksum/timestamp,confirm 可能会被静默拒绝。
deploy = requests.get("4fcK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7M7#2)9J5k6i4y4@1M7X3W2H3k6g2)9J5k6h3y4G2L8g2)9J5c8X3c8W2M7r3I4G2P5g2)9#2k6Y4y4@1j5i4c8#2M7#2)9#2k6X3S2W2L8Y4y4G2L8W2)9J5k6h3A6K6L8$3^5`.").json()
js_checksum = deploy["basil"]["js_checksum"]
rv_timestamp = deploy["basil"]["rv_timestamp"]
八、代理池架构
协议支付的另一个核心组件是代理池。Stripe 和平台都会检查请求的源 IP,用数据中心 IP 直连容易被标记。
8.1 双代理池
最佳实践是使用两个独立的代理池:
- 平台代理池:用于访问 SaaS 平台的 API(需要与账号注册地匹配)
- Stripe 代理池:用于访问 Stripe API(需要住宅 IP,避免被 Radar 标记)
8.2 代理健康检查
class ProxyVerifier:
def verify(self, proxy_url: str, expected_country: str):
resp = requests.get("09cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6A6M7r3q4H3K9g2)9J5k6h3y4G2i4K6u0r3K9Y4y4G2L8W2)9J5c8R3`.`.", proxies=proxy_url)
actual = resp.json()["country_code"]
if actual != expected_country:
raise ProxyCountryMismatchError(expected_country, actual)
8.3 出口 IP 一致性
关键规则:创建 Checkout Session 和确认支付时,必须使用同一个出口 IP。Stripe 会比对两次请求的 IP,如果不一致会拒绝交易。
这意味着整个支付流程中的所有请求(从创建 session 到 confirm)必须绑定同一个代理出口。如果代理池做了负载均衡,需要在支付流程中"钉住"一个代理节点。
8.4 代理类型选择
| 代理类型 | 适用场景 | 风险 |
|---|---|---|
| 住宅代理(Residential) | Stripe API / 平台 API | 最安全,IP 信誉高 |
| ISP 代理(Static Residential) | 长期固定使用 | 中等,某些已被标记 |
| 数据中心代理(Datacenter) | 测试/开发 | 高风险,Stripe Radar 会标记 |
| 移动代理(Mobile) | 高价值交易 | 最安全但最贵 |
生产环境建议住宅代理。数据中心 IP 在 Stripe 的风控系统中通常有较高的 risk score。
8.5 代理故障降级
代理不可用时的处理策略:
async def get_proxy_with_fallback(pool, country):
proxy = await pool.acquire(country=country)
if proxy:
return proxy
# 降级:尝试其他国家的代理
proxy = await pool.acquire(country="US")
if proxy:
return proxy
# 最终降级:直连(仅限测试环境)
if config.allow_direct:
return None
raise NoProxyAvailableError()
注意:不要在生产环境用直连降级——数据中心 IP 直连 Stripe 几乎必被拒。
九、状态机设计
完整的协议支付需要处理多种失败场景和重试逻辑。最佳实践是使用状态机:
初始化 → 刷新Session → 检查权限 → 获取定价 → 创建Checkout
↓ ↓ ↓ ↓ ↓
失败→退出 失败→退出 已有订阅→跳过 失败→退出 失败→退出
↓
更新税务 → 创建Token → 确认支付
↓ ↓ ↓
失败→退出 卡被拒→换卡 3DS→验证
↓
成功 / 失败
每个状态转移都需要处理:
- 卡被拒(Card Declined):分类拒绝原因(余额不足/CVV错误/风控拒绝),决定是换卡重试还是放弃
- 3DS 验证:进入 Challenge 流程
- Session 过期:创建新 Session 重试
- Rate Limiting:TLS 指纹轮换 + 退避重试
9.1 Decline 错误分类
Stripe 的卡拒绝错误不是一个笼统的"失败",而是分为可重试和不可重试两类:
可重试(换卡后重试):
generic_decline— 最常见,通常是风控拒绝,换卡几乎总能过insufficient_funds— 余额不足,充值后或换卡可过processing_error— Stripe 侧临时错误,直接重试
不可重试(放弃本次 session):
stolen_card/lost_card— 卡已被标记,不能再用card_not_supported— 卡类型不支持(如某些预付卡)currency_not_supported— 卡不支持目标币种
需要特殊处理:
incorrect_cvc— CVC 错误,检查是否传错了expired_card— 卡过期,需要从卡池中移除card_velocity_exceeded— 卡的交易频率超限,冷却后可重试
9.2 Fast-Path 优化
一个重要的状态机优化:如果 PI 已经 succeeded,跳过 hCaptcha。
HAR 中发现,有时 confirm 返回 requires_action(hCaptcha challenge),但 PI 实际上已经扣款成功。这时如果盲目去解 captcha,反而可能出错。
正确做法是在收到 requires_action 后,先查询 PI 状态:
pi = stripe.get(f"/v1/payment_intents/{pi_id}", key=pk_live)
if pi["status"] == "succeeded":
# 已经扣款成功,跳过 captcha
return PaymentResult.SUCCESS
elif pi["status"] == "requires_action":
# 确实需要解 captcha
solve_hcaptcha(pi)
9.3 Hosted 模式轮询
cs_live_ 模式在 confirm 之后需要轮询 payment_page 状态:
POST 7bdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2K6N6s2u0A6M7r3g2Q4x3X3g2U0L8$3#2Q4x3V1k6$3x3g2)9J5c8Y4m8S2P5h3#2W2L8Y4c8Q4y4h3k6H3j5h3N6W2M7#2)9J5c8W2)9%4b7X3y4K6i4K6g2X3L8r3W2$3k6g2)9#2k6Y4S2^5P5q4)9%4c8q4)9J5c8Y4m8G2L8r3H3`.
→ {"payment_intent": "pi_xxx", "status": "open"} // 等待中
→ {"payment_intent": "pi_xxx", "payment_object_status": "requires_action"} // 需要 captcha
→ {"payment_intent": "pi_xxx", "payment_object_status": "succeeded"} // 完成
轮询间隔建议 2-3 秒,最多轮询 60 秒。如果超时未变化,检查是否需要手动 approve。
十、安全边界与最佳实践
10.1 Session 隔离
每次支付使用独立的 Session,不复用已失败的 Session。特别是 Hosted 模式下,approve 操作后的 Session 不可再次使用。
10.2 Dry-Run 模式
所有实现都应支持 Dry-Run 模式——执行完整流程但不提交最终的 confirm:
if config.commit_payments:
result = stripe.confirm_payment_intent(pi_id, token)
else:
logger.info("Dry-run: skipping confirm")
这对于调试非常重要:你可以验证整个流程(创建 session、生成 token、税务计算)都正确,而不实际扣钱。
10.3 不要存储敏感数据
- 信用卡号仅在内存中使用,支付完成后立即清除
- Session token 不应写入日志(log 中用
***替代) - 代理凭据不要硬编码,用环境变量或加密存储
- Stripe Publishable Key 可以写入代码(它本来就是公开的),但 Secret Key 绝不能出现在客户端
10.4 请求节奏模拟
纯协议支付的一个风险是请求速度太快。浏览器用户从打开支付页面到点击确认,通常需要 15-60 秒。如果你的自动化在 2 秒内完成全部请求,Stripe Radar 可能标记为异常。
建议在关键步骤间加入随机延迟:
await sleep(random.uniform(1.5, 3.0)) # 创建 session 后
await sleep(random.uniform(0.8, 2.0)) # 更新税务后
await sleep(random.uniform(1.0, 2.5)) # 创建 token 后
await sleep(random.uniform(0.5, 1.5)) # confirm 前
总时间控制在 8-30 秒之间比较自然。
10.5 并发控制
批量处理多个账号时,不要让所有请求同时发出:
- Stripe API:有 rate limit,同一 publishable key 下大量并发 confirm 会触发 429
- 平台 API:多个账号同时操作可能触发风控
- 代理池:每个代理 IP 上同时只运行 1-2 个支付流程
建议使用信号量(semaphore)限制并发:
sem = asyncio.Semaphore(3) # 最多同时处理 3 个支付
async with sem:
await process_payment(account)
十一、实现对比
文末提供的 5 套实现覆盖了不同的技术栈和使用场景:
| 实现 | 语言/框架 | 模式 | 特点 |
|---|---|---|---|
| saas-pay-sdk | Python / CLI | Custom | 最精简的 CLI 工具,TLS 指纹轮换,daemon 上报 |
| subscription-manager | Python Flask / Web UI | Both | 完整 Web 管理面板,支持绑卡、查账单、升级降级 |
| payment-gateway | Python Flask / Web UI | Both | 批量支付面板,账号池管理,barrier 模式批量确认 |
| checkout-server | Python / Local HTTP | Both | 本地支付编排服务器,Playwright 降级,SQLite 持久化 |
| protocol-engine | Node.js / Modules | Both | 模块化协议引擎,CycleTLS,动态 Stripe.js 版本抓取 |
每个实现都有自己的优缺点:
- 想要最快上手 →
saas-pay-sdk(纯 CLI,pip install 即可) - 想要可视化管理 →
subscription-manager(Web UI) - 想要批量处理 →
payment-gateway(账号池 + 批量模式) - 想要理解底层 →
protocol-engine(模块化,可以单独引用任何一层)
下载
注意:所有源码中的 API Key、域名、账号信息均已替换为占位符。使用前需要根据你的实际环境配置
.env文件。
如果你对支付安全感兴趣,欢迎加微信群交流。