Skip to content

Thanh toán QR Napas ​

Chấp nhận thanh toán qua QR Napas — hệ thống thanh toán liên ngân hàng bằng mã QR quốc gia của Việt Nam. Khác với thanh toán thẻ, khách hàng ủy quyền và thanh toán giao dịch bên trong ứng dụng ngân hàng của chính họ sau khi quét mã QR hiển thị trên trang của bạn.

Theo cùng mẫu hình với các phương thức thanh toán bất đồng bộ của Stripe (ví dụ wechat_pay, alipay), Bahn trả về một next_action kiểu display_qrcode từ lệnh gọi confirm. Frontend của bạn đọc payload QR từ action đó và hiển thị nó cho khách hàng quét.

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

Luồng tổng quan ​

sequenceDiagram
    autonumber
    participant User as User
    participant BankApp as Banking App<br/>(User device)
    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: Phase 1: Create PaymentIntent
    User ->> MerchantWeb: Click "Pay with Napas QR"
    MerchantWeb ->> MerchantAPI: Request checkout
    MerchantAPI ->> API: CreatePaymentIntent(amount, currency=704,<br/>payment_method=napas_qr)
    API -->> MerchantAPI: {payment_intent, client_secret}
    MerchantAPI -->> MerchantWeb: {client_secret}
    MerchantWeb ->> SDK: Initialize SDK

    Note over User, API: Phase 2: Confirm & Receive next_action
    MerchantWeb ->> SDK: bahn.ConfirmNapasQRPayment(client_secret)
    SDK ->> API: Confirm with payment_method=napas_qr
    API -->> SDK: payment_intent.status = "requires_action"<br/>next_action.type = "display_qrcode"
    SDK -->> MerchantWeb: { payment_intent, next_action }
    MerchantWeb -->> User: Render QR code from next_action.display_qr_code.data

    Note over User, API: Phase 3: User Pays in Banking App
    User ->> BankApp: Open banking app, scan QR
    BankApp -->> User: Show payment details (amount, merchant)
    User ->> BankApp: Approve payment
    BankApp ->> API: Settle payment via Napas

    Note over MerchantAPI, API: Phase 4: Payment Result
    par Notify
        API ->> MerchantAPI: Webhook: payment_intent.succeeded
        MerchantAPI ->> MerchantAPI: Consumer webhook event
    end

    Note over User, API: Phase 5: Complete
    SDK -->> MerchantWeb: Payment result
    MerchantWeb ->> MerchantAPI: GET Order status
    MerchantAPI -->> MerchantWeb: Order
    MerchantWeb -->> User: Show order status

Cách hoạt động ​

Giai đoạn 1 — Tạo PaymentIntent ​

  1. Người dùng chọn QR Napas — Merchant Web nhận thao tác này và yêu cầu checkout từ backend của bạn.
  2. Frontend yêu cầu checkout — Merchant Web gửi request checkout đến Merchant API của bạn.
  3. Backend gọi CreatePaymentIntent — Merchant API của bạn gọi CreatePaymentIntent với amount, currency (mã số 704 cho VND), và payment_method=napas_qr.
  4. Bahn trả về PaymentIntent — Bahn phản hồi với payment_intent và client_secret tương ứng.
  5. Backend chỉ chuyển tiếp client_secret — Merchant API của bạn trả về { client_secret } cho frontend. payment_intent.id và API key vẫn ở phía backend.
  6. Frontend khởi tạo SDK — Merchant Web truyền client_secret cho Bahn SDK được nhúng.

INFO

client_secret chỉ có hiệu lực cho một phiên thanh toán duy nhất và an toàn khi expose cho client. payment_intent.id và API key của bạn không bao giờ được rời khỏi backend.

