POST
/v1/exchange/orders
兑换成交(原子 · 不可逆 · 无中间态)
Scope
exchange:write
代会员调用 · 必带 x-on-behalf-of
需 x-idempotency-key
动钱 · 扣该会员的 from 资产 + 增他的 to 资产(同一名下,原子)
这个端点会动钱
失败处置见下方响应表。超时(504)用同一把幂等键重试——我方可能已经处理完;业务失败要换新键,同键会原样返回那次失败。
建单与成交合成一步。 会员端分两步是因为客户端要给用户一屏确认;
你的服务器不需要那一屏,而两步意味着你要自己管一张很快过期的报价单
—— 那是一类纯粹由接口形态造出来的失败。
幂等:同一把 x-idempotency-key 重放拿回同一张单(响应 200 +
duplicated: true),不会成交第二次。
⚠ 成交那一刻会重新报一次价,不引用 POST /v1/exchange/quotes
的结果(那个端点也没有发出可引用的 id)。所以你拿到的成交价可能与
几秒前的报价不同 —— 要给终端用户一屏确认的话,把那一屏的有效期
做得比 quote_ttl_sec 短。
⚠ 两侧资产都由你托管(M3 模型):这笔兑换是你自己重新配置了
你持有的资产,我方的池子一分不动。我方只从你的预付里收手续费。
预付不足时你的会员看到的是 service_unavailable(一句不解释原因的
「暂时不可用」)—— 会员不该知道「你的商户没钱了」。
⚠ 需要强认证的方向在开放 API 上一律拒(返回 step_up_required),
不是「跳过」。强认证走我方托管屏,我方绝不接受你在请求体里自证。
⚠ 这个端点不发 exchange.order.executed webhook。 那条事件只在
「会员自己在 App 里换」的那一侧发出;经开放 API 成交的单,成交结果
就在这次的 201 响应里。别在这里等一条不会来的事件。
前置条件
- 该方向已启用且未开强认证要求(开了的话开放 API 一律拒)
- 会员该资产可用余额 ≥ from_amount
- 你的预付账户在该资产上够付我方那笔手续费
请求头
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
x-on-behalf-of |
string | 必填 | 代哪个会员成交。归属由令牌与会员推导,不接受请求体声明。 |
x-idempotency-key |
string | 必填 | UUID。必填,且同一把键 24 小时内重放拿回同一张单。
换了请求体还用同一把键 → 409 idempotency_key_reused。 |
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
from_asset |
string | 必填 | 源资产代码,如 USDT。大小写不敏感。 |
to_asset |
string | 必填 | 目标资产代码,如 USD。 |
from_amount |
string | 必填 | 源资产扣减数量。字符串定点,位数 = from_asset 的
ledger_scale。这是扣多少,不是「换到多少」——
没有反向下单(指定 to_amount)的形态。USDT 是 6 位,形如 500.000000 |
响应
200幂等命中(拿回原单,本次未成交)。响应体多一个
duplicated: true,其余字段与 201 相同。{
"id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45",
"status": "executed",
"from_asset": "USDT",
"to_asset": "USD",
"from_amount": "500.000000",
"to_amount": "498.750000",
"rate": "1.0000",
"duplicated": true
}201已成交。
status 恒为 executed(这条线没有中间态:
要么成交,要么这次请求失败,不存在「处理中」)。
金额是定点十进制串。{
"id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45",
"status": "executed",
"from_asset": "USDT",
"to_asset": "USD",
"from_amount": "500.000000",
"to_amount": "498.750000",
"rate": "1.0000"
}400已登记的对外码:
invalid_request · step_up_required 该方向要求
强认证,开放 API 不开放这一档 · request_rejected 冻结 / 销户 /
风控 · service_unavailable 你的预付不足或这条线不可用 ·
idempotency_key_required · idempotency_key_invalid 不是 UUID ·
member_context_required · member_not_found
⚠ 还落在 500 api_error 的业务拒绝:余额不足 · 超日限额 ·
低于起兑 / 高于上限 · 方向未启用 · 实名不足 · 报价过期 ·
这一单已被取消或已失败。这些重试不会成功,别照着 500 无限重投。409
idempotency_key_reused 同一把键配了不同的请求体 ·
idempotency_in_progress 上一次同键请求还在处理中(稍后重试)
调用样例
curl -X POST 'https://api.zise.com/v1/exchange/orders' \
-H 'authorization: Bearer $TOKEN' \
-H 'x-zise-merchant: $MERCHANT_ID' \
-H 'x-on-behalf-of: $MEMBER_ID' \
-H 'x-idempotency-key: $(uuidgen)' \
-H 'content-type: application/json' \
-d '{
"from_asset": "USDT",
"to_asset": "USD",
"from_amount": "500.000000"
}'
const res = await fetch(
"https://api.zise.com/v1/exchange/orders",
{
method: "POST",
headers: {
"authorization": "Bearer $TOKEN",
"x-zise-merchant": "MERCHANT_ID",
"x-on-behalf-of": "MEMBER_ID",
"x-idempotency-key": "crypto.randomUUID()",
"content-type": "application/json"
},
body: JSON.stringify({
"from_asset": "USDT",
"to_asset": "USD",
"from_amount": "500.000000"
}),
},
);
// 金额一律按字符串读,不要 JSON.parse 成 number
const data = await res.json();
import requests
res = requests.post(
"https://api.zise.com/v1/exchange/orders",
headers={
"authorization": "Bearer $TOKEN",
"x-zise-merchant": "$MERCHANT_ID",
"x-on-behalf-of": "$MEMBER_ID",
"x-idempotency-key": "$(uuidgen)",
"content-type": "application/json"
},
json={
"from_asset": "USDT",
"to_asset": "USD",
"from_amount": "500.000000"
},
)
# 金额用 Decimal(str(...)),不要 float
data = res.json()