支付组件
当你的应用无法自己显示 Stripe 的表单时,可以把用户带到一个安全的 Atli 页面去付款或添加银行卡。
何时使用
AI 助手和 MCP 客户端,它们在聊天中与人对话,自己没有可以放银行卡表单的屏幕。
在流程之外付款,比如 Telegram 或 WhatsApp 里的机器人,或者换到另一台设备上、稍后再完成。
其他无法加载 Stripe SDK 的客户端,比如终端或语音应用。

setup_intent
用户把银行卡保存到账户中,暂不付款,之后你的客户端就可以用它购买。
开始添加银行卡
POST /user/payment-methods/setup-intent会返回client_secret之外的三个字段。对于群组的银行卡,以同样的方式使用
POST /groups/:groupId/cards/setup和POST /groups/:groupId/cards/confirm。它们的响应中把这个 ID 叫作setup_intent_id。curl -X POST https://api.heyatli.com/user/payment-methods/setup-intent \ -H 'Authorization: Bearer YOUR_TOKEN' { "id": "seti_…", "client_secret": "seti_…_secret_…", "sealed_secret": "eyJhbGciOiJFQ0RILUVTIi…", "key_id": "QvWE2gHWYExKj2Yc…", "seal_signature": "c2lnbmF0dXJl…", "options": "{\"lang\":\"en\",\"message\":\"Plan for Australia\"}", "options_signature": "b3B0aW9ucw…" }给对方发一个链接
把这三个值放进指向支付页面的链接,并将
type设为setup_intent。用户只会看到 Stripe 的表单,没有金额。const link = new URL('https://heyatli.com/pay'); link.search = new URLSearchParams({ type: 'setup_intent', sealed_secret: response.sealed_secret, key_id: response.key_id, seal_signature: response.seal_signature, return_id: response.id, // Only when the response has them, and always together. options goes in // base64url-encoded (Buffer in Node; TextEncoder and btoa in a browser). ...(response.options && { options: Buffer.from(response.options).toString('base64url'), options_signature: response.options_signature, }), }).toString();保存银行卡
用户完成后,
POST /user/payment-methods/confirm会把银行卡保存到账户中。之后GET /user/payment-methods会列出它。curl https://api.heyatli.com/user/payment-methods/confirm \ -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "setup_intent_id": "seti_…" }'
链接里放什么
| 参数 | 是否必填 | 作用 |
|---|---|---|
| type | 是 |
|
| sealed_secret | 是 | 来自响应的值,原样传入。 |
| key_id | 是 | 来自响应的值。它指明了密文是为哪把密钥封装的。 |
| seal_signature | 是 | 来自响应的值。页面会检查它,并拒绝缺少该值或与之不匹配的链接。 |
| options | 否 | 响应中的 |
| options_signature | 否 | 响应中的值,原样传递,与 |
| lang | 否 | 页面语言,为本站 15 种语言之一。未指定时,使用此人在 heyatli.com 上选择的语言,其次是浏览器的语言。 |
| theme | 否 |
|
| brand | 否 |
|
| redirect_success_url | 否 | 之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 |
| redirect_cancel_url | 否 | 之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 |
| redirect_failure_url | 否 | 之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 |
| return_id | 否 | 你自己的参考编号,会附加到跳转地址上。字母、数字、 |
如何对待这个链接
原样传递这些值。 编码由
URLSearchParams处理。同时传递页面选项。 响应中有
options和options_signature时,两者都要加入:options用 base64url 编码,options_signature原样传递。编码前不要修改 JSON。留在同一个环境中。 Sandbox 的值只能在
staging.heyatli.com/pay打开,Production 的值只能在heyatli.com/pay打开。把它当作密码。 只发给账户所有者,不要写进日志。
一次一个订单。 新的购买会取消之前待处理的订单及其链接,所以不要保存链接。
如果缺少这三个字段, 请重新调用。绝不要把未加密的
client_secret放进链接。重定向不能证明任何事。 只相信最后一步的检查。
加密和签名如何工作
发送链接不需要用到这些内容。放在这里,是为了让你看清发生了什么,并核对服务器返回的内容。两者都使用公钥,你所选环境的地址如下:
加密
sealed_secret 是以紧凑 JWE 形式表示的 client_secret,这种格式本身就能安全放进 URL。它用本站的 X25519 公钥加密,所以只有本站的服务器能读取。key_id 是该密钥的 id,与 JWE 的 kid 一致。
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
API 会替你完成这一步,使用列表中的第一把密钥。用代码表示就是:
import { CompactEncrypt, importJWK } from 'jose';
const { keys: [key] } = await (await fetch('https://heyatli.com/api/keys')).json();
const publicKey = await importJWK(
{ kty: 'OKP', crv: 'X25519', x: key.public_key },
'ECDH-ES'
);
const sealedSecret = await new CompactEncrypt(new TextEncoder().encode(clientSecret))
.setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM', kid: key.id })
.encrypt(publicKey);签名
由于任何人都能加密,封装后的值并不能说明是谁生成的。因此 API 会用只有它持有的 Ed25519 私钥为其签名。被签名的消息是下面三行的 UTF-8 字节,用换行符连接,末尾没有换行符:
atli-seal-v1
<key_id>
<sealed_secret>API 这样生成签名,所用的私钥绝不会离开 API:
// What the API does. Only it holds the private key.
const message = new TextEncoder().encode(
`atli-seal-v1\n${keyId}\n${sealedSecret}`
);
const signature = await crypto.subtle.sign('Ed25519', privateKey, message);
const sealSignature = Buffer.from(signature).toString('base64url');seal_signature 是 64 字节的原始签名,用不带填充的 base64url 表示,共 86 个字符。每个环境用各自的密钥签名,轮换期间列表中可能有两把。
支付页面在打开任何内容之前会先检查这一点,并拒绝缺少签名或签名不匹配的链接。若想自己核对服务器返回的内容,请重新构造该消息,并用签名密钥验证签名:
// In Node. sealedSecret, keyId and sealSignature come from the API response.
const { keys } = await (await fetch('https://api.heyatli.com/keys')).json();
const base64url = (value) => Uint8Array.from(Buffer.from(value, 'base64url'));
const message = new TextEncoder().encode(
`atli-seal-v1\n${keyId}\n${sealedSecret}`
);
// The list can hold two keys during a rotation: one match is enough.
const checks = keys.map(async (key) =>
crypto.subtle.verify(
'Ed25519',
await crypto.subtle.importKey('raw', base64url(key.public_key), 'Ed25519', false, ['verify']),
base64url(sealSignature),
message
)
);
const valid = (await Promise.all(checks)).some(Boolean);修改 sealed_secret 或 key_id 会使签名失效。签名不涵盖 type、跳转地址或其他参数,也不能证明打开链接的人拥有该账户。只要 Stripe 接受该密文,已签名的链接就一直有效。
页面选项
API 以 JSON 文本返回 options。其中包含 message(最多 300 个字符的纯文本)和 lang(消息所用的语言)。以后可能增加其他键,页面会忽略不认识的键。消息语言与 API 其他部分一样,跟随你的请求:X-Locale、Accept-Language 或 ?lang=。在链接中它以 base64url 形式出现,因此无需转义,任何语言下都很简短。选项是附加内容:缺失或校验不通过时,页面会照常收款。
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature 使用同一密钥对以下四行签名,其中 <options> 是 API 返回的原始 JSON。页面会解码链接中的值并据此校验。由于其中包含加密密文,移到其他链接的选项无法通过校验:
atli-options-v1
<key_id>
<sealed_secret>
<options>