Tiện ích thanh toán

Tiện ích thanh toán

Đưa ai đó đến một trang Atli an toàn để thanh toán hoặc thêm thẻ, khi ứng dụng của bạn không thể tự hiển thị biểu mẫu của Stripe.

Khi nào nên dùng

  • Trợ lý AI và ứng dụng khách MCP, vốn trò chuyện với mọi người trong khung chat và không có màn hình riêng để đặt biểu mẫu thẻ.

  • Thanh toán ngoài luồng, chẳng hạn bot trên Telegram hoặc WhatsApp, hoặc hoàn tất trên thiết bị khác hay để sau.

  • Bất kỳ ứng dụng khách nào khác không thể tải SDK của Stripe, chẳng hạn terminal hoặc ứng dụng giọng nói.

PCI DSS
Biểu mẫu của trang thanh toán, có thẻ và Apple Pay

setup_intent

Người dùng lưu một thẻ vào tài khoản mà không thanh toán ngay, để ứng dụng khách của bạn có thể mua bằng thẻ đó sau.

  1. Bắt đầu thêm thẻ

    POST /user/payment-methods/setup-intent trả lời kèm ba trường bên cạnh client_secret.

    Với thẻ của một nhóm, hãy dùng POST /groups/:groupId/cards/setup và POST /groups/:groupId/cards/confirm theo cách tương tự. Phản hồi của chúng gọi id là 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. Gửi cho người dùng một liên kết

    Đặt ba giá trị vào một liên kết đến trang thanh toán, với type là setup_intent. Người dùng chỉ thấy biểu mẫu của Stripe, không có số tiền.

    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. Lưu thẻ

    POST /user/payment-methods/confirm lưu thẻ vào tài khoản khi người dùng đã xong. Sau đó GET /user/payment-methods sẽ liệt kê thẻ đó.

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

Những gì nằm trong liên kết

Tham sốBắt buộcTác dụng
typeCó

payment_intent hoặc setup_intent. Phải khớp với loại bí mật đã được niêm phong.

sealed_secretCó

Giá trị từ phản hồi, đúng như khi nhận được.

key_idCó

Giá trị từ phản hồi. Nó cho biết khóa mà bí mật được niêm phong cho.

seal_signatureCó

Giá trị từ phản hồi. Trang sẽ kiểm tra nó và từ chối liên kết thiếu hoặc có giá trị không khớp.

optionsKhông

options từ phản hồi, được mã hóa base64url. Hiện tại chúng chứa một thông báo mà trang hiển thị phía trên biểu mẫu, bằng ngôn ngữ của yêu cầu. Chúng chỉ có khi API có điều cần hiển thị.

options_signatureKhông

Giá trị từ phản hồi, giữ nguyên, đi kèm options. Nếu không khớp, trang bỏ qua các tùy chọn và hoạt động như bình thường.

langKhông

Ngôn ngữ của trang, một trong 15 ngôn ngữ của trang web này. Nếu không có, dùng ngôn ngữ người đó đã chọn trên heyatli.com, rồi đến ngôn ngữ của trình duyệt.

themeKhông

dark hoặc light. Mặc định theo trình duyệt.

brandKhông

none ẩn logo và nhãn Atli. Mặc định, chúng được hiển thị.

redirect_success_urlKhông

Nơi chuyển người dùng đến sau đó. Phải là https trên heyatli.com hoặc một trong các tên miền phụ của nó.

redirect_cancel_urlKhông

Nơi chuyển người dùng đến sau đó. Phải là https trên heyatli.com hoặc một trong các tên miền phụ của nó.

redirect_failure_urlKhông

Nơi chuyển người dùng đến sau đó. Phải là https trên heyatli.com hoặc một trong các tên miền phụ của nó.

return_idKhông

Mã tham chiếu riêng của bạn, được thêm vào địa chỉ chuyển hướng. Chữ cái, chữ số, _ và -, tối đa 128 ký tự.

