Skip to content

Lưu thẻ & thanh toán định kỳ ​

Lưu thẻ của khách hàng một lần và tái sử dụng cho các thanh toán sau mà không cần hỏi lại số thẻ. Một SetupIntent tokenize và xác minh thẻ trước (không tính phí), trả về một payment_method_id có thể tái sử dụng. Sau đó bạn tính phí cho thẻ đã lưu đó theo một trong hai cách:

  • CIT (Customer-Initiated Transaction) — khách hàng có mặt và chủ động thanh toán, ví dụ chọn một thẻ đã lưu tại checkout hoặc thanh toán một chạm. Đặt off_session=false.
  • MIT (Merchant-Initiated Transaction) — merchant tính phí thẻ mà không có khách hàng nào có mặt, ví dụ gia hạn subscription hoặc tính phí theo lịch. Đặt off_session=true.
usageNgười khởi tạoKhi nào sử dụngTính phí với
on_sessionKhách hàng (CIT)Khách hàng có mặt và chọn một thẻ đã lưu tại checkout.off_session=false
off_sessionMerchant (MIT)Tính phí định kỳ / subscription / theo lịch, khách hàng vắng mặt.off_session=true

Điều kiện tiên quyết ​

Luồng tổng quan ​

sequenceDiagram
    autonumber
    participant User as User
    participant MerchantWeb as Merchant Web<br/>(Frontend)
    participant SDK as SDK<br/>(Embedded)
    participant MerchantAPI as Merchant API<br/>(Backend)
    participant API as Bahn API

    Note over User, API: Part 1 — Save the card (SetupIntent)
    MerchantAPI ->> API: CreateCustomer (once per customer)
    API -->> MerchantAPI: {customer_id}
    User ->> MerchantWeb: Choose "Save my card"
    MerchantWeb ->> MerchantAPI: Request setup session
    MerchantAPI ->> API: CreateSetupIntent(customer_id, usage)
    API -->> MerchantAPI: {setup_intent, client_secret}
    MerchantAPI -->> MerchantWeb: {client_secret}
    MerchantWeb ->> SDK: Initialize SDK with client_secret
    SDK ->> API: ConfirmSetupIntent(card)
    Note over API: 3DS always required for setup<br/>verification authorization (0 or 5,000 VND, per merchant rules) to confirm the card
    API -->> SDK: requires_action (3DS challenge)
    User ->> SDK: Complete 3DS challenge
    Note over API: SetupIntent → succeeded<br/>card attached to customer
    MerchantAPI ->> API: GetSetupIntent(id)
    API -->> MerchantAPI: {status: succeeded, payment_method_id}

    Note over User, API: Part 2a — Charge later, customer present (CIT)
    User ->> MerchantWeb: Select saved card & pay
    MerchantWeb ->> MerchantAPI: Pay with payment_method_id
    MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=false)
    Note over API: evaluate merchant 3DS rules
    alt 3DS required
        API -->> MerchantAPI: {status: requires_action, client_secret}
        Note over MerchantWeb, SDK: SDK completes 3DS challenge live
    else non-3DS (rules skip authentication)
        API -->> MerchantAPI: {status: processing}
    else 3DS exemption (wallet token / SCA exemption)
        API -->> MerchantAPI: {status: processing}
    end
    API ->> MerchantAPI: Webhook: payment_intent.status.succeeded

    Note over MerchantAPI, API: Part 2b — Charge later, no customer (MIT)
    MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=true)
    Note over API: payment method must be off_session_enabled<br/>evaluate merchant 3DS rules
    alt non-3DS or 3DS exemption (typical for MIT)
        API -->> MerchantAPI: {status: processing}
    else 3DS required
        API -->> MerchantAPI: {status: requires_action}
        Note over MerchantAPI, API: no customer to complete challenge — payment cannot proceed
    end
    API ->> MerchantAPI: Webhook: payment_intent.status.succeeded

Cách hoạt động ​

Giai đoạn 1 — Tạo một SetupIntent ​

  1. Tạo một customer — Tạo customer một lần (qua CreateCustomer) và tái sử dụng customer_id. Thẻ đã lưu được gắn với một customer.
  2. Tạo một SetupIntent — Backend của bạn gọi CreateSetupIntent với customer_id và usage mong muốn (on_session hoặc off_session). Bahn trả về setup_intent và client_secret tương ứng.
  3. Chỉ chuyển tiếp client_secret — Trả về { client_secret } cho frontend của bạn; setup_intent.id và API key vẫn ở phía backend.

Giai đoạn 2 — Confirm & xác minh (SDK) ​

  1. SDK thu thập thông tin thẻ — SDK được nhúng hiển thị các ô nhập thẻ an toàn và xác nhận SetupIntent bằng client_secret. Frontend của bạn không bao giờ xử lý dữ liệu thẻ thô.
  2. Thử thách 3DS — Khác với một thanh toán, 3DS luôn bắt buộc đối với một setup. SDK hiển thị thử thách và khách hàng hoàn tất nó.
  3. Ủy quyền xác minh — Bahn chạy một giao dịch ủy quyền xác minh tài khoản trên thẻ để xác nhận thẻ còn hiệu lực — 0 hoặc 5.000 VND, tùy theo quy tắc của merchant. Không có khoản tiền nào được capture.

