Napas QR Payment
Accept payments via Napas QR — Vietnam's national QR-code interbank payment scheme. Unlike a card payment, the customer authorises and pays the transaction inside their own banking app after scanning a QR code displayed on your page.
Following the same pattern as Stripe's asynchronous payment methods (e.g. wechat_pay, alipay), Bahn returns a next_action of type display_qrcode from the confirm call. Your frontend reads the QR payload from that action and renders it for the customer to scan.
Preliminary
- Webhook
- API Integration
- One Time Payment — the card-based equivalent of this flow
High-Level Flow
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
How It Works
Phase 1 — Create PaymentIntent
- User chooses Napas QR — Merchant Web receives the action and requests a checkout from your backend.
- Frontend requests a checkout — Merchant Web sends a checkout request to your Merchant API.
- Backend calls
CreatePaymentIntent— Your Merchant API callsCreatePaymentIntentwithamount,currency(numeric704for VND), andpayment_method=napas_qr. - Bahn returns the PaymentIntent — Bahn responds with the
payment_intentand itsclient_secret. - Backend forwards only the
client_secret— Your Merchant API returns{ client_secret }to the frontend. Thepayment_intent.idand API key remain on your backend. - Frontend initializes the SDK — Merchant Web passes the
client_secretto the embedded Bahn SDK.
INFO
The client_secret is scoped to a single payment session and safe to expose to the client. Your payment_intent.id and API key must never leave your backend.
Phase 2 — Confirm & Receive next_action
Frontend calls
bahn.ConfirmNapasQRPayment— Merchant Web callsbahn.ConfirmNapasQRPayment(clientSecret). The SDK confirms the PaymentIntent withpayment_method=napas_qr. Because the payment method is asynchronous, Bahn does not capture immediately.tsconst result = await bahn.ConfirmNapasQRPayment(clientSecret); // result.payment_intent.status === 'requires_action' // result.payment_intent.next_action.type === 'display_qrcode'Bahn returns
requires_action— The response containsstatus: "requires_action"and anext_actiondescribing what your frontend must do next: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 surfaces the action to the frontend — The SDK emits the
next_actionpayload to Merchant Web.Frontend renders the QR — Merchant Web reads
next_action.display_qr_code.dataand renders it as a QR image using your QR library of choice (e.g.qrcodefor Node,qrcode.reactfor React). The string is an EMVCo merchant-presented QR payload conforming to the Napas specification.
Phase 3 — User Pays in Banking App
- User scans the QR — The user opens their banking app and scans the QR code from Merchant Web.
- Banking app shows transaction details — The app decodes the EMV payload and presents the merchant name, amount (
8000VND), and order reference. - User authorises — The user confirms the payment in their banking app, which settles the transaction over Napas.
This phase happens entirely on the user's device and the bank's rails. Your frontend stays on the QR-display screen and waits.
Phase 4 — Payment Result
Once Napas confirms the settlement to Bahn, your backend is notified:
- Webhook notification — Bahn delivers the
payment_intent.succeededevent to your registered webhook endpoint. Your Merchant API consumes this event and updates its order state. The webhook is the authoritative source of truth for the final payment outcome.
If the user abandons the QR screen or the QR expires, Bahn marks the PaymentIntent as canceled and a corresponding webhook event is delivered.
Phase 5 — Complete
- SDK surfaces the result — The SDK emits the final payment result to Merchant Web.
- Frontend fetches the order — Merchant Web calls your Merchant API to retrieve the latest order status (updated from the webhook).
- Backend returns the order — Your Merchant API responds with the order.
- User sees the outcome — Merchant Web renders the success or failure screen.
Merchant API Responsibilities
Your backend creates the QR session, reconciles the asynchronous result, and owns the order lifecycle.
1. Create a PaymentIntent
When the user picks Napas QR at checkout, call CreatePaymentIntent with the Napas QR payment method:
POST /api/payment_intentsRequest
{
"amount": "8000",
"currency": "704",
"payment_method": "napas_qr",
"description": "Order #9394905",
"metadata": {
"order_id": "9394905"
}
}| Field | Required | Description |
|---|---|---|
amount | Yes | Amount in the smallest unit of the currency. For VND there is no minor unit, so this is whole đồng (e.g. "8000" = 8,000 VND). |
currency | Yes | ISO 4217 numeric currency code. Use "704" for VND. |
payment_method | Yes | Must be "napas_qr" to receive a QR next_action. |
description | No | Human-readable description for the customer's bank statement. |
metadata | No | Arbitrary key-value pairs — typically your order_id for reconciliation. |
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"
}
}
}Return only the client_secret to your frontend. Keep payment_intent.id on your backend for webhook reconciliation.
Alternative — create and confirm server-side
Instead of letting the SDK confirm on the frontend, your backend can drive the confirmation itself and obtain the QR next_action directly. There are two ways to do this.
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
Option A — Create and confirm in a single call. Pass confirm: true along with payment_method_data and payment_method_options on the create request:
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": {}
}
}Option B — Confirm a PaymentIntent you already created. Call the confirm endpoint with the id and the same napas_qr payment-method payload:
POST /api/payment_intents/{id}/confirm{
"id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
"payment_method_data": {
"napas_qr": {}
},
"payment_method_options": {
"napas_qr": {}
}
}| Field | Required | Description |
|---|---|---|
confirm | No | Set to true on the create call to create and confirm in one step (Option A). Omit when confirming separately via the confirm endpoint (Option B). |
id | Conditional | The payment_intent.id to confirm. Required for Option B; not used in Option A. |
payment_method_data.napas_qr | Yes | Selects the Napas QR payment method. Currently an empty object ({}). |
payment_method_options.napas_qr | Yes | Per-confirmation options for Napas QR. Currently an empty object ({}); reserved for future Napas-specific fields. |
Either way, Bahn confirms the PaymentIntent and returns status: "requires_action" with the QR next_action:
{
"payment_intent": {
"id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
"amount": "8000",
"currency": "704",
"status": "requires_action",
"next_action": {
"type": "display_qrcode",
"display_qr_code": {
"data": "00020101021238630010A000000727013300065460340119QRPMT9NGE4SACXB3J6P0208QRIBFTTA520479225303704540480005802VN5903Tix6003HCM62180814Order 9394905263040CC5"
}
}
}
}Read next_action.display_qr_code.data on your backend and forward only that QR string to your frontend to render. Keep payment_intent.id and your API key on the backend. From here the flow is identical — the user scans the QR, pays in their banking app, and you reconcile the result from the webhook.
2. Render the QR on the Frontend
Your backend does not generate the QR. The Bahn SDK receives the next_action payload from the confirm call and surfaces it to Merchant Web. Your frontend renders it.
Minimal React example using 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>
);
}The display_qr_code.data string is an EMVCo merchant-presented QR payload. Render it at a size that's comfortable to scan from a phone camera (a 240 px square is a good default).
3. Handle the Webhook
payment_intent.succeeded is the authoritative signal that the customer paid. Treat it as the trigger for fulfilment.
| Event | Description |
|---|---|
payment_intent.succeeded | Customer's bank confirmed the Napas settlement. Mark the order as paid. |
payment_intent.canceled | Payment expired, was cancelled by the user, or was rejected by the bank. Free the order or offer a retry. |
INFO
Do not rely on the SDK's in-page status push as the source of truth. The user may close their browser between scanning the QR and the settlement landing. Always reconcile fulfilment from the webhook.
4. Handling Expiry and Retries
Bahn does not auto-cancel an unpaid Napas QR PaymentIntent. Your backend owns the decision of whether to cancel and when — typically driven by your own checkout timer or by an explicit user action ("Cancel payment" button).
To cancel a pending PaymentIntent, call CancelPaymentIntent:
POST /api/payment_intents/{id}/cancelRequest
{
"cancellation_reason": "abandoned"
}| Field | Required | Description |
|---|---|---|
cancellation_reason | No | Free-form reason for cancellation. Common values: abandoned, requested_by_customer, duplicate, fraudulent. |
Response — the updated PaymentIntent with status: "canceled" and canceled_at set. Bahn also delivers a payment_intent.status.canceled webhook event so any other consumers of the order are notified.
You can call CancelPaymentIntent while status is requires_action or processing. Once a PaymentIntent has reached succeeded, it can no longer be canceled — use a Refund instead.
Two outcomes after the user scans the QR:
| Outcome | What you do |
|---|---|
| User paid | payment_intent.status.succeeded webhook arrives. Mark the order paid. Do not call CancelPaymentIntent — the call is rejected once the intent is succeeded. |
| User did not pay in time | Your checkout timer fires (or the user clicks "Cancel"). Call CancelPaymentIntent. The intent transitions to canceled and the payment_intent.status.canceled webhook fires. |
To let the customer retry after a cancellation, create a new PaymentIntent — the previous client_secret and QR payload are no longer valid even if the customer eventually scans the old QR.