API conventions

The rules every Crypto as a Service endpoint follows: envelope, errors, idempotency, rate limits, pagination, filters, money, statuses, timestamps, networks, and pairs.

Every endpoint follows the same rules. Learn them once here; each endpoint page documents only what's specific to it.

Base URL and versioning

Send every request over HTTPS to:

https://api.sandbox.sovera.io/sovx/v1

The version lives in the path (/v1). Send JSON bodies with Content-Type: application/json; a POST or PUT with another content type gets 415 on most routes.

Response envelope

Every response body uses one envelope:

{
  "success": true,
  "data": { "id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d", "object": "withdrawal" },
  "meta": {
    "timestamp": "2026-10-12T14:03:11Z",
    "version": "v1",
    "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
  • success is true on 2xx and false on errors. A failed request carries errors instead of data.
  • data is an object for a single resource and an array for a list. Lists add meta.pagination (see Pagination and filters).
  • A 204 No Content has no body.
  • meta.trace_id is 32 lowercase hex characters. Send your own in the x-trace-id request header to stitch a call to your logs: the API keeps a 32-hex value, compacts a hyphenated UUID, and replaces anything else with a fresh id—an invalid trace id never fails the request. The same value comes back in the x-trace-id response header.

meta.timestamp is the server time of the response, in UTC without milliseconds, on every success and error.

Errors

A failed request returns success: false and an errors array. Each error has a stable code, a human-readable message, and, when the error points at your input, a source:

{
  "success": false,
  "errors": [
    {
      "code": "VALIDATION_ERROR",
      "message": "network must be one of: BITCOIN for BTC",
      "source": { "pointer": "/network" }
    }
  ],
  "meta": {
    "timestamp": "2026-10-12T14:03:11Z",
    "version": "v1",
    "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
source keyPoints at
pointerA body field, as a JSON Pointer (/amount, /individual/birth_date).
parameterA path or query parameter (user_id, filter[limit], cursor).
headerA request header (x-idempotency-key).

Branch on the HTTP status for the category and on code for the exact reason. Treat message as text to log or show, never as something to parse.

StatusMeaning
400A business rule rejected the request (for example INSUFFICIENT_BALANCE, QUOTE_EXPIRED, INVALID_VERIFICATION_CODE).
401The credential is missing, invalid, revoked, or expired. The code is UNAUTHORIZED (login and refresh use INVALID_CREDENTIALS / INVALID_REFRESH_TOKEN). On x-api-key and user-token routes, source.header names the header that carried it; SX Customers session errors carry no source.
403The credential is valid but can't do this (for example VERIFICATION_BLOCKED, ACCOUNT_FROZEN).
404The resource doesn't exist or doesn't belong to your customer. Ids from other customers answer 404, so they can't be probed. A user token that targets another user's user_id in the path gets 403.
409A conflict: a duplicate (EMAIL_ALREADY_IN_USE, CUSTOMER_ALREADY_EXISTS) or a request with the same idempotency key still running (IDEMPOTENCY_IN_PROGRESS).
422Validation failed (VALIDATION_ERROR, one error per field with its source), an action isn't allowed in the current status (INVALID_STATE_TRANSITION), or an idempotency key was reused with a different body (IDEMPOTENCY_KEY_REUSED).
429Rate limit exceeded (RATE_LIMIT_EXCEEDED). See Rate limits.
5xxSomething failed on our side or upstream. Retry with backoff and quote the trace_id if it persists.

Validation is strict: a body field the endpoint doesn't know is rejected with 422 and a pointer to that field. A few reserved names (token, secret, api_key, role, permissions, is_owner, _id, and similar) are dropped silently instead. Before validation, any value that starts with $ or contains ;, |, &&, $(, ${ or HTML/script patterns is refused with 400 BAD_REQUEST (no source). See Error codes for the full list.

Idempotency

Make any POST or PATCH safe to retry by sending an idempotency key:

x-idempotency-key: 9a7c3e1f-5b2d-4c8e-a6f0-1d3b5e7f9a2c
  • The key is a UUID you generate, one per logical operation. A key that isn't a UUID gets 422 with source.header.
  • The API stores the first result (status and body) for 24 hours, scoped to your credential, method, and path.
  • Repeat the same request with the same key and you get the original status and body back, with the response header x-idempotent-replayed: true. Nothing runs twice.
  • Exception: responses that carry a secret are never stored. Login, refresh, credential issuance and user tokens ignore the key, and any other response with a secret frees it, so a retry runs again (and, for example, issues a new secret).
  • Repeat it while the first is still running and you get 409 IDEMPOTENCY_IN_PROGRESS. Wait and retry.
  • Reuse the key with a different body and you get 422 IDEMPOTENCY_KEY_REUSED.
  • A request that fails frees its key, so you can retry with the same one.
  • Resources that record the key echo it as idempotency_key, and money-movement lists let you filter by it.

The header is optional, but send it on every money movement. idempotency-key (without the x-) still works as a deprecated alias.

Rate limits

Every response authenticated with an x-api-key or a user token carries your quota (SX Customers session calls, login and refresh don't):

HeaderMeaning
x-rate-limit-limitRequests allowed in the current window.
x-rate-limit-remainingRequests left in the window.
x-rate-limit-resetSeconds until the window resets.

Each credential gets 100 requests per 15-minute window. User-token refresh grants have their own limits: 1,000 per customer and 20 failed attempts per user every 15 minutes.

Over the limit, the API answers 429 RATE_LIMIT_EXCEEDED with a retry-after header in seconds. Wait that long before you retry; don't hammer the endpoint. Login and refresh 429s carry no retry-after; back off exponentially.

Pagination and filters

Lists use cursor pagination and one filter grammar.

ParameterDescription
filter[limit]Items per page. Default 25, maximum 100.
cursorOpaque token from a previous page's next or previous link. Echo it back as is; never build or edit it.
filter[order]Sort, for example created_at DESC (the default on most lists). id breaks ties.
filter[where][field][op]A filter. Each list documents the fields and operators it accepts.

Operators: eq, neq, gt, gte, lt, lte, inq, nin, between. Pass several values with []:

GET /v1/users/{user_id}/accounts/crypto/withdrawals?filter[where][status][inq][]=PENDING&filter[where][status][inq][]=PROCESSING&filter[where][created_at][gte]=2026-10-01T00:00:00Z&filter[limit]=50
  • Dates in filters are UTC and end in Z.
  • between takes exactly two values: filter[where][created_at][between][]=2026-10-01T00:00:00Z&filter[where][created_at][between][]=2026-10-12T00:00:00Z.
  • A field or operator outside the list's allowlist, a bad value, or a cursor that doesn't match the current filter[order] gets 422 with source.parameter naming the parameter.
  • Up to 6 conditions per request. filter[order] without a direction sorts ascending.
  • Offset pagination is gone: filter[skip], offset, and page get 422. The one exception is the fiat withdrawal list, which keeps its previous offset format until USD Settlement.

Each page reports where you are, with no totals:

"meta": {
  "timestamp": "2026-10-12T14:03:11Z",
  "version": "v1",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "pagination": {
    "records": { "limit": 25, "has_next": true, "has_previous": false },
    "navigation": {
      "next": "/v1/users/5299e81c-3a8c-5b64-a112-b93bf43de74b/accounts/crypto/withdrawals?filter[limit]=25&cursor=Qm9vZ2FsbG9v...",
      "previous": null
    }
  }
}

Follow navigation.next until it's null. The links keep your filters. They start at /v1/…: prefix them with https://api.sandbox.sovera.io/sovx before calling them. An empty list is 200 with data: [].

Money and amounts

Amounts are decimal strings, never JSON numbers, so no precision gets lost:

  • Responses pad each amount to its asset's scale: "0.01000000" for BTC (8 decimals), "25.000000" for USDT and USDC (6), "1004.9504" for USD (4), 18 for ETH. Extra stored decimals are never cut.
  • Single-currency resources (withdrawals, transfers, balances, ledger rows) state the currency once, and every amount next to it is a plain string in that currency.
  • Trading resources carry pair, base_currency, and quote_currency. quantity is in the base asset; amount, prices, and fee are in the quote asset.
  • An on-chain network fee is an object, { "amount", "currency", "paid_by" }, because the gas asset can differ from the asset you moved (a USDT withdrawal on Ethereum pays its fee in ETH). paid_by is USER or SPONSOR.
  • Send amounts as positive decimal strings too ("amount": "0.01"). JSON numbers are still accepted during the deprecation window and will be rejected in the next release.
  • Currency codes are uppercase (BTC, USDT).

Statuses

Status values are UPPER_SNAKE. Money movements share one vocabulary:

StatusMeaning
PENDINGCreated and waiting for an action (for example, the verification code).
PROCESSINGConfirmed and executing.
COMPLETEDDone. Final.
FAILEDCouldn't complete. Final.
CANCELLEDCancelled before it executed. Final.
EXPIREDThe verification window lapsed. Final.
BLOCKEDToo many wrong verification codes. You can still cancel it; otherwise it becomes EXPIRED when the code window lapses.

Each resource page documents the statuses and transitions it uses. When a movement doesn't succeed, status_reason explains why; otherwise it's null:

"status_reason": { "code": "CANCELLED_BY_CLIENT", "message": "The withdrawal was cancelled before confirmation." }

An action that the current status doesn't allow (confirming a cancelled withdrawal, cancelling a completed transfer) answers 422 INVALID_STATE_TRANSITION. Webhook event types keep their dotted names (user.withdrawal.verification_code_issued); UPPER_SNAKE applies to field values, not to event names. Treat every enum as open: tolerate values you don't know yet.

Timestamps

Resource timestamps are ISO 8601 in UTC, without milliseconds: "2026-10-12T14:03:11Z". Fields that haven't happened yet are null (for example completed_at on a pending withdrawal). meta.timestamp is the server time of the response, in the same format.

Money movements carry created_at, updated_at, and completed_at, plus created_by and updated_by: the credential, user token, or session that made the change.

Identifiers

  • Ids are UUIDs that Sovera assigns. They're opaque: store them as strings and don't parse them.
  • Every resource has an id and an object field naming its type ("object": "withdrawal").
  • Older names for the same id stay as aliases with the same value (for example atx_id on withdrawals).
  • References to other resources end in _id (user_id, wallet_id, customer_id). customer_id is your platform customer id.

Networks

network is a canonical, API-owned code, the same on input and output:

CodeChainGas asset
BITCOINBitcoinBTC
ETHEREUMEthereumETH
SOLANASolanaSOL
TRONTronTRX
POLYGONPolygonMATIC
BNB_SMART_CHAINBNB Smart ChainBNB

The list is open: new codes can appear, so don't fail on a value you don't recognize. The API never returns provider network names or the networks/ prefix.

Trading pairs

Pairs are written BASE/QUOTE everywhere in the API: BTC/USD, ETH/USD, USDT/USD. Responses also split them into base_currency and quote_currency. The compact form (btcusd) is still accepted on input for one release.

Deprecated aliases

This release keeps the old forms working so you can migrate at your pace. Each one still answers today and will be removed in the next release:

DeprecatedUse instead
idempotency-key headerx-idempotency-key
JSON numbers for amountsDecimal strings
otp in confirm bodiescode
DELETE on a withdrawal or transferPOST …/cancel
…/verification/codes…/verification-codes
/wallets/{wallet_id}/address and /address/{currency}/wallets/{wallet_id}/addresses
networks[].name on wallet creationnetworks[].network
POST /users/{user_id}/tokenPOST /users/{user_id}/tokens
user_ids on bulk tokensitems
PUT to update users and customersPATCH
dob and flat KYC field namesindividual.birth_date and the individual.* fields
Lowercase side and enum valuesUPPER_SNAKE (BUY, SELL, INDIVIDUAL)
Compact pairs (btcusd)BASE/QUOTE (BTC/USD)
Email poller POST create, DELETE, and /{poller_id}/enable or /disablePUT, PATCH, and POST /email/poller/enable or /disable
Webhook POST create and DELETEPUT and PATCH
Hyphenated UUID in x-trace-id32 lowercase hex characters

See the contracts v1 changelog for every change and what to update.

On this page