Z Zise Developers 简体中文

Pay by QR: hold funds before release; processing means outcome unknown, await webhook

POST /v1/qrpay/payments scope: qrpay:write
On behalf of a member · x-on-behalf-of required Requires x-idempotency-key Moves funds · Hold the member’s available asset balance before notifying the upstream to release payment
This endpoint moves funds

See the response table below for failure handling. Retry timeouts (504) with the same idempotency key — we may already have processed the request; use a new key after a business failure; the same key replays that failure.

funding_source defaults to member; merchant is available only in merchant-hosted mode and debits the merchant prepaid account. Merchant debit = the current upstream digital-asset quote principal + platform fee. The receiving merchant receives the full acquirer_amount. The platform fee is principal × the merchant fee rate. An unset or 0% rate charges nothing; a nonzero rate has a minimum fee of 0.01 U. Fees default to USDT. If the balance after deducting any same-asset principal cannot cover the complete fee, the entire fee uses available USDC instead; fees are never split. Principal remains in the selected quote asset; changing the fee asset never converts the principal. Merchant-funded per-transaction and daily payment limits count principal only, excluding fees; balances must still cover principal plus fees.

Merchant-funded responses additionally include fee_asset, fee_amount as a decimal string, and fee_ledger_scale. customer_total and the legacy fee describe only the debit and same-asset fee in asset. A same-asset fee is already included in customer_total and must not be added again. For a different fee asset, fee=0, and fee_amount of fee_asset is additionally debited. The confirmation screen must show both assets separately, never combine them into one number. For example, principal 10 USDT and fee 0.1 USDC give customer_total="10.000000", fee="0.000000", fee_asset="USDC", and fee_amount="0.100000". Insufficient balance in either asset prevents payment.

We debit the member’s digital-asset balance, and the upstream pays equivalent fiat to the receiving merchant.

⚠ Call POST /v1/qrpay/quotes for confirmation first. Calling payments directly would first show the amount after funds are locked. This endpoint obtains its own new quote before holding funds, so the actual debit may differ by a few minor units. Its response is authoritative.

⚠⚠ Every payment requires strong authentication, since 2026-08-14. The first call returns 400 step_up_required + hosted_url. Send the end user there to verify, then resend the same body with the same idempotency key and x-step-up: <challenge_id>. The action derives from the QR payload, qrpay_pay:<码值摘要>, is single-use, valid for 5 minutes, and bound to the member. A different code requires new verification.

Previously this endpoint had no secondary confirmation: a qrpay:write token and QR payload could spend member balance, while the same member payment in our App required a payment password or biometrics. This deliberately is not a switch: a default-off switch would preserve the gap and add a control nobody enables. Any small-payment exemption must be implemented on both sides and based on an amount threshold, not a boolean. Relaxing only the Open API would give the same member fewer checks in your App than ours, creating an attacker’s preferred path.

The order is mandatory: hold funds first, then tell the upstream to release payment. The latter means we have already collected the funds. Reversing it can release upstream funds without a local hold.

⚠⚠ status: "processing" is not failure; it means the outcome is unknown. A network error while notifying upstream may occur after the request was accepted and payment released. We therefore never unlock funds in this case; we record processing for verification and reconciliation. Wait for the webhook or poll the order ID. Do not declare failure yourself or automatically create another order. Automatic reordering can charge twice for one purchase.

status values: pending, briefly held before upstream notification; processing; completed; failed; expired; canceled; refunded. ⚠ completed is not absolutely terminal; refunds move it to refunded. Store unknown values unchanged and alert, without a default mapping.

⚠ You do not determine the debit asset. We follow the member’s own payment settings. preferred_asset only means try this first. Explicitly selecting an asset without an upstream quote causes rejection, never a silent substitute, because choosing another debit currency would decide on the user’s behalf.

⚠ Supply currency / amount only for custom-amount codes, in acquiring-side fiat. Supplying them for a fixed-amount code changes the merchant’s price. We forward them upstream, which rejects mismatches.

⚠ The amount we report upstream is the upstream quote’s original text, not the member’s total payment. The total includes upstream quote + our cost protection + premium + fee and is inherently larger. Thus customer_total and acquirer_amount are different numbers in different currencies.

⚠ This flow has no refund or cancellation endpoint, because the upstream offers neither. Our administration console also cannot initiate refunds. Only the upstream can initiate one, after which you receive qrpay.order.refunded.

Prerequisites

  • QR payments enabled for you and at least one usable upstream available
  • This QR standard is supported; see GET /v1/qrpay/schemes
  • Member has sufficient funds in at least one usable debit asset and is within per-transaction/daily limits
  • Your prepaid account has sufficient balance in the asset
FieldTypeRequiredDescription
x-on-behalf-of string Required Member making the payment. Debit priority follows their own payment settings.
x-idempotency-key string Required
x-step-up string Optional UUID. Same-key retry retrieves the same order. After 504 or a network timeout, retry with the same key. After a definite business failure, use a new key, since the original replays that failure unchanged.

Request Body

FieldTypeRequiredDescription
funding_source "member" | "merchant" Optional Pay from the member wallet or merchant prepaid account; merchant-funded payments do not debit the member wallet.
code_value string Required Original QR payload, matching the value used for decoding.
currency string Optional Acquiring-side fiat currency, such as THB. Only for custom-amount codes; prohibited for fixed-amount codes.
amount string Optional Acquiring-side fiat amount, such as "250.00", not a digital-asset quantity. Only for custom-amount codes. Precision follows that fiat currency, not ledger_scale.
preferred_asset string Optional Preferred debit asset, such as USDT. Omission selects automatically using the member’s payment priority. ⚠ An explicitly selected asset without a current quote is rejected immediately, without automatic fallback.
payer object Optional Additional payer information. Required only when required_payer_fields from POST /v1/qrpay/decode is nonempty, using exactly those keys: custName / custFirstName / custLastName / custNationCode / gender / mobile / email / legalNationCode / legalType / legalId. Unknown keys are discarded, never forwarded upstream.

