Integration guide

Integration guide

CNIXPay Merchant API V2 guide: Bearer authentication, REST order endpoints, four order statuses, webhook events and signatures, error codes and OpenAPI.

Overview

The CNIXPay Merchant API V2 lets your server create stablecoin payment orders. Each order gets dedicated deposit addresses; one order can offer several network × token payment options, and a payment on any of them counts toward the order. There are only four order statuses, so status alone tells you whether to ship or to step in.

Base URL: https://api.cnixpay.com/api/v2

NetworkchainStandardtoken
TRONtronTRC20USDT
BNB Smart ChainbscBEP20USDT / USDC
EthereumethERC20USDT / USDC
PolygonpolygonERC20USDT / USDC
SolanasolanaSPLUSDT / USDC

Available networks and tokens are configured by the platform; the API response is authoritative.

Quick start

  1. Sign up as a merchant, create an app in the merchant dashboard and submit it to go live.
  2. Copy the API Key and API Secret from the app, set an https callback URL and, optionally, an IP allowlist.
  3. Call Create an order from your server, then show the returned deposit addresses or redirect the payer to cashierUrl.
  4. Implement the webhook endpoint: verify the signature, act on status and return HTTP 2xx.
  5. Run a small real order end to end, then go live.
curl -X POST https://api.cnixpay.com/api/v2/orders \
  -H "Authorization: Bearer $CNIXPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "merchantOrderNo": "ORDER_10086",
  "amount": "100.00",
  "paymentOptions": [
    { "chain": "tron", "token": "USDT" },
    { "chain": "bsc",  "token": "USDC" }
  ],
  "callbackUrl": "https://shop.example.com/notify"
}'

Authentication & limits

Send the app's live API key as Authorization: Bearer <API Key>. The API secret is only used to verify webhook signatures — never send it in requests or ship it in front-end code.

  • V2 accepts live keys only; keys with the test_ prefix return 401. There is no simulated sandbox — test with a small real order.
  • If the app has an IP allowlist, only those IPs may call the API.
  • Rate limits are per app. Over the limit you get HTTP 429 with Retry-After (seconds); wait and retry.
EndpointLimit
Create an order5 req/s
Query and list orders20 req/s shared
Add payment options10 req/s
Close an order5 req/s

Response format

Every response body is { code, message, data, requestId }; code = 0 means success. The HTTP status follows the error type (the first three digits of the error code); on failure you can rely on either the HTTP status or code.

HTTP/1.1 200
X-Request-Id: req_1c9e...

{ "code": 0, "message": "success", "data": { ... }, "requestId": "req_1c9e..." }
HTTP/1.1 400
X-Request-Id: req_8f2a...

{
  "code": 40001,
  "message": "paymentOptions[1].token is required",
  "data": { "field": "paymentOptions[1].token" },
  "requestId": "req_8f2a..."
}
  • requestId: unique per request and also returned in the X-Request-Id header. Include it when you contact us so we can find the exact request.
  • message: English or Chinese according to the Accept-Language header (Chinese by default).
  • On failure data is null, or contains only field (the offending field) or orderNo (the existing order for a duplicate merchant order number).

Amounts & currency

  • All amounts are decimal strings in both requests and responses, e.g. "100.50". A JSON number (100.5) in a request is rejected (40002). Use a decimal library for arithmetic; never convert to floating point.
  • Quote currency currency: USD (default) or a native coin TRX / BNB / ETH / POL / SOL. USD orders are paid in USDT / USDC at 1:1; native-coin orders accept only that coin.
  • USD amounts have 2 decimals and native coins 6; transactions[].amount keeps on-chain precision.
  • USD is booked in cents: the credited amount is rounded down to the cent (sub-cent dust is not credited) and the fee is rounded to the nearest cent, so feeAmount + netAmount = paidAmount.
  • On the same deposit address a USD order accepts both USDT and USDC at 1:1. Wrong assets (e.g. a native coin sent to a USD order) are not counted and are handled manually by the platform.
  • All times are Unix seconds.

Order object

Query, list, create, add-options and close responses and webhooks all return the same order object with every field present.

