Declare a prepaid top-up (primarily for offline settlement)
x-idempotency-key
⚠ This endpoint does not change any balance at all. It registers only a pending declaration. Posting always follows confirmed receipt: on-chain receipts trigger automatic posting, while offline settlement requires dual approval.
This is the definition of the business line, not excessive caution: an endpoint that lets you add your own prepaid balance would grant unlimited credit. For the same reason, POST /v1/deposits, the only endpoint that can create member balances from a declaration, has six gates.
Prerequisites
- The asset exists in the
assetscatalog and is enabled. - This Key has been granted
merchant:write, a restricted scope requiring separate approval from us.
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
x-idempotency-key |
string | Required | UUID v4. Reusing the same key replays the same declaration without inserting another row. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
asset |
string | Required | Asset code, case-insensitive; the server converts it to uppercase. Unrecognized values return asset_not_allowed. |
channel |
"chain" | "fiat" | Optional | ⚠ Only exact fiat selects the fiat path. Every other value,
including omission and case variants, silently becomes chain. |
amount |
string | Required | Declared amount, a positive decimal string, with at most 20 integer digits
and 12 decimal places. Stored unchanged as amount_text."50000.00" |
reference_no |
string | Optional | Bank-transfer receipt reference, maximum 64 characters. Not echoed as a separate field:
it is merged into note in the form 回单号:<value>. |
note |
string | Optional | Notes. Combined with reference_no, then truncated to 300 characters overall. |
Response
status is always pending.{
"id": "5d2c8a41-3f6b-4e0a-9c77-1b8e0d4f2a56",
"asset": "USDT",
"channel": "fiat",
"amount_text": "50000.00",
"status": "pending"
}invalid_request: body is not JSON or asset missing.
invalid_fields: amount is not a positive decimal string (fields[].key = "amount").
asset_not_allowed: asset is not in the catalog or is disabled.insufficient_scope: missing merchant:write, a restricted scope
requiring separate approval from us; selecting it does not mean it was granted.idempotency_key_reused · idempotency_in_progressAdditional Details
⚠ channel defaults to chain, which is probably not what you want
The condition is exact equality to fiat. Omission, misspellings, FIAT, and Fiat all become chain without an error. But the on-chain path needs no declaration: we automatically create and post it when funds arrive. A manually declared chain record has no on-chain transaction hash and will never post automatically; it remains pending for a person to inspect.
For bank transfers, explicitly send "fiat".
Three other details
reference_no, the bank-transfer receipt reference, is not returned as a separate field. It is appended tonotewith the prefix回单号:, and the list endpoint returns onlynote. This is deliberate: without a prefix, the reference could not later be separated from free-text notes, while offline reconciliation depends on it.amountis a decimal string, validated more strictly than the merchant portal: at most 20 integer digits and 12 decimal places, and positive. Otherwise returns 400invalid_fieldswith afieldsarray. Machine-supplied fields need a defined format: a declaration such asamount: "大概五万"would otherwise remain unnoticed until a reviewer opened it, while you were already waiting for credit.assetmust exist in the asset catalog and be enabled. Unrecognized values are rejected, with no fallback: nobody can post a declaration with a misspelled asset, so it would remain pending indefinitely.
The returned status is always pending. Poll GET /v1/merchant/topups for progress; this business line emits no webhooks.
⚠ The idempotency window is 24 hours; after it expires, the same key creates a second declaration
Within the window, the same key and body return the original 201 with X-Idempotent-Replay: true, without inserting another row. But this is the only idempotency layer on this line; there is no database constraint as a second safeguard. A key used 25 hours earlier creates another pending declaration, and two people may approve the two declarations separately. When entering historical records, use a new key per entry. Do not reuse the key indefinitely as the bank transfer's identifier.
curl -X POST 'https://api.zinfra.vip/v1/merchant/topups' \
-H 'x-auth-token: Bearer $TOKEN' \
-H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
-H 'content-type: application/json' \
-d '{
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
}'const res = await fetch("https://api.zinfra.vip/v1/merchant/topups", {
method: "POST",
headers: {
"x-auth-token": "Bearer $TOKEN",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
body: JSON.stringify({
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/merchant/topups",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/merchant/topups",
strings.NewReader(`{
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
}`))
req.Header.Set("x-auth-token", "Bearer $TOKEN")
req.Header.Set("x-idempotency-key", "$IDEMPOTENCY_KEY")
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// Decode amount fields as string, not float64.HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.zinfra.vip/v1/merchant/topups"))
.header("x-auth-token", "Bearer $TOKEN")
.header("x-idempotency-key", "$IDEMPOTENCY_KEY")
.header("content-type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/merchant/topups');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-auth-token: Bearer $TOKEN',
'x-idempotency-key: $IDEMPOTENCY_KEY',
'content-type: application/json',
],
CURLOPT_POSTFIELDS => <<<'JSON'
{
"asset": "USDT",
"channel": "fiat",
"amount": "50000.00",
"reference_no": "TT20260813001",
"note": "8 月备付金"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
{
"id": "5d2c8a41-3f6b-4e0a-9c77-1b8e0d4f2a56",
"asset": "USDT",
"channel": "fiat",
"amount_text": "50000.00",
"status": "pending"
}