Widget di pagamento

Widget di pagamento

Manda qualcuno su una pagina Atli sicura per pagare o aggiungere una carta, quando la tua app non può mostrare da sola il modulo di Stripe.

Quando usarlo

  • Assistenti IA e client MCP, che parlano con le persone in una chat e non hanno uno schermo proprio dove mettere un modulo per la carta.

  • Pagare fuori dal flusso, per esempio con un bot su Telegram o WhatsApp, oppure finendo su un altro dispositivo o più tardi.

  • Qualsiasi altro client che non può caricare l’SDK di Stripe, come un terminale o un’app vocale.

PCI DSS
Il modulo della pagina di pagamento, con carta e Apple Pay

setup_intent

La persona salva una carta nel suo account senza pagare ora, così il tuo client potrà acquistare con quella più avanti.

  1. Avvia l’aggiunta di una carta

    POST /user/payment-methods/setup-intent risponde con i tre campi accanto al client_secret.

    Per la carta di un gruppo, usa POST /groups/:groupId/cards/setup e POST /groups/:groupId/cards/confirm allo stesso modo. Le loro risposte chiamano l’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. Manda alla persona un link

    Metti i tre valori in un link alla pagina di pagamento, con type impostato su setup_intent. La persona vede solo il modulo di Stripe, senza importo.

    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. Salva la carta

    POST /user/payment-methods/confirm salva la carta nell’account quando la persona ha finito. Poi GET /user/payment-methods la elenca.

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

Cosa va nel link

ParametroObbligatorioCosa fa
typeSì

payment_intent o setup_intent. Deve corrispondere al tipo di segreto che è stato sigillato.

sealed_secretSì

Il valore della risposta, esattamente come è arrivato.

key_idSì

Il valore della risposta. Indica la chiave per cui il segreto è stato sigillato.

seal_signatureSì

Il valore della risposta. La pagina lo controlla e rifiuta un link in cui manca o non corrisponde.

optionsNo

Le options della risposta, codificate in base64url. Per ora contengono un messaggio che la pagina mostra sopra il modulo, nella lingua della tua richiesta. Arrivano solo quando l’API ha qualcosa da mostrare.

options_signatureNo

Il valore della risposta, invariato, insieme a options. Se non corrisponde, la pagina tralascia le opzioni e funziona come sempre.

langNo

La lingua della pagina, una delle 15 di questo sito. Senza, quella che la persona ha scelto su heyatli.com, poi quella del browser.

themeNo

dark o light. Per impostazione predefinita, quello del browser.

brandNo

none nasconde il logo Atli e l’etichetta. Per impostazione predefinita, sono visibili.

redirect_success_urlNo

Dove mandare la persona dopo. Deve essere https su heyatli.com o su uno dei suoi sottodomini.

redirect_cancel_urlNo

Dove mandare la persona dopo. Deve essere https su heyatli.com o su uno dei suoi sottodomini.

redirect_failure_urlNo

Dove mandare la persona dopo. Deve essere https su heyatli.com o su uno dei suoi sottodomini.

return_idNo

Un tuo riferimento, aggiunto al reindirizzamento. Lettere, numeri, _ e -, fino a 128 caratteri.

Come trattare il link

  • Passa i valori senza modificarli. URLSearchParams si occupa della codifica.

  • Passa anche le opzioni di pagina. Se la risposta contiene options e options_signature, aggiungili entrambi: options codificato in base64url, options_signature così com’è. Non modificare il JSON prima di codificarlo.

  • Resta in un solo ambiente. I valori di Sandbox si aprono solo su staging.heyatli.com/pay, quelli di Production solo su heyatli.com/pay.

  • Trattalo come una password. Mandalo solo al titolare dell’account e tienilo fuori dai log.

  • Un ordine alla volta. Un nuovo acquisto annulla l’ordine in sospeso precedente e il suo link, quindi non conservare i link.

  • Se mancano i tre campi, ripeti la chiamata. Non mettere mai un client_secret in chiaro in un link.

  • Un reindirizzamento non dimostra nulla. Fidati solo del controllo nell’ultimo passaggio.

Come funzionano la cifratura e la firma

Non ti serve niente di questo per mandare un link. È qui per farti vedere cosa succede e controllare cosa restituisce il server. Entrambi usano chiavi pubbliche, a questi indirizzi per l’ambiente che hai scelto:

Pagina di pagamento
https://heyatli.com/pay
Chiavi di cifratura
https://heyatli.com/api/keys

Cifratura

sealed_secret è il client_secret come JWE compatto, un formato già sicuro in un URL. È cifrato con la chiave pubblica X25519 del sito, quindi solo il server del sito può leggerlo. key_id è l’id di quella chiave e corrisponde al kid del JWE.

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

Lo fa l’API per te, con la prima chiave dell’elenco. Nel codice si riduce a questo:

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

Poiché chiunque può cifrare, un valore sigillato non mostra chi lo ha creato. Per questo l’API lo firma con una chiave privata Ed25519 che solo lei possiede. Il messaggio firmato sono i byte UTF-8 di queste tre righe, unite da un a capo, senza nessuno alla fine:

atli-seal-v1
<key_id>
<sealed_secret>

Ecco come l’API crea la firma, con una chiave privata che non lascia mai l’API:

// 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 è la firma grezza di 64 byte in base64url senza padding, cioè 86 caratteri. Ogni ambiente firma con la propria chiave, e l’elenco può contenerne due durante una rotazione.

La pagina di pagamento lo controlla prima di aprire qualsiasi cosa e rifiuta un link in cui manca o non corrisponde. Per controllare tu stesso ciò che ha restituito il server, ricostruisci quel messaggio e verifica la firma con le chiavi di 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);

Cambiare sealed_secret o key_id rompe la firma. Non copre type, i reindirizzamenti o gli altri parametri, e non dimostra che chi apre il link sia il titolare dell’account. Un link firmato continua a funzionare finché Stripe accetta il segreto.

Opzioni di pagina

L’API restituisce options come testo JSON. Contiene message, testo semplice fino a 300 caratteri, e lang, la lingua del messaggio. Potranno arrivare altre chiavi, e la pagina ignora quelle che non conosce. Il messaggio segue la lingua della tua richiesta: X-Locale, Accept-Language o ?lang=, come in tutta l’API. Nel link è in base64url, quindi non va sottoposto a escape e resta breve in ogni lingua. Le opzioni sono un extra: se mancano o non superano il controllo, la pagina accetta il pagamento senza.

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

options_signature è firmato con la stessa chiave su queste quattro righe, dove <options> è il JSON esattamente come l’ha restituito l’API. La pagina decodifica il valore del link e controlla quello. Poiché ne fa parte il sigillo, opzioni spostate in un altro link non passano:

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