Add a withdrawal address (step-up authentication and a 24-hour cooling period)
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:
- The first call, without
x-step-up, returns 400step_up_required, withchallenge_id/hosted_url/expires_atin Unix seconds, valid for 300 seconds. - The end user opens
hosted_urland completes genuine factor verification on our page. - Resend the same request body with the same
x-idempotency-key, addingx-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.
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
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
| Field | Type | Required | Description |
|---|---|---|---|
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
{
"id": "wad_3c81f0d2-9a44-4d17-8e0b-2f6a1c9d4e77",
"address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"usable_at": "2026-08-13T09:20:00Z",
"cooling_hours": 24,
"upstream_valid": 1
}step_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.duplicate_resource: the address is already in the address book.
idempotency_key_reused · idempotency_in_progress.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.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.
{
"id": "wad_3c81f0d2-9a44-4d17-8e0b-2f6a1c9d4e77",
"address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"usable_at": "2026-08-13T09:20:00Z",
"cooling_hours": 24,
"upstream_valid": 1
}