Contracts v1 (2026-10-12)
Every breaking change in the contracts v1 release of the Crypto as a Service API, what it replaces, and what your integration must change.
Release: contracts v1 · Date: 2026-10-12 · Environment: sandbox first, then production
Contracts v1 puts every endpoint on one consistent contract: one envelope and error shape, decimal-string money, UPPER_SNAKE statuses with a documented lifecycle, cursor pagination, idempotent writes, and full tracking data on every money movement. This is the final v1 contract; no second contract change follows.
Most old forms keep working for one release as deprecated aliases (listed at the end), so you can migrate step by step. The changes below marked as breaking take effect as soon as the release reaches your environment.
Before the release reaches sandbox
Check these first—they break integrations that don't change:
- Send
networkon every crypto withdrawal. - Read the verification code from the
user.withdrawal.verification_code_issuedanduser.transfer.verification_code_issuedwebhooks—the API never returns it. - Treat withdrawals as
PROCESSINGafter confirm, and wait forCOMPLETEDorFAILED. - Renew user tokens every 24 hours with the refresh token.
- Follow
meta.pagination.navigation.nextinstead offilter[skip]on every list.
Platform-wide
These apply to every endpoint. See API conventions.
| Change | Before | After | What you must change |
|---|---|---|---|
| Error envelope and codes | Codes guessed from the message (a 400 could come back as VALIDATION_ERROR); validation errors as 400 with no field; UNPROCESSABLE_ENTITY codes | { code, message, source } with source.pointer, source.parameter, or source.header. Validation is 422 VALIDATION_ERROR, one error per field. Business rules are 400 with a specific code. Illegal state changes are 422 INVALID_STATE_TRANSITION. Codes follow the status first (401 UNAUTHORIZED, with source.header on x-api-key and user-token routes; 403 FORBIDDEN; 429 RATE_LIMIT_EXCEEDED). | Branch on HTTP status plus code; map field errors with source. Handle 422 for validation instead of 400. |
| Unknown body fields | Silently ignored | Rejected with 422 and a pointer to the field (a few reserved names such as token, secret or role are dropped silently) | Send only documented fields. |
| Ownership | Some ids from other customers could be read | Resources outside your customer answer 404 (for example WALLET_NOT_FOUND); a user token that targets another user's user_id answers 403 | Don't rely on cross-customer or cross-user access. |
| Idempotency | No replay protection; withdraw.idempotency_key and is_duplicate in withdrawal responses | x-idempotency-key header (UUID) on POST and PATCH, stored 24 hours; replays return the original response with x-idempotent-replayed: true; 409 IDEMPOTENCY_IN_PROGRESS; 422 IDEMPOTENCY_KEY_REUSED. Resources echo idempotency_key. Responses that carry a secret (login, refresh, credentials, user tokens) are never stored, so retrying them runs again. | Send a fresh UUID per operation and reuse it on retries. |
| Rate limits | Over the limit answered 403 FORBIDDEN | 429 RATE_LIMIT_EXCEEDED with retry-after; responses authenticated with an x-api-key or a user token carry x-rate-limit-limit, x-rate-limit-remaining, x-rate-limit-reset | Handle 429 and wait retry-after seconds. |
| Pagination | filter[skip], filter[sort], total, first and last links, default limit 20 | Cursor pagination: filter[limit] (default 25, max 100), opaque cursor, filter[order]; meta.pagination.records has limit, has_next, has_previous; navigation has next and previous. No total, no skip. filter[skip], offset, and page answer 422. The fiat withdrawal list keeps the previous offset format until USD Settlement. | Page by following navigation.next until it's null. Remove any use of totals. |
| Filters | filter[where][status]=PENDING, [from]/[to] ranges, regex search | filter[where][field][op]=value with eq, neq, gt, gte, lt, lte, inq, nin, between; each list has its own allowlist; anything else answers 422 | Rewrite filters with an explicit operator, for example filter[where][status][eq]=PENDING. |
| Money | JSON numbers (0.01), sometimes 8-decimal strings for every asset | Decimal strings padded to the asset's scale ("0.01000000" BTC, "25.000000" USDT, "1004.9504" USD); the currency appears once; network fees are { amount, currency, paid_by } | Parse amounts as strings with a decimal library. Send amounts as strings. |
| Statuses and enums | Mixed casing (pending, Done, individual) | UPPER_SNAKE everywhere; enums are open | Compare in UPPER_SNAKE and tolerate unknown values. |
| Timestamps | Milliseconds, mixed formats | Resource dates and meta.timestamp in UTC without milliseconds (2026-10-12T14:03:11Z); null until they happen | Parse ISO 8601 without assuming milliseconds. |
| Trace id | Hyphenated UUID; an invalid x-trace-id failed with 400 | 32 lowercase hex characters; an invalid value is replaced, never rejected | Store trace_id as a 32-hex string. |
| Identifiers | Each resource used its own id name; some ids had prefixes (txn-) or were numeric | Every resource has id (UUID) and object; old id names stay as aliases with the same value; references end in _id | Read id. Store ids as opaque strings. |
| Networks | Provider names (networks/bitcoin-testnet, bitcoin-testnet) | Canonical codes on input and output: BITCOIN, ETHEREUM, SOLANA, TRON, POLYGON, BNB_SMART_CHAIN (open list) | Send and read the canonical codes. |
| Pairs | Compact btcusd, or an object keyed by pair | BASE/QUOTE (BTC/USD) everywhere, plus base_currency and quote_currency | Use BASE/QUOTE. |
| Fee configuration | Monetization settings could be managed through the public API | Fee settings are managed by Sovera and no longer part of the public API | Ask your Sovera contact to change fees. |
Authentication, users, and tokens
See Authentication, Users and events, and User tokens.
| Change | Before | After | What you must change |
|---|---|---|---|
| Login and refresh | Flat { token, expires, refresh_token }; wrong credentials coded as VALIDATION_ERROR | Enveloped session object { object, token_type, token, expires_at, refresh_token, refresh_token_expires_at }; 401 INVALID_CREDENTIALS and 401 INVALID_REFRESH_TOKEN | Read the session from data. Store the newest refresh_token after every refresh. |
| User token lifetime | token-<hex> valid about a year, no refresh | utk_… access token valid 24 hours plus a 30-day single-use rt_… refresh token. An expired token answers 401. | Store both tokens. Renew with { "grant_type": "refresh_token", "refresh_token": "…" } before expires_at; the refresh call needs no owner key. |
| Token path and response | POST /users/{user_id}/token | POST /users/{user_id}/tokens (201) returning a user_token object with id, token_last4, expires_at, refresh_expires_at | Call /tokens and read the new object. |
| Token eligibility | Any user | Only VERIFIED, active users; others get 422 INVALID_STATE_TRANSITION | Activate users before issuing tokens. |
| Bulk tokens | { "user_ids": [...] }, 200, all-or-nothing 404 | { "items": [{ "user_id": "…" }] } (1 to 100), 207 with one result per item in request order | Send items and read each item's success, data, or errors. |
| User resource | user_id, lowercase enums, dob, identity number in clear | id (alias user_id), object: "user", customer_id, external_id, is_active, UPPER_SNAKE enums, individual.birth_date, individual.id_number_masked (the full number never comes back), audit fields | Read the new names and casing. |
| Account type | individual or business | INDIVIDUAL or CORPORATE (business is rejected) | Send CORPORATE for companies. |
| Update user | PUT replaced the user; KYC fields under flat names weren't saved | PATCH with merge semantics; KYC fields under individual.* are saved; status, account_type, account_role answer 422 | Switch to PATCH and send only what changes. |
| Duplicates and not found | Generic 409 CONFLICT or EMAIL_ALREADY_EXISTS; USER_NOT_FOUND | 409 EMAIL_ALREADY_IN_USE, PHONE_ALREADY_IN_USE, EXTERNAL_ID_ALREADY_IN_USE, USER_ID_ALREADY_IN_USE with source.pointer; 404 NOT_FOUND with source.parameter: user_id | Update error handling. |
| List users | Page and limit, total, regex search | Cursor list with filters on status, account_type, email, external_id, created_at | Page with the cursor; replace search with eq filters. |
| Activate, deactivate, delete | Activate and deactivate returned a message | Both return the user resource; delete also revokes the user's tokens | Read the user from data. |
| Events | Unenveloped list with totals and internal fields | Enveloped cursor list of event objects with UPPER_SNAKE delivery_status and status_reason; owner key only | Page with the cursor and read the new fields. |
Customers, credentials, email poller, and webhooks
See Customer management, Email poller, Webhooks, and Vault.
| Change | Before | After | What you must change |
|---|---|---|---|
| Customer resource | Lowercase enums, placeholder values, business.accredited_investor and non_agent_intermediary | id, object: "customer", uppercase account_type and account_role, status always VERIFIED, every business key present (null when unknown), is_accredited_investor, is_non_agent_intermediary, audit fields | Read the new names; handle null. |
| Update customer | PUT replaced business wholesale | PATCH deep-merges business (null clears, absent keeps); account_type, account_role, email, status answer 422 | Switch to PATCH; stop sending read-only fields. |
| Customer errors | 400 validation; 403 for a foreign id; 409 with free text | 422 per field; neutral 404 CUSTOMER_NOT_FOUND; 409 CUSTOMER_ALREADY_EXISTS | Update error handling. |
| Account holder actions | Any session of the account could delete the customer and issue or revoke credentials | Those three actions need the account holder's session; team members get 403 ACCOUNT_OWNER_REQUIRED | Run them with the account holder's session. |
| Deleted customers | Still readable | 404 on the customer and its sub-resources; delete also disables the email poller | Don't read deleted customers. |
| Credentials | Create returned { customer_id, customer_secret }; list without pagination | Create returns the full credential resource (secret shown once, never replayed); the list is cursor-paginated and shows only customer_secret_prefix; revoke disables the API key and every user token issued with it; your dashboard session is unaffected | Read the secret from the create response only; page the list. |
| Email poller | POST create, PUT update, physical DELETE, /{poller_id}/enable and /disable; failed verify as 401 with raw text | Singleton: PUT create or replace (201 or 200), PATCH merge (imap: null clears), POST …/verify, …/enable, …/disable returning the poller; repeating a state answers 422 INVALID_STATE_TRANSITION; verify answers 200 with connection_status and a stable status_reason code; tls renamed is_tls_enabled, enabled renamed is_enabled; the password is never returned | Move to the singleton routes and new field names. |
| Webhook endpoint | POST and PUT returned { message }; verify always 200 | Singleton: PUT create or replace, PATCH (url: null clears), POST …/verify returning last_verification; 404 WEBHOOK_NOT_FOUND without a webhook; header keys lowercase and values masked to a 4-character hint | Read the webhook_endpoint resource; handle 404 on verify. |
| Webhook targets | Any URL | HTTPS on a public host only, no redirects; others answer 422 on /url | Use a public HTTPS endpoint. |
| Verification codes | Codes described as emailed; withdrawal email-poller payload without public ids | Delivered only through user.withdrawal.verification_code_issued and user.transfer.verification_code_issued; the withdrawal event carries the public withdrawal_id, destination_address, and network | Configure your webhook and read data.code from these events. |
| Vault reads | Not documented | GET …/vault/health and GET …/vault/treasury/balance with money at asset scale and HEALTHY, DEGRADED, UNHEALTHY status | Optional: monitor your vault. |
Wallets, addresses, balances, and ledgers
See Wallets, Wallet addresses, Account data, and Account management.
| Change | Before | After | What you must change |
|---|---|---|---|
| Address paths | POST and GET /wallets/{wallet_id}/address, GET …/address/{currency} | POST and GET /wallets/{wallet_id}/addresses; filter by currency with filter[where][currency][eq] | Switch paths. |
| Address creation | currency plus a provider network name; 409 on duplicates | currency and canonical network required; 201 for a new address, 200 with the existing one | Send the canonical network; accept 200. |
| Wallet creation | networks[].name with the networks/ prefix | networks[].network with canonical codes; uppercase wallet_type | Send { network, currency } pairs. |
| Wallet resource | Provider wallet id and network names, is_active | id (alias wallet_id), object, customer_id, status, networks[], addresses[] as address objects, audit fields | Read the new fields. |
| Empty wallet list | 404 | 200 with data: [] | Treat an empty list as success. |
| Balances (wallet, user, owner account) | Numbers or 8-decimal strings, balance field | balances[] of { currency, total, available, held } as decimal strings at asset scale, plus as_of | Read total instead of balance. |
| Transactions (wallet, user, owner account) | Several shapes, numeric ids, nested transaction | One flat transaction object: UPPER_SNAKE type and status, decimal-string amounts, pair as BASE/QUOTE, canonical network | Parse the new object. |
| Wallet transactions | A wallet without addresses could list rows that weren't its own | Only the wallet's own rows | Nothing, unless you relied on the old rows. |
Withdrawals
See Withdrawals and the withdrawal flow.
| Change | Before | After | What you must change |
|---|---|---|---|
| Network | Not sent | network required and validated against the currency (422 on /network) | Send network on every crypto withdrawal. |
| Amount | JSON number | Decimal string | Send "amount": "0.01". |
| One object for all calls | Initiate returned { atx_id, routing, message }; confirm { id, status }; resend { message } | Initiate, confirm, resend, cancel, get, and list all return the same withdrawal object, with no routing or message | Parse the withdrawal object everywhere. |
| Status set and transitions | PENDING, COMPLETED, CANCELLED; confirm returned COMPLETED | PENDING → PROCESSING → COMPLETED or FAILED; PENDING → CANCELLED, EXPIRED, BLOCKED, or FAILED; BLOCKED → CANCELLED or EXPIRED; confirm returns PROCESSING. Applies to crypto withdrawals; fiat withdrawals keep their previous shape until USD Settlement | Treat PROCESSING as in flight and poll GET until COMPLETED or FAILED. |
| Confirm body | otp | code (send exactly one of them) | Rename the field. |
| Cancel | DELETE …/withdrawals/{id} answered 204 | POST …/withdrawals/{withdrawal_id}/cancel answers 200 with the CANCELLED withdrawal and status_reason | Switch the method and read the body. |
| Resend code | …/verification/codes; any state, so blocked withdrawals could reopen | …/verification-codes, only while PENDING | Change the path; cancel and start over when blocked. |
| Verification code | Described as emailed | Only in the user.withdrawal.verification_code_issued webhook; never in the API; expires after 5 minutes, 3 attempts | Consume the webhook. |
| New tracking fields | — | id (alias atx_id), status_reason, fee { amount, currency, paid_by } (the real network fee after settlement), debited_wallets[], tx_hash, network, completed_at, idempotency_key, audit fields; initiate and resend also return verification { expires_at, attempts_remaining } | Store id; read the fee from settled withdrawals. |
| Path parameter | The pre-release atx_id | The path takes the withdrawal id; the pre-release atx_id is still accepted | Store and send id. |
| History | Records vanished when the code challenge expired; offset list | Permanent records; cursor list with status, currency, tx_hash, idempotency_key, created_at filters | Page with the cursor. |
Transfers
See Transfers.
| Change | Before | After | What you must change |
|---|---|---|---|
| One object for all calls | { id, transaction: { action, memo, amount, net_proceeds, price, fees, status, wallet_sender_id, … } } | A flat transfer object: id, wallet_id, destination_wallet_id, currency, decimal-string amount, fee (null for internal transfers), status, status_reason, idempotency_key, timestamps, audit fields; initiate and resend also return verification { expires_at, attempts_remaining } | Parse the transfer object; rename wallet_sender_id to wallet_id. |
| Ids | txn- prefixed ids | Bare UUIDs (prefixed ids still accepted on input) | Store the bare id. |
| Statuses | PENDING, COMPLETED, CANCELLED | Adds FAILED, EXPIRED, BLOCKED, each with status_reason; illegal actions answer 422 INVALID_STATE_TRANSITION | Handle the new statuses. |
| Confirm, cancel, resend | otp; DELETE with 204; …/verification/codes with a { "type": "email" } body | code; POST …/cancel with 200 and the transfer; …/verification-codes with no body, only while PENDING | Rename the field, switch the method and path. |
| Verification code | Described as emailed, valid 10 minutes | Only in the user.transfer.verification_code_issued webhook; expires after 5 minutes | Consume the webhook. |
| Lists | Outgoing transfers only, offset pagination | Cursor list with direction OUTGOING (default) or INCOMING | Page with the cursor; read incoming transfers with direction. |
Trading and currencies
See Trading, the trading flow, and Currencies.
| Change | Before | After | What you must change |
|---|---|---|---|
| Quote request | pair: "btcusd", side: "buy", numeric quantity or amount | pair: "BTC/USD", side: "BUY" or "SELL", decimal-string quantity or amount (exactly one, 422 otherwise) | Change all three inputs. |
| Quote response | Raw upstream object with numbers, date_quote, expired_at, fees, is_spread_high | quote object: id (alias quote_id), status (OPEN, EXECUTED, EXPIRED), wallet_id, user_id, pair, base_currency, quote_currency, decimal strings, fee, created_at, expires_at | Read the new fields; quotes last seconds, so check expires_at. |
| Order response | Raw upstream order with numeric ids, Done or Started statuses, net_proceeds | order object: Sovera UUID id (with the order_id alias on the create response), status (PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED), status_reason, quote_id, transaction_id, decimal strings, timestamps | Parse the order object. |
| Order failures | Upstream rejections surfaced as errors | A rejected quote returns 201 with status: "FAILED" and status_reason.code: "QUOTE_REJECTED"; an expired quote answers 400 QUOTE_EXPIRED; an executed quote answers 422 INVALID_STATE_TRANSITION; if the venue's answer is lost, the order returns 201 PENDING with status_reason.code: "EXECUTION_OUTCOME_UNKNOWN" | Check status, not only the HTTP code. Don't retry a quote whose order is EXECUTION_OUTCOME_UNKNOWN; follow the order until it settles. |
| Order tracking | No read routes | GET /users/{wallet_id}/trading/orders/{order_id} and a cursor list GET /users/{wallet_id}/trading/orders | Use them to follow orders. |
| Currencies | Raw provider catalogue, 0/1 flags, provider network names | currency objects with uppercase code, booleans (is_*), decimal_scale, and networks[] with canonical codes; cursor pagination | Read the new fields and page. |
| Currency pairs | Object keyed by pair, or { formatted, symbol, base, quote } | Array of { object, pair, base_currency, quote_currency, is_trading_enabled } with pair as BASE/QUOTE; cursor pagination | Iterate the array. |
Deprecated, removed in the next release
These still answer today so you can migrate. Each one goes away in the next release:
idempotency-keyheader →x-idempotency-key.- JSON numbers for amounts and quantities → decimal strings.
- Hyphenated UUIDs in
x-trace-id→ 32 lowercase hex characters. otpon withdrawal and transfer confirm →code.DELETEon a withdrawal or transfer (204) →POST …/cancel.…/verification/codes→…/verification-codes./wallets/{wallet_id}/addressand/address/{currency}→/wallets/{wallet_id}/addresses.networks[].nameand provider network names →networks[].networkwith canonical codes.- Lowercase
wallet_type,side, and user enums → UPPER_SNAKE. - Compact pairs (
btcusd) →BASE/QUOTE. POST /users/{user_id}/token→POST /users/{user_id}/tokens.user_idson bulk tokens →items.PUT /users/{user_id}andPUT /customers/{customer_id}→PATCH.doband flat KYC names (residential_*,id_type,id_number,id_country_code) →individual.birth_dateandindividual.*.tagandreferenceon withdrawal initiate (ignored) → remove them.typein the transfer resend body (ignored) → send no body.- Email poller
POSTcreate,DELETE,/{poller_id}/enableand/disable, andimap.tls→PUT,PATCH,POST /email/poller/enableor/disable, andis_tls_enabled. - Webhook
POSTcreate andDELETE→PUTandPATCH. business.accredited_investorandbusiness.non_agent_intermediary→is_accredited_investorandis_non_agent_intermediary.- The legacy code notification body (
pin,email,atx_id, …) sent for withdrawal codes captured by the email poller → theuser.withdrawal.verification_code_issuedevent. - Transitional error codes derived from messages (
USER_NOT_FOUND,EMAIL_ALREADY_EXISTS,PHONE_ALREADY_EXISTS,DEPOSIT_NOT_FOUND,CLIENT_NOT_FOUND) → the specific codes on each endpoint page.
Deprecated routes don't send Deprecation or Sunset headers; use this list as the reference.