{
  "orderNo": "20261017105142a1b2c3d4e5f6",
  "merchantOrderNo": "ORDER_10086",
  "status": "paid",
  "closeReason": "",
  "version": 3,
  "amount": "100.00",
  "currency": "USD",
  "paidAmount": "100.00",
  "feeAmount": "0.60",
  "netAmount": "99.40",
  "paymentOptions": [
    { "chain": "tron", "token": "USDT", "address": "TR7NHq...", "amount": "100.00" },
    { "chain": "bsc",  "token": "USDC", "address": "0x55d3...", "amount": "100.00" }
  ],
  "transactions": [
    { "chain": "tron", "token": "USDT", "txHash": "abc123...", "fromAddress": "TUser...",
      "toAddress": "TR7NHq...", "amount": "100.000000", "status": "confirmed", "blockTime": 1760670190 }
  ],
  "txCount": 1,
  "cashierUrl": "https://pay.cnixpay.com/pay/20261017105142a1b2c3d4e5f6",
  "description": "VIP",
  "extra": "user_id=1001",
  "createTime": 1760668600,
  "expireTime": 1760670400,
  "paidTime": 1760670200,
  "updateTime": 1760670200
}
FieldDescription
statusOrder status, see Order statuses
closeReasonWhen status is closed: timeout (not paid in time) or merchant (closed by you); otherwise an empty string
versionStarts at 1 and increases by 1 whenever a visible field changes. Webhooks may arrive out of order or be retried — keep the highest version
amount / currencyAmount due and its currency
paidAmountTotal received (confirmed transactions only). Subtract it from amount to get the shortfall or excess
feeAmount / netAmountPlatform fee / your net amount (what actually reaches your balance). Funds are credited when the order becomes paid or underpaid; both are 0 while pending
paymentOptionsAll payment options; amount is the on-chain quantity due for that option
transactionsOn-chain deposits, up to the latest 50; there may be several, across networks. Which network, which token (token is the asset received and may differ from the one chosen) and which address the payer actually used is all here. status is pending (confirming) or confirmed (counted in paidAmount)
txCountTotal number of deposits
cashierUrlHosted checkout link
extraYour pass-through value, returned as is
updateTimeTime of the latest change, updated together with version

Order statuses

There are four statuses. Everything except pending is normally final (the only exception is a manual credit, see below):

statusMeaningWhat to do
pendingNo outcome yet: awaiting payment, partially paid, confirming, or within the grace period after expiryWait; if paidAmount > 0 you may ask the payer to top up
paidPaid in full (overpayment included)Fulfil
underpaidThe grace period ended with only part paid; the received amount was credited to your balanceHandle manually: partial fulfilment, ask for the rest, or refund
closedNothing received and the order is closed (closeReason tells timeout from merchant)Void the order
pending ──paid in full──▶ paid
   │
   ├──closed by merchant (only if nothing was received)──▶ closed (closeReason = merchant)
   │
   └──grace period ends, still not paid in full
        ├──some payment received──▶ underpaid
        └──nothing received───────▶ closed (closeReason = timeout)
  • Grace period: after expireTime there is a grace period (60 minutes by default). A transfer sent before expiry that lands late, or a top-up within the grace period, still turns the order paid. The order stays pending during the grace period, so the rule is simply: wait on pending, act on everything else.
  • When the outcome arrives: about 10 minutes after the grace period ends the order gets its outcome (underpaid or closed). If a payment is still confirming on-chain at that point, the outcome waits until it is confirmed or fails, and the order stays pending meanwhile.
  • On-chain confirmation: the time needed depends on the order amount and is set by the platform — usually a few seconds to a minute for small amounts, more confirmations for large ones. Until then the transfer is pending in transactions, is not counted in paidAmount, and the order stays pending.
  • Funds arriving after the grace period, or after you closed the order, are not credited automatically; the platform handles them manually: refund to the payer, or credit them after review.
  • A manual credit is the only case where an outcome can change: the order is re-evaluated on what was received, so closed may become paid or underpaid, and underpaid may become paid, with the matching event. An order you already voided may therefore receive order.paid — handle it by version instead of dropping it.
  • Extra funds after paid (e.g. a duplicate payment) are still credited; the status stays paid, the amounts and version update, and no further webhook is sent.
  • paid is final: even if the chain later reorganizes, the order does not change for you; the platform bears the loss.