Giai đoạn 2 — Confirm & nhận next_action ​

  1. Frontend gọi bahn.ConfirmNapasQRPayment — Merchant Web gọi bahn.ConfirmNapasQRPayment(clientSecret). SDK xác nhận PaymentIntent với payment_method=napas_qr. Vì phương thức thanh toán này là bất đồng bộ, Bahn không capture ngay lập tức.

    ts
    const result = await bahn.ConfirmNapasQRPayment(clientSecret);
    // result.payment_intent.status === 'requires_action'
    // result.payment_intent.next_action.type === 'display_qrcode'
  2. Bahn trả về requires_action — Response chứa status: "requires_action" và một next_action mô tả những gì frontend của bạn cần làm tiếp theo:

    json
    {
      "payment_intent": {
        "client_secret": "pi_secret_ilAA93E16NV3uic90rq--tz-7FOTeupgN5DH6GYwji0",
        "amount": "8000",
        "currency": "704",
        "status": "requires_action",
        "next_action": {
          "type": "display_qrcode",
          "display_qr_code": {
            "data": "00020101021238630010A000000727013300065460340119QRPMT9NGE4SACXB3J6P0208QRIBFTTA520479225303704540480005802VN5903Tix6003HCM62180814Order 9394905263040CC5"
          }
        }
      }
    }
  3. SDK trả action về cho frontend — SDK phát ra payload next_action cho Merchant Web.

  4. Frontend hiển thị QR — Merchant Web đọc next_action.display_qr_code.data và hiển thị nó dưới dạng ảnh QR bằng thư viện QR bạn chọn (ví dụ qrcode cho Node, qrcode.react cho React). Chuỗi này là một payload EMVCo merchant-presented QR tuân theo đặc tả Napas.

Giai đoạn 3 — Người dùng thanh toán trong ứng dụng ngân hàng ​

  1. Người dùng quét QR — Người dùng mở ứng dụng ngân hàng của họ và quét mã QR từ Merchant Web.
  2. Ứng dụng ngân hàng hiển thị chi tiết giao dịch — App giải mã payload EMV và hiển thị tên merchant, số tiền (8000 VND), và mã đơn hàng.
  3. Người dùng ủy quyền — Người dùng xác nhận thanh toán trong ứng dụng ngân hàng, việc này settle giao dịch qua Napas.

Giai đoạn này diễn ra hoàn toàn trên thiết bị của người dùng và hạ tầng của ngân hàng. Frontend của bạn vẫn ở màn hình hiển thị QR và chờ đợi.

Giai đoạn 4 — Kết quả thanh toán ​

Khi Napas xác nhận việc settle cho Bahn, backend của bạn sẽ được thông báo:

  • Thông báo qua webhook — Bahn gửi event payment_intent.succeeded đến webhook endpoint đã đăng ký của bạn. Merchant API của bạn xử lý event này và cập nhật trạng thái đơn hàng. Webhook là nguồn sự thật chính thức cho kết quả thanh toán cuối cùng.

Nếu người dùng rời khỏi màn hình QR hoặc mã QR hết hạn, Bahn đánh dấu PaymentIntent là canceled và một webhook event tương ứng sẽ được gửi.

Giai đoạn 5 — Hoàn tất ​

  1. SDK trả kết quả về — SDK phát ra kết quả thanh toán cuối cùng cho Merchant Web.
  2. Frontend lấy thông tin đơn hàng — Merchant Web gọi Merchant API của bạn để lấy trạng thái đơn hàng mới nhất (đã được cập nhật từ webhook).
  3. Backend trả về đơn hàng — Merchant API của bạn phản hồi với đơn hàng.
  4. Người dùng thấy kết quả — Merchant Web hiển thị màn hình thành công hoặc thất bại.

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

Backend của bạn tạo phiên QR, đối chiếu kết quả bất đồng bộ, và sở hữu vòng đời đơn hàng.

1. Tạo một PaymentIntent ​

Khi người dùng chọn QR Napas tại checkout, gọi CreatePaymentIntent với phương thức thanh toán Napas QR:

http
POST /api/payment_intents

Request

json
{
  "amount": "8000",
  "currency": "704",
  "payment_method": "napas_qr",
  "description": "Order #9394905",
  "metadata": {
    "order_id": "9394905"
  }
}
TrườngBắt buộcMô tả
amountCóSố tiền theo đơn vị nhỏ nhất của tiền tệ. Với VND không có đơn vị lẻ, vì vậy đây là đồng nguyên (ví dụ "8000" = 8.000 VND).
currencyCóMã tiền tệ số ISO 4217. Dùng "704" cho VND.
payment_methodCóPhải là "napas_qr" để nhận được next_action dạng QR.
descriptionKhôngMô tả dễ đọc cho sao kê ngân hàng của khách hàng.
metadataKhôngCác cặp key-value tùy ý — thường là order_id của bạn để đối chiếu.

Response

json
{
  "payment_intent": {
    "id": "pi_xxxxxxxx",
    "client_secret": "pi_secret_ilAA93E16NV3uic90rq--tz-7FOTeupgN5DH6GYwji0",
    "amount": "8000",
    "currency": "704",
    "status": "requires_payment_method",
    "payment_method": "napas_qr",
    "metadata": {
      "order_id": "9394905"
    }
  }
}

