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
| Network | chain | Standard | token |
|---|---|---|---|
| TRON | tron | TRC20 | USDT |
| BNB Smart Chain | bsc | BEP20 | USDT / USDC |
| Ethereum | eth | ERC20 | USDT / USDC |
| Polygon | polygon | ERC20 | USDT / USDC |
| Solana | solana | SPL | USDT / USDC |
Available networks and tokens are configured by the platform; the API response is authoritative.
Quick start
- Sign up as a merchant, create an app in the merchant dashboard and submit it to go live.
- Copy the
API KeyandAPI Secretfrom the app, set an https callback URL and, optionally, an IP allowlist. - Call Create an order from your server, then show the returned deposit addresses or redirect the payer to
cashierUrl. - Implement the webhook endpoint: verify the signature, act on
statusand return HTTP 2xx. - 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"
}'import os
import requests
resp = requests.post(
"https://api.cnixpay.com/api/v2/orders",
headers={"Authorization": f"Bearer {os.environ['CNIXPAY_API_KEY']}"},
json={
"merchantOrderNo": "ORDER_10086",
"amount": "100.00", # amounts are strings
"paymentOptions": [{"chain": "tron", "token": "USDT"}, {"chain": "bsc", "token": "USDC"}],
"callbackUrl": "https://shop.example.com/notify",
},
timeout=10,
)
body = resp.json()
if body["code"] != 0:
raise RuntimeError(f"{body['code']} {body['message']} (requestId={body['requestId']})")
order = body["data"]const res = await fetch('https://api.cnixpay.com/api/v2/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CNIXPAY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchantOrderNo: 'ORDER_10086',
amount: '100.00', // amounts are strings
paymentOptions: [{ chain: 'tron', token: 'USDT' }, { chain: 'bsc', token: 'USDC' }],
callbackUrl: 'https://shop.example.com/notify',
}),
})
const body = await res.json()
if (body.code !== 0) throw new Error(`${body.code} ${body.message} (requestId=${body.requestId})`)
const order = body.data$ch = curl_init('https://api.cnixpay.com/api/v2/orders');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('CNIXPAY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantOrderNo' => 'ORDER_10086',
'amount' => '100.00', // amounts are strings
'paymentOptions' => [['chain' => 'tron', 'token' => 'USDT'], ['chain' => 'bsc', 'token' => 'USDC']],
'callbackUrl' => 'https://shop.example.com/notify',
]),
]);
$body = json_decode(curl_exec($ch), true);
if ($body['code'] !== 0) {
throw new RuntimeException("{$body['code']} {$body['message']} (requestId={$body['requestId']})");
}
$order = $body['data'];payload := strings.NewReader(`{
"merchantOrderNo": "ORDER_10086",
"amount": "100.00",
"paymentOptions": [{"chain": "tron", "token": "USDT"}, {"chain": "bsc", "token": "USDC"}],
"callbackUrl": "https://shop.example.com/notify"
}`)
req, _ := http.NewRequest(http.MethodPost, "https://api.cnixpay.com/api/v2/orders", payload)
req.Header.Set("Authorization", "Bearer "+os.Getenv("CNIXPAY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var body struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
RequestID string `json:"requestId"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return err
}
if body.Code != 0 {
return fmt.Errorf("%d %s (requestId=%s)", body.Code, body.Message, body.RequestID)
}String json = """
{
"merchantOrderNo": "ORDER_10086",
"amount": "100.00",
"paymentOptions": [{"chain": "tron", "token": "USDT"}, {"chain": "bsc", "token": "USDC"}],
"callbackUrl": "https://shop.example.com/notify"
}""";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.cnixpay.com/api/v2/orders"))
.header("Authorization", "Bearer " + System.getenv("CNIXPAY_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Parse the JSON: code == 0 means success and data is the order; log requestId on failureAuthentication & 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.
| Endpoint | Limit |
|---|---|
| Create an order | 5 req/s |
| Query and list orders | 20 req/s shared |
| Add payment options | 10 req/s |
| Close an order | 5 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 theX-Request-Idheader. Include it when you contact us so we can find the exact request.message: English or Chinese according to theAccept-Languageheader (Chinese by default).- On failure
dataisnull, or contains onlyfield(the offending field) ororderNo(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 coinTRX/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[].amountkeeps 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
}| Field | Description |
|---|---|
status | Order status, see Order statuses |
closeReason | When status is closed: timeout (not paid in time) or merchant (closed by you); otherwise an empty string |
version | Starts 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 / currency | Amount due and its currency |
paidAmount | Total received (confirmed transactions only). Subtract it from amount to get the shortfall or excess |
feeAmount / netAmount | Platform fee / your net amount (what actually reaches your balance). Funds are credited when the order becomes paid or underpaid; both are 0 while pending |
paymentOptions | All payment options; amount is the on-chain quantity due for that option |
transactions | On-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) |
txCount | Total number of deposits |
cashierUrl | Hosted checkout link |
extra | Your pass-through value, returned as is |
updateTime | Time 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):
status | Meaning | What to do |
|---|---|---|
pending | No outcome yet: awaiting payment, partially paid, confirming, or within the grace period after expiry | Wait; if paidAmount > 0 you may ask the payer to top up |
paid | Paid in full (overpayment included) | Fulfil |
underpaid | The grace period ended with only part paid; the received amount was credited to your balance | Handle manually: partial fulfilment, ask for the rest, or refund |
closed | Nothing 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
expireTimethere 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 orderpaid. The order stayspendingduring the grace period, so the rule is simply: wait onpending, act on everything else. - When the outcome arrives: about 10 minutes after the grace period ends the order gets its outcome (
underpaidorclosed). If a payment is still confirming on-chain at that point, the outcome waits until it is confirmed or fails, and the order stayspendingmeanwhile. - 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
pendingintransactions, is not counted inpaidAmount, and the order stayspending. - 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
closedmay becomepaidorunderpaid, andunderpaidmay becomepaid, with the matching event. An order you already voided may therefore receiveorder.paid— handle it byversioninstead of dropping it. - Extra funds after
paid(e.g. a duplicate payment) are still credited; the status stayspaid, the amounts andversionupdate, and no further webhook is sent. paidis 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
| Field | Type | Required | Description |
|---|---|---|---|
merchantOrderNo | string ≤64 | Yes | Your order number, unique per app |
amount | string | Yes | Amount due as a decimal string, greater than 0. USD up to 2 decimals, native coins up to 6 |
currency | string | No | USD (default) or TRX / BNB / ETH / POL / SOL |
paymentOptions | array | No | 1–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[].chain | string | Yes | Network, see Overview |
paymentOptions[].token | string | Yes | USDT / USDC / a native coin symbol |
expireMinutes | integer | No | Validity in minutes, 5–1440, default 30 |
callbackUrl | string ≤500 | No | Webhook URL, https only and not a local or private address; defaults to the app's (which must also be https) |
returnUrl | string ≤500 | No | Redirect after paying in the hosted checkout, https only |
description | string ≤200 | No | Order summary shown in the checkout |
extra | string ≤500 | No | Pass-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
paymentOptionsand renderpaymentOptions[].addressandamount. - Hosted checkout: omit
paymentOptionsand redirect the payer tocashierUrlto 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
| Endpoint | Description |
|---|---|
GET/orders/{orderNo} | Get an order by system order number, including transactions. 404 / 40401 if not found |
GET/orders | List orders; returns { items, total, page, pageSize }, each item a full order object (including transactions) |
| Parameter | Description |
|---|---|
merchantOrderNo | Exact merchant order numbers; repeatable, up to 100: ?merchantOrderNo=A&merchantOrderNo=B |
orderNo | System order numbers, same rules |
status | Order status; repeatable |
createTimeFrom / createTimeTo | Creation time range (Unix seconds, from inclusive, to exclusive) |
page / pageSize | Default 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_KEYAdd payment options
POST/orders/{orderNo}/payment-options
Body { "paymentOptions": [{ "chain": "polygon", "token": "USDC" }] }; returns the order object.
- Only
pendingorders with nothing received and not pastexpireTime; 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
pendingorders with nothing received can be closed (including ones pastexpireTimebut within the grace period). If a payment was received, or one is still confirming on-chain (atransactionsitem withstatuspending), you get 409 / 40902. - After closing,
status = closedandcloseReason = 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.
event | When | Order status | What to do |
|---|---|---|---|
order.partially_paid | A payment arrived but is not enough | pending | Optional: ask the payer to top up |
order.paid | Paid in full, sent immediately | paid | Fulfil |
order.underpaid | Grace period ended with part paid | underpaid | Handle manually |
order.closed | Grace period ended with nothing received | closed (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 getsorder.paid; a manual credit that changes the outcome sends anotherorder.paidororder.underpaid. - An unpaid order gets
order.closedabout 10 minutes after the grace period ends (about 70 minutes after expiry by default); release stock earlier based onexpireTimeif you need to. - Retries resend the same body and may arrive after later events: dedupe by
X-Webhook-Id, keep the highestdata.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:
- Read the
X-Webhook-Timestampheader and check it is within 5 minutes of now (replay protection; regenerated on every retry). - Compute
HMAC-SHA256(timestamp + "." + raw body)with your API secret, as lowercase hex, prefixed withv2=. - Compare it with the
X-Webhook-Signatureheader 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)import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, headers, api_secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp", "")
signature = headers.get("X-Webhook-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = "v2=" + hmac.new(
api_secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)func VerifyWebhook(r *http.Request, rawBody []byte, apiSecret string) bool {
timestamp := r.Header.Get("X-Webhook-Timestamp")
sec, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "v2=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Webhook-Signature")))
}function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $apiSecret): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'v2=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $apiSecret);
return hash_equals($expected, $signature);
}
// $raw = file_get_contents('php://input');
// verifyWebhook($raw, $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $secret);static boolean verifyWebhook(byte[] rawBody, String timestamp, String signature, String apiSecret)
throws GeneralSecurityException {
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expected = "v2=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
}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
code | HTTP | Meaning |
|---|---|---|
40001 | 400 | Missing or malformed parameter; data.field names it |
40002 | 400 | Invalid amount: not a string, not greater than 0, or too many decimals |
40003 | 400 | Amount exceeds the per-order limit for that network and token |
40004 | 400 | Network or token not supported / not enabled |
40005 | 400 | Too many payment options |
40101 | 401 | Missing or invalid API key (including test keys) |
40301 | 403 | Request IP not in the app's allowlist |
40302 | 403 | App not live or disabled, or the merchant account is disabled |
40401 | 404 | Order not found |
40901 | 409 | Merchant order number already exists (data.orderNo is the existing order) |
40902 | 409 | The order's status does not allow this operation |
42901 | 429 | Too many requests; retry after Retry-After |
50001 | 500 | System 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
- Always verify webhook signatures. Never trust an unverified notification.
- Make the webhook handler idempotent. Dedupe by
X-Webhook-Idand drop stale data byversion. - Return 2xx quickly. Move slow work to a queue to avoid retries.
- Confirm before critical actions. Query the order before shipping; periodically batch-query open orders by
merchantOrderNoto catch missed webhooks. - Never retry a timed-out create with a new order number. Retry with the same
merchantOrderNo; on 40901 querydata.orderNo. - Use decimals for money. Every amount is a string; avoid floating-point errors.
- Log
requestId. Record it with the time when a call fails and share it with us when troubleshooting.