On a webhook (or a query):
  dedupe by the X-Webhook-Id header; ignore it if order.version is older than what you stored
  switch order.status:
    paid       → fulfil (consider refunding the excess if paidAmount > amount)
    underpaid  → handle manually (partial fulfilment, ask for the rest, or refund)
    closed     → void the order
    pending    → wait (if paidAmount > 0 you may ask the payer to send amount − paidAmount)

Create an order

POST/orders

FieldTypeRequiredDescription
merchantOrderNostring ≤64YesYour order number, unique per app
amountstringYesAmount due as a decimal string, greater than 0. USD up to 2 decimals, native coins up to 6
currencystringNoUSD (default) or TRX / BNB / ETH / POL / SOL
paymentOptionsarrayNo1–10 payment options. Without it, USD orders let the payer choose in the hosted checkout and native-coin orders use that coin's network
paymentOptions[].chainstringYesNetwork, see Overview
paymentOptions[].tokenstringYesUSDT / USDC / a native coin symbol
expireMinutesintegerNoValidity in minutes, 5–1440, default 30
callbackUrlstring ≤500NoWebhook URL, https only and not a local or private address; defaults to the app's (which must also be https)
returnUrlstring ≤500NoRedirect after paying in the hosted checkout, https only
descriptionstring ≤200NoOrder summary shown in the checkout
extrastring ≤500NoPass-through value, returned in queries and webhooks

On success the full order object is returned (version = 1). Two ways to use it:

  • Your own checkout: pass paymentOptions and render paymentOptions[].address and amount.
  • Hosted checkout: omit paymentOptions and redirect the payer to cashierUrl to pick a network and token.

A duplicate merchant order number returns 409 / 40901 with the existing order in data.orderNo. If a create call timed out and you are not sure it succeeded, query that order — do not retry with a new order number.

HTTP/1.1 409

{ "code": 40901, "message": "The merchant order number already exists", "data": { "orderNo": "20261017105142a1b2c3d4e5f6" }, "requestId": "req_..." }

Query & list

EndpointDescription
GET/orders/{orderNo}Get an order by system order number, including transactions. 404 / 40401 if not found
GET/ordersList orders; returns { items, total, page, pageSize }, each item a full order object (including transactions)
ParameterDescription
merchantOrderNoExact merchant order numbers; repeatable, up to 100: ?merchantOrderNo=A&merchantOrderNo=B
orderNoSystem order numbers, same rules
statusOrder status; repeatable
createTimeFrom / createTimeToCreation time range (Unix seconds, from inclusive, to exclusive)
page / pageSizeDefault 1 / 20; pageSize up to 100

Results are sorted newest first by creation time. Webhook fallback: every few minutes, query the orders you still have open by merchantOrderNo (up to 100 per request) and act on any status change as described in Order statuses. Even if your webhook endpoint is down past the retry window, no order goes unfulfilled.

GET https://api.cnixpay.com/api/v2/orders?merchantOrderNo=ORDER_10086&merchantOrderNo=ORDER_10087&pageSize=100
Authorization: Bearer $CNIXPAY_API_KEY

Add payment options

POST/orders/{orderNo}/payment-options

Body { "paymentOptions": [{ "chain": "polygon", "token": "USDC" }] }; returns the order object.

  • Only pending orders with nothing received and not past expireTime; otherwise 409 / 40902. Native-coin orders cannot add options.
  • Existing options are idempotent (no error, no duplicate); tokens on the same network share one deposit address.
  • At most 10 in total; options can be added but not removed.

Close an order

POST/orders/{orderNo}/close

  • No body. Only pending orders with nothing received can be closed (including ones past expireTime but within the grace period). If a payment was received, or one is still confirming on-chain (a transactions item with status pending), you get 409 / 40902.
  • After closing, status = closed and closeReason = merchant; later deposits are not credited automatically.
  • Closing an order you already closed succeeds and returns it. Closing is your own action, so no webhook is sent.

Webhook events

Changes that happen on our side are pushed to callbackUrl. The body is { id, event, createTime, data }, where data is the order object at the moment of the event.

eventWhenOrder statusWhat to do
order.partially_paidA payment arrived but is not enoughpendingOptional: ask the payer to top up
order.paidPaid in full, sent immediatelypaidFulfil
order.underpaidGrace period ended with part paidunderpaidHandle manually
order.closedGrace period ended with nothing receivedclosed (timeout)Void the order
POST /notify HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_7f3c9a...
X-Webhook-Timestamp: 1760670205
X-Webhook-Signature: v2=5b1f0e...

