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.

setup_intent
La persona salva una carta nel suo account senza pagare ora, così il tuo client potrà acquistare con quella più avanti.
Avvia l’aggiunta di una carta
POST /user/payment-methods/setup-intentrisponde con i tre campi accanto alclient_secret.Per la carta di un gruppo, usa
POST /groups/:groupId/cards/setupePOST /groups/:groupId/cards/confirmallo stesso modo. Le loro risposte chiamano l’idsetup_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…" }Manda alla persona un link
Metti i tre valori in un link alla pagina di pagamento, con
typeimpostato susetup_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();Salva la carta
POST /user/payment-methods/confirmsalva la carta nell’account quando la persona ha finito. PoiGET /user/payment-methodsla 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
| Parametro | Obbligatorio | Cosa fa |
|---|---|---|
| type | Sì |
|
| sealed_secret | Sì | Il valore della risposta, esattamente come è arrivato. |
| key_id | Sì | Il valore della risposta. Indica la chiave per cui il segreto è stato sigillato. |
| seal_signature | Sì | Il valore della risposta. La pagina lo controlla e rifiuta un link in cui manca o non corrisponde. |
| options | No | Le |
| options_signature | No | Il valore della risposta, invariato, insieme a |
| lang | No | La lingua della pagina, una delle 15 di questo sito. Senza, quella che la persona ha scelto su heyatli.com, poi quella del browser. |
| theme | No |
|
| brand | No |
|
| redirect_success_url | No | Dove mandare la persona dopo. Deve essere |
| redirect_cancel_url | No | Dove mandare la persona dopo. Deve essere |
| redirect_failure_url | No | Dove mandare la persona dopo. Deve essere |
| return_id | No | Un tuo riferimento, aggiunto al reindirizzamento. Lettere, numeri, |
Come trattare il link
Passa i valori senza modificarli.
URLSearchParamssi occupa della codifica.Passa anche le opzioni di pagina. Se la risposta contiene
optionseoptions_signature, aggiungili entrambi:optionscodificato in base64url,options_signaturecosì 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 suheyatli.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_secretin 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
- Chiavi di firma
- https://api.heyatli.com/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>