Chỉ trả về client_secret cho frontend của bạn. Giữ payment_intent.id ở backend để đối chiếu webhook.

Cách khác — tạo và xác nhận phía server ​

Thay vì để SDK xác nhận ở frontend, backend của bạn có thể tự điều khiển việc xác nhận và lấy next_action dạng QR trực tiếp. Có hai cách để làm điều này.

sequenceDiagram
    autonumber
    participant User as User
    participant BankApp as Banking App<br/>(User device)
    participant MerchantWeb as Merchant Web<br/>(Frontend)
    participant MerchantAPI as Merchant API<br/>(Backend)
    participant API as Bahn API

    Note over User, API: Phase 1: Create & Confirm (server-side)
    User ->> MerchantWeb: Click "Pay with Napas QR"
    MerchantWeb ->> MerchantAPI: Request checkout

    alt Option A — single call
    MerchantAPI ->> API: CreatePaymentIntent(amount, currency=704,<br/>confirm=true, payment_method_data=napas_qr)
    else Option B — confirm existing intent
    MerchantAPI ->> API: CreatePaymentIntent(amount, currency=704)
    API -->> MerchantAPI: {payment_intent.id}
    MerchantAPI ->> API: ConfirmPaymentIntent(id,<br/>payment_method_data=napas_qr)
    end

    API -->> MerchantAPI: status = "requires_action"<br/>next_action.type = "display_qrcode"
    MerchantAPI -->> MerchantWeb: { qr_data }

    Note over User, API: Phase 2: Render QR
    MerchantWeb -->> User: Render QR from next_action.display_qr_code.data

    Note over User, API: Phase 3: User Pays in Banking App
    User ->> BankApp: Open banking app, scan QR
    BankApp -->> User: Show payment details (amount, merchant)
    User ->> BankApp: Approve payment
    BankApp ->> API: Settle payment via Napas

    Note over MerchantAPI, API: Phase 4: Payment Result
    par Notify
    API ->> MerchantAPI: Webhook: payment_intent.succeeded
    MerchantAPI ->> MerchantAPI: Consume webhook event
    end

    Note over User, API: Phase 5: Complete
    MerchantWeb ->> MerchantAPI: GET Order status
    MerchantAPI -->> MerchantWeb: Order
    MerchantWeb -->> User: Show order status

Phương án A — Tạo và xác nhận trong một lệnh gọi. Truyền confirm: true cùng với payment_method_data và payment_method_options trên request tạo:

POST /api/payment_intents
{
  "amount": "8000",
  "currency": "704",
  "description": "Order #9394905",
  "confirm": true,
  "metadata": {
    "order_id": "9394905"
  },
  "payment_method_data": {
    "napas_qr": {}
  },
  "payment_method_options": {
    "napas_qr": {}
  }
}

Phương án B — Xác nhận một PaymentIntent bạn đã tạo trước đó. Gọi endpoint confirm với id và cùng payload phương thức thanh toán napas_qr:

POST /api/payment_intents/{id}/confirm
{
  "id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
  "payment_method_data": {
    "napas_qr": {}
  },
  "payment_method_options": {
    "napas_qr": {}
  }
}
TrườngBắt buộcMô tả
confirmKhôngĐặt thành true trên lệnh gọi tạo để tạo và xác nhận trong một bước (Phương án A). Bỏ qua khi xác nhận riêng qua endpoint confirm (Phương án B).
idCó điều kiệnpayment_intent.id cần xác nhận. Bắt buộc cho Phương án B; không dùng trong Phương án A.
payment_method_data.napas_qrCóChọn phương thức thanh toán Napas QR. Hiện tại là một object rỗng ({}).
payment_method_options.napas_qrCóCác tùy chọn riêng cho từng lần xác nhận Napas QR. Hiện tại là một object rỗng ({}); dành riêng cho các trường đặc thù Napas trong tương lai.

Dù theo cách nào, Bahn cũng sẽ xác nhận PaymentIntent và trả về status: "requires_action" cùng next_action dạng QR:

{
  "payment_intent": {
    "id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
    "amount": "8000",
    "currency": "704",
    "status": "requires_action",
    "next_action": {
      "type": "display_qrcode",
      "display_qr_code": {
        "data": "00020101021238630010A000000727013300065460340119QRPMT9NGE4SACXB3J6P0208QRIBFTTA520479225303704540480005802VN5903Tix6003HCM62180814Order 9394905263040CC5"
      }
    }
  }
}

