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
- Webhook
- Tích hợp API
- Thanh toán một lần — phiên bản tương đương dựa trên thẻ của luồng này
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
- 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.
- Frontend yêu cầu checkout — Merchant Web gửi request checkout đến Merchant API của bạn.
- Backend gọi
CreatePaymentIntent— Merchant API của bạn gọiCreatePaymentIntentvớiamount,currency(mã số704cho VND), vàpayment_method=napas_qr. - Bahn trả về PaymentIntent — Bahn phản hồi với
payment_intentvàclient_secrettương ứng. - Backend chỉ chuyển tiếp
client_secret— Merchant API của bạn trả về{ client_secret }cho frontend.payment_intent.idvà API key vẫn ở phía backend. - Frontend khởi tạo SDK — Merchant Web truyền
client_secretcho 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
Frontend gọi
bahn.ConfirmNapasQRPayment— Merchant Web gọibahn.ConfirmNapasQRPayment(clientSecret). SDK xác nhận PaymentIntent vớipayment_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.tsconst result = await bahn.ConfirmNapasQRPayment(clientSecret); // result.payment_intent.status === 'requires_action' // result.payment_intent.next_action.type === 'display_qrcode'Bahn trả về
requires_action— Response chứastatus: "requires_action"và mộtnext_actionmô 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" } } } }SDK trả action về cho frontend — SDK phát ra payload
next_actioncho Merchant Web.Frontend hiển thị QR — Merchant Web đọc
next_action.display_qr_code.datavà hiển thị nó dưới dạng ảnh QR bằng thư viện QR bạn chọn (ví dụqrcodecho Node,qrcode.reactcho 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
- 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.
- Ứ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 (
8000VND), và mã đơn hàng. - 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
- SDK trả kết quả về — SDK phát ra kết quả thanh toán cuối cùng cho Merchant Web.
- 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).
- Backend trả về đơn hàng — Merchant API của bạn phản hồi với đơn hàng.
- 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:
POST /api/payment_intentsRequest
{
"amount": "8000",
"currency": "704",
"payment_method": "napas_qr",
"description": "Order #9394905",
"metadata": {
"order_id": "9394905"
}
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
amount | Có | 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). |
currency | Có | Mã tiền tệ số ISO 4217. Dùng "704" cho VND. |
payment_method | Có | Phải là "napas_qr" để nhận được next_action dạng QR. |
description | Không | Mô tả dễ đọc cho sao kê ngân hàng của khách hàng. |
metadata | Không | Các cặp key-value tùy ý — thường là order_id của bạn để đối chiếu. |
Response
{
"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ường | Bắt buộc | Mô tả |
|---|---|---|
confirm | Khô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). |
id | Có điều kiện | payment_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_qr | Có | 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_qr | Có | 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:
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.
| Event | Mô tả |
|---|---|
payment_intent.succeeded | Ngâ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.canceled | Thanh 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:
POST /api/payment_intents/{id}/cancelRequest
{
"cancellation_reason": "abandoned"
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
cancellation_reason | Không | Lý 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án | Webhook 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ời | Bộ đế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ũ.