# CNIXPay — Integration guide > CNIXPay Merchant API V2 guide: Bearer authentication, REST order endpoints, four order statuses, webhook events and signatures, error codes and OpenAPI. Canonical: https://cnixpay.com/docs/ ## 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 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. ```bash 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" }' ``` ```python 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"] ``` ```js 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 ``` ```php $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']; ``` ```go 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) } ``` ```java 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 failure ``` ## Authentication & limits Send the app's live API key as `Authorization: Bearer `. 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 HTTP/1.1 200 X-Request-Id: req_1c9e... { "code": 0, "message": "success", "data": { ... }, "requestId": "req_1c9e..." } ``` ```http 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. ```json { "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 | ```text 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. ```text 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 `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 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. ```http 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. | `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 | ```http 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. ```js 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) ``` ```python 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) ``` ```go 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"))) } ``` ```php 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); ``` ```java 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`](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`](/llms.txt) (index) and [`/llms-full.txt`](/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. --- # CNIXPay — 接入指南 > CNIXPay 商户接口 V2 接入指南:Bearer 认证、REST 订单接口、四种订单状态、回调事件与验签、错误码和 OpenAPI。 Canonical: https://cnixpay.com/zh/docs/ ## 概述 CNIXPay 商户接口 V2 用于在你的服务端创建稳定币收款订单。每笔订单分配独立的收款地址;一笔订单可以同时挂多个「网络 × 币种」收款方式,用户在哪条网络付款都会计入订单。订单状态只有 4 个,只看 `status` 就知道能不能发货、要不要人工处理。 **Base URL:** `https://api.cnixpay.com/api/v2` | 网络 | `chain` | 标准 | `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 | 可用的网络与币种由平台配置,以接口返回为准。 ## 快速开始 1. 注册商户账号,在商户后台创建应用并提交上线。 2. 在应用详情中获取 `API Key` 与 `API Secret`,配置 https 回调地址,按需配置 IP 白名单。 3. 在你的服务端调用 创建订单,把返回的收款地址展示给用户,或让用户跳转到 `cashierUrl`。 4. 实现回调接口:验签 后按 `status` 处理,返回 HTTP 2xx。 5. 用一笔小额订单在真实网络上走通全流程,即可上线。 ```bash 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" }' ``` ```python 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", # 金额必须是字符串 "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"] ``` ```js 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', // 金额必须是字符串 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 ``` ```php $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', // 金额必须是字符串 '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']; ``` ```go 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) } ``` ```java 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()); // 解析 JSON:code == 0 为成功,data 为订单对象;失败时记录 requestId ``` ## 认证与限流 请求头 `Authorization: Bearer ` 传递应用的正式 API Key。API Secret 只用于验证回调签名,不要在请求里传,也不要放进前端代码。 - V2 只接受正式 Key;带 `test_` 前缀的 Key 返回 401。平台不提供模拟沙箱,联调请用小额真实订单。 - 应用配置了 IP 白名单时,只有白名单内的 IP 可以调用。 - 限流按应用计,超限返回 HTTP 429,并带 `Retry-After`(秒),等待后重试即可。 | 接口 | 限流 | | --- | --- | | 创建订单 | 5 次/秒 | | 查询订单、订单列表 | 共享 20 次/秒 | | 追加收款方式 | 10 次/秒 | | 关闭订单 | 5 次/秒 | ## 响应格式 响应体固定为 `{ code, message, data, requestId }`,`code = 0` 为成功。HTTP 状态码跟随错误类型(错误码前三位即 HTTP 状态码),失败时 HTTP 状态码和 `code` 都可以用来判断。 ```http HTTP/1.1 200 X-Request-Id: req_1c9e... { "code": 0, "message": "success", "data": { ... }, "requestId": "req_1c9e..." } ``` ```http HTTP/1.1 400 X-Request-Id: req_8f2a... { "code": 40001, "message": "paymentOptions[1].token 不能为空", "data": { "field": "paymentOptions[1].token" }, "requestId": "req_8f2a..." } ``` - `requestId`:每个请求唯一,同时在响应头 `X-Request-Id` 里。反馈问题时提供它,可直接定位到那一次请求。 - `message`:按请求头 `Accept-Language` 返回中文或英文,默认中文。 - 失败时 `data` 为 `null`,或只包含 `field`(出错的字段名)、`orderNo`(商户订单号重复时已有的订单)。 ## 金额与币种 - **金额一律是十进制字符串**,请求和响应都是,如 `"100.50"`。请求里传 JSON 数字(`100.5`)会被拒绝(40002)。做加减或比较请用 Decimal 库,不要转成浮点数。 - 报价币种 `currency`:`USD`(默认),或原生币 `TRX` / `BNB` / `ETH` / `POL` / `SOL`。USD 订单按 USDT / USDC 1:1 收款;原生币订单只收该币。 - USD 金额 2 位小数,原生币 6 位;到账明细 `transactions[].amount` 按链上精度。 - USD 按分记账:入账金额取整到分(不足一分的零头不入账),手续费四舍五入到分,因此 `feeAmount + netAmount = paidAmount`。 - 同一收款地址上,USD 订单接受 USDT 和 USDC,都按 1:1 计入。付错币(如 USD 订单收到原生币)不计入订单,由平台人工处理。 - 时间一律是 Unix 秒。 ## 订单对象 查询、列表、创建、追加、关闭的返回和回调都是同一个订单对象,字段齐全。 ```json { "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 } ``` | 字段 | 说明 | | --- | --- | | `status` | 订单状态,见 订单状态 | | `closeReason` | `status` 为 `closed` 时:`timeout` 超时未付款,`merchant` 商户关闭;其他为空字符串 | | `version` | 订单版本号,创建时为 1,对外可见的字段每变化一次加 1。回调可能乱序或重试送达,**以 `version` 大的为准** | | `amount` / `currency` | 应收金额和币种 | | `paidAmount` | 累计已到账(只算已确认的交易)。少付或多付多少,用 `amount` 与 `paidAmount` 相减 | | `feeAmount` / `netAmount` | 平台手续费 / 商户实收(实际进入商户余额的金额)。钱在 `paid` 或 `underpaid` 时入账,`pending` 时都为 0 | | `paymentOptions` | 全部收款方式,`amount` 为该方式应付的链上资产数量 | | `transactions` | 链上到账明细,最多最近 50 条,可能有多笔、跨多条链。付款人实际用哪条链、哪个币(`token` 是实际到账的币,可能与下单时选的不同)、付到哪个地址,都以这里为准。`status` 为 `pending`(确认中)或 `confirmed`(已确认,计入 `paidAmount`) | | `txCount` | 到账明细总笔数 | | `cashierUrl` | 平台收银台链接 | | `extra` | 下单时透传的内容,原样返回 | | `updateTime` | 最近一次变化的时间,与 `version` 同时更新 | ## 订单状态 只有 4 个状态,除 `pending` 外正常情况下都是最终结果(唯一例外见下方「人工补单」): | `status` | 含义 | 你该做什么 | | --- | --- | --- | | `pending` | 还没有结果:待付款、已收到部分款、确认中,以及过期后的宽限期内 | 等待;`paidAmount > 0` 时可提示用户补足 | | `paid` | 已足额支付(多付也算) | **发货** | | `underpaid` | 宽限期结束,只收到部分款,已按实收入账到商户余额 | **人工处理**:补发部分商品、联系用户补款或退款 | | `closed` | 没收到钱,订单已关闭(`closeReason` 区分超时和商户关闭) | 作废订单 | ```text pending ──足额到账──▶ paid │ ├──商户关闭(仅未收到付款时)──▶ closed(closeReason = merchant) │ └──宽限期结束仍未付足 ├──收到过付款──▶ underpaid └──没收到钱────▶ closed(closeReason = timeout) ``` - **宽限期**:订单过了 `expireTime` 后还有一段宽限期(默认 60 分钟)。付款人在过期前转账但链上到得晚,或在宽限期内补足,订单仍会变为 `paid`。宽限期内对外仍是 `pending`,所以你只需要记住:** `pending` 就等待,其他状态就处理**。 - **出结果的时间**:宽限期结束后约 10 分钟内给出结果(`underpaid` 或 `closed`);如果这时还有链上确认中的到账,会等它确认完成或失败后再给出结果,期间订单保持 `pending`。 - **链上确认**:确认所需时间由平台按订单金额设定,小额通常几秒到 1 分钟,大额需要更多确认。确认完成前,`transactions` 里这笔为 `pending`、不计入 `paidAmount`,订单仍为 `pending`。 - 宽限期结束后才到的钱、商户关闭后才到的钱,不会自动入账,由平台人工处理:退回付款人,或核实后补单。 - **人工补单**是结果唯一可能变化的情况:补单后按实收重新判定,`closed` 可能变为 `paid` 或 `underpaid`,`underpaid` 可能变为 `paid`,并推送对应事件。所以已作废的订单也可能收到 `order.paid`,请按 `version` 处理,不要直接丢弃。 - `paid` 之后又到账的钱(如重复付款)照常入账,状态保持 `paid`,金额和 `version` 更新,不再推送回调。 - `paid` 是最终结果:之后即使链上发生回滚,订单对你也完全不变,损失由平台承担。 ```text 收到回调(或查询订单): 按请求头 X-Webhook-Id 去重;order.version 小于本地记录的直接忽略 switch order.status: paid → 发货(paidAmount > amount 时可考虑退还多付部分) underpaid → 人工处理(补发部分商品、联系用户补款或退款) closed → 作废订单 pending → 等待(paidAmount > 0 时可提示用户补足 amount − paidAmount) ``` ## 创建订单 POST`/orders` | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `merchantOrderNo` | string ≤64 | 是 | 商户订单号,应用内唯一 | | `amount` | string | 是 | 应收金额,十进制字符串,大于 0。USD 最多 2 位小数,原生币最多 6 位 | | `currency` | string | 否 | `USD`(默认)或 `TRX` / `BNB` / `ETH` / `POL` / `SOL` | | `paymentOptions` | array | 否 | 收款方式,1–10 条。不传时 USD 订单由收银台让付款人选择,原生币订单自动用该币所在的链 | | `paymentOptions[].chain` | string | 是 | 网络,见 概述 | | `paymentOptions[].token` | string | 是 | `USDT` / `USDC` / 原生币符号 | | `expireMinutes` | integer | 否 | 有效期(分钟),5–1440,默认 30 | | `callbackUrl` | string ≤500 | 否 | 回调地址,只允许 https,不能是本地或内网地址;不传则用应用配置(也须为 https) | | `returnUrl` | string ≤500 | 否 | 收银台支付完成后跳转,只允许 https | | `description` | string ≤200 | 否 | 订单摘要,收银台展示 | | `extra` | string ≤500 | 否 | 透传,查询和回调原样返回 | 成功返回完整的订单对象(`version = 1`)。两种用法: - **自建收银台**:传 `paymentOptions`,用返回的 `paymentOptions[].address` 和 `amount` 渲染支付页。 - **平台收银台**:不传 `paymentOptions`,把用户跳转到 `cashierUrl`,由付款人选择网络和币种。 **商户订单号重复**返回 409 / 40901,并在 `data.orderNo` 带上已有订单。下单超时、不确定是否成功时,用它查询确认即可,**不要换订单号重下**。 ```http HTTP/1.1 409 { "code": 40901, "message": "商户订单号已存在", "data": { "orderNo": "20261017105142a1b2c3d4e5f6" }, "requestId": "req_..." } ``` ## 查询与列表 | 接口 | 说明 | | --- | --- | | GET`/orders/{orderNo}` | 按系统订单号查询,返回订单对象(含 `transactions`)。不存在返回 404 / 40401 | | GET`/orders` | 订单列表,返回 `{ items, total, page, pageSize }`,列表项是完整的订单对象(含 `transactions`) | | 参数 | 说明 | | --- | --- | | `merchantOrderNo` | 按商户订单号查(精确),可重复传多个,最多 100 个:`?merchantOrderNo=A&merchantOrderNo=B` | | `orderNo` | 按系统订单号查,同上 | | `status` | 订单状态,可重复传多个 | | `createTimeFrom` / `createTimeTo` | 按创建时间筛选(Unix 秒,左闭右开) | | `page` / `pageSize` | 默认 1 / 20,`pageSize` 最大 100 | 按创建时间倒序返回。**回调兜底**:每隔几分钟,把本地还没出结果的订单按 `merchantOrderNo` 批量查一次(每次最多 100 个),状态变了就按 订单状态 处理。这样即使回调接口宕机、重试用尽,也不会漏发货。 ```http GET https://api.cnixpay.com/api/v2/orders?merchantOrderNo=ORDER_10086&merchantOrderNo=ORDER_10087&pageSize=100 Authorization: Bearer $CNIXPAY_API_KEY ``` ## 追加收款方式 POST`/orders/{orderNo}/payment-options` 请求体 `{ "paymentOptions": [{ "chain": "polygon", "token": "USDC" }] }`,返回订单对象。 - 只有 `pending`、还没收到付款、未过 `expireTime` 的订单可以追加,否则 409 / 40902。原生币订单不能追加。 - 已存在的收款方式幂等(不报错、不重复添加);同一网络的不同币种共用一个收款地址。 - 合计不超过 10 条,只能追加,不能删除。 ## 关闭订单 POST`/orders/{orderNo}/close` - 无请求体。只能关闭还没收到付款的 `pending` 订单(含已过 `expireTime`、仍在宽限期内的);已收到付款,或有链上确认中的到账(`transactions` 里 `status` 为 `pending`),返回 409 / 40902。 - 关闭后 `status = closed`、`closeReason = merchant`,之后再到账不会自动入账。 - 已由商户关闭的订单重复关闭视为成功,直接返回当前订单。关闭由你主动触发,不推送回调。 ## 回调事件 平台这边发生、你不知道的变化会推送到 `callbackUrl`。请求体是 `{ id, event, createTime, data }`,`data` 是事件发生那一刻的订单对象。 | `event` | 推送时机 | 订单状态 | 你该做什么 | | --- | --- | --- | --- | | `order.partially_paid` | 收到一笔付款但还不够 | `pending` | 可选:提示用户补足 | | `order.paid` | 足额到账,立即推送 | `paid` | 发货 | | `order.underpaid` | 宽限期结束,只收到部分款 | `underpaid` | 人工处理 | | `order.closed` | 宽限期结束,没收到钱 | `closed`(`timeout`) | 作废订单 | ```http 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, "...": "..." } } ``` - 返回 HTTP 2xx 即视为送达。单次请求超时 5 秒,不跟随重定向(3xx 算失败)。失败后按时间表重试:15 秒、1 分钟、5 分钟、10 分钟、30 分钟、1 小时、2 小时、6 小时、12 小时,共约 22 小时。四种事件都会重试。 - 订单没有回调地址(下单没传 `callbackUrl`,应用也没配置)时不推送任何事件。 - 回调经平台的转发服务发出,**来源 IP 不固定**:请不要按 IP 白名单限制回调接口,用签名校验来源。 - 正常情况下每个订单只推送一个结果事件(`paid` / `underpaid` / `closed`),宽限期内补足的只会收到 `order.paid`;平台人工补单改变结果时会再推送 `order.paid` 或 `order.underpaid`。 - 没收到钱的订单要在宽限期结束后约 10 分钟才收到 `order.closed`(默认即过期后约 70 分钟);需要更早释放库存,可按 `expireTime` 自行处理。 - 重试时请求体不变,可能晚于后续事件送达:按 `X-Webhook-Id` 去重,按 `data.version` 取最新,或收到后再查询订单。 - 商户后台的订单详情可查看每个事件的推送记录,并可重新推送。 ## 回调验签 签名只在请求头,对**原始请求体**整体签名,不用解析 JSON 就能验证,以后新增字段也不影响验签: 1. 取请求头 `X-Webhook-Timestamp`,校验与当前时间相差 5 分钟内(防重放;每次重试都会重新生成)。 2. 以 API Secret 为密钥,计算 `HMAC-SHA256(timestamp + "." + 原始请求体)`,输出小写十六进制,前面加 `v2=`。 3. 与请求头 `X-Webhook-Signature` 做常量时间比较。 ```js import crypto from 'node:crypto' // rawBody 必须是原始请求体(Buffer),不要先解析成 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) ``` ```python 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) ``` ```go 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"))) } ``` ```php 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); ``` ```java 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)); } ``` 很多框架默认会先把请求体解析成 JSON。验签必须用原始字节,例如 Express 用 `express.raw()`,PHP 用 `file_get_contents('php://input')`。 ## 错误码 | `code` | HTTP | 说明 | | --- | --- | --- | | `40001` | 400 | 参数缺失或格式错误,`data.field` 为字段名 | | `40002` | 400 | 金额格式错误:不是字符串、不大于 0 或超出小数位 | | `40003` | 400 | 金额超出该链、该币的单笔上限 | | `40004` | 400 | 链或币种不支持 / 未开放 | | `40005` | 400 | 收款方式数量超限 | | `40101` | 401 | API Key 缺失或无效(含测试 Key) | | `40301` | 403 | 请求 IP 不在应用白名单 | | `40302` | 403 | 应用未上线、已停用,或商户账户已禁用 | | `40401` | 404 | 订单不存在 | | `40901` | 409 | 商户订单号已存在(`data.orderNo` 为已有订单) | | `40902` | 409 | 订单状态不允许此操作 | | `42901` | 429 | 请求过于频繁,按 `Retry-After` 重试 | | `50001` | 500 | 系统繁忙,可稍后重试 | ## OpenAPI 与 AI - **OpenAPI 3.1**:[`https://api.cnixpay.com/api/v2/openapi.json`](https://api.cnixpay.com/api/v2/openapi.json)(公开,无需认证)。完整描述接口、字段、枚举、错误码和回调格式,可直接导入 Postman / Apifox,或交给 AI 编程助手生成对接代码。 - **llms.txt**:[`/llms.txt`](/llms.txt)(索引)与 [`/llms-full.txt`](/llms-full.txt)(本指南全文的纯文本),方便交给 AI 使用。 ## 最佳实践 1. **回调必须验签。**不要信任任何未通过签名校验的通知。 2. **回调接口保持幂等。**按 `X-Webhook-Id` 去重,按 `version` 丢弃旧数据。 3. **快速返回 2xx。**耗时逻辑放进异步队列,避免触发重试。 4. **关键操作前查询确认。**发货前可再调一次查询订单;定时按 `merchantOrderNo` 批量查询未完结的订单,兜底漏收的回调。 5. **下单超时不要换单号重下。**用同一个 `merchantOrderNo` 重试,收到 40901 就按 `data.orderNo` 查询。 6. **金额使用 Decimal。**所有金额都是字符串,避免浮点误差。 7. **记录 `requestId`。**出错时把它连同时间一起记进日志,排查时提供给我们。 --- # Underpaid, paid in parts, or overpaid — what happens to the order > The most common "wrong amount" situations in stablecoin payments, and how running totals, the grace period, the underpaid status and webhook events handle each of them. Canonical: https://cnixpay.com/blog/handling-partial-payments/ Published: 2026-09-22 On-chain payments do not work like card charges. You cannot pull an exact amount. The customer types the number, the exchange deducts a withdrawal fee, they misread a decimal, they send twice — and what lands on the address is often not what you asked for. This post explains what happens to an order in each case and what your system should do about it. ## One rule to remember CNIXPay looks at one thing: **has the total received for this order reached the amount due?** - Yes → the order becomes `paid`, is settled to your balance, and `order.paid` is sent. - No → the order stays `pending` and keeps adding up; each payment sends an `order.partially_paid`. - Still short at expiry → there is a **grace period** (60 minutes by default). If it is still short when the grace period ends, the amount received is credited, the order becomes `underpaid`, and `order.underpaid` is sent. There are only four statuses, and for you there is only one rule: **wait on `pending`, act on everything else**. | `status` | What to do | | --- | --- | | `pending` | Wait; if `paidAmount > 0` you may ask the customer to top up | | `paid` | Fulfil | | `underpaid` | Handle manually: partial fulfilment, ask for the rest, or refund | | `closed` | Void the order | ## Scenario 1: underpaid The order is for 100 USD and the customer sends 99.5 USDT. The order stays `pending` and you receive an `order.partially_paid`: ```json { "event": "order.partially_paid", "data": { "orderNo": "20261017105142a1b2c3d4e5f6", "status": "pending", "amount": "100.00", "paidAmount": "99.50", "version": 2 } } ``` The order has not failed or closed — it is waiting for the remaining 0.5. - If the customer sends 0.5 more before expiry or within the grace period, the total reaches 100, the order becomes `paid` and you receive `order.paid`. - If it is still 99.5 when the grace period ends, the order becomes `underpaid`, the 99.5 is credited to your balance (minus the fee), and you receive `order.underpaid`. **What to do:** while `pending`, you can show "0.5 USDT still due" (`amount − paidAmount`), but do not fulfil. On `underpaid`, decide whether to fulfil partially, ask for the rest, or refund. ## Scenario 2: paid in several transfers The order is for 10 and the customer sends 3, then 4, then 3. You receive, in order: ```json { "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "3.00", "txCount": 1, "version": 2 } } { "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "7.00", "txCount": 2, "version": 3 } } { "event": "order.paid", "data": { "status": "paid", "paidAmount": "10.00", "txCount": 3, "version": 4 } } ``` Three things to note: 1. `paidAmount` is a **running total**, not the size of the last transfer; each transfer is listed in `transactions[]`. 2. `version` goes up by 1 on every change. Webhooks may arrive out of order — keep the highest `version`. 3. The final transfer produces `order.paid`, not a third `order.partially_paid`. **What to do:** fulfil only when `status == "paid"`. `order.partially_paid` is good for a progress bar or a top-up reminder. ## Scenario 3: overpaid The order is for 100 and the customer sends 120. 120 ≥ 100, so the order becomes `paid` straight away with `paidAmount` of `"120.00"`. The extra 20 is credited to your merchant balance as well — what gets settled is what actually arrived. If the customer pays again after the order is `paid`, that payment is credited too: the status stays `paid`, `paidAmount` and `version` update, but no further webhook is sent. **What to do:** apply your own rules on whether to refund the difference (`paidAmount − amount`). The CNIXPay merchant API has no refund endpoint; send refunds from your own wallet. ## Scenario 4: the payment arrives after expiry Every order has an expiry (`expireTime`, 30 minutes by default). On-chain transfers take time, so a customer can hit send before the countdown ends and have the block confirm after the order has expired. That is what the **grace period** is for: for a while after expiry the order is still `pending`, and payments and top-ups count as usual. You never need to work out "has it expired yet" — as long as it is `pending`, keep waiting. When the grace period ends: - nothing received → the order becomes `closed` (`closeReason = timeout`) and `order.closed` is sent; - some payment received → the order becomes `underpaid` and `order.underpaid` is sent. Funds that arrive after the grace period are not credited automatically; the platform handles them manually — refunding the payer, or crediting them after review. A manual credit re-evaluates the order on what was received, so it may go from `closed` to `paid`, with `order.paid` sent. **What to do:** if you need to release stock earlier you can act on `expireTime` yourself, but do not delete the order outright — you still need to find it if `order.paid` arrives. ## Putting it in code A robust handler looks roughly like this (for signature verification and deduplication see [Callback verification and idempotency](/blog/callback-verification-and-idempotency/)): ```js async function onOrderChanged(order) { // keep only the newest version: UPDATE ... WHERE order_no = ? AND version < ? if (!(await orders.saveIfNewer(order))) return switch (order.status) { case 'pending': // optional: if paidAmount > 0, tell the customer amount − paidAmount is still due break case 'paid': await fulfillOnce(order.orderNo) // fulfil, idempotent per order break case 'underpaid': await createManualReview(order.orderNo, order.paidAmount) break case 'closed': await voidOrder(order.orderNo) break } } ``` Webhooks and queries return the same order object, so this function serves both. Add a periodic batch query of open orders by `merchantOrderNo` as a fallback and no order slips through. ## Summary | Scenario | Order status | Events | What you do | | --- | --- | --- | --- | | Underpaid, then topped up | `pending` → `paid` | `partially_paid` → `paid` | Wait, fulfil on `paid` | | Underpaid, never topped up | `pending` → `underpaid` | `partially_paid` → `underpaid` | Handle manually | | Paid in parts | `pending` → `paid` | several `partially_paid`, then `paid` | Fulfil only on `paid` | | Overpaid | `paid` | `paid`, `paidAmount` is the actual amount | Handle the difference per your rules | | Paid within the grace period | `pending` → `paid` | `paid` | Don't give up too early | | Never paid | `closed` (`timeout`) | `closed` | Void | Full status and event details are in the [integration guide · Order statuses](/docs/#status). --- # Callback verification and idempotency, ready to copy > The webhook endpoint is the easiest thing to forge and to hit twice. The signature algorithm, code in five languages, and handling duplicates, ordering and missed webhooks. Canonical: https://cnixpay.com/blog/callback-verification-and-idempotency/ Published: 2026-09-21 A payment webhook endpoint has two natural enemies: **forgery** (someone posts a fake `order.paid` to your `/notify`) and **duplication** (you receive the same event twice). Signature verification handles the first; idempotency handles the second. Get both right and the webhook can safely drive fulfilment. ## What a webhook looks like A CNIXPay webhook is a JSON event with the signature in the headers: ```http 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, "...": "..." } } ``` `data` is the full order object at the moment of the event — the same object the get-order endpoint returns. ## How the signature works Three steps: 1. Read the `X-Webhook-Timestamp` header and check it is within 5 minutes of now (replay protection); 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**. The signature covers the **raw bytes of the request body**, which leads to the most common mistake: - **Do not parse the JSON and re-serialize it before verifying.** Any difference in key order, whitespace or number format breaks the signature. Many frameworks parse the body for you by default; get the raw bytes first — `express.raw()` in Express, `file_get_contents('php://input')` in PHP. The upside comes from the same design: verification does not care which fields exist, so when new fields are added to the order object your verification code does not change at all. ## Verification in five languages ### Node.js ```js import crypto from 'node:crypto' // rawBody must be the raw request body (Buffer) 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) ``` ### Python ```python 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) ``` ### Go ```go 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"))) } ``` ### PHP ```php 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); ``` ### Java ```java 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)); } ``` All five use a **constant-time comparison** (`timingSafeEqual` / `compare_digest` / `hmac.Equal` / `hash_equals` / `MessageDigest.isEqual`). Do not replace it with `==`. ## Why you get duplicate or out-of-order webhooks All of these are normal: 1. **Retries.** If you do not return HTTP 2xx within 5 seconds (a timeout, a 4xx / 5xx, or a 3xx redirect all count as failures), the event is resent after 15s, 1m, 5m, 10m, 30m, 1h, 2h, 6h and 12h — about 22 hours in total. All four event types are retried. 2. **Several events per order.** If the customer pays in parts, every short payment sends an `order.partially_paid`, followed by `order.paid` once it is paid in full. 3. **Out of order.** A retry resends exactly the same body and may arrive after a later event — for example the third retry of an `order.partially_paid` landing after `order.paid`. So your handler **must accept the same event any number of times, and never let an old event overwrite a newer state**. ## Handling duplicates and ordering Two fields are enough: - **`X-Webhook-Id`** (the same as `id` in the body): unique per event, unchanged across retries. Dedupe on it. - **`data.version`**: the order version, incremented on every change. Keep only the copy with the highest `version`; ignore anything older. ```sql CREATE TABLE webhook_events ( id VARCHAR(64) PRIMARY KEY, -- X-Webhook-Id order_no VARCHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` ```js app.post('/notify', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyWebhook(req.body, req.headers, API_SECRET)) return res.status(400).end() const event = JSON.parse(req.body) // INSERT succeeds = first time seen; primary-key conflict = already handled, just return 2xx const isNew = await db.insertIgnore('webhook_events', { id: event.id, order_no: event.data.orderNo }) if (isNew) await queue.push('order-changed', event.data) res.status(200).end() }) // queue consumer async function onOrderChanged(order) { // write only if newer: UPDATE ... WHERE order_no = ? AND version < ? const updated = await orders.saveIfNewer(order) if (updated && order.status === 'paid') await fulfillOnce(order.orderNo) } ``` Three details: - **Persist, return 2xx, fulfil from a queue.** The handler only verifies, dedupes and enqueues, returning within tens of milliseconds so a timeout never triggers a retry. - **Fulfil only on `status == "paid"`**, and make fulfilment itself idempotent per order (`fulfillOnce`). Send `underpaid` to manual review and void `closed`. - **A voided order can still receive `order.paid`.** Funds that arrive after the grace period may be credited manually by the platform after review, turning the order from `closed` into `paid`. Handling by `version` gets this right — do not drop it just because you voided the order locally. ## What if a webhook never arrives? Webhooks are pushed, and pushes can be lost: your service is down for more than 22 hours, the callback URL is misconfigured, or a verification bug keeps returning 4xx. So add an **active query** as a fallback: ```js // every few minutes: batch-query the orders you still have open (up to 100 per request) const open = await orders.findOpen({ limit: 100 }) const res = await cnixpay.get('/orders', { merchantOrderNo: open.map((o) => o.merchantOrderNo) }) for (const order of res.data.items) await onOrderChanged(order) ``` The query returns the authoritative order — the same object as the webhook's `data` — so the same handler works for both. When the customer clicks "I've paid" on your payment page, you can also fetch that single order for instant feedback. ## Checklist Before going live: - [ ] Verify against the raw body, check the timestamp is within 5 minutes, compare in constant time - [ ] Return 4xx and log when verification fails - [ ] Dedupe by `X-Webhook-Id` and drop stale data by `version` - [ ] Fulfil only on `status == "paid"`, idempotently per order - [ ] The handler only records and enqueues, returning 2xx within 5 seconds - [ ] Periodically batch-query open orders by `merchantOrderNo` as a fallback - [ ] The API secret lives only on the server — never in the front end, never in logs Full event and field definitions are in the [integration guide · Webhook events](/docs/#callbacks). --- # Hosted checkout or your own? A decision you can make in an afternoon > The two CNIXPay integration modes differ by one field when creating an order. Who each one suits, what each one requires, and how to move from one to the other without touching your backend. Canonical: https://cnixpay.com/blog/hosted-vs-custom-checkout/ Published: 2026-09-20 The first decision when integrating CNIXPay is who renders the payment page. At the API level the two options differ by a single field, but the difference in effort and control is real. ## Two modes, one field **Mode A · your own checkout**: pass `paymentOptions` when creating the order (`POST /orders`) to declare which network × token combinations you accept. The `paymentOptions` array in the response adds the deposit address and amount due for each — render them in your own page. ```json // Request { "merchantOrderNo": "ORDER_001", "amount": "100.00", "paymentOptions": [ { "chain": "tron", "token": "USDT" }, { "chain": "bsc", "token": "USDC" } ] } // Response (excerpt) { "paymentOptions": [ { "chain": "tron", "token": "USDT", "address": "TR7NHq...", "amount": "100.00" }, { "chain": "bsc", "token": "USDC", "address": "0x55d3...", "amount": "100.00" } ] } ``` **Mode B · hosted checkout**: omit `paymentOptions`. No address is allocated up front (`paymentOptions` in the response is empty); the response includes a `cashierUrl` and you redirect the customer there. Network selection, address and QR code, countdown and the result page are all handled by the platform, which then sends the customer back to your `returnUrl`. ```json // Request { "merchantOrderNo": "ORDER_002", "amount": "50.00", "returnUrl": "https://shop.example.com/orders/ORDER_002" } // Response (excerpt) { "cashierUrl": "https://pay.example.com/pay/20261017...", "paymentOptions": [] } ``` Callbacks, signatures, order statuses and the query endpoint are identical in both modes. ## Who each mode suits | | Your own checkout | Hosted checkout | | --- | --- | --- | | Effort | Build a payment page: address, QR, countdown, status polling | None — just redirect | | Brand and UX | Fully custom; customer never leaves your domain | Platform page, returns to your site afterwards | | Network choice | You decide what the customer sees | Customer picks on the platform page | | Best for | Products with an existing checkout, in-app payments, high UX bar | Fast launches, MVPs, campaign pages, no frontend capacity | | Time to ship | One or two days | One afternoon | An easily missed point: **with your own checkout, the payment options the customer sees are fixed when you create the order**. If they reach the payment page and say "I only have USDC on Polygon", call the add payment options endpoint (`POST /orders/{orderNo}/payment-options`) to append it — no need to close and recreate the order. ## What your own checkout must get right If you choose Mode A, the payment page needs at least: 1. **One card per option**: network, token, address, QR code, amount due. Amounts can differ per network (`paymentOptions[].amount`) — use each one's own value. 2. **Full address plus one-tap copy.** Addresses are case-sensitive on TRON and Solana; never "prettify" them. 3. **A countdown driven by `expireTime`** (Unix seconds), not local time plus a fixed duration. 4. **Status polling**: while the customer is on the page, hit your own backend every 5–10 seconds; the backend reads the callback-persisted state or gets the order. Never call CNIXPay from the browser. 5. **An "I have paid" button** that triggers an immediate query for instant feedback. The hosted checkout already does all of this — which is why it takes an afternoon. ## Moving from hosted to your own A common path: launch on the hosted checkout to validate the business, then build your own once the flow is proven. Switching is cheap because the backend does not change: 1. Pass `paymentOptions` when creating the order. 2. Render the page from `paymentOptions` in the response. 3. Leave the callback handler untouched. You can even mix per order: some channels pass `paymentOptions` and use your page, others omit it and use the hosted checkout, all under the same app. ## Recommendation - **Ship first, optimise later.** Without specific UX requirements, start with the hosted checkout and use small real orders to prove callbacks, idempotency and reconciliation. - **Have an app or strict brand consistency? Build your own from the start.** A day or two of work buys a fully controlled experience. - **Either way, the callback handler is the same.** Verify, deduplicate, fulfil only on `paid`, poll on timeout — see [Callback verification and idempotency](/blog/callback-verification-and-idempotency/). Field details are in the [integration guide · Create an order](/docs/#create-order). --- # Which networks should you enable for USDT / USDC? A practical comparison > TRON, BSC, Ethereum, Polygon and Solana compared by audience, fee level, address format and integration gotchas — and why the answer is usually "enable several and let the customer choose". Canonical: https://cnixpay.com/blog/choosing-a-network/ Published: 2026-09-18 CNIXPay accepts USDT / USDC on five networks, and a single order can offer several of them at once. So the question is not "which one" but "which ones by default, and when to offer fewer". Comparison first, strategy second. ## The five networks at a glance | Network | `chain` | Standard | Tokens | Address format | Payer fee level | | --- | --- | --- | --- | --- | --- | | TRON | `tron` | TRC20 | USDT | starts with `T`, 34 chars, case-sensitive | Medium (paid in TRX, fluctuates) | | BNB Smart Chain | `bsc` | BEP20 | USDT / USDC | starts with `0x`, 42 chars | Low | | Ethereum | `eth` | ERC20 | USDT / USDC | starts with `0x`, 42 chars | High, varies with congestion | | Polygon | `polygon` | ERC20 | USDT / USDC | starts with `0x`, 42 chars | Very low | | Solana | `solana` | SPL | USDT / USDC | Base58, 32–44 chars, case-sensitive | Very low | Fees are paid by the customer in each chain's native coin and change constantly, so only the level is given here. You do not need to think about confirmation counts — the platform handles that; you only watch the order status. ## What each network is like **TRON** carries one of the largest USDT supplies and is especially familiar to users in Asia. Nearly everyone holds TRC20 USDT, so it has the highest payment success rate. Downsides: fees are not the lowest, and TRON has USDT only, no USDC. **BNB Smart Chain** offers low fees and a large user base, with both USDT and USDC. It uses EVM addresses — the same format as Ethereum and Polygon — which is the most common pitfall: a customer sends BSC USDT to an Ethereum address, or vice versa. If you build your own checkout, make the network name impossible to miss. **Ethereum** mainnet has the highest fees but is preferred by institutions and large payers. Worth enabling if your average order is in the thousands of dollars; nobody will use it for small amounts. **Polygon** fees are practically negligible, ideal for small and frequent payments. Its reach is a bit smaller than BSC. Note that USDC on Polygon is Circle's native version, not the older bridged USDC.e. **Solana** is fast with extremely low fees and an active USDC ecosystem. Its address format is nothing like EVM, so customers cannot confuse networks. A good fit for international products with crypto-native users. ## Strategy: enable several by default, narrow when justified **Default recommendation: TRON + BSC + Polygon + Solana, all at once.** ```json { "merchantOrderNo": "ORDER_001", "amount": "100.00", "paymentOptions": [ { "chain": "tron", "token": "USDT" }, { "chain": "bsc", "token": "USDT" }, { "chain": "bsc", "token": "USDC" }, { "chain": "polygon", "token": "USDC" }, { "chain": "solana", "token": "USDC" } ] } ``` A payment on any of these networks counts toward the order. Extra payment options cost you nothing and mean "there is always one I can pay with" for the customer. When to offer fewer: - **Very small orders (< 5 USDT)**: drop TRON so the fee does not dwarf the amount and scare the customer off. - **Very large orders**: add Ethereum. - **Your withdrawals go out on one specific chain**: you can still receive on many; withdrawal rules are shown in the merchant dashboard. - **Hosted checkout**: omit `paymentOptions`; the customer picks on the checkout page from the networks enabled for your app. An order can hold up to 10 payment options, which is plenty. ## Three reminders for your own checkout 1. **The network name matters more than the address.** A customer seeing `0x55d3...` cannot tell BSC from Polygon. Title the card "BNB Smart Chain · USDT (BEP20)". 2. **Do not validate addresses in the frontend.** They are derived by the platform and always well-formed; your validation would only add bugs. 3. **Same chain, different tokens, same address.** BSC USDT and BSC USDC return the same `address` — that is expected; a USD order counts either USDT or USDC received there at 1:1. ## One-line summary Enabling more networks costs nothing; enabling fewer costs you customers. Unless you have a specific reason, start with four and let real order data tell you whether to narrow. The current list of available combinations is in the [integration guide](/docs/#overview) and in API responses. --- # 用户少付、分几次付、付多了,订单怎么处理 > 稳定币收款里最常见的几种“金额不对”场景,以及 CNIXPay 的累计到账、宽限期、underpaid 状态和回调事件分别是怎么兜住它们的。 Canonical: https://cnixpay.com/zh/blog/handling-partial-payments/ Published: 2026-09-22 在链上收款,你不能像刷卡那样“扣一笔精确的钱”。用户手动填金额、交易所提币扣手续费、看错小数位、分两次转——最后到账的数字经常和应付不一样。这篇文章讲清楚每种情况下订单会发生什么,以及你的系统该怎么应对。 ## 先记住一个原则 CNIXPay 判断订单只看一件事:**这笔订单累计到账的金额,是否达到了应付金额**。 - 达到 → 订单变为 `paid`,结算入你的余额,推送 `order.paid`。 - 没达到 → 订单保持 `pending`,继续累计,每收到一笔就推送一次 `order.partially_paid`。 - 到了过期时间还没付足 → 再等一段**宽限期**(默认 60 分钟);宽限期结束仍没付足,按实收入账,订单变为 `underpaid`,推送 `order.underpaid`。 订单一共只有 4 个状态,对你来说规则也只有一条:**`pending` 就等,其他状态就处理**。 | `status` | 你该做什么 | | --- | --- | | `pending` | 等待;`paidAmount > 0` 时可以提示用户补足 | | `paid` | 发货 | | `underpaid` | 人工处理:补发部分商品、联系用户补款或退款 | | `closed` | 作废订单 | ## 场景一:少付了 应付 100 USD,用户转了 99.5 USDT。 订单仍是 `pending`,你会收到一条 `order.partially_paid`: ```json { "event": "order.partially_paid", "data": { "orderNo": "20261017105142a1b2c3d4e5f6", "status": "pending", "amount": "100.00", "paidAmount": "99.50", "version": 2 } } ``` 订单没有失败,也没有关闭——它在等剩下的 0.5。 - 用户在过期前或宽限期内补一笔 0.5,累计到 100,订单变为 `paid`,你收到 `order.paid`。 - 宽限期结束还是 99.5,订单变为 `underpaid`,99.5 按实收入账到你的余额(扣除手续费),你收到 `order.underpaid`。 **你该做什么:** `pending` 时可以在页面上提示“还差 0.5 USDT”(`amount − paidAmount`),但不要发货。收到 `underpaid` 再决定是补发部分、找用户补款,还是退款。 ## 场景二:分几次付 应付 10,用户先付 3,再付 4,最后付 3。你会依次收到: ```json { "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "3.00", "txCount": 1, "version": 2 } } { "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "7.00", "txCount": 2, "version": 3 } } { "event": "order.paid", "data": { "status": "paid", "paidAmount": "10.00", "txCount": 3, "version": 4 } } ``` 注意三点: 1. `paidAmount` 是**累计值**,不是单笔金额;每一笔的明细在 `transactions[]` 里。 2. 每次变化 `version` 都加 1。回调可能乱序送达,以 `version` 大的为准。 3. 最后一笔到账时收到的是 `order.paid`,而不是第三条 `order.partially_paid`。 **你该做什么:** 只在 `status == "paid"` 时发货。`order.partially_paid` 适合用来更新进度条或提醒用户补款。 ## 场景三:付多了 应付 100,用户转了 120。 累计 120 ≥ 100,订单直接变为 `paid`,`paidAmount` 是 `"120.00"`。多出的 20 同样结算进你的商户余额——系统入账的是实际到账金额。 如果订单 `paid` 之后用户又重复付了一笔,这笔钱也会照常入账:状态保持 `paid`,`paidAmount` 和 `version` 更新,但不会再推送回调。 **你该做什么:** 按你的业务规则决定是否退还差额(`paidAmount − amount`)。CNIXPay 的商户 API 不包含退款接口,退款需要你从自己的钱包转给用户。 ## 场景四:订单过期后才到账 订单有过期时间(创建时返回的 `expireTime`,默认 30 分钟)。链上转账有延迟,用户可能在倒计时结束前点了发送、区块确认时订单已经过期。 这就是**宽限期**的作用:过期后的一段时间内,订单对外仍是 `pending`,期间到账、补足都正常计入。所以你不用自己判断“过期了没”——只要还是 `pending`,就继续等。 宽限期结束后: - 一分钱都没收到 → 订单变为 `closed`(`closeReason = timeout`),推送 `order.closed`; - 收到过一部分 → 订单变为 `underpaid`,推送 `order.underpaid`。 宽限期之后才到的钱不会自动入账,由平台人工处理:退回付款人,或核实后补单。补单后订单会按实收重新判定,可能从 `closed` 变成 `paid`,并推送 `order.paid`。 **你该做什么:** 需要更早释放库存,可以按 `expireTime` 自己处理,但不要把订单“彻底删除”——收到 `order.paid` 时仍要能找到它。 ## 把这些落到代码里 一个健壮的处理器大致是这样(验签与去重的写法见 [回调验签与幂等](/zh/blog/callback-verification-and-idempotency/)): ```js async function onOrderChanged(order) { // 只保留版本最新的订单:UPDATE ... WHERE order_no = ? AND version < ? if (!(await orders.saveIfNewer(order))) return switch (order.status) { case 'pending': // 可选:paidAmount > 0 时提示用户还差 amount − paidAmount break case 'paid': await fulfillOnce(order.orderNo) // 发货,按订单号幂等 break case 'underpaid': await createManualReview(order.orderNo, order.paidAmount) break case 'closed': await voidOrder(order.orderNo) break } } ``` 回调和主动查询返回的是同一个订单对象,这个函数两边都能用。再加上定时按 `merchantOrderNo` 批量查询未完结订单的兜底,就不会漏单。 ## 小结 | 场景 | 订单状态 | 回调事件 | 你要做的 | | --- | --- | --- | --- | | 少付,后来补足 | `pending` → `paid` | `partially_paid` → `paid` | 等待,`paid` 时发货 | | 少付,没补 | `pending` → `underpaid` | `partially_paid` → `underpaid` | 人工处理 | | 分次付 | `pending` → `paid` | 多次 `partially_paid`,最后 `paid` | 只在 `paid` 发货 | | 多付 | `paid` | `paid`,`paidAmount` 为实际值 | 按业务规则处理差额 | | 过期后宽限期内到账 | `pending` → `paid` | `paid` | 别过早判死 | | 一直没付 | `closed`(`timeout`) | `closed` | 作废 | 完整的状态和事件说明见 [接入指南 · 订单状态](/zh/docs/#status)。 --- # 回调验签与幂等:一份可以直接抄的实现 > 支付回调是你系统里最容易被伪造、也最容易重复的入口。这篇给出 CNIXPay 回调的签名算法、五种语言的验签代码,以及去重、乱序与兜底查询的完整做法。 Canonical: https://cnixpay.com/zh/blog/callback-verification-and-idempotency/ Published: 2026-09-21 支付回调接口有两个天然的敌人:**伪造**(有人往你的 `/notify` 发一条假的 `order.paid`)和**重复**(同一个事件你收到了两次)。前者靠验签解决,后者靠幂等解决。两个都做对,回调才能作为发货依据。 ## 回调长什么样 CNIXPay 的回调是一个 JSON 事件,签名放在请求头里: ```http 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, "...": "..." } } ``` `data` 是事件发生那一刻的完整订单对象,和查询订单接口返回的一样。 ## 签名是怎么算的 三步: 1. 取请求头 `X-Webhook-Timestamp`,确认它和当前时间相差不超过 5 分钟(防重放); 2. 用你的 API Secret 计算 `HMAC-SHA256(timestamp + "." + 原始请求体)`,转成小写十六进制,前面加上 `v2=`; 3. 和请求头 `X-Webhook-Signature` 做**常量时间比较**。 签名覆盖的是**原始请求体的字节**,所以有一个最容易踩的坑: - **不要先解析 JSON 再序列化回去验签。** 字段顺序、空格、数字格式只要有一点不同,签名就对不上。很多框架默认会把请求体解析成对象,验签前要拿到原始字节——Express 用 `express.raw()`,PHP 用 `file_get_contents('php://input')`。 好处也来自这里:验签不用关心有哪些字段,以后平台给订单对象加字段,你的验签代码一行都不用改。 ## 五种语言的验签 ### Node.js ```js import crypto from 'node:crypto' // rawBody 必须是原始请求体(Buffer) 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) ``` ### Python ```python 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) ``` ### Go ```go 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"))) } ``` ### PHP ```php 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); ``` ### Java ```java 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)); } ``` 五段代码都用了**常量时间比较**(`timingSafeEqual` / `compare_digest` / `hmac.Equal` / `hash_equals` / `MessageDigest.isEqual`),不要换成 `==`。 ## 为什么会收到重复或乱序的回调 都是正常行为: 1. **重试。** 你没有在 5 秒内返回 HTTP 2xx(超时、返回 4xx / 5xx、返回 3xx 重定向都算失败),平台会按 15 秒、1 分钟、5 分钟、10 分钟、30 分钟、1 小时、2 小时、6 小时、12 小时重发,共约 22 小时。四种事件都会重试。 2. **一个订单有多个事件。** 用户分几次付款,每笔不足额的到账都会推一条 `order.partially_paid`,付足后再推 `order.paid`。 3. **乱序。** 重试的请求体和第一次完全相同,可能晚于后面的事件才送到——比如 `order.partially_paid` 的第三次重试,在 `order.paid` 之后才到。 所以你的处理器**必须能安全地接受同一个事件任意多次,并且不会被旧事件覆盖新状态**。 ## 幂等和乱序怎么处理 用两个字段就够: - **`X-Webhook-Id`**(同请求体里的 `id`):每个事件唯一,重试不变。按它去重。 - **`data.version`**:订单版本号,订单每变化一次加 1。本地只保留 `version` 最大的那份订单,比它小的直接忽略。 ```sql CREATE TABLE webhook_events ( id VARCHAR(64) PRIMARY KEY, -- X-Webhook-Id order_no VARCHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` ```js app.post('/notify', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyWebhook(req.body, req.headers, API_SECRET)) return res.status(400).end() const event = JSON.parse(req.body) // INSERT 成功 = 第一次见到;主键冲突 = 已处理过,直接 2xx const isNew = await db.insertIgnore('webhook_events', { id: event.id, order_no: event.data.orderNo }) if (isNew) await queue.push('order-changed', event.data) res.status(200).end() }) // 队列消费者 async function onOrderChanged(order) { // 只在版本更新时写入:UPDATE ... WHERE order_no = ? AND version < ? const updated = await orders.saveIfNewer(order) if (updated && order.status === 'paid') await fulfillOnce(order.orderNo) } ``` 三个细节: - **先落库再返回 2xx,履约放队列。** 回调处理器只做「验签 + 去重 + 入队」,几十毫秒内返回,避免超时触发重试。 - **只在 `status == "paid"` 时发货**,发货本身也要按订单号幂等(`fulfillOnce`)。`underpaid` 转人工,`closed` 作废。 - **已作废的订单也可能收到 `order.paid`。** 宽限期结束后才到的钱,平台核实后可能人工补单,订单会从 `closed` 变成 `paid`。按 `version` 处理就不会出错,不要因为本地已作废就丢弃。 ## 收不到回调怎么办 回调是推送,推送就可能丢:你的服务宕机超过 22 小时、回调地址配错、验签代码有 bug 一直返回 4xx。所以要有一道**主动查询**兜底: ```js // 每隔几分钟:把本地还没出结果的订单批量查一次(每次最多 100 个) const open = await orders.findOpen({ limit: 100 }) const res = await cnixpay.get('/orders', { merchantOrderNo: open.map((o) => o.merchantOrderNo) }) for (const order of res.data.items) await onOrderChanged(order) ``` 查询返回的就是订单的权威状态,和回调里的 `data` 是同一个对象,可以直接复用同一套处理逻辑。用户在支付页点「我已付款」时,也可以单独查一次这笔订单,给出即时反馈。 ## 检查清单 上线前把这几条过一遍: - [ ] 用原始请求体验签,校验时间戳在 5 分钟内,常量时间比较 - [ ] 验签失败返回 4xx 并记日志 - [ ] 按 `X-Webhook-Id` 去重,按 `version` 丢弃旧数据 - [ ] 只在 `status == "paid"` 时发货,发货按订单号幂等 - [ ] 处理器只记录 + 入队,5 秒内返回 2xx - [ ] 定时按 `merchantOrderNo` 批量查询未完结订单兜底 - [ ] API Secret 只在服务端,不进前端、不进日志 事件与字段的完整定义见 [接入指南 · 回调事件](/zh/docs/#callbacks)。 --- # 自建收银台还是用平台收银台?一个下午能做完的决定 > CNIXPay 的两种接入方式差别只在创建订单时传不传 paymentOptions。这篇讲清楚各自适合谁、各自要做什么,以及怎么从一种平滑切到另一种。 Canonical: https://cnixpay.com/zh/blog/hosted-vs-custom-checkout/ Published: 2026-09-20 接入 CNIXPay 时第一个要做的决定,就是支付页面由谁来渲染。两种方式在 API 层面只差一个字段,但对你的开发量和控制力影响很大。 ## 两种方式,一个字段 **方式 A · 自建收银台**:创建订单(`POST /orders`)时传 `paymentOptions`,告诉平台你接受哪些「网络 × 币种」。响应里的 `paymentOptions` 会带上每个收款方式的地址和应付数量,你拿去渲染自己的支付页。 ```json // 请求 { "merchantOrderNo": "ORDER_001", "amount": "100.00", "paymentOptions": [ { "chain": "tron", "token": "USDT" }, { "chain": "bsc", "token": "USDC" } ] } // 响应(节选) { "paymentOptions": [ { "chain": "tron", "token": "USDT", "address": "TR7NHq...", "amount": "100.00" }, { "chain": "bsc", "token": "USDC", "address": "0x55d3...", "amount": "100.00" } ] } ``` **方式 B · 平台收银台**:创建订单时不传 `paymentOptions`。此时不预分配地址(响应里 `paymentOptions` 为空),只返回 `cashierUrl`,你把用户跳过去就行。选网络、展示地址和二维码、倒计时、支付结果页,全部由平台页面处理;支付完成跳回你传的 `returnUrl`。 ```json // 请求 { "merchantOrderNo": "ORDER_002", "amount": "50.00", "returnUrl": "https://shop.example.com/orders/ORDER_002" } // 响应(节选) { "cashierUrl": "https://pay.example.com/pay/20261017...", "paymentOptions": [] } ``` 回调、签名、订单状态、查询接口,两种方式完全一样。 ## 各自适合谁 | | 自建收银台 | 平台收银台 | | --- | --- | --- | | 开发量 | 需要做支付页:地址、二维码、倒计时、状态轮询 | 零,只需跳转 | | 品牌与体验 | 完全自定义,用户不离开你的域名 | 平台页面,可跳回你的站 | | 网络选择 | 你决定给用户哪些选项 | 用户在平台页面自选 | | 适合 | 已有成熟结算页的产品、App 内嵌支付、对体验要求高 | 快速上线、MVP、运营活动页、没有前端资源 | | 上线时间 | 一到两天 | 一个下午 | 一个常被忽略的点:**自建收银台里,用户看到的收款方式是你下单时决定的**。如果用户到了支付页才说"我只有 Polygon 上的 USDC",你可以调追加收款方式接口(`POST /orders/{orderNo}/payment-options`)把它加进去,不用关单重建。 ## 自建收银台要做对的几件事 如果选方式 A,支付页至少要处理这些: 1. **每个收款方式一张卡片**:网络名、币种、地址、二维码、应付数量。不同网络的应付数量可能不同(`paymentOptions[].amount`),要用各自的值。 2. **地址完整展示 + 一键复制**。链上地址大小写敏感(TRON、Solana),不要做任何"美化"。 3. **倒计时**用 `expireTime`(Unix 秒)算,不要用本地时间加固定时长。 4. **状态轮询**:用户停留在支付页时,每 5–10 秒调一次你自己的后端,后端再查询订单或读回调落库的状态。不要让前端直接调 CNIXPay。 5. **"我已付款"按钮**:点击后立即触发一次查询,给用户即时反馈。 这些事情平台收银台已经替你做了——这也是它"一个下午"的原因。 ## 从平台收银台切到自建 很多团队的路径是:先用平台收银台上线验证业务,跑顺了再自建。切换成本很低,因为后端逻辑不变: 1. 创建订单时加上 `paymentOptions`; 2. 用响应的 `paymentOptions` 渲染页面; 3. 回调处理器一行不改。 甚至可以按订单混用:某些渠道传 `paymentOptions` 走自建,某些渠道不传走平台收银台,都在同一个应用下。 ## 建议 - **先上线再优化。** 没有特殊体验要求的话,从平台收银台开始,用真实小额订单把回调、幂等、对账跑通。 - **有 App 或对品牌一致性有要求,直接自建。** 一到两天的开发量换来完全可控的体验,值得。 - **无论哪种,回调处理器都一样写。** 验签、幂等、只在 `paid` 履约、超时主动查询——见 [回调验签与幂等](/zh/blog/callback-verification-and-idempotency/)。 字段细节见 [接入指南 · 创建订单](/zh/docs/#create-order)。 --- # 收 USDT / USDC 该开哪几条网络?五条链的实用对比 > TRON、BSC、Ethereum、Polygon、Solana 各自的用户群、手续费量级、地址格式和接入注意事项,以及为什么答案通常是“多开几条,让用户选”。 Canonical: https://cnixpay.com/zh/blog/choosing-a-network/ Published: 2026-09-18 CNIXPay 支持五条网络上的 USDT / USDC。一笔订单可以同时挂多条,所以问题不是"选哪一条",而是"默认开哪几条、什么情况下少开"。先看对比,再谈策略。 ## 五条网络一览 | 网络 | `chain` | 标准 | 币种 | 地址格式 | 付款方手续费量级 | | --- | --- | --- | --- | --- | --- | | TRON | `tron` | TRC20 | USDT | `T` 开头,34 位,区分大小写 | 中(以 TRX 计,波动) | | BNB Smart Chain | `bsc` | BEP20 | USDT / USDC | `0x` 开头,42 位 | 低 | | Ethereum | `eth` | ERC20 | USDT / USDC | `0x` 开头,42 位 | 高,随网络拥堵变化 | | Polygon | `polygon` | ERC20 | USDT / USDC | `0x` 开头,42 位 | 很低 | | Solana | `solana` | SPL | USDT / USDC | Base58,32–44 位,区分大小写 | 很低 | 手续费由付款方承担、以各链原生币支付,具体数字随时在变,这里只给量级。接入时不需要关心确认数——那是平台的事,你只看订单状态。 ## 每条链的特点 **TRON** 是 USDT 流通量最大的网络之一,亚洲用户尤其熟悉。用户几乎都有 TRC20 USDT,是收款成功率最高的选择。缺点是手续费不算低,且 TRON 上只有 USDT、没有 USDC。 **BNB Smart Chain** 手续费低、用户基数大,USDT 和 USDC 都有。EVM 地址,和 Ethereum、Polygon 通用格式——这也是最常见的坑:用户把 BSC 的 USDT 转到了 Ethereum 地址(或反过来)。自建收银台时要把网络名写得非常显眼。 **Ethereum** 主网手续费最高,但机构和大额用户偏好。如果你的客单价是几千美元以上,值得开;小额订单开了也没人用。 **Polygon** 手续费几乎可以忽略,适合小额高频。用户覆盖面比 BSC 小一些。注意 Polygon 上的 USDC 是 Circle 原生版本,不是早期的桥接 USDC.e。 **Solana** 速度快、手续费极低,USDC 生态活跃。地址格式和 EVM 完全不同,用户不会混淆网络。适合面向海外、有加密原生用户的产品。 ## 策略:默认多开,按情况收窄 **默认建议:TRON + BSC + Polygon + Solana,四条一起开。** ```json { "merchantOrderNo": "ORDER_001", "amount": "100.00", "paymentOptions": [ { "chain": "tron", "token": "USDT" }, { "chain": "bsc", "token": "USDT" }, { "chain": "bsc", "token": "USDC" }, { "chain": "polygon", "token": "USDC" }, { "chain": "solana", "token": "USDC" } ] } ``` 用户在任一网络付款都计入订单;多开收款方式对你没有额外成本,对用户是"总有一条我能付"。 什么时候少开: - **客单价很小(< 5 USDT)**:可以去掉 TRON,避免手续费占比过高把用户吓走。 - **客单价很大**:加上 Ethereum。 - **你自己的提现路径固定在某条链**:收款照样可以多开,提现规则以商户后台为准。 - **走平台收银台**:不传 `paymentOptions`,用户在收银台页面自己选,平台按你的应用配置展示可用网络。 单笔订单最多 10 个收款方式,够用。 ## 自建收银台时的三个提醒 1. **网络名比地址更重要。** 用户看到 `0x55d3...` 不会知道是 BSC 还是 Polygon。卡片标题直接写"BNB Smart Chain · USDT (BEP20)"。 2. **不要在前端做地址校验。** 地址由平台派生,格式一定正确;你做校验只会引入 bug。 3. **同链多币复用同一个地址。** BSC 上的 USDT 和 USDC 返回的是同一个 `address`,这是正常的——USD 订单在这个地址上收到 USDT 或 USDC 都按 1:1 计入。 ## 一句话总结 多开网络的成本是零,少开的代价是流失用户。除非有明确理由,默认四条起步,用一段时间的订单数据再决定要不要收窄。 网络与币种的最新可用组合以 [接入指南](/zh/docs/#overview) 和接口返回为准。