Đọc next_action.display_qr_code.data ở backend của bạn và chỉ chuyển tiếp chuỗi QR đó cho frontend để hiển thị. Giữ payment_intent.id và API key của bạn ở backend. Từ đây, luồng này giống hệt — người dùng quét QR, thanh toán trong ứng dụng ngân hàng, và bạn đối chiếu kết quả từ webhook.

2. Hiển thị QR trên frontend ​

Backend của bạn không tạo ra mã QR. Bahn SDK nhận payload next_action từ lệnh gọi confirm và trả nó về cho Merchant Web. Frontend của bạn hiển thị nó.

Ví dụ React tối giản dùng qrcode.react:

tsx
import { QRCodeCanvas } from 'qrcode.react';

function NapasQrScreen({ nextAction }: { nextAction: NextAction }) {
  if (nextAction.type !== 'display_qrcode') return null;
  return (
    <div className="qr-pane">
      <h2>Scan with your banking app</h2>
      <QRCodeCanvas value={nextAction.display_qr_code.data} size={240} />
      <p>Amount: 8,000 ₫</p>
    </div>
  );
}

Chuỗi display_qr_code.data là một payload EMVCo merchant-presented QR. Hiển thị nó với kích thước dễ quét bằng camera điện thoại (một hình vuông 240 px là mặc định hợp lý).

3. Xử lý webhook ​

payment_intent.succeeded là tín hiệu chính thức cho biết khách hàng đã thanh toán. Hãy coi đây là điểm kích hoạt cho việc xử lý đơn hàng.

EventMô tả
payment_intent.succeededNgân hàng của khách hàng đã xác nhận việc settle qua Napas. Đánh dấu đơn hàng đã thanh toán.
payment_intent.canceledThanh toán đã hết hạn, bị người dùng hủy, hoặc bị ngân hàng từ chối. Giải phóng đơn hàng hoặc đề nghị thử lại.

INFO

Không dựa vào việc push trạng thái trong trang của SDK làm nguồn sự thật. Người dùng có thể đóng trình duyệt giữa lúc quét QR và lúc việc settle hoàn tất. Luôn đối chiếu việc xử lý đơn hàng dựa trên webhook.

4. Xử lý hết hạn và thử lại ​

Bahn không tự động hủy một PaymentIntent QR Napas chưa được thanh toán. Backend của bạn sở hữu quyết định có hủy hay không và khi nào — thường được điều khiển bởi bộ đếm thời gian checkout của riêng bạn hoặc bởi một hành động rõ ràng của người dùng (nút "Hủy thanh toán").

Để hủy một PaymentIntent đang chờ, hãy gọi CancelPaymentIntent:

http
POST /api/payment_intents/{id}/cancel

Request

json
{
  "cancellation_reason": "abandoned"
}
TrườngBắt buộcMô tả
cancellation_reasonKhôngLý do hủy dạng tự do. Các giá trị phổ biến: abandoned, requested_by_customer, duplicate, fraudulent.

Response — PaymentIntent đã cập nhật với status: "canceled" và canceled_at được đặt. Bahn cũng gửi một webhook event payment_intent.status.canceled để mọi bên tiêu thụ khác của đơn hàng được thông báo.

Bạn có thể gọi CancelPaymentIntent khi status là requires_action hoặc processing. Khi một PaymentIntent đã đạt trạng thái succeeded, nó không thể bị hủy nữa — hãy dùng Refund thay vào đó.

Hai kết quả sau khi người dùng quét QR:

Kết quảViệc bạn cần làm
Người dùng đã thanh toánWebhook payment_intent.status.succeeded sẽ đến. Đánh dấu đơn hàng đã thanh toán. Không gọi CancelPaymentIntent — lệnh gọi sẽ bị từ chối khi intent đã ở trạng thái succeeded.
Người dùng không thanh toán kịp thờiBộ đếm thời gian checkout của bạn kích hoạt (hoặc người dùng nhấn "Hủy"). Gọi CancelPaymentIntent. Intent chuyển sang canceled và webhook payment_intent.status.canceled được kích hoạt.

Để cho phép khách hàng thử lại sau khi hủy, hãy tạo một PaymentIntent mới — client_secret và payload QR trước đó không còn hợp lệ ngay cả khi khách hàng cuối cùng cũng quét mã QR cũ.

Developers