Widget de pago
Envía a alguien a una página segura de Atli para pagar o añadir una tarjeta, cuando tu app no pueda mostrar el formulario de Stripe por sí misma.
Cuándo usarlo
Asistentes de IA y clientes MCP, que hablan con las personas en un chat y no tienen pantalla propia donde poner un formulario de tarjeta.
Pagar fuera del flujo, como un bot en Telegram o WhatsApp, o terminar en otro dispositivo o más tarde.
Cualquier otro cliente que no pueda cargar el SDK de Stripe, como una terminal o una app de voz.

setup_intent
La persona guarda una tarjeta en su cuenta sin pagar ahora, para que tu cliente pueda comprar con ella más tarde.
Empezar a añadir una tarjeta
POST /user/payment-methods/setup-intentresponde con los tres campos junto alclient_secret.Para la tarjeta de un grupo, usa
POST /groups/:groupId/cards/setupyPOST /groups/:groupId/cards/confirmdel mismo modo. Sus respuestas llaman al idsetup_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…" }Envía a la persona un enlace
Pon los tres valores en un enlace a la página de pago, con
typeigual asetup_intent. La persona ve solo el formulario de Stripe, sin importe.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();Guardar la tarjeta
POST /user/payment-methods/confirmguarda la tarjeta en la cuenta cuando la persona ha terminado. Después,GET /user/payment-methodsla 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_…" }'
Qué va en el enlace
| Parámetro | Obligatorio | Qué hace |
|---|---|---|
| type | Sí |
|
| sealed_secret | Sí | El valor de la respuesta, tal como llegó. |
| key_id | Sí | El valor de la respuesta. Indica la clave para la que se selló el secreto. |
| seal_signature | Sí | El valor de la respuesta. La página lo comprueba y rechaza un enlace en el que falta o no coincide. |
| options | No | Las |
| options_signature | No | El valor de la respuesta, sin cambios, junto con |
| lang | No | El idioma de la página, uno de los 15 de este sitio. Sin él, el idioma que la persona eligió en heyatli.com y luego el del navegador. |
| theme | No |
|
| brand | No |
|
| redirect_success_url | No | Adónde enviar a la persona después. Debe ser |
| redirect_cancel_url | No | Adónde enviar a la persona después. Debe ser |
| redirect_failure_url | No | Adónde enviar a la persona después. Debe ser |
| return_id | No | Tu propia referencia, añadida a la redirección. Letras, números, |
Cómo tratar el enlace
Pasa los valores sin cambios.
URLSearchParamsse encarga de la codificación.Pasa también las opciones de página. Si la respuesta trae
optionsyoptions_signature, añade los dos:optionscodificado en base64url yoptions_signaturetal como llegó. No cambies el JSON antes de codificarlo.Quédate en un solo entorno. Los valores de Sandbox solo se abren en
staging.heyatli.com/pay, y los de Production solo enheyatli.com/pay.Trátalo como una contraseña. Envíalo solo al titular de la cuenta y mantenlo fuera de los registros.
Un pedido cada vez. Una compra nueva cancela el pedido pendiente anterior y su enlace, así que no guardes enlaces.
Si faltan los tres campos, vuelve a hacer la llamada. Nunca pongas un
client_secretsin cifrar en un enlace.Una redirección no demuestra nada. Confía solo en la comprobación del último paso.
Cómo funcionan el cifrado y la firma
No necesitas nada de esto para enviar un enlace. Está aquí para que veas lo que ocurre y compruebes lo que devuelve el servidor. Ambos usan claves públicas, en estas direcciones para el entorno que has elegido:
- Página de pago
- https://heyatli.com/pay
- Claves de cifrado
- https://heyatli.com/api/keys
- Claves de firma
- https://api.heyatli.com/keys
Cifrado
sealed_secret es el client_secret como un JWE compacto, un formato que ya es seguro en una URL. Está cifrado con la clave pública X25519 del sitio, así que solo el servidor del sitio puede leerlo. key_id es el id de esa clave y coincide con el kid del JWE.
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
La API lo hace por ti, con la primera clave de la lista. En código, queda así:
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);Firma
Como cualquiera puede cifrar, un valor sellado no muestra quién lo creó. Por eso la API lo firma con una clave privada Ed25519 que solo ella tiene. El mensaje firmado son los bytes UTF-8 de estas tres líneas, unidas por un salto de línea, sin ninguno al final:
atli-seal-v1
<key_id>
<sealed_secret>Así forma la API la firma, con una clave privada que nunca sale de ella:
// 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 es la firma en bruto de 64 bytes en base64url sin relleno, es decir, 86 caracteres. Cada entorno firma con su propia clave, y la lista puede tener dos durante una rotación.
La página de pago lo comprueba antes de abrir nada y rechaza un enlace en el que falta o no coincide. Para comprobar tú mismo lo que devolvió el servidor, reconstruye ese mensaje y verifica la firma con las claves de firma:
// 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);Cambiar sealed_secret o key_id rompe la firma. No cubre type, las redirecciones ni los demás parámetros, y no demuestra que quien abre el enlace sea el titular de la cuenta. Un enlace firmado sigue funcionando mientras Stripe acepte el secreto.
Opciones de página
La API devuelve options como texto JSON. Contiene message, texto plano de hasta 300 caracteres, y lang, el idioma del mensaje. Pueden llegar más claves, y la página ignora las que no conoce. El mensaje sigue el idioma de tu solicitud: X-Locale, Accept-Language o ?lang=, como en toda la API. En el enlace va en base64url, así que no necesita escaparse y sigue siendo corto en cualquier idioma. Las opciones son un extra: si faltan o no superan la comprobación, la página cobra sin ellas.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature se firma con la misma clave sobre estas cuatro líneas, donde <options> es el JSON exactamente como lo devolvió la API. La página decodifica el valor del enlace y lo comprueba. Como el sello forma parte de ella, unas opciones movidas a otro enlace no pasan:
atli-options-v1
<key_id>
<sealed_secret>
<options>