{
  "id": "evt_7f3c9a...",
  "event": "order.paid",
  "createTime": 1760670200,
  "data": { "orderNo": "20261017105142a1b2c3d4e5f6", "status": "paid", "version": 3, "...": "..." }
}
  • Any HTTP 2xx counts as delivered. Each request times out after 5 seconds and redirects are not followed (3xx counts as a failure). Failures are retried after 15s, 1m, 5m, 10m, 30m, 1h, 2h, 6h and 12h — about 22 hours in total. All four events are retried.
  • If the order has no callback URL (none passed when creating it and none configured on the app), no events are sent.
  • Webhooks are sent through the platform's relay, so the source IP is not fixed: do not restrict your webhook endpoint with an IP allowlist — verify the signature instead.
  • Normally an order gets one outcome event (paid / underpaid / closed), and an order topped up within the grace period only gets order.paid; a manual credit that changes the outcome sends another order.paid or order.underpaid.
  • An unpaid order gets order.closed about 10 minutes after the grace period ends (about 70 minutes after expiry by default); release stock earlier based on expireTime if you need to.
  • Retries resend the same body and may arrive after later events: dedupe by X-Webhook-Id, keep the highest data.version, or query the order on receipt.
  • The order detail page in the merchant dashboard shows the delivery history of each event and can resend it.

Webhook signature

The signature is in the headers and covers the raw request body, so you can verify it without parsing JSON and new fields never break it:

  1. Read the X-Webhook-Timestamp header and check it is within 5 minutes of now (replay protection; regenerated on every retry).
  2. Compute HMAC-SHA256(timestamp + "." + raw body) with your API secret, as lowercase hex, prefixed with v2=.
  3. Compare it with the X-Webhook-Signature header in constant time.
import crypto from 'node:crypto'

// rawBody must be the raw request body (Buffer); do not re-serialize parsed JSON
export function verifyWebhook(rawBody, headers, apiSecret) {
  const timestamp = headers['x-webhook-timestamp']
  const signature = headers['x-webhook-signature'] ?? ''
  if (!/^\d+$/.test(timestamp ?? '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const expected =
    'v2=' + crypto.createHmac('sha256', apiSecret).update(`${timestamp}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Express: app.post('/notify', express.raw({ type: 'application/json' }), handler)

Many frameworks parse the body as JSON by default. Verification needs the raw bytes — e.g. express.raw() in Express or file_get_contents('php://input') in PHP.

Error codes

codeHTTPMeaning
40001400Missing or malformed parameter; data.field names it
40002400Invalid amount: not a string, not greater than 0, or too many decimals
40003400Amount exceeds the per-order limit for that network and token
40004400Network or token not supported / not enabled
40005400Too many payment options
40101401Missing or invalid API key (including test keys)
40301403Request IP not in the app's allowlist
40302403App not live or disabled, or the merchant account is disabled
40401404Order not found
40901409Merchant order number already exists (data.orderNo is the existing order)
40902409The order's status does not allow this operation
42901429Too many requests; retry after Retry-After
50001500System busy; retry later

OpenAPI & AI

  • OpenAPI 3.1: https://api.cnixpay.com/api/v2/openapi.json (public, no auth). Describes every endpoint, field, enum, error code and the webhook format; import it into Postman / Apifox or give it to an AI coding assistant to generate integration code.
  • llms.txt: /llms.txt (index) and /llms-full.txt (this guide as plain text) for AI tools.

Best practices

  1. Always verify webhook signatures. Never trust an unverified notification.
  2. Make the webhook handler idempotent. Dedupe by X-Webhook-Id and drop stale data by version.
  3. Return 2xx quickly. Move slow work to a queue to avoid retries.
  4. Confirm before critical actions. Query the order before shipping; periodically batch-query open orders by merchantOrderNo to catch missed webhooks.
  5. Never retry a timed-out create with a new order number. Retry with the same merchantOrderNo; on 40901 query data.orderNo.
  6. Use decimals for money. Every amount is a string; avoid floating-point errors.
  7. Log requestId. Record it with the time when a call fails and share it with us when troubleshooting.