決済ウィジェット

決済ウィジェット

アプリで Stripe のフォームを表示できないときは、支払いやカード追加のために、安全な Atli のページへ案内できます。

使うのはどんなとき

  • AI アシスタントと MCP クライアント。チャットで人と話し、カードフォームを置く画面を自分では持っていません。

  • フローの外での支払い。たとえば Telegram や WhatsApp のボット、別の端末での完了、あとで完了する場合です。

  • Stripe の SDK を読み込めない、そのほかのクライアント。たとえばターミナルや音声アプリです。

PCI DSS
カードと Apple Pay を選べる決済ページのフォーム

setup_intent

相手は今は支払わずにカードをアカウントへ保存し、あとであなたのクライアントがそのカードで購入できるようにします。

  1. カード追加を始める

    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…"
    }
  2. 相手にリンクを送る

    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();
  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を変更しないでください。

  • 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>