Response

201Funds held and request sent upstream. customer_total and fee are fixed-point decimal strings using the asset’s ledger_scale; scale is not returned here, so look it up by asset. acquirer_currency / acquirer_amount describe the acquiring-side fiat. ⚠ 201 does not mean payment succeeded. Inspect status; processing means wait for the webhook.
{
  "id": "qrp_9f2c1b40-0e2a-4d7c-9d21-6f0c1c3e5a11",
  "status": "processing",
  "asset": "USDT",
  "customer_total": "128.500000",
  "fee": "0.500000",
  "payee": "Bangkok Coffee Co.",
  "acquirer_currency": "THB",
  "acquirer_amount": "4500.00"
}
400Registered public codes: qr_code_invalid, undecodable code or upstream rejection; service_unavailable, no usable upstream, insufficient prepaid funds, or line not enabled for you, all deliberately sharing one response without explaining the cause; invalid_request; step_up_required, required for every payment, as described above; idempotency_key_required; idempotency_key_invalid; member_context_required; member_not_found. ⚠ Still returning 500 api_error: insufficient member balance; no usable debit asset because none of the upstream assets is enabled here or the selected preferred_asset has no quote; below minimum payment; per-transaction maximum exceeded; daily amount exceeded; KYC threshold triggered; member blocklisted, account frozen, or card funds protected; corridor unavailable; amount outside this QR code’s range.
409idempotency_key_reused · idempotency_in_progress

Emitted Events

Green = successful terminal state · Red = terminal state requiring action · Purple = intermediate state. Open an event for its payload and signature verification details.

Request
curl -X POST 'https://api.zinfra.vip/v1/qrpay/payments' \
  -H 'x-auth-token: Bearer $TOKEN' \
  -H 'x-on-behalf-of: $MEMBER_ID' \
  -H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
  -H 'content-type: application/json' \
  -d '{
    "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
    "currency": "THB",
    "amount": "250.00",
    "preferred_asset": "USDT",
    "payer": {
      "custName": "CHAN TAI MAN",
      "legalId": "A1234567"
    }
  }'
const res = await fetch("https://api.zinfra.vip/v1/qrpay/payments", {
  method: "POST",
  headers: {
    "x-auth-token": "Bearer $TOKEN",
    "x-on-behalf-of": "$MEMBER_ID",
    "x-idempotency-key": "$IDEMPOTENCY_KEY",
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
    "currency": "THB",
    "amount": "250.00",
    "preferred_asset": "USDT",
    "payer": {
      "custName": "CHAN TAI MAN",
      "legalId": "A1234567"
    }
  }),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();
import requests

res = requests.post(
    "https://api.zinfra.vip/v1/qrpay/payments",
    headers={
        "x-auth-token": "Bearer $TOKEN",
        "x-on-behalf-of": "$MEMBER_ID",
        "x-idempotency-key": "$IDEMPOTENCY_KEY",
        "content-type": "application/json",
    },
    json={
      "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
      "currency": "THB",
      "amount": "250.00",
      "preferred_asset": "USDT",
      "payer": {
        "custName": "CHAN TAI MAN",
        "legalId": "A1234567"
      }
    },
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()
req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/qrpay/payments",
    strings.NewReader(`{
  "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
  "currency": "THB",
  "amount": "250.00",
  "preferred_asset": "USDT",
  "payer": {
    "custName": "CHAN TAI MAN",
    "legalId": "A1234567"
  }
}`))
req.Header.Set("x-auth-token", "Bearer $TOKEN")
req.Header.Set("x-on-behalf-of", "$MEMBER_ID")
req.Header.Set("x-idempotency-key", "$IDEMPOTENCY_KEY")
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// Decode amount fields as string, not float64.
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://api.zinfra.vip/v1/qrpay/payments"))
    .header("x-auth-token", "Bearer $TOKEN")
    .header("x-on-behalf-of", "$MEMBER_ID")
    .header("x-idempotency-key", "$IDEMPOTENCY_KEY")
    .header("content-type", "application/json")
    .method("POST", HttpRequest.BodyPublishers.ofString("""
{
  "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
  "currency": "THB",
  "amount": "250.00",
  "preferred_asset": "USDT",
  "payer": {
    "custName": "CHAN TAI MAN",
    "legalId": "A1234567"
  }
}
"""))
    .build();
// Use String / BigDecimal for amounts, not double.
$ch = curl_init('https://api.zinfra.vip/v1/qrpay/payments');
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'x-auth-token: Bearer $TOKEN',
    'x-on-behalf-of: $MEMBER_ID',
    'x-idempotency-key: $IDEMPOTENCY_KEY',
    'content-type: application/json',
  ],
  CURLOPT_POSTFIELDS => <<<'JSON'
{
  "code_value": "00020101021229300012D156000000000510A93FO3230Q...",
  "currency": "THB",
  "amount": "250.00",
  "preferred_asset": "USDT",
  "payer": {
    "custName": "CHAN TAI MAN",
    "legalId": "A1234567"
  }
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
201
{
  "id": "qrp_9f2c1b40-0e2a-4d7c-9d21-6f0c1c3e5a11",
  "status": "processing",
  "asset": "USDT",
  "customer_total": "128.500000",
  "fee": "0.500000",
  "payee": "Bangkok Coffee Co.",
  "acquirer_currency": "THB",
  "acquirer_amount": "4500.00"
}