Widget pembayaran

Widget pembayaran

Arahkan seseorang ke halaman Atli yang aman untuk membayar atau menambahkan kartu, saat aplikasi Anda tidak bisa menampilkan formulir Stripe sendiri.

Kapan memakainya

  • Asisten AI dan klien MCP, yang berbicara dengan orang lewat obrolan dan tidak punya layar sendiri untuk menaruh formulir kartu.

  • Membayar di luar alur, misalnya bot di Telegram atau WhatsApp, atau menyelesaikannya di perangkat lain atau nanti.

  • Klien lain mana pun yang tidak bisa memuat SDK Stripe, seperti terminal atau aplikasi suara.

PCI DSS
Formulir halaman pembayaran, dengan kartu dan Apple Pay

setup_intent

Orang tersebut menyimpan kartu ke akunnya tanpa membayar sekarang, supaya klien Anda bisa membeli dengan kartu itu nanti.

  1. Mulai menambahkan kartu

    POST /user/payment-methods/setup-intent menjawab dengan tiga kolom di samping client_secret.

    Untuk kartu grup, pakai POST /groups/:groupId/cards/setup dan POST /groups/:groupId/cards/confirm dengan cara yang sama. Jawabannya menyebut id itu 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. Kirim tautan kepada orangnya

    Taruh ketiga nilai itu dalam tautan ke halaman pembayaran, dengan type diatur ke setup_intent. Orang tersebut hanya melihat formulir Stripe, tanpa jumlah.

    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. Simpan kartu

    POST /user/payment-methods/confirm menyimpan kartu ke akun setelah orang tersebut selesai. Lalu GET /user/payment-methods mencantumkannya.

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

Apa yang masuk ke tautan

ParameterWajibFungsinya
typeYa

payment_intent atau setup_intent. Harus cocok dengan jenis rahasia yang disegel.

sealed_secretYa

Nilai dari respons, persis seperti yang diterima.

key_idYa

Nilai dari respons. Ini menyebut kunci tempat rahasia disegel.

seal_signatureYa

Nilai dari respons. Halaman memeriksanya dan menolak tautan yang nilainya hilang atau tidak cocok.

optionsTidak

options dari respons, dienkode base64url. Untuk saat ini berisi pesan yang ditampilkan halaman di atas formulir, dalam bahasa permintaan Anda. Opsi ini hanya ada jika API punya sesuatu untuk ditampilkan.

options_signatureTidak

Nilai dari respons, tanpa diubah, bersama options. Jika tidak cocok, halaman mengabaikan opsi dan berjalan seperti biasa.

langTidak

Bahasa halaman, salah satu dari 15 bahasa situs ini. Tanpanya, bahasa yang dipilih orang itu di heyatli.com, lalu bahasa browser.

themeTidak

dark atau light. Secara bawaan, mengikuti browser.

brandTidak

none menyembunyikan logo dan label Atli. Secara bawaan, keduanya tampil.

redirect_success_urlTidak

Ke mana orang tersebut diarahkan setelahnya. Harus https di heyatli.com atau salah satu subdomainnya.

redirect_cancel_urlTidak

Ke mana orang tersebut diarahkan setelahnya. Harus https di heyatli.com atau salah satu subdomainnya.

redirect_failure_urlTidak

Ke mana orang tersebut diarahkan setelahnya. Harus https di heyatli.com atau salah satu subdomainnya.

return_idTidak

Referensi Anda sendiri, ditambahkan ke pengalihan. Huruf, angka, _ dan -, hingga 128 karakter.

