Widget de paiement
Envoyez quelqu’un sur une page Atli sécurisée pour payer ou ajouter une carte, quand votre appli ne peut pas afficher elle-même le formulaire de Stripe.
Quand l’utiliser
Les assistants IA et les clients MCP, qui parlent aux gens dans un chat et n’ont pas d’écran à eux pour afficher un formulaire de carte.
Payer en dehors du parcours, par exemple avec un bot sur Telegram ou WhatsApp, ou en terminant sur un autre appareil ou plus tard.
Tout autre client qui ne peut pas charger le SDK de Stripe, comme un terminal ou une appli vocale.

setup_intent
La personne enregistre une carte sur son compte sans payer maintenant, pour que votre client puisse acheter avec plus tard.
Commencer l’ajout d’une carte
POST /user/payment-methods/setup-intentrépond avec les trois champs à côté duclient_secret.Pour la carte d’un groupe, utilisez
POST /groups/:groupId/cards/setupetPOST /groups/:groupId/cards/confirmde la même façon. Leurs réponses appellent l’identifiantsetup_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…" }Envoyez un lien à la personne
Mettez les trois valeurs dans un lien vers la page de paiement, avec
typedéfini sursetup_intent. La personne ne voit que le formulaire de Stripe, sans montant.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();Enregistrer la carte
POST /user/payment-methods/confirmenregistre la carte sur le compte une fois que la personne a terminé. Ensuite,GET /user/payment-methodsla liste.curl https://api.heyatli.com/user/payment-methods/confirm \ -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "setup_intent_id": "seti_…" }'
Ce que contient le lien
| Paramètre | Obligatoire | Ce qu’il fait |
|---|---|---|
| type | Oui |
|
| sealed_secret | Oui | La valeur de la réponse, exactement telle qu’elle est arrivée. |
| key_id | Oui | La valeur de la réponse. Elle désigne la clé pour laquelle le secret a été scellé. |
| seal_signature | Oui | La valeur de la réponse. La page la vérifie et refuse un lien où elle est absente ou ne correspond pas. |
| options | Non | Les |
| options_signature | Non | La valeur de la réponse, telle quelle, avec |
| lang | Non | La langue de la page, l’une des 15 de ce site. À défaut, celle que la personne a choisie sur heyatli.com, puis celle du navigateur. |
| theme | Non |
|
| brand | Non |
|
| redirect_success_url | Non | Où envoyer la personne ensuite. Ce doit être en |
| redirect_cancel_url | Non | Où envoyer la personne ensuite. Ce doit être en |
| redirect_failure_url | Non | Où envoyer la personne ensuite. Ce doit être en |
| return_id | Non | Votre propre référence, ajoutée à la redirection. Lettres, chiffres, |
Comment traiter le lien
Transmettez les valeurs telles quelles.
URLSearchParamsgère l’encodage.Transmettez aussi les options de page. Si la réponse contient
optionsetoptions_signature, ajoutez les deux :optionsencodé en base64url,options_signaturetel quel. Ne modifiez pas le JSON avant de l’encoder.Restez dans un seul environnement. Les valeurs de Sandbox ne s’ouvrent que sur
staging.heyatli.com/pay, et celles de Production que surheyatli.com/pay.Traitez-le comme un mot de passe. Envoyez-le uniquement au titulaire du compte et gardez-le hors des journaux.
Une commande à la fois. Un nouvel achat annule la commande en attente précédente et son lien : ne stockez donc pas les liens.
Si les trois champs sont absents, refaites l’appel. Ne mettez jamais un
client_secreten clair dans un lien.Une redirection ne prouve rien. Ne vous fiez qu’à la vérification de la dernière étape.
Comment fonctionnent le chiffrement et la signature
Vous n’avez besoin de rien de tout cela pour envoyer un lien. C’est là pour que vous voyiez ce qui se passe et vérifiiez ce que renvoie le serveur. Les deux utilisent des clés publiques, à ces adresses pour l’environnement choisi :
- Page de paiement
- https://heyatli.com/pay
- Clés de chiffrement
- https://heyatli.com/api/keys
- Clés de signature
- https://api.heyatli.com/keys
Chiffrement
sealed_secret est le client_secret sous forme de JWE compact, un format déjà sûr dans une URL. Il est chiffré avec la clé publique X25519 du site, donc seul le serveur du site peut le lire. key_id est l’id de cette clé et correspond au kid du JWE.
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
L’API le fait pour vous, avec la première clé de la liste. En code, cela donne :
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
Comme tout le monde peut chiffrer, une valeur scellée ne montre pas qui l’a produite. L’API la signe donc avec une clé privée Ed25519 qu’elle est seule à détenir. Le message signé correspond aux octets UTF-8 de ces trois lignes, séparées par un saut de ligne, sans saut à la fin :
atli-seal-v1
<key_id>
<sealed_secret>Voici comment l’API forme la signature, avec une clé privée qui ne la quitte jamais :
// 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 est la signature brute de 64 octets en base64url sans remplissage, soit 86 caractères. Chaque environnement signe avec sa propre clé, et la liste peut en contenir deux pendant une rotation.
La page de paiement vérifie cela avant d’ouvrir quoi que ce soit, et refuse un lien où il est absent ou ne correspond pas. Pour vérifier vous-même ce que le serveur a renvoyé, reconstituez ce message et vérifiez la signature avec les clés de signature :
// 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);Modifier sealed_secret ou key_id invalide la signature. Elle ne couvre ni type, ni les redirections, ni les autres paramètres, et elle ne prouve pas que la personne qui ouvre le lien est titulaire du compte. Un lien signé continue de fonctionner tant que Stripe accepte le secret.
Options de page
L’API renvoie options sous forme de texte JSON. Il contient message, du texte brut de 300 caractères au plus, et lang, la langue du message. D’autres clés pourront s’ajouter, et la page ignore celles qu’elle ne connaît pas. Le message suit la langue de votre requête : X-Locale, Accept-Language ou ?lang=, comme partout dans l’API. Dans le lien, il est en base64url : il n’a besoin d’aucun échappement et reste court dans toutes les langues. Les options sont un plus : si elles manquent ou ne passent pas la vérification, la page encaisse le paiement sans elles.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature est fait avec la même clé, sur ces quatre lignes, où <options> est le JSON exactement tel que l’API l’a renvoyé. La page décode la valeur du lien et vérifie cela. Comme le secret scellé en fait partie, des options déplacées vers un autre lien ne passent pas :
atli-options-v1
<key_id>
<sealed_secret>
<options>