> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thedecard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# QR Pay

> QR Pay is a spending capability specific to the DeCard-Managed model: the three-step flow of decode, create and confirm, plus the order-list and order-detail queries, with the debit landing on the user's DeCard-Managed account balance.

> **Scope**: QR Pay is a spending capability **available only in the DeCard-Managed model**. This page belongs to the Managing Transactions group.

## Scan, pay, and the same balance is debited

QR Pay lets a cardholder pay by **scanning a code** instead of swiping or tapping a physical card, at any merchant that accepts QR payments. Just as with a card authorization, **the debit ultimately lands on the user's DeCard-Managed account balance**: the available balance falls and the total falls with it (referred to below by the internal DCS shorthand `free` and `total`; at API level `free` maps to `availableBalance`, while the held portion is `frozenBalance`, see [Account and Asset Model](../../basic-concepts/ledgering-system)). It is the same balance a card payment draws on. The only difference is how the payment starts: QR Pay does not travel through a card authorization message on the card network. Instead the DCS acquiring side decodes the code and creates the order, and you then guide the cardholder to confirm the payment.

QR Pay consists of 5 endpoints, all under the `/qrpay/v1/` prefix: the main payment flow of **decode, create and confirm**, plus **order-list and order-detail** for queries.

## Prerequisites

* The user is registered in the DeCard-Managed model, has passed KYC and holds an `externalUserId` (see [Signing Up a Customer](../signing-up-a-customer/overview)).
* The user holds a card and their **DeCard-Managed account holds enough available balance (`free`)** (for top-ups, see [User Balance](./user-balance)).
* Callers must send the site-wide authentication headers (see the [Authentication Guide](../../integration-resources/overview)); the examples below omit those headers and show only the business payload.

<Note>
  The site-wide response envelope is `CommonRet`: `{ code, message, messageDetail{message,title,type,icon,action,linkTitle,linkUrl}, data }`, with **no `success` boolean**; on success, `code = SYS_SUCCESS`. Every sample response below uses this envelope.
</Note>

***

## The payment flow

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/dcs-0bf7a937/oUoeUKtwWWK2o-Li/imgs/en/diagrams/va-qrpay-flow-light.svg?fit=max&auto=format&n=oUoeUKtwWWK2o-Li&q=85&s=21ccb192714e5fbc573af55805a1f6bf" alt="QR Pay payment flow" width="560" height="674" data-path="imgs/en/diagrams/va-qrpay-flow-light.svg" />

  <img className="hidden dark:block" src="https://mintcdn.com/dcs-0bf7a937/oUoeUKtwWWK2o-Li/imgs/en/diagrams/va-qrpay-flow-dark.svg?fit=max&auto=format&n=oUoeUKtwWWK2o-Li&q=85&s=8e9874e77af42e0edf959f26e1ecac3c" alt="QR Pay payment flow" width="560" height="674" data-path="imgs/en/diagrams/va-qrpay-flow-dark.svg" />
</Frame>

<Warning>
  `decode` only decodes and previews, and **debits nothing**; the user's balance is only held or debited at the `confirm` step. Between `create` and `confirm` the order is valid until its `expiryTime` (a millisecond timestamp), after which you have to start again.
</Warning>

***

## 1. Decode the QR code

**`POST /qrpay/v1/decode`** decodes the QR code and returns an order preview (amount, merchant, exchange rate, fee, limits) for your app to show the cardholder for confirmation.

### Request

| Field            | Type   | Required | Description                                            | Constraint         |
| ---------------- | ------ | -------- | ------------------------------------------------------ | ------------------ |
| `externalUserId` | string | Yes      | External user ID                                       | Max 64 characters  |
| `qrCodeValue`    | string | Yes      | Scanned value (the raw QR code or payment code string) | Max 512 characters |
| `clientIP`       | string | Yes      | Client request IP                                      | Max 50 characters  |

