決済ウィジェット
アプリで Stripe のフォームを表示できないときは、支払いやカード追加のために、安全な Atli のページへ案内できます。
使うのはどんなとき
AI アシスタントと MCP クライアント。チャットで人と話し、カードフォームを置く画面を自分では持っていません。
フローの外での支払い。たとえば Telegram や WhatsApp のボット、別の端末での完了、あとで完了する場合です。
Stripe の SDK を読み込めない、そのほかのクライアント。たとえばターミナルや音声アプリです。

setup_intent
相手は今は支払わずにカードをアカウントへ保存し、あとであなたのクライアントがそのカードで購入できるようにします。
カード追加を始める
POST /user/payment-methods/setup-intentは、client_secretのほかに 3 つのフィールドを返します。グループのカードには、同じ要領で
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…" }相手にリンクを送る
3 つの値を決済ページへのリンクに入れ、
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を変更しないでください。1 つの環境で完結させます。 Sandbox の値は
staging.heyatli.com/payでのみ、Production の値はheyatli.com/payでのみ開けます。パスワードのように扱います。 アカウントの所有者にだけ送り、ログには残さないでください。
注文は一度に 1 件です。 新しい購入を行うと、保留中の以前の注文とそのリンクは取り消されるため、リンクは保存しないでください。
3 つのフィールドがない場合は、 もう一度呼び出してください。平文の
client_secretをリンクに入れてはいけません。リダイレクトは何の証明にもなりません。 信頼できるのは最後のステップの確認だけです。
暗号化と署名のしくみ
リンクを送るだけなら、ここは読まなくて大丈夫です。何が起きているかを見たり、サーバーが返した内容を確かめたりするための説明です。どちらも公開鍵を使い、選んだ環境のアドレスは次のとおりです。
暗号化
sealed_secret は、client_secret をコンパクトな JWE にしたもので、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 の秘密鍵で署名します。署名の対象は、次の 3 行を改行でつないだ UTF-8 のバイト列で、末尾に改行は付きません。
atli-seal-v1
<key_id>
<sealed_secret>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 文字です。署名は環境ごとに別の鍵で行われ、鍵のローテーション中はリストに 2 つ入ることがあります。
決済ページは、何かを開く前にこれを確認し、ない場合や一致しない場合のリンクは受け付けません。サーバーが返した内容を自分で確かめるには、そのメッセージを組み立て直し、署名鍵で署名を検証します。
// 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は options をJSONテキストで返します。300文字までのプレーンテキスト message と、その言語を示す lang が入っています。今後キーが増えることがあり、ページは知らないキーを無視します。メッセージはAPIのどこでも同じく、リクエストの言語に従います:X-Locale、Accept-Language、?lang=。リンクではbase64urlにするので、エスケープは不要で、どの言語でも短く収まります。オプションは補助的なものです。ない場合や検証に通らない場合も、ページはオプションなしで支払いを受け付けます。
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature は同じ鍵で次の4行に対して作られます。<options> はAPIが返したとおりのJSONです。ページはリンクの値をデコードしてそれを検証します。封印したシークレットも含まれるため、別のリンクに移したオプションは通りません:
atli-options-v1
<key_id>
<sealed_secret>
<options>