Виджет оплаты
Отправьте человека на защищённую страницу Atli, чтобы оплатить или добавить карту, когда ваше приложение не может само показать форму Stripe.
Когда это нужно
ИИ-ассистенты и клиенты MCP, которые общаются с людьми в чате и не имеют своего экрана для формы карты.
Оплата вне основного сценария, например через бота в Telegram или WhatsApp, либо на другом устройстве или позже.
Любой другой клиент, который не может загрузить SDK Stripe, например терминал или голосовое приложение.

setup_intent
Человек сохраняет карту в своём аккаунте, не платя сейчас, чтобы ваш клиент мог позже покупать с её помощью.
Начните добавление карты
POST /user/payment-methods/setup-intentотвечает тремя полями рядом сclient_secret.Для карты группы используйте
POST /groups/:groupId/cards/setupиPOST /groups/:groupId/cards/confirmтак же. В их ответах идентификатор называется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 | Нет | Куда отправить человека потом. Это должен быть |
| redirect_cancel_url | Нет | Куда отправить человека потом. Это должен быть |
| redirect_failure_url | Нет | Куда отправить человека потом. Это должен быть |
| return_id | Нет | Ваш собственный идентификатор, добавляемый к перенаправлению. Буквы, цифры, |
Как обращаться со ссылкой
Передавайте значения без изменений. Кодированием занимается
URLSearchParams.Передавайте и параметры страницы. Если в ответе есть
optionsиoptions_signature, добавляйте оба:optionsв кодировке base64url,options_signatureбез изменений. Не меняйте JSON перед кодированием.Оставайтесь в одной среде. Значения из Sandbox открываются только на
staging.heyatli.com/pay, а из Production только наheyatli.com/pay.Обращайтесь с ней как с паролем. Отправляйте её только владельцу аккаунта и не пишите в логи.
Один заказ за раз. Новая покупка отменяет предыдущий неоплаченный заказ и его ссылку, поэтому не храните ссылки.
Если трёх полей нет, повторите вызов. Никогда не помещайте в ссылку открытый
client_secret.Перенаправление ничего не доказывает. Доверяйте только проверке на последнем шаге.
Как работают шифрование и подпись
Чтобы отправить ссылку, ничего из этого не нужно. Это здесь для того, чтобы вы могли увидеть, что происходит, и проверить, что возвращает сервер. В обоих случаях используются открытые ключи, по этим адресам для выбранной среды:
- Страница оплаты
- https://heyatli.com/pay
- Ключи шифрования
- https://heyatli.com/api/keys
- Ключи подписи
- https://api.heyatli.com/keys
Шифрование
sealed_secret — это client_secret в виде компактного JWE, формата, уже безопасного для URL. Он зашифрован открытым ключом X25519 сайта, поэтому прочитать его может только сервер сайта. key_id — это id этого ключа, он совпадает с kid в JWE.
- 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 формирует подпись так, закрытым ключом, который никогда его не покидает:
// 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 возвращает options как текст JSON. В нём message, простой текст до 300 символов, и lang, язык сообщения. Позже могут появиться другие ключи, а незнакомые страница пропускает. Сообщение приходит на языке вашего запроса: X-Locale, Accept-Language или ?lang=, как везде в API. В ссылке оно передаётся в base64url, поэтому его не нужно экранировать, и ссылка остаётся короткой на любом языке. Параметры необязательны: если их нет или они не прошли проверку, страница принимает оплату без них.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature создаётся тем же ключом над этими четырьмя строками, где <options> — JSON ровно в том виде, в каком его вернул API. Страница декодирует значение из ссылки и проверяет его. Запечатанный секрет входит в подпись, поэтому параметры, перенесённые в другую ссылку, не пройдут проверку:
atli-options-v1
<key_id>
<sealed_secret>
<options>