Changelog

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:

  1. Send network on every crypto withdrawal.
  2. Read the verification code from the user.withdrawal.verification_code_issued and user.transfer.verification_code_issued webhooks—the API never returns it.
  3. Treat withdrawals as PROCESSING after confirm, and wait for COMPLETED or FAILED.
  4. Renew user tokens every 24 hours with the refresh token.
  5. Follow meta.pagination.navigation.next instead of filter[skip] on every list.

Platform-wide

These apply to every endpoint. See API conventions.

ChangeBeforeAfterWhat you must change
Error envelope and codesCodes 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 fieldsSilently ignoredRejected 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.
OwnershipSome ids from other customers could be readResources outside your customer answer 404 (for example WALLET_NOT_FOUND); a user token that targets another user's user_id answers 403Don't rely on cross-customer or cross-user access.
IdempotencyNo replay protection; withdraw.idempotency_key and is_duplicate in withdrawal responsesx-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 limitsOver the limit answered 403 FORBIDDEN429 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-resetHandle 429 and wait retry-after seconds.
Paginationfilter[skip], filter[sort], total, first and last links, default limit 20Cursor 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.
Filtersfilter[where][status]=PENDING, [from]/[to] ranges, regex searchfilter[where][field][op]=value with eq, neq, gt, gte, lt, lte, inq, nin, between; each list has its own allowlist; anything else answers 422Rewrite filters with an explicit operator, for example filter[where][status][eq]=PENDING.
MoneyJSON numbers (0.01), sometimes 8-decimal strings for every assetDecimal 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 enumsMixed casing (pending, Done, individual)UPPER_SNAKE everywhere; enums are openCompare in UPPER_SNAKE and tolerate unknown values.
TimestampsMilliseconds, mixed formatsResource dates and meta.timestamp in UTC without milliseconds (2026-10-12T14:03:11Z); null until they happenParse ISO 8601 without assuming milliseconds.
Trace idHyphenated UUID; an invalid x-trace-id failed with 40032 lowercase hex characters; an invalid value is replaced, never rejectedStore trace_id as a 32-hex string.
IdentifiersEach resource used its own id name; some ids had prefixes (txn-) or were numericEvery resource has id (UUID) and object; old id names stay as aliases with the same value; references end in _idRead id. Store ids as opaque strings.
NetworksProvider 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.
PairsCompact btcusd, or an object keyed by pairBASE/QUOTE (BTC/USD) everywhere, plus base_currency and quote_currencyUse BASE/QUOTE.
Fee configurationMonetization settings could be managed through the public APIFee settings are managed by Sovera and no longer part of the public APIAsk your Sovera contact to change fees.

Authentication, users, and tokens

See Authentication, Users and events, and User tokens.

