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/v1The 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"
}
}successistrueon 2xx andfalseon errors. A failed request carrieserrorsinstead ofdata.datais an object for a single resource and an array for a list. Lists addmeta.pagination(see Pagination and filters).- A
204 No Contenthas no body. meta.trace_idis 32 lowercase hex characters. Send your own in thex-trace-idrequest 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 thex-trace-idresponse 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 key | Points at |
|---|---|
pointer | A body field, as a JSON Pointer (/amount, /individual/birth_date). |
parameter | A path or query parameter (user_id, filter[limit], cursor). |
header | A 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.
| Status | Meaning |
|---|---|
400 | A business rule rejected the request (for example INSUFFICIENT_BALANCE, QUOTE_EXPIRED, INVALID_VERIFICATION_CODE). |
401 | The 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. |
403 | The credential is valid but can't do this (for example VERIFICATION_BLOCKED, ACCOUNT_FROZEN). |
404 | The 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. |
409 | A conflict: a duplicate (EMAIL_ALREADY_IN_USE, CUSTOMER_ALREADY_EXISTS) or a request with the same idempotency key still running (IDEMPOTENCY_IN_PROGRESS). |
422 | Validation 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). |
429 | Rate limit exceeded (RATE_LIMIT_EXCEEDED). See Rate limits. |
5xx | Something 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
422withsource.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):
| Header | Meaning |
|---|---|
x-rate-limit-limit | Requests allowed in the current window. |
x-rate-limit-remaining | Requests left in the window. |
x-rate-limit-reset | Seconds 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.
| Parameter | Description |
|---|---|
filter[limit] | Items per page. Default 25, maximum 100. |
cursor | Opaque 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. betweentakes 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]gets422withsource.parameternaming the parameter. - Up to 6 conditions per request.
filter[order]without a direction sorts ascending. - Offset pagination is gone:
filter[skip],offset, andpageget422. 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
currencyonce, and every amount next to it is a plain string in that currency. - Trading resources carry
pair,base_currency, andquote_currency.quantityis in the base asset;amount, prices, andfeeare 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_byisUSERorSPONSOR. - 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:
| Status | Meaning |
|---|---|
PENDING | Created and waiting for an action (for example, the verification code). |
PROCESSING | Confirmed and executing. |
COMPLETED | Done. Final. |
FAILED | Couldn't complete. Final. |
CANCELLED | Cancelled before it executed. Final. |
EXPIRED | The verification window lapsed. Final. |
BLOCKED | Too 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
idand anobjectfield naming its type ("object": "withdrawal"). - Older names for the same id stay as aliases with the same value (for example
atx_idon withdrawals). - References to other resources end in
_id(user_id,wallet_id,customer_id).customer_idis your platform customer id.
Networks
network is a canonical, API-owned code, the same on input and output:
| Code | Chain | Gas asset |
|---|---|---|
BITCOIN | Bitcoin | BTC |
ETHEREUM | Ethereum | ETH |
SOLANA | Solana | SOL |
TRON | Tron | TRX |
POLYGON | Polygon | MATIC |
BNB_SMART_CHAIN | BNB Smart Chain | BNB |
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:
| Deprecated | Use instead |
|---|---|
idempotency-key header | x-idempotency-key |
| JSON numbers for amounts | Decimal strings |
otp in confirm bodies | code |
DELETE on a withdrawal or transfer | POST …/cancel |
…/verification/codes | …/verification-codes |
/wallets/{wallet_id}/address and /address/{currency} | /wallets/{wallet_id}/addresses |
networks[].name on wallet creation | networks[].network |
POST /users/{user_id}/token | POST /users/{user_id}/tokens |
user_ids on bulk tokens | items |
PUT to update users and customers | PATCH |
dob and flat KYC field names | individual.birth_date and the individual.* fields |
Lowercase side and enum values | UPPER_SNAKE (BUY, SELL, INDIVIDUAL) |
Compact pairs (btcusd) | BASE/QUOTE (BTC/USD) |
Email poller POST create, DELETE, and /{poller_id}/enable or /disable | PUT, PATCH, and POST /email/poller/enable or /disable |
Webhook POST create and DELETE | PUT and PATCH |
Hyphenated UUID in x-trace-id | 32 lowercase hex characters |
See the contracts v1 changelog for every change and what to update.
Authentication
Authenticate in three tiers: account login (Bearer session), customer API credentials (owner x-api-key), then per-user tokens (Bearer, 24 hours with a 30-day refresh token).
Webhooks
What webhooks are for, how Sovera delivers them, how to verify and process them safely, and every event your endpoint can receive.