Widget de pagamento

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.

PCI DSS
O formulário da página de pagamento, com cartão e Apple Pay

setup_intent

A pessoa salva um cartão na conta sem pagar agora, para que o seu cliente possa comprar com ele depois.

  1. Começar a adicionar um cartão

    POST /user/payment-methods/setup-intent responde com os três campos ao lado do client_secret.

    Para o cartão de um grupo, use POST /groups/:groupId/cards/setup e POST /groups/:groupId/cards/confirm da mesma forma. As respostas deles chamam o id de 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. Envie um link para a pessoa

    Coloque os três valores num link para a página de pagamento, com type definido como setup_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();
  3. Salvar o cartão

    POST /user/payment-methods/confirm salva o cartão na conta quando a pessoa termina. Depois, GET /user/payment-methods o 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âmetroObrigatórioO que faz
typeSim

payment_intent ou setup_intent. Deve corresponder ao tipo de segredo que foi selado.

sealed_secretSim

O valor da resposta, exatamente como veio.

key_idSim

O valor da resposta. Indica a chave para a qual o segredo foi selado.

seal_signatureSim

O valor da resposta. A página o verifica e recusa um link em que ele falta ou não confere.

optionsNão

As options da resposta, codificadas em base64url. Por enquanto, contêm uma mensagem que a página mostra acima do formulário, no idioma do seu pedido. Só vêm quando a API tem algo a mostrar.

options_signatureNão

O valor da resposta, sem alterações, junto com options. Se não corresponder, a página ignora as opções e funciona normalmente.

langNã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.

themeNão

dark ou light. Por padrão, o do navegador.

brandNão

none oculta o logotipo da Atli e a etiqueta. Por padrão, eles aparecem.

redirect_success_urlNão

Para onde enviar a pessoa depois. Deve ser https em heyatli.com ou em um dos seus subdomínios.

redirect_cancel_urlNão

Para onde enviar a pessoa depois. Deve ser https em heyatli.com ou em um dos seus subdomínios.

redirect_failure_urlNão

Para onde enviar a pessoa depois. Deve ser https em heyatli.com ou em um dos seus subdomínios.

return_idNão

Sua própria referência, adicionada ao redirecionamento. Letras, números, _ e -, até 128 caracteres.

Como tratar o link

  • Passe os valores sem alterá-los. O URLSearchParams cuida da codificação.

  • Passe também as opções de página. Se a resposta trouxer options e options_signature, adicione os dois: options codificado em base64url e options_signature como 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ó em heyatli.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_secret sem 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>