Widget de pago

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.

PCI DSS
El formulario de la página de pago, con tarjeta y Apple Pay

setup_intent

La persona guarda una tarjeta en su cuenta sin pagar ahora, para que tu cliente pueda comprar con ella más tarde.

  1. Empezar a añadir una tarjeta

    POST /user/payment-methods/setup-intent responde con los tres campos junto al client_secret.

    Para la tarjeta de un grupo, usa POST /groups/:groupId/cards/setup y POST /groups/:groupId/cards/confirm del mismo modo. Sus respuestas llaman al id 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. Envía a la persona un enlace

    Pon los tres valores en un enlace a la página de pago, con type igual a setup_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();
  3. Guardar la tarjeta

    POST /user/payment-methods/confirm guarda la tarjeta en la cuenta cuando la persona ha terminado. Después, GET /user/payment-methods la 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ámetroObligatorioQué hace
typeSí

payment_intent o setup_intent. Debe coincidir con el tipo de secreto que se selló.

sealed_secretSí

El valor de la respuesta, tal como llegó.

key_idSí

El valor de la respuesta. Indica la clave para la que se selló el secreto.

seal_signatureSí

El valor de la respuesta. La página lo comprueba y rechaza un enlace en el que falta o no coincide.

optionsNo

Las options de la respuesta, codificadas en base64url. Por ahora contienen un mensaje que la página muestra sobre el formulario, en el idioma de tu solicitud. Solo vienen cuando la API tiene algo que mostrar.

options_signatureNo

El valor de la respuesta, sin cambios, junto con options. Si no coincide, la página omite las opciones y funciona con normalidad.

langNo

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.

themeNo

dark o light. Por defecto, el del navegador.

brandNo

none oculta el logotipo de Atli y la etiqueta. Por defecto, se muestran.

redirect_success_urlNo

Adónde enviar a la persona después. Debe ser https en heyatli.com o en uno de sus subdominios.

redirect_cancel_urlNo

Adónde enviar a la persona después. Debe ser https en heyatli.com o en uno de sus subdominios.

redirect_failure_urlNo

Adónde enviar a la persona después. Debe ser https en heyatli.com o en uno de sus subdominios.

return_idNo

Tu propia referencia, añadida a la redirección. Letras, números, _ y -, hasta 128 caracteres.

Cómo tratar el enlace

  • Pasa los valores sin cambios. URLSearchParams se encarga de la codificación.

  • Pasa también las opciones de página. Si la respuesta trae options y options_signature, añade los dos: options codificado en base64url y options_signature tal 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 en heyatli.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_secret sin 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:

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>