Widżet płatności

Widżet płatności

Wyślij kogoś na bezpieczną stronę Atli, by zapłacił lub dodał kartę, gdy Twoja aplikacja nie może sama pokazać formularza Stripe.

Kiedy z tego korzystać

  • Asystenci AI i klienci MCP, którzy rozmawiają z ludźmi na czacie i nie mają własnego ekranu na formularz karty.

  • Płacenie poza przepływem, na przykład przez bota w Telegramie lub WhatsAppie, albo dokończenie na innym urządzeniu lub później.

  • Każdy inny klient, który nie może załadować SDK Stripe, na przykład terminal lub aplikacja głosowa.

PCI DSS
Formularz strony płatności, z kartą i Apple Pay

setup_intent

Osoba zapisuje kartę na swoim koncie bez płacenia teraz, żeby Twój klient mógł nią później kupować.

  1. Rozpocznij dodawanie karty

    POST /user/payment-methods/setup-intent odpowiada trzema polami obok client_secret.

    Dla karty grupy użyj POST /groups/:groupId/cards/setup i POST /groups/:groupId/cards/confirm w ten sam sposób. W ich odpowiedziach identyfikator nazywa się 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. Wyślij osobie link

    Umieść te trzy wartości w linku do strony płatności, z type ustawionym na setup_intent. Osoba zobaczy tylko formularz Stripe, bez kwoty.

    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. Zapisz kartę

    POST /user/payment-methods/confirm zapisuje kartę na koncie, gdy osoba skończy. Potem GET /user/payment-methods ją wyświetla.

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

Co trafia do linku

ParametrWymaganyCo robi
typeTak

payment_intent lub setup_intent. Musi pasować do rodzaju zaszyfrowanego sekretu.

sealed_secretTak

Wartość z odpowiedzi, dokładnie taka, jaka przyszła.

key_idTak

Wartość z odpowiedzi. Wskazuje klucz, dla którego zaszyfrowano sekret.

seal_signatureTak

Wartość z odpowiedzi. Strona ją sprawdza i odrzuca link, w którym jej brakuje lub się nie zgadza.

optionsNie

options z odpowiedzi zakodowane w base64url. Na razie zawierają wiadomość, którą strona pokazuje nad formularzem, w języku Twojego zapytania. Pojawiają się tylko wtedy, gdy API ma coś do pokazania.

options_signatureNie

Wartość z odpowiedzi, bez zmian, razem z options. Jeśli się nie zgadza, strona pomija opcje i działa jak zwykle.

langNie

Język strony, jeden z 15 używanych w tej witrynie. Bez niego język wybrany przez tę osobę na heyatli.com, a potem język przeglądarki.

themeNie

dark lub light. Domyślnie motyw przeglądarki.

brandNie

none ukrywa logo i plakietkę Atli. Domyślnie są widoczne.

redirect_success_urlNie

Dokąd wysłać osobę potem. Musi to być https na heyatli.com lub jednej z jej subdomen.

redirect_cancel_urlNie

Dokąd wysłać osobę potem. Musi to być https na heyatli.com lub jednej z jej subdomen.

redirect_failure_urlNie

Dokąd wysłać osobę potem. Musi to być https na heyatli.com lub jednej z jej subdomen.

return_idNie

Twój własny identyfikator, dodany do przekierowania. Litery, cyfry, _ i -, do 128 znaków.

Jak traktować link

  • Przekazuj wartości bez zmian. Kodowaniem zajmuje się URLSearchParams.

  • Przekaż też opcje strony. Jeśli odpowiedź zawiera options i options_signature, dodaj oba: options zakodowane w base64url, options_signature bez zmian. Nie zmieniaj JSON-a przed zakodowaniem.

  • Trzymaj się jednego środowiska. Wartości z Sandbox otwierają się tylko na staging.heyatli.com/pay, a z Production tylko na heyatli.com/pay.

  • Traktuj go jak hasło. Wysyłaj go tylko właścicielowi konta i nie zapisuj w logach.

  • Jedno zamówienie naraz. Nowy zakup anuluje wcześniejsze oczekujące zamówienie i jego link, więc nie przechowuj linków.

  • Jeśli brakuje trzech pól, wykonaj wywołanie ponownie. Nigdy nie umieszczaj jawnego client_secret w linku.

  • Przekierowanie niczego nie dowodzi. Ufaj tylko sprawdzeniu w ostatnim kroku.

Jak działają szyfrowanie i podpis

Nic z tego nie jest potrzebne do wysłania linku. Jest tu, byś mógł zobaczyć, co się dzieje, i sprawdzić, co zwraca serwer. Oba używają kluczy publicznych, pod tymi adresami dla wybranego środowiska:

Strona płatności
https://heyatli.com/pay
Klucze podpisujące
https://api.heyatli.com/keys

Szyfrowanie

sealed_secret to client_secret jako zwarty JWE, format już bezpieczny w adresie URL. Jest zaszyfrowany kluczem publicznym X25519 witryny, więc odczyta go tylko jej serwer. key_id to id tego klucza i odpowiada kid w JWE.

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

API robi to za Ciebie, używając pierwszego klucza z listy. W kodzie wygląda to tak:

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);

Podpis

Ponieważ każdy może szyfrować, zaszyfrowana wartość nie pokazuje, kto ją utworzył. Dlatego API podpisuje ją prywatnym kluczem Ed25519, który ma tylko ono. Podpisana wiadomość to bajty UTF-8 tych trzech wierszy, połączonych znakiem nowego wiersza, bez żadnego na końcu:

atli-seal-v1
<key_id>
<sealed_secret>

Tak API tworzy podpis, kluczem prywatnym, który nigdy go nie opuszcza:

// 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 to surowy 64-bajtowy podpis w base64url bez dopełnienia, czyli 86 znaków. Każde środowisko podpisuje własnym kluczem, a podczas rotacji lista może zawierać dwa.

Strona płatności sprawdza to, zanim cokolwiek otworzy, i odrzuca link, w którym tego brakuje lub się nie zgadza. Aby samodzielnie sprawdzić, co zwrócił serwer, odtwórz tę wiadomość i zweryfikuj podpis kluczami podpisującymi:

// 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);

Zmiana sealed_secret lub key_id psuje podpis. Nie obejmuje on type, przekierowań ani pozostałych parametrów i nie dowodzi, że osoba otwierająca link jest właścicielem konta. Podpisany link działa tak długo, jak Stripe akceptuje sekret.

Opcje strony

API zwraca options jako tekst JSON. Zawiera on message, zwykły tekst do 300 znaków, oraz lang, język wiadomości. Mogą dojść kolejne klucze, a nieznane strona pomija. Wiadomość jest w języku Twojego zapytania: X-Locale, Accept-Language lub ?lang=, jak wszędzie w API. W linku jest w base64url, więc nie wymaga escapowania i pozostaje krótka w każdym języku. Opcje to dodatek: jeśli ich brak lub nie przejdą weryfikacji, strona przyjmie płatność bez nich.

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

options_signature powstaje tym samym kluczem nad tymi czterema wierszami, gdzie <options> to JSON dokładnie w takiej postaci, w jakiej zwróciło go API. Strona dekoduje wartość z linku i sprawdza właśnie ją. Pieczęć jest częścią podpisu, więc opcje przeniesione do innego linku nie przejdą:

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