3DS và giao dịch ủy quyền vi mô chạy bất đồng bộ sau khi khách hàng hoàn tất thử thách. SetupIntent chuyển trạng thái requires_payment_method → requires_action → processing → succeeded. Nếu xác minh thất bại, nó quay về requires_payment_method với last_setup_error được đặt, và khách hàng có thể thử lại với thẻ khác.

Giai đoạn 3 — Lấy thẻ đã lưu ​

Khi thành công, Bahn gắn thẻ với customer và điền payment_method_id trên SetupIntent. Truy vấn SetupIntent để đọc giá trị này khi trạng thái là succeeded. Lưu payment_method_id vào bản ghi customer của bạn — đây là handle bạn dùng cho mọi lần tính phí trong tương lai.

Giai đoạn 4 — Tính phí thẻ đã lưu (CIT / MIT) ​

Tạo và xác nhận một PaymentIntent tham chiếu đến payment_method_id đã lưu. Dùng off_session=false khi khách hàng có mặt (CIT) và off_session=true khi họ vắng mặt (MIT). Từ đó, việc ủy quyền, webhook, và capture hoạt động giống hệt luồng Thanh toán một lần.

Đối với subscription và kế hoạch trả góp, MIT cũng phải chỉ rõ thỏa thuận nào nó đang thực thi — xem Thanh toán định kỳ & MIT không định kỳ để biết cách thiết lập một mandate định kỳ và tính phí dựa trên đó.


Trách nhiệm của merchant API ​

1. Tạo một customer ​

Thẻ đã lưu được gắn với một customer. Tạo customer một lần và tái sử dụng customer_id cho mọi SetupIntent và thanh toán trong tương lai. merchant_id được suy ra từ API key của bạn.

POST /api/customers

Request

{
  "name": "Nguyen Van A",
  "email": "[email protected]",
  "phone": "+84901234567"
}
TrườngBắt buộcMô tả
nameKhôngHọ tên đầy đủ của customer.
emailKhôngĐịa chỉ email của customer.
phoneKhôngSố điện thoại của customer.
metadataKhôngCác cặp key-value tùy ý để lưu thêm thông tin.

Các trường địa chỉ — addr_line1, addr_line2, ward, district, province, postal_code, country — cũng được chấp nhận và là tùy chọn.

Response

{
  "customer": {
    "id": "cus_xxxxxxxx",
    "name": "Nguyen Van A",
    "email": "[email protected]",
    "status": "active"
  }
}

Lưu customer.id trả về vào bản ghi người dùng của bạn. Bạn truyền nó dưới dạng customer_id khi tạo SetupIntent và khi tính phí thẻ đã lưu.

2. Tạo một SetupIntent ​

Khi khách hàng chọn lưu thẻ, tạo một SetupIntent cho họ. Bahn trả về một client_secret mà SDK dùng để xác nhận thay mặt khách hàng.

POST /api/setup_intents

Request

{
  "customer_id": "cus_xxxxxxxx",
  "usage": "off_session",
  "description": "Save card for subscription",
  "metadata": {
    "order_id": "1234"
  }
}
TrườngBắt buộcMô tả
customer_idCóCustomer mà thẻ được lưu gắn với.
usageCóon_session (tái sử dụng khi khách hàng có mặt) hoặc off_session (tái sử dụng do merchant khởi tạo).
descriptionKhôngMô tả dễ đọc.
metadataKhôngCác cặp key-value tùy ý để lưu thêm thông tin.

Response

{
  "setup_intent": {
    "id": "seti_xxxxxxxx",
    "client_secret": "seti_xxxxxxxx_secret_xxxxxxxx",
    "status": "requires_payment_method",
    "usage": "off_session",
    "customer_id": "cus_xxxxxxxx"
  }
}

Chỉ trả về client_secret cho frontend của bạn — SDK dùng nó để khởi tạo và xác nhận setup. Giữ setup_intent.id ở backend của bạn, và không bao giờ để lộ API key cho client.

3. Lấy kết quả ​

Sau khi khách hàng hoàn tất 3DS, việc xác minh hoàn tất bất đồng bộ. Truy vấn SetupIntent để xác nhận nó đã thành công và đọc payment_method_id kết quả.

GET /api/setup_intents/{id}

Response

{
  "setup_intent": {
    "id": "seti_xxxxxxxx",
    "status": "succeeded",
    "payment_method_id": "pm_xxxxxxxx",
    "usage": "off_session",
    "customer_id": "cus_xxxxxxxx"
  }
}
Trạng tháiÝ nghĩa
requires_actionĐang chờ khách hàng hoàn tất thử thách 3DS.
processing3DS đã hoàn tất; đang chờ kết quả ủy quyền vi mô để xác minh.
succeededThẻ đã được xác minh và lưu. payment_method_id giờ đã sẵn sàng để tái sử dụng.
requires_payment_methodXác minh thất bại (xem last_setup_error). Yêu cầu khách hàng thử lại với thẻ khác.

