Z Zise Developers 简体中文

Add a withdrawal address (step-up authentication and a 24-hour cooling period)

POST /v1/withdraw-addresses scope: withdrawals:write
On behalf of a member · x-on-behalf-of required Requires x-idempotency-key

The on-chain address is the only freely entered withdrawal value whose errors are irreversible. An incorrect bank account may return the money; an incorrect chain address loses it. We therefore require all three protections, not a choice between them: step-up authentication, a cooling period, and an address snapshot in the order at creation.

Step-up authentication uses our hosted screen; merchants cannot attest to it themselves

A body value such as step_up_passed: true is never accepted; that would hand the security gate to the caller. The interaction takes two requests:

  1. The first call, without x-step-up, returns 400 step_up_required, with challenge_id / hosted_url / expires_at in Unix seconds, valid for 300 seconds.
  2. The end user opens hosted_url and completes genuine factor verification on our page.
  3. Resend the same request body with the same x-idempotency-key, adding x-step-up: <challenge_id>.

⚠ Step 3 works because the idempotency layer deliberately does not replay this particular step_up_required 400. request_hash covers only the body, not headers, so adding x-step-up leaves the hash unchanged. Any other error is replayed unchanged when the key is reused and requires a new key for another attempt.

The ticket is single-use and bound to the member, action, and validity period. It expires upon use; do not cache it for reuse.

Merchants cannot bypass the 24-hour cooling period

Cooling duration comes from the channel (withdraw_channels.cooling_hours, default 24 hours), not an input parameter. No parameter can shorten it. Its purpose is to give the user time to see the three notifications: email, push, and in-app message. This is the only step in the address-security model where the end user can independently detect an anomaly, and your server is not on that path. We therefore do not emit address creation as a webhook to you.

Order of seven validation steps; failure rejects without persistence:

Address-count limit → local format and EIP-55 checksum → our own deposit addresses → platform-wide blocklist → upstream validation, allowing and auditing unavailability → unique index → three notifications. ⚠ Unknown networks are always rejected. Accepting an address without knowing the chain's validation rules would leave correctness entirely to the user's typing.

Prerequisites

  • A successfully verified step-up ticket is available; impossible on the first call, as explained in the two-request flow.
  • An enabled withdrawal channel exists for the asset/network combination.
  • The member has not reached the address-count limit, default 20.
FieldTypeRequiredDescription
x-on-behalf-of string Required The member for whom to add the address.
x-idempotency-key string Required UUID. The step-up retry must reuse the same key; see the description.
x-step-up string Optional The challenge_id returned by the previous request. Omit on the first call, when none exists. The end user must already have verified the ticket on the hosted screen; otherwise it is treated as absent.

Request Body

FieldTypeRequiredDescription
asset string Optional Asset code. Empty defaults to USDT. This is the only field with an implicit default; do not depend on it, supply it explicitly.
network string Required Machine-readable network code, such as BSC. Case-sensitive. Unknown networks are rejected; currently only the supported EVM-family networks are accepted.
address string Required On-chain address. EVM addresses must be 0x plus 40 hexadecimal digits. Mixed-case addresses must have a valid EIP-55 checksum; all-lowercase and all-uppercase addresses are accepted. Before persistence, the address is normalized to EIP-55, and the response returns that normalized value.
label string Optional Address label; truncated beyond 40 characters without an error.

Response

201Added to the address book; cooling period started.
{
  "id": "wad_3c81f0d2-9a44-4d17-8e0b-2f6a1c9d4e77",
  "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
  "usable_at": "2026-08-13T09:20:00Z",
  "cooling_hours": 24,
  "upstream_valid": 1
}
400step_up_required: step-up authentication required; response includes challenge_id / hosted_url / expires_at. This is a step in the flow, not an error. invalid_request: body is not valid JSON. invalid_fields: network or address missing. address_not_allowed: invalid format, one of our own deposit addresses, or platform-blocklisted. All three return the same response; do not infer which case occurred. limit_exceeded: the member has reached the address-count limit. product_not_available: no available channel for this asset/network combination.
409duplicate_resource: the address is already in the address book. idempotency_key_reused · idempotency_in_progress.
500⚠ Known discrepancy: an invalid EIP-55 checksum, a zero/burn address, or a mismatch between network and address rules should return 400, but currently return api_error + 500 because their internal codes lack public mappings. Do not retry indefinitely: the same address will not succeed regardless of retry count. Check the address itself first.
Request
curl -X POST 'https://api.zinfra.vip/v1/withdraw-addresses' \
  -H 'x-auth-token: Bearer $TOKEN' \
  -H 'x-on-behalf-of: $MEMBER_ID' \
  -H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
  -H 'content-type: application/json' \
  -d '{
    "asset": "USDT",
    "network": "BSC",
    "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
    "label": "我的冷钱包"
  }'
const res = await fetch("https://api.zinfra.vip/v1/withdraw-addresses", {
  method: "POST",
  headers: {
    "x-auth-token": "Bearer $TOKEN",
    "x-on-behalf-of": "$MEMBER_ID",
    "x-idempotency-key": "$IDEMPOTENCY_KEY",
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "asset": "USDT",
    "network": "BSC",
    "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
    "label": "我的冷钱包"
  }),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();
import requests

res = requests.post(
    "https://api.zinfra.vip/v1/withdraw-addresses",
    headers={
        "x-auth-token": "Bearer $TOKEN",
        "x-on-behalf-of": "$MEMBER_ID",
        "x-idempotency-key": "$IDEMPOTENCY_KEY",
        "content-type": "application/json",
    },
    json={
      "asset": "USDT",
      "network": "BSC",
      "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
      "label": "我的冷钱包"
    },
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()
req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/withdraw-addresses",
    strings.NewReader(`{
  "asset": "USDT",
  "network": "BSC",
  "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
  "label": "我的冷钱包"
}`))
req.Header.Set("x-auth-token", "Bearer $TOKEN")
req.Header.Set("x-on-behalf-of", "$MEMBER_ID")
req.Header.Set("x-idempotency-key", "$IDEMPOTENCY_KEY")
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// Decode amount fields as string, not float64.
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://api.zinfra.vip/v1/withdraw-addresses"))
    .header("x-auth-token", "Bearer $TOKEN")
    .header("x-on-behalf-of", "$MEMBER_ID")
    .header("x-idempotency-key", "$IDEMPOTENCY_KEY")
    .header("content-type", "application/json")
    .method("POST", HttpRequest.BodyPublishers.ofString("""
{
  "asset": "USDT",
  "network": "BSC",
  "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
  "label": "我的冷钱包"
}
"""))
    .build();
// Use String / BigDecimal for amounts, not double.
$ch = curl_init('https://api.zinfra.vip/v1/withdraw-addresses');
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'x-auth-token: Bearer $TOKEN',
    'x-on-behalf-of: $MEMBER_ID',
    'x-idempotency-key: $IDEMPOTENCY_KEY',
    'content-type: application/json',
  ],
  CURLOPT_POSTFIELDS => <<<'JSON'
{
  "asset": "USDT",
  "network": "BSC",
  "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
  "label": "我的冷钱包"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
201
{
  "id": "wad_3c81f0d2-9a44-4d17-8e0b-2f6a1c9d4e77",
  "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
  "usable_at": "2026-08-13T09:20:00Z",
  "cooling_hours": 24,
  "upstream_valid": 1
}