Виджет оплаты

Виджет оплаты

Отправьте человека на защищённую страницу Atli, чтобы оплатить или добавить карту, когда ваше приложение не может само показать форму Stripe.

Когда это нужно

  • ИИ-ассистенты и клиенты MCP, которые общаются с людьми в чате и не имеют своего экрана для формы карты.

  • Оплата вне основного сценария, например через бота в Telegram или WhatsApp, либо на другом устройстве или позже.

  • Любой другой клиент, который не может загрузить SDK Stripe, например терминал или голосовое приложение.

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 так же. В их ответах идентификатор называется 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Нет

Куда отправить человека потом. Это должен быть https на heyatli.com или на одном из его поддоменов.

redirect_cancel_urlНет

Куда отправить человека потом. Это должен быть https на heyatli.com или на одном из его поддоменов.

redirect_failure_urlНет

Куда отправить человека потом. Это должен быть https на heyatli.com или на одном из его поддоменов.

return_idНет

Ваш собственный идентификатор, добавляемый к перенаправлению. Буквы, цифры, _ и -, до 128 символов.

Как обращаться со ссылкой

  • Передавайте значения без изменений. Кодированием занимается 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>