4. Tính phí thẻ đã lưu — khách hàng có mặt (CIT) ​

Khi khách hàng đang ở bước checkout và chọn một thẻ đã lưu, tạo và xác nhận một PaymentIntent tham chiếu đến payment_method_id với off_session=false. Việc charge này có bỏ qua hay cần 3DS hay không phụ thuộc vào quy tắc merchant của bạn. Nếu một quy tắc yêu cầu, Bahn trả về requires_action và một client_secret để SDK hoàn tất thử thách trực tiếp; nếu không, charge tiếp tục mà không có thử thách.

POST /api/payment_intents

Request

{
  "amount": "10000",
  "currency": "usd",
  "customer_id": "cus_xxxxxxxx",
  "payment_method_id": "pm_xxxxxxxx",
  "confirm": true,
  "off_session": false
}
TrườngBắt buộcMô tả
customer_idCóPhải sở hữu phương thức thanh toán.
payment_method_idCóThẻ đã lưu được trả về bởi SetupIntent.
confirmCóĐặt thành true để tạo và xác nhận trong một lệnh gọi.
off_sessionKhôngfalse (mặc định) cho thanh toán khi khách hàng có mặt.

5. Tính phí thẻ đã lưu — không có khách hàng (MIT) ​

Đối với subscription, gia hạn, hoặc tính phí theo lịch, tạo và xác nhận một PaymentIntent với off_session=true. Phương thức thanh toán phải được lưu với usage=off_session (phải được bật off-session), nếu không request sẽ bị từ chối.

POST /api/payment_intents

Request

{
  "amount": "10000",
  "currency": "usd",
  "customer_id": "cus_xxxxxxxx",
  "payment_method_id": "pm_xxxxxxxx",
  "confirm": true,
  "off_session": true
}

Việc một charge MIT có bỏ qua hay cần 3DS hay không phụ thuộc vào quy tắc merchant của bạn. Vì không có khách hàng nào có mặt để hoàn tất thử thách, MIT thường được cấu hình để bỏ qua 3DS — thẻ đã được xác thực khi được lưu, và xác thực đó được mang theo. off_session=true yêu cầu payment_method_id — dữ liệu thẻ inline và confirmation token bị từ chối.

6. Kết quả thanh toán — 3DS, non-3DS & exemption ​

Khi bạn xác nhận một PaymentIntent với một payment_method_id đã lưu, Bahn đánh giá các quy tắc 3DS của bạn và trả về một trong ba kết quả:

Kết quảKhi nàoResponseViệc bạn cần làm
3DS bắt buộcQuy tắc 3DS của bạn (hoặc số tiền) yêu cầu xác thực và không có ngoại lệ nào áp dụng.status: requires_action + next_action + client_secretCIT: truyền client_secret cho SDK để chạy thử thách trực tiếp. MIT: không thể hoàn tất (không có khách hàng nào có mặt) — thanh toán sẽ không thể tiếp tục.
Non-3DSQuy tắc của bạn cho phép bỏ qua thử thách (ví dụ số tiền dưới ngưỡng).status: processingChờ webhook payment_intent.status.succeeded / payment_intent.payment_failed.
Ngoại lệ 3DS (3DS exemption)Xác thực được thỏa mãn mà không cần thử thách — ví dụ một device network token của ví điện tử (Google Pay DPAN), hoặc một ngoại lệ SCA như merchant-initiated (credential-on-file), giá trị thấp, hoặc TRA. Giao dịch được gắn cờ với ngoại lệ để trách nhiệm pháp lý được xử lý đúng.status: processingGiống như non-3DS — chờ webhook.

Ngoại lệ áp dụng tự động hiện nay là device network token của ví điện tử (Google Pay DPAN), có cryptogram mang theo xác thực và sự chuyển giao trách nhiệm. Các ngoại lệ SCA khác (MIT credential-on-file, giá trị thấp, TRA) được quản lý bởi quy tắc merchant của bạn.

7. Lưu thẻ trong khi thanh toán ​

Để lưu thẻ như một phần của một thanh toán thông thường (checkbox "lưu thẻ này cho lần sau") thay vì một SetupIntent riêng biệt, hãy truyền setup_future_usage trên PaymentIntent. Khi thanh toán thành công, thẻ được gắn với customer để tái sử dụng CIT/MIT trong tương lai.

{
  "amount": "10000",
  "currency": "usd",
  "customer_id": "cus_xxxxxxxx",
  "setup_future_usage": "off_session"
}

8. Xử lý webhook ​

Việc tính phí một thẻ đã lưu phát ra cùng các event như bất kỳ thanh toán nào khác. Hãy coi webhook là nguồn sự thật chính thức cho kết quả cuối cùng.

EventMô tả
payment_intent.status.succeededThanh toán đã được capture thành công. Đánh dấu đơn hàng / lần gia hạn đã thanh toán.
payment_intent.payment_failedCharge bị từ chối. Đối với MIT, thử lại sau hoặc thông báo cho khách hàng cập nhật thẻ.
charge.capturedCharge đã được capture.

Developers