Payment Widget
Schicke jemanden auf eine sichere Atli-Seite, um zu bezahlen oder eine Karte hinzuzufügen, wenn deine App Stripes Formular nicht selbst anzeigen kann.
Wann du es nutzt
KI-Assistenten und MCP-Clients, die in einem Chat mit Menschen sprechen und keinen eigenen Bildschirm für ein Kartenformular haben.
Bezahlen außerhalb des Ablaufs, etwa über einen Bot in Telegram oder WhatsApp, oder auf einem anderen Gerät oder später.
Jeder andere Client, der Stripes SDK nicht laden kann, etwa ein Terminal oder eine Sprach-App.

setup_intent
Die Person speichert eine Karte in ihrem Konto, ohne jetzt zu bezahlen, damit dein Client später damit kaufen kann.
Das Hinzufügen einer Karte starten
POST /user/payment-methods/setup-intentantwortet mit den drei Feldern neben demclient_secret.Für die Karte einer Gruppe nutzt du
POST /groups/:groupId/cards/setupundPOST /groups/:groupId/cards/confirmauf dieselbe Weise. Ihre Antworten nennen die 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…" }Schicke der Person einen Link
Setze die drei Werte in einen Link zur Zahlungsseite, mit
typeaufsetup_intent. Die Person sieht nur Stripes Formular, ohne Betrag.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();Die Karte speichern
POST /user/payment-methods/confirmspeichert die Karte im Konto, sobald die Person fertig ist. Danach listetGET /user/payment-methodssie auf.curl https://api.heyatli.com/user/payment-methods/confirm \ -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "setup_intent_id": "seti_…" }'
Was in den Link gehört
| Parameter | Pflicht | Was er bewirkt |
|---|---|---|
| type | Ja |
|
| sealed_secret | Ja | Der Wert aus der Antwort, genau so, wie er kam. |
| key_id | Ja | Der Wert aus der Antwort. Er nennt den Schlüssel, für den das Secret versiegelt wurde. |
| seal_signature | Ja | Der Wert aus der Antwort. Die Seite prüft ihn und weist einen Link ab, in dem er fehlt oder nicht passt. |
| options | Nein | Die |
| options_signature | Nein | Der Wert aus der Antwort, unverändert, zusammen mit |
| lang | Nein | Die Sprache der Seite, eine der 15 dieser Website. Ohne sie die Sprache, die die Person auf heyatli.com gewählt hat, dann die des Browsers. |
| theme | Nein |
|
| brand | Nein |
|
| redirect_success_url | Nein | Wohin die Person danach geschickt wird. Es muss |
| redirect_cancel_url | Nein | Wohin die Person danach geschickt wird. Es muss |
| redirect_failure_url | Nein | Wohin die Person danach geschickt wird. Es muss |
| return_id | Nein | Deine eigene Referenz, die an die Weiterleitung angehängt wird. Buchstaben, Ziffern, |
Wie du mit dem Link umgehst
Gib die Werte unverändert weiter.
URLSearchParamsübernimmt die Kodierung.Gib auch die Seitenoptionen weiter. Enthält die Antwort
optionsundoptions_signature, füge beide hinzu:optionsbase64url-kodiert,options_signatureunverändert. Ändere das JSON vor dem Kodieren nicht.Bleib in einer Umgebung. Sandbox-Werte öffnen sich nur auf
staging.heyatli.com/pay, Production-Werte nur aufheyatli.com/pay.Behandle ihn wie ein Passwort. Sende ihn nur an den Inhaber des Kontos und halte ihn aus Logs heraus.
Eine Bestellung auf einmal. Ein neuer Kauf storniert die frühere offene Bestellung und ihren Link, also speichere keine Links.
Fehlen die drei Felder, rufe die API noch einmal auf. Setze nie ein unverschlüsseltes
client_secretin einen Link.Eine Weiterleitung beweist nichts. Vertraue nur der Prüfung im letzten Schritt.
So funktionieren Verschlüsselung und Signatur
Für das Senden eines Links brauchst du nichts davon. Es steht hier, damit du siehst, was passiert, und prüfen kannst, was der Server zurückgibt. Beides nutzt öffentliche Schlüssel, unter diesen Adressen für die gewählte Umgebung:
- Zahlungsseite
- https://heyatli.com/pay
- Verschlüsselungsschlüssel
- https://heyatli.com/api/keys
- Signaturschlüssel
- https://api.heyatli.com/keys
Verschlüsselung
sealed_secret ist das client_secret als kompaktes JWE, ein Format, das in einer URL schon sicher ist. Es ist an den öffentlichen X25519-Schlüssel der Website verschlüsselt, sodass nur der Server der Website es lesen kann. key_id ist die id dieses Schlüssels und entspricht der kid des JWE.
- alg
- ECDH-ES
- enc
- A256GCM
- kid
- key_id
- Key
- JWK: kty OKP, crv X25519, x = public_key
Das erledigt die API für dich, mit dem ersten Schlüssel aus der Liste. Im Code läuft es darauf hinaus:
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);Signatur
Weil jeder verschlüsseln kann, verrät ein versiegelter Wert nicht, wer ihn erzeugt hat. Deshalb signiert die API ihn mit einem privaten Ed25519-Schlüssel, den nur sie besitzt. Die signierte Nachricht sind die UTF-8-Bytes dieser drei Zeilen, durch einen Zeilenumbruch verbunden, ohne einen am Ende:
atli-seal-v1
<key_id>
<sealed_secret>So bildet die API die Signatur, mit einem privaten Schlüssel, der sie nie verlässt:
// 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 ist die rohe 64-Byte-Signatur als base64url ohne Padding, also 86 Zeichen. Jede Umgebung signiert mit einem eigenen Schlüssel, und die Liste kann bei einem Wechsel zwei enthalten.
Die Zahlungsseite prüft das, bevor sie etwas öffnet, und weist einen Link ab, in dem es fehlt oder nicht passt. Um selbst zu prüfen, was der Server zurückgegeben hat, baue diese Nachricht neu zusammen und verifiziere die Signatur mit den Signaturschlüsseln:
// 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);Wer sealed_secret oder key_id ändert, bricht die Signatur. Sie deckt weder type noch Weiterleitungen oder die anderen Parameter ab, und sie zeigt nicht, dass die Person, die den Link öffnet, das Konto besitzt. Ein signierter Link funktioniert weiter, solange Stripe das Secret akzeptiert.
Seitenoptionen
Die API liefert options als JSON-Text. Er enthält message, reinen Text mit bis zu 300 Zeichen, und lang, die Sprache der Nachricht. Weitere Schlüssel können folgen, und unbekannte ignoriert die Seite. Die Nachricht folgt der Sprache deiner Anfrage: X-Locale, Accept-Language oder ?lang=, wie überall in der API. Im Link steht es als base64url, braucht dort also kein Escaping und bleibt in jeder Sprache kurz. Optionen sind ein Extra: Fehlen sie oder bestehen sie die Prüfung nicht, nimmt die Seite die Zahlung ohne sie an.
// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}options_signature wird mit demselben Schlüssel über diese vier Zeilen erstellt, wobei <options> das JSON genau so ist, wie die API es geliefert hat. Die Seite dekodiert den Wert aus dem Link und prüft das. Weil das Siegel dazugehört, bestehen Optionen, die in einen anderen Link verschoben wurden, die Prüfung nicht:
atli-options-v1
<key_id>
<sealed_secret>
<options>