Payment Widget

Payment Widget

Send someone to a secure Atli page to pay or add a card, when your app can’t show Stripe’s form itself.

When to use it

  • AI assistants and MCP clients, which talk to people in a chat and have no screen of their own to put a card form on.

  • Paying outside the flow, such as a bot in Telegram or WhatsApp, or finishing on another device or later on.

  • Any other client that can’t load Stripe’s SDK, such as a terminal or a voice app.

PCI DSS
The payment page’s form, with card and Apple Pay

setup_intent

The person saves a card to their account without paying now, so your client can buy with it later.

  1. Start adding a card

    POST /user/payment-methods/setup-intent answers with the three fields beside the client_secret.

    For a group’s card, use POST /groups/:groupId/cards/setup and POST /groups/:groupId/cards/confirm in the same way. Their answers call the 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. Send the person a link

    Put the three values into a link to the payment page, with type set to setup_intent. The person sees only Stripe’s form, with no amount.

    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. Save the card

    POST /user/payment-methods/confirm saves the card to the account once the person has finished. Then GET /user/payment-methods lists it.

    curl https://api.heyatli.com/user/payment-methods/confirm \
      -H 'Authorization: Bearer YOUR_TOKEN' \
      -H 'Content-Type: application/json' \
      -d '{ "setup_intent_id": "seti_…" }'

What goes in the link

ParameterRequiredWhat it does
typeYes

payment_intent or setup_intent. It must match the kind of secret that was sealed.

sealed_secretYes

The value from the response, exactly as it came.

key_idYes

The value from the response. It names the key the secret was sealed to.

seal_signatureYes

The value from the response. The page checks it, and refuses a link where it’s missing or doesn’t match.

optionsNo

The options from the response, base64url-encoded. For now they hold a message the page shows above the form, in the language your request asked for. They’re only there when the API has something to show.

options_signatureNo

The value from the response, unchanged, with options. If it doesn’t match, the page leaves the options out and works as usual.

langNo

The page’s language, one of the 15 this site uses. Without it, the language the person chose on heyatli.com, then the browser’s.

themeNo

dark or light. By default, the browser’s.

brandNo

none hides the Atli logo and pill. By default, they show.

redirect_success_urlNo

Where to send the person afterwards. It must be https on heyatli.com or one of its subdomains.

redirect_cancel_urlNo

Where to send the person afterwards. It must be https on heyatli.com or one of its subdomains.

redirect_failure_urlNo

Where to send the person afterwards. It must be https on heyatli.com or one of its subdomains.

return_idNo

Your own reference, added to the redirect. Letters, numbers, _ and -, up to 128 characters.

How to treat the link

  • Pass the values unchanged. URLSearchParams handles the encoding.

  • Pass the page options on too. When the response has options and options_signature, add both: options base64url-encoded, options_signature as it came. Don’t change the JSON before encoding it.

  • Stay in one environment. Sandbox values open only on staging.heyatli.com/pay, and Production values only on heyatli.com/pay.

  • Treat it like a password. Send it only to the account’s owner, and keep it out of logs.

  • One order at a time. A new purchase cancels the earlier pending order and its link, so don’t store links.

  • If the three fields are missing, make the call again. Never put a plain client_secret in a link.

  • A redirect proves nothing. Trust only the check in the last step.

How the encryption and signature work

You don’t need any of this to send a link. It’s here so you can see what happens and check what the server returns. Both use public keys, on these addresses for the environment you’ve chosen:

Encryption

sealed_secret is the client_secret as a compact JWE, a format that’s already safe in a URL. It’s encrypted to the site’s X25519 public key, so only the site’s server can read it. key_id is that key’s id, and matches the JWE’s kid.

alg
ECDH-ES
enc
A256GCM
kid
key_id
Key
JWK: kty OKP, crv X25519, x = public_key

The API does this for you, using the first key from the list. In code, it comes to this:

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);

Signature

Because anyone can encrypt, a sealed value doesn’t show who made it. So the API signs it with an Ed25519 private key that only it holds. The signed message is the UTF-8 bytes of these three lines, joined by a newline, with none at the end:

atli-seal-v1
<key_id>
<sealed_secret>

The API forms the signature like this, with a private key that never leaves it:

// 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 is the raw 64-byte signature as base64url with no padding, which is 86 characters. Each environment signs with its own key, and the list can hold two during a rotation.

The payment page checks this before it opens anything, and refuses a link where it’s missing or doesn’t match. To check what the server returned yourself, rebuild that message and verify the signature against the signing keys:

// 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);

Changing sealed_secret or key_id breaks the signature. It doesn’t cover type, redirects or the other parameters, and it doesn’t show that the person opening the link owns the account. A signed link keeps working for as long as Stripe accepts the secret.

Page options

The API returns options as JSON text. It holds message, plain text of up to 300 characters, and lang, the language the message is in. More keys may come, and the page ignores those it doesn’t know. The message follows the language of your request: X-Locale, Accept-Language or ?lang=, as everywhere in the API. In the link it’s base64url, so it needs no escaping there and stays short in any language. Options are extras: if they’re missing or don’t check out, the page takes the payment without them.

// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}

options_signature is made with the same key, over these four lines, where <options> is the JSON exactly as the API returned it. The page decodes the link’s value and checks that. Because the seal is part of it, options moved to another link don’t pass:

atli-options-v1
<key_id>
<sealed_secret>
<options>