Skip to content

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 ​

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 ​

  1. User chooses Napas QR — Merchant Web receives the action and requests a checkout from your backend.
  2. Frontend requests a checkout — Merchant Web sends a checkout request to your Merchant API.
  3. Backend calls CreatePaymentIntent — Your Merchant API calls CreatePaymentIntent with amount, currency (numeric 704 for VND), and payment_method=napas_qr.
  4. Bahn returns the PaymentIntent — Bahn responds with the payment_intent and its client_secret.
  5. Backend forwards only the client_secret — Your Merchant API returns { client_secret } to the frontend. The payment_intent.id and API key remain on your backend.
  6. Frontend initializes the SDK — Merchant Web passes the client_secret to 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 ​

  1. Frontend calls bahn.ConfirmNapasQRPayment — Merchant Web calls bahn.ConfirmNapasQRPayment(clientSecret). The SDK confirms the PaymentIntent with payment_method=napas_qr. Because the payment method is asynchronous, Bahn does not capture immediately.

    ts
    const result = await bahn.ConfirmNapasQRPayment(clientSecret);
    // result.payment_intent.status === 'requires_action'
    // result.payment_intent.next_action.type === 'display_qrcode'
  2. Bahn returns requires_action — The response contains status: "requires_action" and a next_action describing 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"
          }
        }
      }
    }
  3. SDK surfaces the action to the frontend — The SDK emits the next_action payload to Merchant Web.

  4. Frontend renders the QR — Merchant Web reads next_action.display_qr_code.data and renders it as a QR image using your QR library of choice (e.g. qrcode for Node, qrcode.react for React). The string is an EMVCo merchant-presented QR payload conforming to the Napas specification.

Phase 3 — User Pays in Banking App ​

  1. User scans the QR — The user opens their banking app and scans the QR code from Merchant Web.
  2. Banking app shows transaction details — The app decodes the EMV payload and presents the merchant name, amount (8000 VND), and order reference.
  3. 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.succeeded event 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 ​

  1. SDK surfaces the result — The SDK emits the final payment result to Merchant Web.
  2. Frontend fetches the order — Merchant Web calls your Merchant API to retrieve the latest order status (updated from the webhook).
  3. Backend returns the order — Your Merchant API responds with the order.
  4. 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:

http
POST /api/payment_intents

Request

json
{
  "amount": "8000",
  "currency": "704",
  "payment_method": "napas_qr",
  "description": "Order #9394905",
  "metadata": {
    "order_id": "9394905"
  }
}
FieldRequiredDescription
amountYesAmount 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).
currencyYesISO 4217 numeric currency code. Use "704" for VND.
payment_methodYesMust be "napas_qr" to receive a QR next_action.
descriptionNoHuman-readable description for the customer's bank statement.
metadataNoArbitrary key-value pairs — typically your order_id for reconciliation.

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"
    }
  }
}

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": {}
  }
}
FieldRequiredDescription
confirmNoSet 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).
idConditionalThe payment_intent.id to confirm. Required for Option B; not used in Option A.
payment_method_data.napas_qrYesSelects the Napas QR payment method. Currently an empty object ({}).
payment_method_options.napas_qrYesPer-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:

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

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.

EventDescription
payment_intent.succeededCustomer's bank confirmed the Napas settlement. Mark the order as paid.
payment_intent.canceledPayment 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:

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

Request

json
{
  "cancellation_reason": "abandoned"
}
FieldRequiredDescription
cancellation_reasonNoFree-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:

OutcomeWhat you do
User paidpayment_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 timeYour 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.

Developers