Execute exchange: atomic, irreversible, no intermediate state
x-on-behalf-of required
Requires x-idempotency-key
Moves funds · Debit the member’s from asset and credit their to asset atomically under the same member
See the response table below for failure handling. Retry timeouts (504) with the same idempotency key — we may already have processed the request; use a new key after a business failure; the same key replays that failure.
Order creation and execution are one step. The member client uses two steps to show a confirmation screen; your server does not need that screen. Two steps would require managing a short-lived quote, a failure category caused solely by the API shape.
Idempotency: replaying the same x-idempotency-key retrieves the same order, with 200 + duplicated: true, without a second execution.
Two pricing modes, determined by whether quote_id is supplied:
- Without it: obtain a new quote at execution and use that price, which may differ from
POST /v1/exchange/quotesa few seconds earlier. - With it: execute the locked quote obtained from
POST /v1/exchange/quotes+lock: true. Use this when showing the end user a confirmation screen.
⚠ With quote_id, from_asset / to_asset / from_amount become optional; quote_id alone is sufficient. If supplied, they must match the quote or return invalid_fields. We do not silently prefer the quote, which could silently execute 500 when you believed you ordered 5000.
⚠ Expired quotes are always rejected with state_invalid, never executed at a new price. Obtain another quote and a new quote_id.
⚠ Each quote executes once. Reusing an already-executed quote_id retrieves the original order, 200 + duplicated: true, regardless of whether the idempotency key changed.
⚠ An unrecognized quote_id, nonexistent, another member’s, another merchant’s, or not locked through the Open API, returns 404 resource_not_found; all four cases share one response.
⚠ You custody both assets, the M3 model. This exchange reallocates assets you hold; our funding pool is untouched. We charge only the fee to your prepaid balance. If prepaid funds are insufficient, the member sees service_unavailable, a generic temporary-unavailability message: they must not learn that their merchant lacks funds.
⚠ Directions requiring strong authentication use a two-stage hosted flow, since 2026-08-14. The first call returns 400 step_up_required + hosted_url. Send the user there to verify, then resend the same body with the same idempotency key and x-step-up: <challenge_id>. The action is exchange:<FROM>:<TO>, single-use, valid for 5 minutes, and bound to the member.
Previously this case was always rejected, so enabling a verification requirement made that direction permanently unusable through the Open API, despite having a code, documentation, and tests. Authentication still happens only on our hosted screen; request-body self-attestation is never accepted.
⚠ This endpoint does not emit exchange.order.executed webhooks. That event is emitted only when the member exchanges through the App. Open API execution returns its result in this 201 response. Do not wait for an event that will not arrive.
Prerequisites
- Direction enabled and strong authentication not required; if required, the Open API always rejects it
- Member’s available asset balance ≥ from_amount
- Your prepaid account has enough of the asset to cover our fee
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
x-on-behalf-of |
string | Required | Member for whom exchange executes. Ownership is derived from token and member context, never declared in the body. |
x-idempotency-key |
string | Required | |
x-step-up |
string | Optional | UUID, required. Replays with the same key within 24 hours retrieve the same order. Reusing it with a changed body returns 409 idempotency_key_reused. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
quote_id |
string | Optional | Locked-quote credential from POST /v1/exchange/quotes + lock: true. Accepted with or without exc_.
It is the order ID itself: the executed response’s id matches it character for character, and GET /v1/exchange/orders/{id} accepts it. If this request times out, query it first rather than blindly retrying. |
from_asset |
string | Optional | Source asset code, such as USDT, case-insensitive. Optional with quote_id; if supplied, it must match the quote. |
to_asset |
string | Optional | Destination asset code, such as USD. Optional with quote_id; if supplied, it must match the quote. |
from_amount |
string | Optional | Source amount to debit. Fixed-point string using from_asset’s ledger_scale. This is how much is debited, not received; reverse ordering by to_amount is unsupported. Optional with quote_id; if supplied, it must match the quote digit for digit.USDT uses 6 decimals, for example 500.000000 |
Response
duplicated: true; other fields match the 201 response.{
"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
}status is always executed; this flow has no intermediate state. It either executes or the request fails, never “processing”. Amounts are fixed-point decimal strings.{
"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"
}invalid_request; invalid_fields for nonstring quote_id
or mismatched from_asset / to_asset / from_amount;
step_up_required when the direction requires strong authentication, with challenge_id /
hosted_url / expires_at, even if the requirement was enabled after locking:
authorization is checked at execution, not at quote locking;
state_invalid for expired quotes or cancelled/failed orders;
request_rejected for frozen/closed accounts or risk rejection;
service_unavailable for insufficient prepaid funds or an unavailable line;
idempotency_key_required; idempotency_key_invalid for non-UUID keys;
member_context_required; member_not_found.
⚠ Business rejections still returning 500 api_error: insufficient balance, daily limits exceeded,
below minimum / above maximum, direction disabled, insufficient KYC.
Retries cannot resolve these; do not retry 500 indefinitely.resource_not_found: unrecognized quote_id, nonexistent, another member’s, another merchant’s, or not locked through the Open API. All four share one response, avoiding a quote-discovery endpoint.idempotency_key_reused: same key with another body. · idempotency_in_progress: the previous same-key request is still processing; retry later.Emitted Events
Green = successful terminal state · Red = terminal state requiring action · Purple = intermediate state. Open an event for its payload and signature verification details.
curl -X POST 'https://api.zinfra.vip/v1/exchange/orders' \
-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 '{
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
}'const res = await fetch("https://api.zinfra.vip/v1/exchange/orders", {
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({
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/exchange/orders",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-on-behalf-of": "$MEMBER_ID",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/exchange/orders",
strings.NewReader(`{
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
}`))
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/exchange/orders"))
.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("""
{
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/exchange/orders');
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'
{
"quote_id": "exc_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
{
"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
}