Widget de paiement

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.

PCI DSS
Le formulaire de la page de paiement, avec carte et Apple Pay

setup_intent

La personne enregistre une carte sur son compte sans payer maintenant, pour que votre client puisse acheter avec plus tard.

  1. Commencer l’ajout d’une carte

    POST /user/payment-methods/setup-intent répond avec les trois champs à côté du client_secret.

    Pour la carte d’un groupe, utilisez POST /groups/:groupId/cards/setup et POST /groups/:groupId/cards/confirm de la même façon. Leurs réponses appellent l’identifiant 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. Envoyez un lien à la personne

    Mettez les trois valeurs dans un lien vers la page de paiement, avec type défini sur setup_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();
  3. Enregistrer la carte

    POST /user/payment-methods/confirm enregistre la carte sur le compte une fois que la personne a terminé. Ensuite, GET /user/payment-methods la 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ètreObligatoireCe qu’il fait
typeOui

payment_intent ou setup_intent. Il doit correspondre au type de secret qui a été scellé.

sealed_secretOui

La valeur de la réponse, exactement telle qu’elle est arrivée.

key_idOui

La valeur de la réponse. Elle désigne la clé pour laquelle le secret a été scellé.

seal_signatureOui

La valeur de la réponse. La page la vérifie et refuse un lien où elle est absente ou ne correspond pas.

optionsNon

Les options de la réponse, encodées en base64url. Pour l’instant, elles contiennent un message que la page affiche au-dessus du formulaire, dans la langue de votre requête. Elles ne sont présentes que si l’API a quelque chose à afficher.

options_signatureNon

La valeur de la réponse, telle quelle, avec options. Si elle ne correspond pas, la page ignore les options et fonctionne normalement.

langNon

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.

themeNon

dark ou light. Par défaut, celui du navigateur.

brandNon

none masque le logo Atli et la pastille. Par défaut, ils s’affichent.

redirect_success_urlNon

Où envoyer la personne ensuite. Ce doit être en https sur heyatli.com ou l’un de ses sous-domaines.

redirect_cancel_urlNon

Où envoyer la personne ensuite. Ce doit être en https sur heyatli.com ou l’un de ses sous-domaines.

redirect_failure_urlNon

Où envoyer la personne ensuite. Ce doit être en https sur heyatli.com ou l’un de ses sous-domaines.

return_idNon

Votre propre référence, ajoutée à la redirection. Lettres, chiffres, _ et -, jusqu’à 128 caractères.

Comment traiter le lien

  • Transmettez les valeurs telles quelles. URLSearchParams gère l’encodage.

  • Transmettez aussi les options de page. Si la réponse contient options et options_signature, ajoutez les deux : options encodé en base64url, options_signature tel 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 sur heyatli.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_secret en 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

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>