ChangeBeforeAfterWhat you must change
Login and refreshFlat { token, expires, refresh_token }; wrong credentials coded as VALIDATION_ERROREnveloped session object { object, token_type, token, expires_at, refresh_token, refresh_token_expires_at }; 401 INVALID_CREDENTIALS and 401 INVALID_REFRESH_TOKENRead the session from data. Store the newest refresh_token after every refresh.
User token lifetimetoken-<hex> valid about a year, no refreshutk_… 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 responsePOST /users/{user_id}/tokenPOST /users/{user_id}/tokens (201) returning a user_token object with id, token_last4, expires_at, refresh_expires_atCall /tokens and read the new object.
Token eligibilityAny userOnly VERIFIED, active users; others get 422 INVALID_STATE_TRANSITIONActivate 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 orderSend items and read each item's success, data, or errors.
User resourceuser_id, lowercase enums, dob, identity number in clearid (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 fieldsRead the new names and casing.
Account typeindividual or businessINDIVIDUAL or CORPORATE (business is rejected)Send CORPORATE for companies.
Update userPUT replaced the user; KYC fields under flat names weren't savedPATCH with merge semantics; KYC fields under individual.* are saved; status, account_type, account_role answer 422Switch to PATCH and send only what changes.
Duplicates and not foundGeneric 409 CONFLICT or EMAIL_ALREADY_EXISTS; USER_NOT_FOUND409 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_idUpdate error handling.
List usersPage and limit, total, regex searchCursor list with filters on status, account_type, email, external_id, created_atPage with the cursor; replace search with eq filters.
Activate, deactivate, deleteActivate and deactivate returned a messageBoth return the user resource; delete also revokes the user's tokensRead the user from data.
EventsUnenveloped list with totals and internal fieldsEnveloped cursor list of event objects with UPPER_SNAKE delivery_status and status_reason; owner key onlyPage with the cursor and read the new fields.

Customers, credentials, email poller, and webhooks

See Customer management, Email poller, Webhooks, and Vault.

ChangeBeforeAfterWhat you must change
Customer resourceLowercase enums, placeholder values, business.accredited_investor and non_agent_intermediaryid, 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 fieldsRead the new names; handle null.
Update customerPUT replaced business wholesalePATCH deep-merges business (null clears, absent keeps); account_type, account_role, email, status answer 422Switch to PATCH; stop sending read-only fields.
Customer errors400 validation; 403 for a foreign id; 409 with free text422 per field; neutral 404 CUSTOMER_NOT_FOUND; 409 CUSTOMER_ALREADY_EXISTSUpdate error handling.
Account holder actionsAny session of the account could delete the customer and issue or revoke credentialsThose three actions need the account holder's session; team members get 403 ACCOUNT_OWNER_REQUIREDRun them with the account holder's session.
Deleted customersStill readable404 on the customer and its sub-resources; delete also disables the email pollerDon't read deleted customers.
CredentialsCreate returned { customer_id, customer_secret }; list without paginationCreate 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 unaffectedRead the secret from the create response only; page the list.
Email pollerPOST create, PUT update, physical DELETE, /{poller_id}/enable and /disable; failed verify as 401 with raw textSingleton: 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 returnedMove to the singleton routes and new field names.
Webhook endpointPOST and PUT returned { message }; verify always 200Singleton: 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 hintRead the webhook_endpoint resource; handle 404 on verify.
Webhook targetsAny URLHTTPS on a public host only, no redirects; others answer 422 on /urlUse a public HTTPS endpoint.
Verification codesCodes described as emailed; withdrawal email-poller payload without public idsDelivered only through user.withdrawal.verification_code_issued and user.transfer.verification_code_issued; the withdrawal event carries the public withdrawal_id, destination_address, and networkConfigure your webhook and read data.code from these events.
Vault readsNot documentedGET …/vault/health and GET …/vault/treasury/balance with money at asset scale and HEALTHY, DEGRADED, UNHEALTHY statusOptional: monitor your vault.

Wallets, addresses, balances, and ledgers

See Wallets, Wallet addresses, Account data, and Account management.

ChangeBeforeAfterWhat you must change
Address pathsPOST 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 creationcurrency plus a provider network name; 409 on duplicatescurrency and canonical network required; 201 for a new address, 200 with the existing oneSend the canonical network; accept 200.
Wallet creationnetworks[].name with the networks/ prefixnetworks[].network with canonical codes; uppercase wallet_typeSend { network, currency } pairs.
Wallet resourceProvider wallet id and network names, is_activeid (alias wallet_id), object, customer_id, status, networks[], addresses[] as address objects, audit fieldsRead the new fields.
Empty wallet list404200 with data: []Treat an empty list as success.
Balances (wallet, user, owner account)Numbers or 8-decimal strings, balance fieldbalances[] of { currency, total, available, held } as decimal strings at asset scale, plus as_ofRead total instead of balance.
Transactions (wallet, user, owner account)Several shapes, numeric ids, nested transactionOne flat transaction object: UPPER_SNAKE type and status, decimal-string amounts, pair as BASE/QUOTE, canonical networkParse the new object.
Wallet transactionsA wallet without addresses could list rows that weren't its ownOnly the wallet's own rowsNothing, unless you relied on the old rows.

Withdrawals

See Withdrawals and the withdrawal flow.

ChangeBeforeAfterWhat you must change
NetworkNot sentnetwork required and validated against the currency (422 on /network)Send network on every crypto withdrawal.
AmountJSON numberDecimal stringSend "amount": "0.01".
One object for all callsInitiate 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 messageParse the withdrawal object everywhere.
Status set and transitionsPENDING, COMPLETED, CANCELLED; confirm returned COMPLETEDPENDING → 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 SettlementTreat PROCESSING as in flight and poll GET until COMPLETED or FAILED.
Confirm bodyotpcode (send exactly one of them)Rename the field.
CancelDELETE …/withdrawals/{id} answered 204POST …/withdrawals/{withdrawal_id}/cancel answers 200 with the CANCELLED withdrawal and status_reasonSwitch the method and read the body.
Resend code…/verification/codes; any state, so blocked withdrawals could reopen…/verification-codes, only while PENDINGChange the path; cancel and start over when blocked.
Verification codeDescribed as emailedOnly in the user.withdrawal.verification_code_issued webhook; never in the API; expires after 5 minutes, 3 attemptsConsume 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 parameterThe pre-release atx_idThe path takes the withdrawal id; the pre-release atx_id is still acceptedStore and send id.
HistoryRecords vanished when the code challenge expired; offset listPermanent records; cursor list with status, currency, tx_hash, idempotency_key, created_at filtersPage with the cursor.

Transfers

See Transfers.

ChangeBeforeAfterWhat 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.
Idstxn- prefixed idsBare UUIDs (prefixed ids still accepted on input)Store the bare id.
StatusesPENDING, COMPLETED, CANCELLEDAdds FAILED, EXPIRED, BLOCKED, each with status_reason; illegal actions answer 422 INVALID_STATE_TRANSITIONHandle the new statuses.
Confirm, cancel, resendotp; DELETE with 204; …/verification/codes with a { "type": "email" } bodycode; POST …/cancel with 200 and the transfer; …/verification-codes with no body, only while PENDINGRename the field, switch the method and path.
Verification codeDescribed as emailed, valid 10 minutesOnly in the user.transfer.verification_code_issued webhook; expires after 5 minutesConsume the webhook.
ListsOutgoing transfers only, offset paginationCursor list with direction OUTGOING (default) or INCOMINGPage with the cursor; read incoming transfers with direction.

Trading and currencies

See Trading, the trading flow, and Currencies.

ChangeBeforeAfterWhat you must change
Quote requestpair: "btcusd", side: "buy", numeric quantity or amountpair: "BTC/USD", side: "BUY" or "SELL", decimal-string quantity or amount (exactly one, 422 otherwise)Change all three inputs.
Quote responseRaw upstream object with numbers, date_quote, expired_at, fees, is_spread_highquote object: id (alias quote_id), status (OPEN, EXECUTED, EXPIRED), wallet_id, user_id, pair, base_currency, quote_currency, decimal strings, fee, created_at, expires_atRead the new fields; quotes last seconds, so check expires_at.
Order responseRaw upstream order with numeric ids, Done or Started statuses, net_proceedsorder 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, timestampsParse the order object.
Order failuresUpstream rejections surfaced as errorsA 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 trackingNo read routesGET /users/{wallet_id}/trading/orders/{order_id} and a cursor list GET /users/{wallet_id}/trading/ordersUse them to follow orders.
CurrenciesRaw provider catalogue, 0/1 flags, provider network namescurrency objects with uppercase code, booleans (is_*), decimal_scale, and networks[] with canonical codes; cursor paginationRead the new fields and page.
Currency pairsObject 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 paginationIterate 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-key header → x-idempotency-key.
  • JSON numbers for amounts and quantities → decimal strings.
  • Hyphenated UUIDs in x-trace-id → 32 lowercase hex characters.
  • otp on withdrawal and transfer confirm → code.
  • DELETE on a withdrawal or transfer (204) → POST …/cancel.
  • …/verification/codes → …/verification-codes.
  • /wallets/{wallet_id}/address and /address/{currency} → /wallets/{wallet_id}/addresses.
  • networks[].name and provider network names → networks[].network with 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_ids on bulk tokens → items.
  • PUT /users/{user_id} and PUT /customers/{customer_id} → PATCH.
  • dob and flat KYC names (residential_*, id_type, id_number, id_country_code) → individual.birth_date and individual.*.
  • tag and reference on withdrawal initiate (ignored) → remove them.
  • type in the transfer resend body (ignored) → send no body.
  • Email poller POST create, DELETE, /{poller_id}/enable and /disable, and imap.tls → PUT, PATCH, POST /email/poller/enable or /disable, and is_tls_enabled.
  • Webhook POST create and DELETE → PUT and PATCH.
  • business.accredited_investor and business.non_agent_intermediary → is_accredited_investor and is_non_agent_intermediary.
  • The legacy code notification body (pin, email, atx_id, …) sent for withdrawal codes captured by the email poller → the user.withdrawal.verification_code_issued event.
  • 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.

On this page