Widget de pagamento
Envie alguém a uma página segura da Atli para pagar ou adicionar um cartão, quando o seu app não puder mostrar o formulário da Stripe por conta própria.
Quando usar
Assistentes de IA e clientes MCP, que conversam com as pessoas num chat e não têm uma tela própria para colocar um formulário de cartão.
Pagar fora do fluxo, como um bot no Telegram ou no WhatsApp, ou concluir em outro dispositivo ou mais tarde.
Qualquer outro cliente que não consiga carregar o SDK da Stripe, como um terminal ou um app de voz.

setup_intent
A pessoa salva um cartão na conta sem pagar agora, para que o seu cliente possa comprar com ele depois.
Começar a adicionar um cartão
POST /user/payment-methods/setup-intentresponde com os três campos ao lado doclient_secret.Para o cartão de um grupo, use
POST /groups/:groupId/cards/setupePOST /groups/:groupId/cards/confirmda mesma forma. As respostas deles chamam o id desetup_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…" }Envie um link para a pessoa
Coloque os três valores num link para a página de pagamento, com
typedefinido comosetup_intent. A pessoa vê apenas o formulário da Stripe, sem valor.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();Salvar o cartão
POST /user/payment-methods/confirmsalva o cartão na conta quando a pessoa termina. Depois,GET /user/payment-methodso lista.curl https://api.heyatli.com/user/payment-methods/confirm \ -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "setup_intent_id": "seti_…" }'
O que vai no link
| Parâmetro | Obrigatório | O que faz |
|---|---|---|
| type | Sim |
|
| sealed_secret | Sim | O valor da resposta, exatamente como veio. |
| key_id | Sim | O valor da resposta. Indica a chave para a qual o segredo foi selado. |
| seal_signature | Sim | O valor da resposta. A página o verifica e recusa um link em que ele falta ou não confere. |
| options | Não | As |
| options_signature | Não | O valor da resposta, sem alterações, junto com |
| lang | Não | O idioma da página, um dos 15 deste site. Sem ele, o idioma que a pessoa escolheu no heyatli.com e depois o do navegador. |
| theme | Não |
|
| brand | Não |
|
| redirect_success_url | Não | Para onde enviar a pessoa depois. Deve ser |
| redirect_cancel_url | Não | Para onde enviar a pessoa depois. Deve ser |
| redirect_failure_url | Não | Para onde enviar a pessoa depois. Deve ser |
| return_id | Não | Sua própria referência, adicionada ao redirecionamento. Letras, números, |
Como tratar o link
Passe os valores sem alterá-los. O
URLSearchParamscuida da codificação.Passe também as opções de página. Se a resposta trouxer
optionseoptions_signature, adicione os dois:optionscodificado em base64url eoptions_signaturecomo veio. Não altere o JSON antes de codificá-lo.Fique em um só ambiente. Valores de Sandbox só abrem em
staging.heyatli.com/pay, e os de Production só emheyatli.com/pay.Trate como uma senha. Envie apenas ao dono da conta e mantenha fora dos logs.
Um pedido por vez. Uma nova compra cancela o pedido pendente anterior e o link dele, então não guarde links.
Se os três campos estiverem ausentes, faça a chamada de novo. Nunca coloque um
client_secretsem criptografia em um link.Um redirecionamento não prova nada. Confie apenas na verificação da última etapa.
Como funcionam a criptografia e a assinatura
Você não precisa de nada disto para enviar um link. Está aqui para que você veja o que acontece e confira o que o servidor devolve. Ambos usam chaves públicas, nestes endereços para o ambiente que você escolheu:
- Página de pagamento
- https://heyatli.com/pay
- Chaves de criptografia
- https://heyatli.com/api/keys
- Chaves de assinatura
- https://api.heyatli.com/keys
Criptografia
sealed_secret é o client_secret como um JWE compacto, um formato que já é seguro em uma URL. Ele é criptografado com a chave pública X25519 do site, então só o servidor do site consegue lê-lo. key_id é o id dessa chave e corresponde ao kid do JWE.
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
A API faz isso por você, usando a primeira chave da lista. Em código, fica assim:
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);Assinatura
Como qualquer pessoa pode criptografar, um valor selado não mostra quem o criou. Por isso a API o assina com uma chave privada Ed25519 que só ela possui. A mensagem assinada são os bytes UTF-8 destas três linhas, unidas por uma quebra de linha, sem nenhuma no final:
atli-seal-v1
<key_id>
<sealed_secret>É assim que a API forma a assinatura, com uma chave privada que nunca sai dela:
// 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 é a assinatura bruta de 64 bytes em base64url sem preenchimento, ou seja, 86 caracteres. Cada ambiente assina com a sua própria chave, e a lista pode ter duas durante uma rotação.
A página de pagamento verifica isso antes de abrir qualquer coisa e recusa um link em que falta ou não confere. Para conferir você mesmo o que o servidor devolveu, reconstrua essa mensagem e verifique a assinatura com as chaves de assinatura:
// 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);Alterar sealed_secret ou key_id quebra a assinatura. Ela não cobre type, os redirecionamentos nem os outros parâmetros, e não mostra que a pessoa que abre o link é dona da conta. Um link assinado continua funcionando enquanto a Stripe aceitar o segredo.
Opções de página
A API devolve options como texto JSON. Ele contém message, texto simples de até 300 caracteres, e lang, o idioma da mensagem. Outras chaves podem vir, e a página ignora as que não conhece. A mensagem segue o idioma do seu pedido: X-Locale, Accept-Language ou ?lang=, como em toda a API. No link vai em base64url, então não precisa de escape e continua curto em qualquer idioma. As opções são um extra: se faltarem ou não passarem na verificação, a página recebe o pagamento sem elas.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature é feita com a mesma chave sobre estas quatro linhas, em que <options> é o JSON exatamente como a API o devolveu. A página decodifica o valor do link e verifica isso. Como o selo faz parte dela, opções movidas para outro link não passam:
atli-options-v1
<key_id>
<sealed_secret>
<options>