```json theme={null}
{
  "externalUserId": "<external-user-id>",
  "qrCodeValue": "<qr-code-value>",
  "clientIP": "<client-ip>"
}
```

### Response (`data`)

| Field                       | Type      | Description                                                                               | Constraint          |
| --------------------------- | --------- | ----------------------------------------------------------------------------------------- | ------------------- |
| `needCashier`               | boolean   | Whether the cashier page is required (`true` = required, `false` = not required)          |                     |
| `cashierExtensionInfo`      | string    | Cashier extension information                                                             | Max 1000 characters |
| `orderId`                   | string    | System order number                                                                       | Max 20 characters   |
| `acqCurrency`               | string    | Acquiring currency (SGD, for example)                                                     | Max 3 characters    |
| `acqAmount`                 | number    | Acquiring amount                                                                          |                     |
| `payCurrency`               | string    | Actual payment currency                                                                   | Max 3 characters    |
| `payAmount`                 | number    | Actual payment amount                                                                     |                     |
| `merchantName`              | string    | Acquiring merchant name                                                                   | Max 128 characters  |
| `expiryTime`                | integer   | Order expiry timestamp (milliseconds)                                                     |                     |
| `qrCodeType`                | string    | QR code type                                                                              | Max 20 characters   |
| `promoInfo`                 | object\[] | List of promotions                                                                        | See below           |
| `promoInfo[].promoName`     | string    | Promotion name                                                                            | Max 128 characters  |
| `promoInfo[].promoCurrency` | string    | Promotion currency                                                                        | Max 3 characters    |
| `promoInfo[].promoAmount`   | number    | Promotion amount                                                                          |                     |
| `sdkActionType`             | string    | SDK action type (A+ only): `HANDLE_BY_PSP` / `HANDLE_BY_SDK`                              | Max 20 characters   |
| `sdkActionPayload`          | string    | SDK action payload (A+ with `HANDLE_BY_SDK` only)                                         | Max 512 characters  |
| `rateInfo`                  | object    | Exchange rate information (`baseCurrency` / `quoteCurrency` / `rate`)                     |                     |
| `usdAmount`                 | number    | Amount in USD                                                                             |                     |
| `feeAmount`                 | number    | Fee amount                                                                                |                     |
| `feeCurrency`               | string    | Fee currency                                                                              | Max 3 characters    |
| `minAmount`                 | number    | Minimum amount (in the merchant's collection currency)                                    |                     |
| `maxAmount`                 | number    | Maximum amount (in the merchant's collection currency)                                    |                     |
| `maxSingleAmount`           | number    | Maximum single-payment amount (in the merchant's collection currency)                     |                     |
| `remainingAmount`           | number    | The user's remaining allowance for the day (in the merchant's acquiring currency)         |                     |
| `cashierSession`            | string    | Cashier session (returned when `needCashier=true`)                                        | Max 32 characters   |
| `acqOrderNo`                | string    | Merchant acquiring order number                                                           | Max 32 characters   |
| `orderType`                 | string    | Order type: the customer scans the merchant code, or the merchant scans the customer code | Max 15 characters   |

```json theme={null}
{
  "code": "SYS_SUCCESS",
  "message": "",
  "messageDetail": null,
  "data": {
    "needCashier": false,
    "orderId": "<order-id>",
    "acqCurrency": "SGD",
    "acqAmount": 12.50,
    "payCurrency": "USD",
    "payAmount": 9.30,
    "merchantName": "<merchant-name>",
    "expiryTime": 1717855575000,
    "qrCodeType": "<qr-code-type>",
    "promoInfo": [],
    "rateInfo": { "baseCurrency": "SGD", "quoteCurrency": "USD", "rate": "0.744" },
    "usdAmount": 9.30,
    "feeAmount": 0.10,
    "feeCurrency": "USD",
    "minAmount": 0.01,
    "maxAmount": 5000.00,
    "maxSingleAmount": 2000.00,
    "remainingAmount": 1990.70,
    "acqOrderNo": "<acq-order-no>",
    "orderType": "POSITIVE_SCAN"
  }
}
```

***

## 2. Create the order

**`POST /qrpay/v1/create`** creates and locks the order once the amount has been decoded and confirmed. Compared with `decode` it also accepts `currency` and `amount`, for the case where the user enters the amount because the merchant code carries none.

### Request

| Field            | Type   | Required | Description                                                                    | Constraint         |
| ---------------- | ------ | -------- | ------------------------------------------------------------------------------ | ------------------ |
| `externalUserId` | string | Yes      | External user ID                                                               | Max 64 characters  |
| `qrCodeValue`    | string | Yes      | Scanned value                                                                  | Max 512 characters |
| `clientIP`       | string | Yes      | Client request IP                                                              | Max 50 characters  |
| `currency`       | string | No       | Currency (SGD, for example), required when the merchant code carries no amount | Max 10 characters  |
| `amount`         | string | No       | Amount, required when the merchant code carries no amount                      | Max 20 characters  |

<Warning>
  The `currency` value "SGD" in the example is illustrative only. Send the currency that matches your acquiring or payment scenario, and do not hard-code SGD.
</Warning>

```json theme={null}
{
  "externalUserId": "<external-user-id>",
  "qrCodeValue": "<qr-code-value>",
  "clientIP": "<client-ip>",
  "currency": "SGD",
  "amount": "12.50"
}
```

### Response (`data`)

The fields are largely the same as the `data` returned by `decode` (`orderId` / `acqCurrency` / `acqAmount` / `payCurrency` / `payAmount` / `merchantName` / `expiryTime` / `qrCodeType` / `promoInfo` / `sdkActionType` / `sdkActionPayload` / `rateInfo` / `usdAmount` / `feeAmount` / `feeCurrency` / `acqOrderNo` / `orderType`), minus `needCashier`, `cashierSession` and the limit fields. Carry the returned `orderId` into the confirmation step.

```json theme={null}
{
  "code": "SYS_SUCCESS",
  "message": "",
  "messageDetail": null,
  "data": {
    "orderId": "<order-id>",
    "acqCurrency": "SGD",
    "acqAmount": 12.50,
    "payCurrency": "USD",
    "payAmount": 9.30,
    "merchantName": "<merchant-name>",
    "expiryTime": 1717855575000,
    "qrCodeType": "<qr-code-type>",
    "promoInfo": [],
    "sdkActionType": "",
    "sdkActionPayload": "",
    "rateInfo": { "baseCurrency": "SGD", "quoteCurrency": "USD", "rate": "0.744" },
    "usdAmount": 9.30,
    "feeAmount": 0.10,
    "feeCurrency": "USD",
    "acqOrderNo": "<acq-order-no>",
    "orderType": "POSITIVE_SCAN"
  }
}
```

***

## 3. Confirm the payment

**`POST /qrpay/v1/confirm`** confirms payment for an `orderId` that has already been created. This step **actually debits the user's DeCard-Managed account** and fails if the available balance (`free`) is insufficient.

### Request

| Field            | Type   | Required | Description                     | Constraint        |
| ---------------- | ------ | -------- | ------------------------------- | ----------------- |
| `externalUserId` | string | Yes      | External user ID                | Max 64 characters |
| `orderId`        | string | Yes      | Order ID (returned by `create`) | Max 50 characters |

<Warning>
  `externalUserId` is the **external user ID**, the same as on every other endpoint on this page.
</Warning>

```json theme={null}
{
  "externalUserId": "<external-user-id>",
  "orderId": "<order-id>"
}
```

### Response (`data`)

| Field         | Type   | Description                                                     | Constraint         |
| ------------- | ------ | --------------------------------------------------------------- | ------------------ |
| `payStatus`   | string | Payment status                                                  | Max 10 characters  |
| `transId`     | string | Transaction ID (optional)                                       | Max 20 characters  |
| `bizTransId`  | string | Business transaction ID (optional)                              | Max 32 characters  |
| `redirectUrl` | string | Redirect URL after payment (optional, for the cashier scenario) | Max 256 characters |

```json theme={null}
{
  "code": "SYS_SUCCESS",
  "message": "",
  "messageDetail": null,
  "data": {
    "payStatus": "SUCCESS",
    "transId": "<trans-id>",
    "bizTransId": "<biz-trans-id>",
    "redirectUrl": ""
  }
}
```

<Note>
  A successful `confirm` means the payment has been accepted, not completed. If a `redirectUrl` comes back (the cashier or step-up verification scenario), send the cardholder through it. The final state comes from `transList` in `order-detail` or from the webhook. The balance movement caused by the debit is notified through the `BALANCE_CHANGE` webhook, see [Webhook & WebSocket](../../integration-resources/webhook-websocket).
</Note>

***

## Querying orders

### 4. Order list

**`GET /qrpay/v1/order-list`** returns a user's QR Pay orders with cursor pagination.

| Query parameter  | Type   | Required | Description                                                                            | Constraint        |
| ---------------- | ------ | -------- | -------------------------------------------------------------------------------------- | ----------------- |
| `externalUserId` | string | Yes      | External user ID                                                                       | Max 64 characters |
| `cursorOrderId`  | string | No       | Cursor order ID (for paging, pass the `orderId` of the last item on the previous page) |                   |
| `limit`          | string | No       | Items per page, `"30"` by default (passed as a string in the query)                    |                   |

```
GET /qrpay/v1/order-list?externalUserId=<external-user-id>&cursorOrderId=<cursor>&limit=30
```

`data` comes back as an **array**, where each item carries:

| Field          | Type    | Description             | Constraint         |
| -------------- | ------- | ----------------------- | ------------------ |
| `orderId`      | integer | System order number     |                    |
| `orderStatus`  | string  | Order status            | Max 10 characters  |
| `acqCurrency`  | string  | Acquiring currency      | Max 3 characters   |
| `acqAmount`    | number  | Acquiring amount        |                    |
| `payCurrency`  | string  | Payment currency        | Max 3 characters   |
| `payAmount`    | number  | Payment amount          |                    |
| `createTime`   | string  | Creation time           |                    |
| `merchantName` | string  | Acquiring merchant name | Max 128 characters |

```json theme={null}
{
  "code": "SYS_SUCCESS",
  "message": "",
  "messageDetail": null,
  "data": [
    {
      "orderId": 100000000000000001,
      "orderStatus": "SUCCESS",
      "acqCurrency": "SGD",
      "acqAmount": 12.50,
      "payCurrency": "USD",
      "payAmount": 9.30,
      "createTime": "2026-06-08 14:16:15",
      "merchantName": "<merchant-name>"
    }
  ]
}
```

> Cursor pagination: take the `orderId` of the last item on this page as the `cursorOrderId` of the next request; an empty array means you have reached the last page.

### 5. Order detail

**`GET /qrpay/v1/order-detail`** returns one order in detail, including its status and the underlying transaction list `transList`.

| Query parameter  | Type   | Required | Description      | Constraint        |
| ---------------- | ------ | -------- | ---------------- | ----------------- |
| `externalUserId` | string | Yes      | External user ID | Max 64 characters |
| `orderId`        | string | Yes      | Order ID         | Max 50 characters |

```
GET /qrpay/v1/order-detail?externalUserId=<external-user-id>&orderId=<order-id>
```

Beyond the amount, merchant, exchange rate and fee fields shared with `decode` and `create`, `data` also carries:

| Field                     | Type      | Description                                                     | Constraint        |
| ------------------------- | --------- | --------------------------------------------------------------- | ----------------- |
| `orderStatus`             | string    | Order status                                                    | Max 10 characters |
| `createTime`              | string    | Transaction time                                                |                   |
| `transList`               | object\[] | Transaction list (see below)                                    |                   |
| `transList[].transId`     | integer   | Transaction number                                              | Up to 20 digits   |
| `transList[].transType`   | string    | Transaction type: `PAY` / `REFUND` / `REFUND_PART` / `REVERSAL` | Max 20 characters |
| `transList[].transStatus` | string    | Transaction status: `INIT` / `SUCCESS` / `FAILED`               | Max 10 characters |
| `transList[].transTime`   | string    | Transaction time                                                | Max 19 characters |

One order can have more than one underlying transaction, for instance a `PAY` followed by a `REFUND_PART`. The `transType` and `transStatus` enumerations:

| `transType`   | Meaning        |
| ------------- | -------------- |
| `PAY`         | Payment        |
| `REFUND`      | Full refund    |
| `REFUND_PART` | Partial refund |
| `REVERSAL`    | Reversal       |

| `transStatus` | Meaning              |
| ------------- | -------------------- |
| `INIT`        | Processing (initial) |
| `SUCCESS`     | Succeeded            |
| `FAILED`      | Failed               |

> `transList[].transId` is defined as an integer in the API; the "20" is the maximum number of digits, not a string length.

```json theme={null}
{
  "code": "SYS_SUCCESS",
  "message": "",
  "messageDetail": null,
  "data": {
    "orderId": "<order-id>",
    "acqCurrency": "SGD",
    "acqAmount": 12.50,
    "payCurrency": "USD",
    "payAmount": 9.30,
    "merchantName": "<merchant-name>",
    "expiryTime": 1717855575000,
    "qrCodeType": "<qr-code-type>",
    "promoInfo": [],
    "sdkActionType": "",
    "sdkActionPayload": "",
    "rateInfo": { "baseCurrency": "SGD", "quoteCurrency": "USD", "rate": "0.744" },
    "usdAmount": 9.30,
    "orderStatus": "SUCCESS",
    "createTime": "2026-06-08 14:16:15",
    "transList": [
      {
        "transId": 200000000000000001,
        "transType": "PAY",
        "transStatus": "SUCCESS",
        "transTime": "2026-06-08 14:16:15"
      }
    ],
    "feeAmount": 0.10,
    "feeCurrency": "USD",
    "acqOrderNo": "<acq-order-no>",
    "orderType": "POSITIVE_SCAN"
  }
}
```

***

## Error handling

* When a call does not succeed, `code ≠ SYS_SUCCESS` and `message` and `messageDetail` carry readable information. Judge the outcome from `code` and the business result in `data`, never from an HTTP 200 alone.
* Common failure causes: insufficient `free` balance on the user's DeCard-Managed account (at `confirm`), an expired order (past `expiryTime`), an invalid or unsupported QR code (at `decode`), and an amount outside `minAmount`, `maxAmount`, `maxSingleAmount` or the remaining daily allowance `remainingAmount`.
* A `confirm` that returns as accepted can still fail asynchronously; the final state comes from `transList[].transStatus` in `order-detail` or from the webhook.
* For the complete error code dictionary, ask the DCS team.

## How this relates to the DeCard-Managed balance model

QR Pay and card spending share the same per-user account: the debit happens at `confirm` and lands on the user's **DeCard-Managed account** (the `free` available balance). (`free` and `total` are not returned by these endpoints; those balance fields come from the account model APIs such as `/user-asset/v1/balance`, where `free` maps to `availableBalance` and the held portion to `frozenBalance`. See [User Balance](./user-balance) and [Account and Asset Model (Ledgering)](../../basic-concepts/ledgering-system).) Make sure the user account has been topped up before starting a QR Pay.

## Next steps

* Account and asset model (`free` / `freeze` / `total`): [Account and Asset Model (Ledgering)](../../basic-concepts/ledgering-system)
* Reading and adjusting user balances: [User Balance](./user-balance)
* Receiving payment result and balance change notifications: [Webhook & WebSocket](../../integration-resources/webhook-websocket)
* Authentication headers and IP allowlisting: [Authentication Guide](../../integration-resources/overview)
