支付组件

支付组件

当你的应用无法自己显示 Stripe 的表单时,可以把用户带到一个安全的 Atli 页面去付款或添加银行卡。

何时使用

  • AI 助手和 MCP 客户端,它们在聊天中与人对话,自己没有可以放银行卡表单的屏幕。

  • 在流程之外付款,比如 Telegram 或 WhatsApp 里的机器人,或者换到另一台设备上、稍后再完成。

  • 其他无法加载 Stripe SDK 的客户端,比如终端或语音应用。

PCI DSS
付款页面的表单,可选银行卡和 Apple Pay

setup_intent

用户把银行卡保存到账户中,暂不付款,之后你的客户端就可以用它购买。

  1. 开始添加银行卡

    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…"
    }
  2. 给对方发一个链接

    把这三个值放进指向支付页面的链接,并将 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();
  3. 保存银行卡

    用户完成后,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是

payment_intent 或 setup_intent。必须与被封装的密文类型一致。

sealed_secret是

来自响应的值,原样传入。

key_id是

来自响应的值。它指明了密文是为哪把密钥封装的。

seal_signature是

来自响应的值。页面会检查它,并拒绝缺少该值或与之不匹配的链接。

options否

响应中的 options,经 base64url 编码。目前其中有一条消息,页面会以你请求的语言显示在表单上方。只有当 API 有内容要显示时才会返回。

options_signature否

响应中的值,原样传递,与 options 一起使用。如果不匹配,页面会忽略这些选项并照常工作。

lang否

页面语言,为本站 15 种语言之一。未指定时,使用此人在 heyatli.com 上选择的语言,其次是浏览器的语言。

theme否

dark 或 light。默认跟随浏览器。

brand否

none 会隐藏 Atli 的标志和标签。默认显示。

redirect_success_url否

之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 https 地址。

redirect_cancel_url否

之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 https 地址。

redirect_failure_url否

之后把用户送往哪里。必须是 heyatli.com 或其子域名上的 https 地址。

return_id否

你自己的参考编号,会附加到跳转地址上。字母、数字、_ 和 -,最多 128 个字符。

如何对待这个链接

  • 原样传递这些值。 编码由 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>