Cách xử lý liên kết

  • Truyền nguyên các giá trị. URLSearchParams sẽ lo phần mã hóa.

  • Chuyển cả tùy chọn trang. Khi phản hồi có options và options_signature, hãy thêm cả hai: options mã hóa base64url, options_signature giữ nguyên. Đừng sửa JSON trước khi mã hóa.

  • Giữ trong một môi trường. Giá trị từ Sandbox chỉ mở được trên staging.heyatli.com/pay, còn từ Production chỉ mở được trên heyatli.com/pay.

  • Hãy coi nó như mật khẩu. Chỉ gửi cho chủ tài khoản và đừng để nó vào nhật ký.

  • Mỗi lần một đơn hàng. Giao dịch mua mới sẽ hủy đơn đang chờ trước đó và liên kết của đơn, nên đừng lưu liên kết.

  • Nếu thiếu ba trường này, hãy gọi lại. Đừng bao giờ đưa client_secret chưa mã hóa vào liên kết.

  • Chuyển hướng không chứng minh được gì. Chỉ tin vào bước kiểm tra cuối cùng.

Mã hóa và chữ ký hoạt động thế nào

Bạn không cần điều nào trong số này để gửi liên kết. Chúng ở đây để bạn xem điều gì xảy ra và kiểm tra những gì máy chủ trả về. Cả hai đều dùng khóa công khai, tại các địa chỉ này cho môi trường bạn đã chọn:

Trang thanh toán
https://heyatli.com/pay

Mã hóa

sealed_secret là client_secret dưới dạng JWE gọn, một định dạng vốn đã an toàn trong URL. Nó được mã hóa bằng khóa công khai X25519 của trang web, nên chỉ máy chủ của trang web đọc được. key_id là id của khóa đó, và khớp với kid của JWE.

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

API làm việc này cho bạn, dùng khóa đầu tiên trong danh sách. Trong mã, nó là:

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

Chữ ký

Vì ai cũng có thể mã hóa, một giá trị đã niêm phong không cho biết ai đã tạo ra nó. Vì vậy API ký nó bằng khóa riêng Ed25519 mà chỉ API nắm giữ. Thông điệp được ký là các byte UTF-8 của ba dòng này, nối với nhau bằng dấu xuống dòng, không có dấu nào ở cuối:

atli-seal-v1
<key_id>
<sealed_secret>

API tạo chữ ký như sau, bằng khóa riêng không bao giờ rời khỏi 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 là chữ ký thô 64 byte dạng base64url không đệm, tức 86 ký tự. Mỗi môi trường ký bằng khóa riêng, và danh sách có thể chứa hai khóa trong lúc luân chuyển.

Trang thanh toán kiểm tra điều này trước khi mở bất cứ thứ gì, và từ chối liên kết thiếu hoặc không khớp. Để tự kiểm tra những gì máy chủ trả về, hãy dựng lại thông điệp đó và xác minh chữ ký bằng các khóa ký:

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

Đổi sealed_secret hoặc key_id sẽ làm hỏng chữ ký. Chữ ký không bao gồm type, các địa chỉ chuyển hướng hay các tham số khác, và không chứng minh người mở liên kết là chủ tài khoản. Liên kết đã ký vẫn dùng được chừng nào Stripe còn chấp nhận bí mật.

Tùy chọn trang

API trả về options dưới dạng văn bản JSON. Nó chứa message, văn bản thuần tối đa 300 ký tự, và lang, ngôn ngữ của thông báo. Có thể có thêm khóa khác, và trang bỏ qua những khóa nó không biết. Thông báo theo ngôn ngữ của yêu cầu: X-Locale, Accept-Language hoặc ?lang=, như ở mọi nơi trong API. Trong liên kết, nó ở dạng base64url nên không cần thoát ký tự và luôn ngắn với mọi ngôn ngữ. Tùy chọn chỉ là phần thêm: nếu thiếu hoặc không qua kiểm tra, trang vẫn nhận thanh toán mà không có chúng.

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

options_signature được tạo bằng cùng khóa, trên bốn dòng này, trong đó <options> là JSON đúng như API trả về. Trang giải mã giá trị trong liên kết và kiểm tra nó. Vì bí mật đã niêm phong là một phần của nó, tùy chọn chuyển sang liên kết khác sẽ không hợp lệ:

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