curl -X POST https://api.liddie.io/api/v1/conversions \
-H "Authorization: Bearer lid_live_..." \
-H "Content-Type: application/json" \
-d '{
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": "100.00000000",
"idempotencyKey": "conv-20260713-0001",
"quotedRate": 0.0000011573046036242277,
"quotedToAmount": "0.00011526"
}'
const res = await fetch('https://api.liddie.io/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: 'Bearer lid_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
fromCurrency: 'DOGE',
toCurrency: 'BTC',
fromAmount: '100.00000000',
idempotencyKey: 'conv-20260713-0001',
quotedRate: 0.0000011573046036242277,
quotedToAmount: '0.00011526'
})
})
const conversion = await res.json()
// conversion.status === "completed" (internal) or "processing" (external — keep polling)
{
"_id": "66b1f0c2a4d9e8f1b2c3d4e5",
"merchantId": "6650a1b2c3d4e5f6a7b8c9d0",
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": 100,
"toAmount": 0.00011526,
"exchangeRate": 0.0000011573046036242277,
"commissionRate": 0.004,
"commissionAmount": 0.4,
"netFromAmount": 99.6,
"status": "completed",
"executionMode": "internal",
"idempotencyKey": "conv-20260713-0001",
"requestedAt": "2026-07-13T00:42:31.101Z",
"completedAt": "2026-07-13T00:42:31.230Z",
"createdAt": "2026-07-13T00:42:31.010Z",
"updatedAt": "2026-07-13T00:42:31.230Z"
}
Conversions
Execute a conversion
Execute a crypto currency conversion in the Liddie API. It moves real balance, so drive your integration off the conversion status, never the HTTP code.
POST
/
api
/
v1
/
conversions
curl -X POST https://api.liddie.io/api/v1/conversions \
-H "Authorization: Bearer lid_live_..." \
-H "Content-Type: application/json" \
-d '{
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": "100.00000000",
"idempotencyKey": "conv-20260713-0001",
"quotedRate": 0.0000011573046036242277,
"quotedToAmount": "0.00011526"
}'
const res = await fetch('https://api.liddie.io/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: 'Bearer lid_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
fromCurrency: 'DOGE',
toCurrency: 'BTC',
fromAmount: '100.00000000',
idempotencyKey: 'conv-20260713-0001',
quotedRate: 0.0000011573046036242277,
quotedToAmount: '0.00011526'
})
})
const conversion = await res.json()
// conversion.status === "completed" (internal) or "processing" (external — keep polling)
{
"_id": "66b1f0c2a4d9e8f1b2c3d4e5",
"merchantId": "6650a1b2c3d4e5f6a7b8c9d0",
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": 100,
"toAmount": 0.00011526,
"exchangeRate": 0.0000011573046036242277,
"commissionRate": 0.004,
"commissionAmount": 0.4,
"netFromAmount": 99.6,
"status": "completed",
"executionMode": "internal",
"idempotencyKey": "conv-20260713-0001",
"requestedAt": "2026-07-13T00:42:31.101Z",
"completedAt": "2026-07-13T00:42:31.230Z",
"createdAt": "2026-07-13T00:42:31.010Z",
"updatedAt": "2026-07-13T00:42:31.230Z"
}
Executes a conversion. This moves real balance. The
Never returned (stripped at the API boundary): the internal
The status code is the reliable signal, but read the code with it. For the two idempotency codes (
201 response returns the conversion record — read its status.
201 ≠ finished. Which execution mode a conversion uses is a platform setting, resolved server-side — see the execution modes. In internal mode the 201 body already carries status: "completed" (the debit, credit and completion commit atomically). In external mode the full source amount is debited immediately and the 201 returns status: "processing" — no credit exists yet. You must poll GET /api/v1/conversions/:conversionId until status is completed or failed. Always drive your integration off the conversion status, never off the HTTP code.Authorization
- API key: the elevated
conversions:executescope. Possession of a key bearingconversions:executeis the credential — no MFA fields are required. - Dashboard JWT: the
conversions:managepermission plus per-call MFA (otpCode, orpasskeyResponse+challengeKey). MFA applies to dashboard (JWT) callers only. A JWT account with neither TOTP nor a passkey is refused (403 MFA_SETUP_REQUIRED).
merchant_admin, merchant_member or super_admin role; API-key callers are gated by scope only. See the Authentication guide.
Rate limit: 5/min.
curl -X POST https://api.liddie.io/api/v1/conversions \
-H "Authorization: Bearer lid_live_..." \
-H "Content-Type: application/json" \
-d '{
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": "100.00000000",
"idempotencyKey": "conv-20260713-0001",
"quotedRate": 0.0000011573046036242277,
"quotedToAmount": "0.00011526"
}'
const res = await fetch('https://api.liddie.io/api/v1/conversions', {
method: 'POST',
headers: {
Authorization: 'Bearer lid_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
fromCurrency: 'DOGE',
toCurrency: 'BTC',
fromAmount: '100.00000000',
idempotencyKey: 'conv-20260713-0001',
quotedRate: 0.0000011573046036242277,
quotedToAmount: '0.00011526'
})
})
const conversion = await res.json()
// conversion.status === "completed" (internal) or "processing" (external — keep polling)
{
"_id": "66b1f0c2a4d9e8f1b2c3d4e5",
"merchantId": "6650a1b2c3d4e5f6a7b8c9d0",
"fromCurrency": "DOGE",
"toCurrency": "BTC",
"fromAmount": 100,
"toAmount": 0.00011526,
"exchangeRate": 0.0000011573046036242277,
"commissionRate": 0.004,
"commissionAmount": 0.4,
"netFromAmount": 99.6,
"status": "completed",
"executionMode": "internal",
"idempotencyKey": "conv-20260713-0001",
"requestedAt": "2026-07-13T00:42:31.101Z",
"completedAt": "2026-07-13T00:42:31.230Z",
"createdAt": "2026-07-13T00:42:31.010Z",
"updatedAt": "2026-07-13T00:42:31.230Z"
}
Body
The body schema setsadditionalProperties: false, but unknown fields are silently ignored (stripped before validation), not rejected — the request still succeeds. Double-check field names, especially idempotencyKey, quotedRate and quotedToAmount: a misspelled name is dropped without error, silently disabling the drift or idempotency protection it was meant to arm.
string
required
Source currency ticker.
string
required
Target currency ticker. Must differ from
fromCurrency — same currency yields 400 SAME_CURRENCY.string | number
required
Prefer the decimal string from the quote’s
fromAmountStr. Numbers are accepted for backward compatibility.string
A caller-chosen token that makes retries safe. Max 256 characters. Opt-in. Omitted → the server generates a unique key, so two identical requests execute twice. Double-submit protection is the caller’s job.
number
Must be greater than 0. The
exchangeRate you showed the user. Triggers the drift guard.string
The
toAmountStr the user accepted. Binds the output, catching a fee move the rate check cannot see.Always send your own
idempotencyKey. Without one the server generates a unique key per request, so a network retry of the same submit executes a second conversion. With one, a retry of an already-completed conversion normally replays the original record as a 201 carrying the same _id — nothing executes twice. Any other collision is a 409 that tells you whether funds already moved — see idempotency conflicts. So treat a 201 on a retry as “already done”, not as a second execution: compare the _id.One caveat: the drift guard runs before the replay. A retry that echoes quotedRate/quotedToAmount during a >1 % market move returns 409 RATE_DRIFT first — even though the original request on that key already moved funds. A 409 RATE_DRIFT on a retry therefore does not mean nothing happened on the order. On any retry of a possibly-executed key, resubmit with the same key (never a fresh one) or reconcile via List conversions before treating the order as unexecuted.Conversion record fields
Returned byPOST / (201), GET / and GET /:conversionId.
toAmount, exchangeRate, commissionRate, commissionAmount and netFromAmount are all OPTIONAL and are ABSENT on any conversion that ended before a quote ever existed: a rate-drift or accepted-output-shortfall cancel, an execution-rate refusal, a failure released before pricing, or an interrupted initializing conversion recovered automatically. A conversion can legitimately reach a terminal state with none of them. Render — (or merchantMessage) when a field is missing — never substitute 0, which would read as a free conversion or a zero rate.string
Conversion id, as a string.
string
Merchant id, as a string.
string
Source currency.
string
Target currency.
number
JSON number (display precision). Always present.
number
JSON number (display precision). Optional — see the warning above.
number
JSON number (display precision). Optional — see the warning above.
number
JSON number (display precision). Optional — see the warning above.
number
JSON number (display precision). Optional — see the warning above.
number
JSON number (display precision). Optional — see the warning above.
string
One of
initializing, pending, processing, completed, failed, cancelled — see the status lifecycle.string
"internal" or "external"; absent on older records (treated as internal).string
External mode: the exact amount credited as a decimal string — normally at least the quoted minimum, but a late-deposit shortfall fill can be lower. Use this, not
toAmount or the quote, for reconciliation.number
External mode: the price-protection band of the accepted quote.
string
Merchant-safe explanation of a terminal outcome. The only failure text you should ever render.
string
Echoed when you supplied one.
string
ISO string.
string
ISO string.
string
ISO string.
string
ISO string.
error text, the provider request id, the provider deposit address, the exact funding amounts and both on-chain transaction hashes. Do not build against them.
Rate and output drift
409 RATE_DRIFT. Tolerance is 1 %. Two independent guards protect you:
-
Rate drift — if you send
quotedRateand the market moved more than 1 %, the request is refused before your MFA factor is consumed:Re-present409 RATE_DRIFT{ "error": "RATE_DRIFT", "message": "The market moved since your quote — a new quote is available", "driftPct": 1.84, "newQuote": { "...": "a full quote object — see Get a quote" } }newQuoteto the user and resubmit. A passkey assertion is single-use — obtain a fresh one before retrying. A TOTP code stays valid inside its window. In internal mode the server also re-checks the rate against an uncached spot rate at execution time, so the guard holds even if you omitquotedRate. In external mode there is no such re-check — the only guards are thequotedRateandquotedToAmountyou send — so on an external pair, omitting them means the conversion executes at whatever the provider quotes with no drift veto at all. Always send them. -
Output shortfall — if you send
quotedToAmount, a freshly computed output more than 1 % below it also raises409 RATE_DRIFT. This is the only guard that catches a fee change between quote and execute (the rate alone cannot see it). It is one-sided on purpose: better-than-accepted terms always execute.
cancelled; a request rejected earlier — at validation, or by the idempotency gate — never creates a row to cancel.
Errors
| Status | Body | Why |
|---|---|---|
| 403 | { "error": "CONVERSIONS_DISABLED", "message": "…" } — flat body, not the envelope | Feature switched off platform-wide. |
| 400 | { "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "…" } } | Missing/malformed field or unsupported currency. |
| 400 | { "ok": false, "error": { "code": "SAME_CURRENCY", "message": "…" } } | fromCurrency === toCurrency. |
| 409 | { "ok": false, "error": { "code": "CONVERSION_IN_PROGRESS", "message": "A conversion with this idempotency key is already in progress" } } | This idempotencyKey is still running (or your retry lost the re-claim race). Retry the same key after a backoff — see idempotency conflicts. |
| 409 | { "ok": false, "error": { "code": "IDEMPOTENCY_KEY_CONSUMED", "message": "This idempotency key already executed a conversion that moved funds — use a new idempotency key to retry" } } | That key already executed a conversion that MOVED FUNDS. Do not retry that key — see idempotency conflicts. |
| 409 | { "error": "RATE_DRIFT", "message": "…", "driftPct": …, "newQuote": { … } } — flat body | Rate or accepted-output drift over 1 % — see above. |
All error responses
All error responses
| Status | Body | Why |
|---|---|---|
| 400 | { "ok": false, "error": { "code": "NO_MERCHANT_CONTEXT", "message": "…" } } | No merchant context on the credential. |
| 400 | { "ok": false, "error": { "code": "CONVERSION_FAILED", "message": "…" } } | A genuine domain failure — not used for idempotency conflicts. message follows the merchant-safe passthrough rule (fallback "Conversion failed"). |
| 400 | { "ok": false, "error": { "code": "MFA_REQUIRED", "message": "…" } } | JWT caller supplied no otpCode and no passkey assertion. |
| 403 | { "ok": false, "error": { "code": "MFA_SETUP_REQUIRED", "message": "…" } } | JWT account has neither TOTP nor a passkey. |
| 403 | { "ok": false, "error": { "code": "TOTP_MISCONFIGURED", "message": "…" } } | TOTP enabled but the secret is missing — re-enroll. |
| 401 | { "ok": false, "error": { "code": "USER_NOT_FOUND", "message": "…" } } | Session user no longer exists. |
| varies | { "error": "…" } — flat body | A failed MFA factor — sent with the verification status code, including 429 when the account is locked out. |
CONVERSION_IN_PROGRESS, IDEMPOTENCY_KEY_CONSUMED), 409 = your key collided, nothing new was executed by this request — funds may already have moved on the original request, so reconcile rather than re-quote. A 409 RATE_DRIFT is separate: the market moved, so re-present newQuote. A 400 CONVERSION_FAILED is a genuine domain failure.
See also
- Get a conversion: the status source of truth — poll it after any external-mode
201. - Get a quote: where
fromAmountStr,quotedRateandquotedToAmountcome from. - Get MFA options: which factor a dashboard user can present on this call.
- Conversions Overview: execution modes, status lifecycle and the idempotency-conflict contract.