Cara memperlakukan tautan

  • Teruskan nilainya tanpa diubah. URLSearchParams menangani pengodeannya.

  • Teruskan juga opsi halaman. Jika respons berisi options dan options_signature, tambahkan keduanya: options dienkode base64url, options_signature apa adanya. Jangan mengubah JSON sebelum mengenkodenya.

  • Tetap di satu lingkungan. Nilai Sandbox hanya terbuka di staging.heyatli.com/pay, dan nilai Production hanya di heyatli.com/pay.

  • Perlakukan seperti kata sandi. Kirim hanya ke pemilik akun, dan jangan biarkan masuk ke log.

  • Satu pesanan dalam satu waktu. Pembelian baru membatalkan pesanan tertunda sebelumnya beserta tautannya, jadi jangan simpan tautan.

  • Jika ketiga kolom itu tidak ada, lakukan panggilan lagi. Jangan pernah menaruh client_secret biasa di tautan.

  • Pengalihan tidak membuktikan apa pun. Percayai hanya pemeriksaan di langkah terakhir.

Cara kerja enkripsi dan tanda tangan

Anda tidak memerlukan semua ini untuk mengirim tautan. Ini ada agar Anda bisa melihat apa yang terjadi dan memeriksa apa yang dikembalikan server. Keduanya memakai kunci publik, di alamat berikut untuk lingkungan yang Anda pilih:

Halaman pembayaran
https://heyatli.com/pay
Kunci penandatanganan
https://api.heyatli.com/keys

Enkripsi

sealed_secret adalah client_secret sebagai JWE ringkas, format yang sudah aman di URL. Isinya dienkripsi ke kunci publik X25519 situs, jadi hanya server situs yang bisa membacanya. key_id adalah id kunci itu, dan cocok dengan kid milik JWE.

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

API melakukannya untuk Anda, dengan kunci pertama dari daftar. Dalam kode, bentuknya seperti ini:

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

Tanda tangan

Karena siapa pun bisa mengenkripsi, nilai yang disegel tidak menunjukkan siapa pembuatnya. Maka API menandatanganinya dengan kunci privat Ed25519 yang hanya dimilikinya. Pesan yang ditandatangani adalah byte UTF-8 dari tiga baris ini, digabung dengan baris baru, tanpa baris baru di akhir:

atli-seal-v1
<key_id>
<sealed_secret>

Beginilah API membentuk tanda tangan, dengan kunci privat yang tidak pernah keluar darinya:

// 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 adalah tanda tangan mentah 64 byte dalam base64url tanpa padding, yaitu 86 karakter. Setiap lingkungan menandatangani dengan kuncinya sendiri, dan daftar bisa memuat dua kunci selama rotasi.

Halaman pembayaran memeriksa ini sebelum membuka apa pun, dan menolak tautan yang tidak memilikinya atau tidak cocok. Untuk memeriksa sendiri apa yang dikembalikan server, susun ulang pesan itu dan verifikasi tanda tangannya dengan kunci penandatanganan:

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

Mengubah sealed_secret atau key_id merusak tanda tangan. Tanda tangan tidak mencakup type, pengalihan, atau parameter lain, dan tidak menunjukkan bahwa orang yang membuka tautan adalah pemilik akun. Tautan bertanda tangan tetap berfungsi selama Stripe menerima rahasianya.

Opsi halaman

API mengembalikan options sebagai teks JSON. Isinya message, teks biasa hingga 300 karakter, dan lang, bahasa pesannya. Kunci lain bisa ditambahkan, dan halaman mengabaikan kunci yang tidak dikenalnya. Pesan mengikuti bahasa permintaan Anda: X-Locale, Accept-Language, atau ?lang=, seperti di seluruh API. Di tautan, nilainya base64url sehingga tidak perlu di-escape dan tetap pendek dalam bahasa apa pun. Opsi hanyalah tambahan: jika tidak ada atau gagal diperiksa, halaman tetap menerima pembayaran tanpanya.

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

options_signature dibuat dengan kunci yang sama atas empat baris ini, dengan <options> adalah JSON persis seperti yang dikembalikan API. Halaman mendekode nilai dari tautan dan memeriksanya. Karena segel termasuk di dalamnya, opsi yang dipindahkan ke tautan lain tidak akan lolos:

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