Internal prepaid-asset conversion (atomic, irreversible, no intermediate state)
x-idempotency-key
Moves funds · Your prepayment: debit the from asset / credit the to asset, under the same owner and balanced separately per asset.
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.
Converts one prepaid asset into another. Only your own reserve funds move; member balances and your custody declaration (custody) remain untouched.
Atomic, irreversible, with no intermediate state: either the exchange completes in this request or it fails. There is no processing state, no cancellation, no recovery, and no retry. Incorrect postings require a dual-approved reversal by our funds-management team. Choose min_to_amount carefully before ordering.
Prerequisites
- Your
custody_modelismerchant_hosted; platform-operated entities have no prepaid pool. - The direction exists in our price list and is enabled.
- Both assets are enabled and included in your asset allowlist; no allowlist means unrestricted access.
- Available balance of the asset minus queued amounts ≥
from_amount.
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
x-idempotency-key |
string | Required | UUID v4, required. Reusing the key within 24 hours replays the same order.
Reusing it with a different body returns 409 idempotency_key_reused. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
from_asset |
string | Required | Source asset code, case-insensitive; the server converts it to uppercase. |
to_asset |
string | Required | Destination asset code, case-insensitive. Matching from_asset always returns 400. |
from_amount |
string | Required | Amount to pay, a decimal string with no more decimal places than
ledger_scale for from_asset. Must be positive.USDT(scale 6):"5000.000000" |
min_to_amount |
string | Optional | Slippage floor: rejects the whole exchange if the actual received amount is lower (amount_out_of_range).
Checked before posting. Omit for no floor, executing at the price available at that moment.
Precision follows the ledger_scale of to_asset. |
quote_id |
string | Optional | ⚠ Not accepted. Merely including this key in the request causes rejection, including "" and null,
with 400 quote_lock_unsupported. The condition is
quote_id !== undefined, not whether the value is nonempty.
When unused, omit the field entirely.
⚠ This matters especially for strongly typed SDKs and fixed request templates:
serializing an unused field as "" / null is natural,
but causes every order to fail with an error referring to a feature you did not intend to use.
This endpoint prices at execution time; it does not support price locking. |
Response
duplicated: true; all other fields are identical to the original.
Replay within 24 hours uses a different path and replays the original 201, as described above.ledger_scale_from / ledger_scale_to respectively.{
"id": "mcv_7a1e5c30-2b44-4c11-9f8e-31d0a7b62c45",
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"to_amount": "4990.003750",
"rate": "0.999500",
"fee_asset": "USDC",
"fee_amount": "7.496250",
"ledger_scale_from": 6,
"ledger_scale_to": 6,
"created_at": "2026-08-13T04:12:55.108Z"
}quote_lock_unsupported: quote_id was supplied.
invalid_request: body is not JSON, asset missing, same-asset pair, or invalid amount format/precision.
asset_not_allowed: asset delisted or outside your allowlist.
product_not_available: direction disabled or your custody model has no prepaid pool.
amount_out_of_range: below minimum, above per-transaction maximum, or slippage floor not met.
merchant_insufficient_funds: available balance minus queued amounts is insufficient.
limit_exceeded: daily cumulative amount or count limit reached, per direction; see thresholds in the price list.
⚠ This error does not include limit_type / limit_scope.
service_unavailable: our global exchange switch is off, pricing is unavailable, or your account
for this asset is under a non-normal restriction status.insufficient_scope: missing merchant:write.
⚠ This scope is restricted and requires separate approval from us;
selecting it does not mean it has been granted. Read-only Keys always receive 403 here.idempotency_key_reused: same key with a different body. idempotency_in_progress.Additional Details
Two idempotency layers with different behavior
- Within 24 hours, the same key and body replay the original response unchanged:
201, an identical body, andX-Idempotent-Replay: true. The handler does not execute. - After 24 hours, the key is treated as a new request, but the funds layer still recognizes it through a database unique constraint with no TTL. The handler retrieves the original order and returns
200+duplicated: true. No second exchange occurs in this case either.
⚠ duplicated therefore appears only in the second case. Normal execution and replay within 24 hours do not include this key. if (res.duplicated === false) is always false; test for truth instead. The most reliable replay indicator is the X-Idempotent-Replay response header.
⚠ Retrying service_unavailable with the same key is supported. We do not cache this 400, because replenishing prepayment or restoring the global switch can resolve it. Other business failures are replayed unchanged and require a new key for another attempt.
Four essential integration details
- ⚠ Supplying
quote_idalways returns 400quote_lock_unsupported; it is not silently ignored. Ignoring it would make you believe the price was locked when it was not, leaving you to absorb the difference without warning. Usemin_to_amountagainst price movement. - ⚠ Failure to meet the slippage floor returns
amount_out_of_range, not a dedicated slippage code. The same code covers below-minimum, above-per-transaction-maximum, and nonpositive amounts. To distinguish them locally, first read limits fromGET /v1/merchant/conversion-pairs. - ⚠ The balance gate subtracts queued amounts. Orders in
pending_merchant_fundsalready have a claim on that prepayment, though it is not yet frozen. You may therefore receivemerchant_insufficient_fundsdespite an apparently sufficient balance. This is correct: otherwise converting all USDT to USDC could permanently block the queued orders. CheckGET /v1/merchant/pending. - ⚠
product_not_availabledoes not necessarily mean temporarily unsupported. Only entities withmerchant_hostedcustody have prepaid pools; platform-operated entities have no such reserve to convert. A disabled direction or one absent from the price list also uses this code.
Pricing uses our price-list row, not the retail price you set for members. We are the actual counterparty here; using your retail price would let the counterparty set the execution price. Fees are charged in the asset selected by fee_side, explicitly identified by fee_asset / fee_amount.
All amounts are decimal strings. from_amount must not exceed the ledger_scale of from_asset, except that excess trailing digits are accepted if all are 0; otherwise returns 400 invalid_request. Same-asset conversion (from_asset == to_asset) also returns 400 invalid_request.
⚠ This endpoint emits no webhooks. The execution result is in this response; do not wait for an event that will never arrive.
curl -X POST 'https://api.zinfra.vip/v1/merchant/conversions' \
-H 'x-auth-token: Bearer $TOKEN' \
-H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
-H 'content-type: application/json' \
-d '{
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
}'const res = await fetch("https://api.zinfra.vip/v1/merchant/conversions", {
method: "POST",
headers: {
"x-auth-token": "Bearer $TOKEN",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
body: JSON.stringify({
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/merchant/conversions",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/merchant/conversions",
strings.NewReader(`{
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
}`))
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/conversions"))
.header("x-auth-token", "Bearer $TOKEN")
.header("x-idempotency-key", "$IDEMPOTENCY_KEY")
.header("content-type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/merchant/conversions');
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'
{
"from_asset": "USDT",
"to_asset": "USDC",
"from_amount": "5000.000000",
"min_to_amount": "4990.000000"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
// No response example is declared in the specification for this operation.