# Skipo API > The Skipo public API — reference, guides, and code examples This file contains all documentation content in a single document following the llmstxt.org standard. ## Account {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Account The authenticated account ## `GET /v2/account` Get the account for the authenticated key Identifies the account your key belongs to, and carries the settings other endpoints refer back to — most importantly `preferredCurrency`, which is the unit of every `balancePrefCurrency` field on `GET /v2/balances`. That unit is published here and nowhere else, so a client that formats balances needs this call once at start-up. **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`AccountResource`](#accountresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### AccountResource | Field | Type | Description | |---|---|---| | `id` | `string` | | | `username` | `string` | | | `email` | `string` | | | `fullName` | `string` | e.g. `Jane Doe`. | | `nationalId` | `string` \| `null` | | | `verificationStatus` | `string` | | | `preferredCurrency` | `string` | | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Assets {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Assets Supported assets — fiat, stablecoins, crypto and tokenised equities ## `GET /v2/assets` List supported assets Reference data, identical for every key. This is the authoritative source for an asset's `assetClass` (fiat, stablecoin, crypto or tokenised equity) — balances deliberately do not publish one, so join on `assetSymbol` rather than inferring a kind from the code. It also carries `amountIncrement`, the operating precision to quantise amounts to before sending them, and the rebase factor for a tokenised equity. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`AssetResource`](#assetresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/assets/{assetSymbol}` Get a supported asset by its symbol Reference data is identical for every key, so this response does not depend on who asks. Accepts either spelling of a tokenised equity: the settled SPV code or the rebased ticker. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `assetSymbol` | path | `string` | yes | The asset code published as `assetSymbol` by `GET /v2/assets` (e.g. `BTC`, `NVDASPV`). For a tokenised equity the REBASED ticker also resolves here — `NVDAX` returns the same asset as `NVDASPV` — so either spelling works. Case-insensitive. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`AssetResource`](#assetresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### AssetResource | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | Asset code. This is the value every endpoint takes as `assetSymbol` — there is no bare `asset` parameter anywhere on the public API. e.g. `BTC`. | | `rebasedSymbol` | `string` | The REBASED ticker — the name a holder sees for this asset. For a tokenised equity this is the underlying equity code (`NVDAX`) while `assetSymbol` stays the settled SPV code (`NVDASPV`); the two are the same value for every other asset. Always present, so a UI can read this field unconditionally instead of branching. ⚠️ Equal to `assetSymbol` is NOT a claim that the asset does not rebase — use `assetClass`. e.g. `BTC`. | | `assetName` | `string` | e.g. `Bitcoin`. | | `assetClass` | `FIAT` \| `STABLECOIN` \| `STOCK` \| `CRYPTO` | What kind of asset this is. `STOCK` is a tokenised equity (xStock) and is the only class whose amounts rebase — see `rebaseMultiplier`. Do not infer this from any numeric field. e.g. `CRYPTO`. | | `amountIncrement` | `string` | Smallest amount step Skipo operates on for this asset. An amount finer than this is TRUNCATED — not rejected, and not rounded up. e.g. `0.00000001`. | | `displayIncrement` | `string` | Step Skipo uses when DISPLAYING this asset — coarser than `amountIncrement`. Presentation only; never validate an amount against it. e.g. `0.01`. | | `minimumDeposit` | `string` | Smallest deposit that will be credited. e.g. `0.0001`. | | `minimumWithdrawal` | `string` | Smallest withdrawal to an external destination, as a PLATFORM-wide floor for this asset. A chain may impose a higher one of its own, and the effective minimum is the larger of the two — so a withdrawal at exactly this figure can still be rejected on a chain with a higher floor. Read `networks[].minimumWithdrawal` for the figure that applies to a given chain. e.g. `0.001`. | | `minimumInternalWithdrawal` | `string` | Smallest withdrawal to another Skipo account. Usually lower than the external minimum, because no network fee is involved. e.g. `0.0001`. | | `withdrawalFee` | `string` | Flat fee the account pays Skipo for an external withdrawal of this asset, charged in that same asset. `"0"` for fiat. Deposits are always free. ⚠️ On a multichain asset this is the CHEAPEST chain’s fee, because there is no single answer — USDT costs one thing on BNB Smart Chain and another on Tron. The fee actually charged is the one of the chain your destination contact is on: read `networks[].withdrawalFee`, matched on the contact’s `crypto.networkId`. This field is kept for clients written before an asset could have more than one chain, and reports the lowest so that it never OVERstates what a withdrawal costs. e.g. `0.0005`. | | `maxIndicativeQuoteAmount` | `string` | Largest amount Skipo will quote for an account with no balance and no available credit. A quote within this limit is INDICATIVE — priced, but confirming it still requires funds. e.g. `0.2`. | | `rebaseMultiplier` | `string` | Rebase factor currently in effect for a tokenised equity. It multiplies the SETTLED quantity to give the underlying shares: `shares = settledTokens × rebaseMultiplier`, so one settled token is `rebaseMultiplier` shares. To go the other way, divide. `"1"` for every non-rebasing asset. This is the CURRENT factor — to render a PAST trade, use the point-in-time `multiplier` on the fill, never this one. e.g. `1`. | | `networks` | [`NetworkResource`](#networkresource)[] | Networks this asset can be withdrawn over. Empty for fiat. | ### NetworkResource | Field | Type | Description | |---|---|---| | `networkName` | `string` | Human-readable network name. e.g. `Bitcoin`. | | `networkId` | `string` \| `null` | The CANONICAL chain identifier, from maintainers chain registry — `BSC`, `ETH`, `TRON`, `BITCOIN`, `STELLAR_NETWORK`. A stable key: one row per chain, unique, and the same value wherever v2 publishes it. ⚠️ This REPLACES the old `networkSymbol`, which was removed from this resource. That field was never a key — it mixed chain symbols, token standards and prose (`Stellar Network`, `XRP Ledger`, `Dogecoin`) and disagreed with itself. Key on this instead. It is also what the industry uses: Binance identifies BNB Smart Chain as `network: "BSC"`, and so does this. e.g. `BITCOIN`. | | `evmChainId` | `number` \| `null` | EIP-155 chain id for an EVM chain — 56 for BNB Smart Chain, 1 for Ethereum. `null` on a non-EVM chain (Bitcoin, Solana, XRP…). The most portable identifier there is, when it exists. e.g. `1`. | | `networkNativeAsset` | `string` \| `null` | The chain NATIVE COIN symbol — `BNB` for BNB Smart Chain, `ETH` for Ethereum. This is the asset that pays gas on the chain, NOT an identifier for the chain itself; `networkId` is that. Published because the two are easy to confuse and both are useful. e.g. `BTC`. | | `assetType` | `string` \| `null` | The TOKEN STANDARD on this chain — `ERC20`, `BEP20`, `TRC20`, `SOL_ASSET`, or `BASE_ASSET` when the asset is the chain native coin. A different axis from `networkId`: USDT on BNB Smart Chain is `networkId: "BSC"` with `assetType: "BEP20"`. The removed `networkSymbol` collapsed both onto one column, which is why it disagreed with itself. e.g. `BEP20`. | | `withdrawalFee` | `string` | Fee charged for a withdrawal over THIS network, denominated in the asset itself. It is per-network, so a multichain asset can price each chain differently. This is the authoritative figure — the asset-level `withdrawalFee` reports the cheapest chain. e.g. `0.0005`. | | `minimumWithdrawal` | `string` | Smallest withdrawal Skipo will send over THIS network — the EFFECTIVE minimum, already resolved as the larger of the platform floor for the asset and the chain’s own floor. Validate against this rather than the asset-level `minimumWithdrawal`, which reports only the platform half and can therefore be lower than what this chain will accept. It applies to the quantity that is SENT. Under the default `feeMode` of `deduct` that is `amount` − `withdrawalFee`, so the smallest `amount` this network accepts is `minimumWithdrawal` + `withdrawalFee`. e.g. `0.001`. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Balances {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Balances Current balances and historical balances ## `GET /v2/balances` List the account asset balances Amounts are in the underlying equity for tokenised assets by default; pass `assetFormat=base` for the settled SPV quantities that moved on chain and in the ledger. Every response echoes `assetFormat` and `multiplier`, so the units are never implicit. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what is held on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `object` | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/balances/{assetSymbol}/history` Historical balances for an asset End-of-period balance for one asset, at day, week or month granularity. Amounts are SETTLED quantities and this endpoint takes no `assetFormat`. The points carry no per-point rebase factor, so expressing them as the underlying equity would mean applying TODAY's ratio to past history — after a 10:1 split every earlier point would appear to jump tenfold. A correct unscaled series beats a scaled and possibly false one. `assetSymbol` still accepts either spelling: `NVDAX` reads the same series as `NVDASPV`, because that only selects which asset to read, never how it is expressed. The response echoes the settled code, so the label always matches the units. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `assetSymbol` | path | `string` | yes | The asset code published as `assetSymbol` by `GET /v2/assets`. For a tokenised equity the rebased ticker also resolves — `NVDAX` reads the same series as `NVDASPV`. | | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `startDate` | query | `string` | no | ISO-8601 start date. Defaults to the first movement. | | `endDate` | query | `string` | no | ISO-8601 end date. Defaults to now. | | `granularity` | query | `day` \| `week` \| `month` | no | Default: `day`. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`HistoricalBalancesResponse`](#historicalbalancesresponse) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### BalanceBaseRepresentation | Field | Type | Description | |---|---|---| | `asset` | `string` | Asset code of the settled token — the SPV code for a tokenised equity, and the ordinary code otherwise. Always present. e.g. `NVDASPV`. | | `balance` | `string` | Balance in settled units. e.g. `10`. | | `balanceFrozen` | `string` | Frozen balance in settled units. e.g. `0`. | | `balancePending` | `string` | Pending balance in settled units. e.g. `0`. | ### BalanceResource | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | The asset, named in the representation the amounts below are in — the rebased ticker on a `rebased` read of a tokenised equity, the settled code otherwise. e.g. `NVDAX`. | | `balance` | `string` | e.g. `10.5`. | | `balanceFrozen` | `string` | e.g. `0`. | | `balancePending` | `string` | e.g. `0`. | | `balancePrefCurrency` | `string` | This balance converted to the account's preferred currency. The unit is not repeated here — read `preferredCurrency` from `GET /v2/account`. Named for its unit, like `balanceUSD`. e.g. `1837.50`. | | `balanceUSD` | `string` | e.g. `1837.50`. | | `balanceCapacityTotal` *(optional)* | `string` | e.g. `5000`. | | `balanceCapacityAvailable` *(optional)* | `string` | e.g. `5000`. | | `balanceCapacitySpent` *(optional)* | `string` | e.g. `0`. | | `multiplier` | `string` | This asset's rebase factor as it stands NOW, published whether or not it was applied — so a client can convert between the two representations without a second read. `"1"` when the asset does not rebase. On a `rebased` read divide an amount by it to recover the settled (SPV) figure; on a `base` read multiply. Unlike an order or a fill — whose factor is the one stamped when they settled — a balance is a current holding, so the current factor is the correct one. e.g. `1.05`. | | `assetFormat` | `any` | Which representation the amounts and `assetSymbol` above are in — the value you asked for, or the default. Always present, so a client never has to infer the units it was given. e.g. `rebased`. | | `base` | `any` | The same holding in SETTLED units — what is held on chain and in the ledger. Present in BOTH representations, so a client reconciling against a webhook or a ledger entry never has to re-read with a different `assetFormat`. On a `base` read it repeats the figures above rather than disappearing, which is what keeps the shape stable. | ### HistoricalBalancePoint | Field | Type | Description | |---|---|---| | `date` | `string` | | | `balance` | `string` | Balance at the end of the period, as an exact decimal string in plain notation — never exponential. Truncated to the asset operating precision. Parse it as a decimal, not a float. For a tokenised equity this is the SETTLED quantity: see the note on `assetSymbol` for why this one series is not offered in the rebased representation. e.g. `500.62`. | ### HistoricalBalancesResponse | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | The SETTLED asset code, echoed regardless of which spelling the request used — a tokenised equity is named by its SPV code here because the balances below are settled quantities. This series carries no `assetFormat` and is never rebased: the points have no per-point factor, so scaling them would apply today's ratio to past history. e.g. `BTC`. | | `granularity` | `day` \| `week` \| `month` | | | `startDate` | `string` \| `null` | | | `endDate` | `string` \| `null` | | | `data` | [`HistoricalBalancePoint`](#historicalbalancepoint)[] | | | `pagination` | [`PaginationMeta`](#paginationmeta) | | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Contacts {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Contacts Withdrawal contacts (read + reference edit) ## `GET /v2/contacts` List contacts Saved withdrawal destinations. A contact is what `POST /v2/withdrawals` sends to, and its asset is fixed — a withdrawal whose asset does not match its destination contact is rejected. Contacts cannot be created through v2; add them from the Skipo dashboard. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `type` | query | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `BANK_ACCOUNT` | no | Filter by contact type. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`ContactResource`](#contactresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/contacts/{contactId}` Get a contact by id Use it to confirm a destination before signing a withdrawal — the asset and the address or account it resolves to are what the withdrawal will actually use. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `contactId` | path | `string` | yes | | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`ContactResource`](#contactresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `PATCH /v2/contacts/{contactId}` Edit a contact's reference/alias Edits YOUR label for the contact, nothing else. The destination itself — address, network, bank account, asset — is immutable through this API, so this call can never redirect where money goes. To change a destination, create a new contact from the dashboard. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `contactId` | path | `string` | yes | | **Request body** — [`UpdateContactReferenceDto`](#updatecontactreferencedto) **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`ContactResource`](#contactresource) | | `400` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`validation_error`](https://docs.skipo.com/errors/validation_error) (not retryable). | — | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `422` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unprocessable`](https://docs.skipo.com/errors/unprocessable) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### BankContactDetails | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` \| `null` | Asset this account settles in. Always a FIAT code — a bank account cannot be denominated in a crypto asset or a tokenised equity. e.g. `CLP`. | | `nationalId` | `string` \| `null` | The account holder's national id. | | `bankId` | `string` \| `null` | | | `bankName` | `string` \| `null` | e.g. `Banco de Chile`. | | `accountTypeId` | `string` \| `null` | | | `bankAccountType` | `string` \| `null` | e.g. `CHECKING`. | | `bankAccountNumber` | `string` \| `null` | | | `accountEmail` | `string` \| `null` | | ### ContactResource | Field | Type | Description | |---|---|---| | `id` | `string` | | | `reference` | `string` \| `null` | | | `type` | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `BANK_ACCOUNT` | Which kind of destination this is, and therefore which detail block is present: `EXTERNAL_CRYPTO` → `crypto`, `BANK_ACCOUNT` → `bank`, `INTERNAL` → neither. | | `alias` | `string` | | | `createdAt` | `string` | | | `updatedAt` | `string` | | | `crypto` *(optional)* | `any` | Present only when `type` is `EXTERNAL_CRYPTO`. | | `bank` *(optional)* | `any` | Present only when `type` is `BANK_ACCOUNT`. | ### CryptoContactDetails | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` \| `null` | Asset code this address receives. e.g. `BTC`. | | `assetName` | `string` \| `null` | e.g. `Bitcoin`. | | `networkSymbol` | `string` \| `null` | Network the address lives on, exactly as it was stored when the contact was created — passed through UNNORMALIZED. Kept for compatibility; it is NOT a stable key. **Use `networkId` below instead** — it is the canonical chain identifier and it is published on `GET /v2/assets` too, so the two sides now join directly. Why this field cannot be that key: it conflates the chain with the token standard and with the chain native coin. A BNB Smart Chain contact reports `BNB` here (the native COIN), while a movement on the same chain, to the same address, reports `BEP20` (the TOKEN STANDARD). Neither is the chain, which is `BSC`. The inconsistency is not confined to contacts. Measured on production, the MOVEMENT rows carry two spellings for one chain: 164,192 withdrawals say `BEP20` while 1,614 say `BSC`; 1,501 say `ETH` while 455 say `ERC20`; Avalanche appears as both `AVAXC` and `AVAX`. The field also carries values that are not networks at all (`INTERNAL` on 56,745 withdrawals, `FIAT_NETWORK` on 15,396, plus `EARN` and `REDPAY`) and prose (`Dogecoin`, `Stellar Network`, `XRP Ledger`). Movements are unchanged and still read this way. `GET /v2/assets` no longer publishes a `networkSymbol` at all — it was removed rather than rewritten, because the reference surface now reads the canonical registry (`crypto_network`) and any value carried over would have silently changed meaning. Contacts keep theirs because here it is the contact own stored data, not a lookup. e.g. `BTC`. | | `networkName` | `string` \| `null` | Human-readable network name, for DISPLAY. It agrees with `GET /v2/assets` and with the movement payloads, but do not key on it: it is prose, and `Ethereum (ERC20)` is the one name on production that does not resolve to a single network. **To match a contact against an asset supported networks, use `networkId`.** It is the canonical chain identifier and it is now published on BOTH this resource and `GET /v2/assets`, so the two sides join directly. Earlier revisions of this field said to match on the name until a canonical registry existed — it exists, and this is it. e.g. `Bitcoin`. | | `networkId` | `string` \| `null` | The CANONICAL chain identifier, from maintainers chain registry — `BSC`, `ETH`, `TRON`, `BITCOIN`, `STELLAR_NETWORK`. This is the field to match against the `networkId` published by `GET /v2/assets`, and it is a stable key, which `networkSymbol` above is not. It is also what the industry keys on: Binance identifies BNB Smart Chain as `network: "BSC"`, and this says `BSC` too. `null` only when the network does not resolve in the registry. e.g. `BITCOIN`. | | `evmChainId` | `number` \| `null` | EIP-155 chain id for an EVM chain — 56 for BNB Smart Chain, 1 for Ethereum. `null` on a non-EVM chain. e.g. `1`. | | `networkNativeAsset` | `string` \| `null` | The chain NATIVE COIN symbol — `BNB` for BNB Smart Chain. This is the asset that pays gas, NOT an identifier for the chain; `networkId` is that. Note `networkSymbol` above happens to carry this value today, which is exactly the confusion `networkId` resolves. e.g. `BTC`. | | `address` | `string` \| `null` | The on-chain destination address. | | `tag` | `string` \| `null` | Destination tag / memo, for chains that require one (XRP, XLM…). Null otherwise. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | ### UpdateContactReferenceDto | Field | Type | Description | |---|---|---| | `reference` | `string` | New reference/alias label for the contact (does not change the destination). | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Deposit addresses {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Deposit addresses ## `GET /v2/deposit-addresses` List deposit addresses The addresses to send funds TO in order to fund this account, one per (asset, chain) pair. These are Skipo-owned addresses that credit your balance — the opposite direction from `GET /v2/contacts`, which lists the external destinations you can withdraw to. **Send only over the `networkId` the address is listed under.** Several chains share an address format, so the same string can appear under more than one, and a deposit sent over a chain Skipo does not sweep is not recoverable. **Where `tagRequired` is true, the tag is mandatory.** Skipo shares one address across accounts on those chains and the tag is the only thing identifying yours; a deposit without it does not credit automatically. Addresses are stable — Skipo does not rotate or retire them, and reading this endpoint never mints one. A pair with no address yet is simply absent rather than listed empty; it appears once provisioning completes. **Only assets this account has ADDED are listed.** Skipo pre-provisions an address for effectively the whole catalogue before you ask for any of it, so holding an address does not mean the asset is in use — and listing all of them would bury the handful you actually fund. An asset you have not added answers `[]`, including when you name it with `?assetSymbol=`. Add it in the Skipo app (or open the deposit screen and select it) and it appears on the next call — the address itself already exists and does not change. This is the same set `GET /v2/balances` returns, so the two endpoints always agree about which assets are yours. Fiat has no chain and is never listed: `?assetSymbol=CLP` returns `[]`, not an error. Minimums live on `GET /v2/assets` (`minimumDeposit`); this resource carries no amounts. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `assetSymbol` | query | `string` | no | Restrict to one asset. Omit for every asset you can deposit. Accepts the rebased ticker of a tokenised equity as a synonym for its settled code — nothing here is scaled, so the two spellings name the same rows. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `object` | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### DepositAddressResource | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | Asset this address credits, as the SETTLED asset code — the same value every other v2 endpoint takes as `assetSymbol`. e.g. `USDT`. | | `networkId` | `string` | The chain this address is on. The SAME key as `GET /v2/assets` `networks[].networkId` and `GET /v2/contacts` `crypto.networkId`, so an address, an asset and a destination line up without parsing prose. ⚠️ An address is only valid on THIS chain. Several chains can share an address format — every EVM chain does — so the same string may appear under more than one `networkId`. Sending over a chain that is not listed here loses the funds: Skipo credits only the chains it sweeps. e.g. `TRON`. | | `networkName` | `string` | Human-readable chain name. Display only. e.g. `Tron (TRC20)`. | | `address` | `string` | The address to send to. Stable: Skipo does not rotate or retire deposit addresses, so it is safe to cache and to re-display. Re-reading this endpoint never mints a new one. e.g. `TDunhSa7jkTNuKrusUTU1MUHtqXoBPKETV`. | | `tag` | `string` \| `null` | Destination tag / memo, on the chains that route by it. `null` when the chain has none — never an empty string, so a missing tag and a blank tag cannot be confused. ⚠️ When `tagRequired` is true this value is MANDATORY. On those chains Skipo shares one address across accounts and the tag is the only thing that identifies yours: a deposit sent without it, or with the wrong one, does not credit automatically. e.g. `null`. | | `tagRequired` | `boolean` | Whether the chain routes by tag. Read THIS rather than testing `tag` for null — it is the chain's property, not this row's. e.g. `false`. | | `tagType` | `any` \| `null` | Which of the two names this chain uses for the tag — `MEMO` (Stellar) or `DESTINATION_TAG` (XRP). `null` when `tagRequired` is false. A machine key, not a label: render it in your own copy and your own language. e.g. `null`. | | `tagLabel` | `string` \| `null` | The chain's own word for the tag, as Skipo shows it. Display only; branch on `tagType`. e.g. `Memo`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Deposit instructions {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Deposit instructions ## `GET /v2/deposit-instructions` List fiat deposit instructions Where to send a BANK TRANSFER to fund this account in CLP. The fiat counterpart of `GET /v2/deposit-addresses`, which covers crypto. **The transfer must come from a bank account in your own name and RUT.** Skipo matches an incoming transfer to an account by the originator RUT the bank reports — there is no reference code, no memo and no per-customer account number. A transfer sent from anyone else's account cannot be matched and is NOT credited, and recovering it is a manual support case. Check the RUT Skipo holds for you with `GET /v2/account` (`nationalId`). Skipo publishes more than one account. **They are interchangeable** — a deposit into any of them credits the same balance. Prefer the one at your own bank: a same-bank transfer in Chile settles faster and costs less than an interbank one. CLP only. There is no USD bank account, so this never returns one; crypto funding is `GET /v2/deposit-addresses`. Amounts are not here — `minimumDeposit` is on `GET /v2/assets`. **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `object` | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### BankDepositInstructionResource | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | Currency this account accepts. CLP only today. e.g. `CLP`. | | `accountHolder` | `string` \| `null` | Account holder name, exactly as the receiving bank has it. e.g. `Skipo Chile SpA`. | | `accountHolderNationalId` | `string` \| `null` | The RUT of the account HOLDER — Skipo's, not yours. It identifies the destination; it is not the RUT the transfer has to come from. See `mustMatchSenderNationalId`. e.g. `77.777.777-7`. | | `bankName` | `string` \| `null` | e.g. `Banco de Chile`. | | `accountType` | `string` \| `null` | e.g. `Cuenta Corriente`. | | `accountNumber` | `string` \| `null` | e.g. `00-123-45678-90`. | | `accountEmail` | `string` \| `null` | Email some Chilean banks require on the transfer form. e.g. `deposits@skipo.com`. | | `mustMatchSenderNationalId` | `boolean` | Always `true`, and the single most important field on this resource. The transfer MUST come from a bank account held in YOUR OWN name, under the same `nationalId` that `GET /v2/account` reports (in Chile, your RUT). Skipo matches an incoming transfer to an account by the originator RUT the bank reports, and there is no reference code, no memo and no per-customer account number to fall back on. ⚠️ A transfer from a third party — a company account for a personal balance, a partner, a payment processor, a friend — CANNOT be matched and is NOT credited. Recovering it is a manual support case, not an automatic return. This is the fiat equivalent of omitting a destination tag on a crypto deposit. e.g. `true`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Deposits {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Deposits Deposit history ## `GET /v2/deposits` List deposits Every credit to the account — on-chain arrivals, bank transfers and internal movements — newest first. `subType` tells them apart, and only the on-chain ones carry a `transactionHash`. Amounts default to `rebased`. For a tokenised equity that means they are stated as the underlying equity, NOT as the tokens that moved on chain — read the `base` block, which is always present, to reconcile against a ledger entry or a webhook. `assetSymbol` accepts either spelling of a tokenised equity: filtering by the rebased ticker finds the same deposits as the settled code. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `assetSymbol` | query | `string` | no | | | `status` | query | `string` | no | | | `subType` | query | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `EXTERNAL_FIAT_BANK` \| `EXTERNAL_FIAT_REDPAY_CHARGEBACK` | no | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what was credited on chain and in the ledger. No effect on other assets. | | `startDate` | query | `string` | no | ISO-8601 start date. | | `endDate` | query | `string` | no | ISO-8601 end date. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`DepositResource`](#depositresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/deposits/{id}` Get a deposit by id Takes the same `assetFormat` as the list, so one deposit reports the same figures whichever way you reach it. The id is the one carried as `source.id` on the matching ledger entry, so a ledger row and this resource can be joined without a search. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what was credited on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`DepositResource`](#depositresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### DepositBaseRepresentation | Field | Type | Description | |---|---|---| | `asset` | `string` \| `null` | Asset code of the settled token. Populated on every read, including a rebased one — the rebased ticker maps back to the settled code through the maintainers snapshot. `null` only if that lookup misses, which means the asset is unknown to the snapshot rather than that the code was unobtainable. It is never the underlying ticker: a plausible-looking wrong code is worse than an obvious gap when reconciling against a webhook. e.g. `USDT`. | | `amount` | `string` | Amount deposited, in settled units. Positive magnitude. e.g. `2000`. | | `fee` | `string` | Always zero — Skipo charges no deposit fees. e.g. `0`. | | `total` | `string` | Total credited, in settled units. Positive magnitude. e.g. `2000`. | ### DepositResource | Field | Type | Description | |---|---|---| | `id` | `string` | Deposit id. Use it to re-fetch this deposit and to match its ledger entry (`source.id`). e.g. `503c85d1-b415-4ad4-9c0c-3881e105101e`. | | `type` | `string` | e.g. `DEPOSIT`. | | `subType` | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `EXTERNAL_FIAT_BANK` \| `EXTERNAL_FIAT_REDPAY_CHARGEBACK` | | | `assetSymbol` | `string` | Asset code of the deposit. e.g. `USDT`. | | `amount` | `string` | Amount deposited. Positive magnitude, in `assetSymbol`. e.g. `2000`. | | `fee` | `string` | Fee charged by Skipo for this deposit. Always zero — Skipo does not charge deposit fees. Present for a consistent shape across resources. e.g. `0`. | | `total` | `string` | Total credited to the balance. Positive magnitude, in `assetSymbol`. e.g. `2000`. | | `status` | `string` | e.g. `COMPLETED`. | | `createdAt` | `string` | e.g. `2026-07-15T18:08:14.637Z`. | | `transactionHash` | `string` \| `null` | On-chain transaction hash, when this deposit had one. `null` otherwise — an off-chain deposit (bank transfer, internal transfer, earn distribution) never has one, and an on-chain deposit that has not been broadcast yet does not have one YET. Top-level so that reading it never requires branching on `subType`. It is the same value as `depositData.transactionHash`, which remains for the sub-types that already published it; this field additionally covers on-chain sub-types that carry a hash but publish no `depositData` block. NOT a bank reference: on a fiat deposit this is always `null`, even though the underlying record may hold the bank's own transaction id. Do not assume a `0x` prefix — BTC, Solana and Tron hashes have none. e.g. `0x6392dc8a37abbfdc125c599aa57704100f7423e460d001ccabd09bc939c89e4d`. | | `bankReference` | `string` \| `null` | The bank's or payment provider's own reference for this deposit, when one was recorded. `null` on every non-fiat deposit, and `null` on a fiat deposit that carries no reference (the large majority — 225 of 112,281 bank deposits on prod have one). The counterpart of `transactionHash`: a deposit is settled either on a chain or through a bank, so at most one of the two fields is ever populated. Free-form and short (1-19 characters, not always numeric) — it is whatever the bank supplied, so treat it as an opaque string for reconciliation, never parse it. e.g. `null`. | | `senderName` | `string` \| `null` | Name of the bank account the transfer came FROM, as the sending bank reported it. `null` on every non-fiat deposit and on a fiat deposit created by an operator. Unnormalised — casing and accents are the bank’s, not ours. e.g. `JANE DOE`. | | `senderNationalId` | `string` \| `null` | National id of the sending account holder (in Chile, the RUT), and the key Skipo matches an incoming transfer on. ⚠️ Published ONLY when it equals your own — the `nationalId` on `GET /v2/account`. It is `null` whenever the money came from anyone else, so this field confirms a deposit was self-funded and never discloses a third party. On the automated rail that is the normal case by construction: Skipo credits whoever owns the sending id, so the sender IS the holder. Compared ignoring formatting, so `12.345.678-9` and `123456789` match. e.g. `12.345.678-9`. | | `senderBankId` | `string` \| `null` | Identifier of the sending bank, as the rail reported it. Opaque — do not parse. e.g. `BANCO_DE_CHILE`. | | `senderAccount` | `string` \| `null` | Account number the transfer came from, as the sending bank reported it. e.g. `00-123-45678-90`. | | `bankDescription` | `string` \| `null` | Free text the sender put on the transfer, when the rail carried any. Empty on most deposits (76.5% on prod), so treat its absence as normal rather than as an error. Distinct from `bankReference`, which is the operator’s own number for the movement. e.g. `transferencia`. | | `valueDate` | `string` \| `null` | When the money actually MOVED, as the rail reported it — as opposed to `createdAt`, which is when Skipo credited it. `null` on every non-fiat deposit. Use this to reconcile a month-end: on prod the two fall in different months on 1 deposit in 27 months, and the median gap is 91 seconds. Join to the ledger on `source.id`. NOT the clearing deadline the rail also publishes — that one falls AFTER the credit and would push deposits into the following month. e.g. `2026-08-20T14:03:11Z`. | | `assetFormat` | `any` | Which representation the top-level money fields are expressed in. Echoes what the request RESOLVED to, so a response that took the default is as self-describing as one that asked. e.g. `rebased`. | | `multiplier` | `string` | Rebase factor as it stood when the deposit was created — not today. `"1"` when no scaling applies. `base.amount x multiplier = amount` when `assetFormat` is `rebased`. `"1"` is NOT a claim that the asset does not rebase; read `GET /v2/assets` for its class. e.g. `1`. | | `base` | [`DepositBaseRepresentation`](#depositbaserepresentation) | | | `depositData` | `object` | Sub-type-specific details. The keys depend on `transactionSubType`, so branch on that rather than probing for fields. For `EXTERNAL_CRYPTO` this carries the CANONICAL chain identity, the same five fields `GET /v2/assets[].networks` and `GET /v2/contacts` publish — `networkId` (`BSC`, `ETH`, `TRON`), `networkName`, `networkNativeAsset`, `evmChainId` and `assetType` (the token standard, `BEP20`/`ERC20`/`TRC20`). **Join on `networkId`**: it is the one field that means the same thing on every v2 resource, so a movement, an asset and a saved destination line up without parsing prose. ⚠️ `networkSymbol` has been REMOVED from this block. It was the raw stored value and it disagreed with itself across the estate — 164,192 movements say `BEP20` for the same chain 1,614 call `BSC`, and it also carried values that are not networks (`INTERNAL`, `FIAT_NETWORK`) and prose (`Dogecoin`, `XRP Ledger`). `networkId` replaces it and is a stable key. `networkName` above is the human-readable form. The canonical fields are always present on an `EXTERNAL_CRYPTO` movement and are `null` when the chain cannot be resolved, so the shape never changes underneath you. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Endpoint reference {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Endpoint reference This is the **text** reference for the v2 API, generated from the OpenAPI specification. To try calls interactively, use the [API Reference](/reference). | Section | Endpoints | |---|---| | [Account](/api/account) | `GET /v2/account` | | [Assets](/api/assets) | `GET /v2/assets` · `GET /v2/assets/{assetSymbol}` | | [Balances](/api/balances) | `GET /v2/balances` · `GET /v2/balances/{assetSymbol}/history` | | [Contacts](/api/contacts) | `GET /v2/contacts` · `GET /v2/contacts/{contactId}` · `PATCH /v2/contacts/{contactId}` | | [Deposit addresses](/api/deposit-addresses) | `GET /v2/deposit-addresses` | | [Deposit instructions](/api/deposit-instructions) | `GET /v2/deposit-instructions` | | [Deposits](/api/deposits) | `GET /v2/deposits` · `GET /v2/deposits/{id}` | | [Ledger](/api/ledger) | `GET /v2/ledger` · `GET /v2/ledger/{id}` | | [Markets](/api/markets) | `GET /v2/markets` · `GET /v2/markets/{market}` · `GET /v2/markets/{market}/price` | | [Orders](/api/orders) | `GET /v2/fills` · `GET /v2/orders` · `POST /v2/orders` · `GET /v2/orders/{id}` · `GET /v2/orders/{id}/fills` · `POST /v2/quotes` | | [System](/api/system) | `GET /v2/health` · `GET /v2/time` | | [Webhooks](/api/webhooks) | `GET /v2/.well-known/webhook-jwks.json` | | [Withdrawals](/api/withdrawals) | `GET /v2/withdrawals` · `POST /v2/withdrawals` · `GET /v2/withdrawals/{id}` | See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Ledger {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Ledger Double-entry ledger entries, each linked to the resource that caused it. **Amounts here are SETTLED quantities and this tag takes no `assetFormat`.** A ledger entry records what actually moved on chain and in the ledger at the time it moved, and the entries carry no per-entry rebase factor — so expressing them as the underlying equity would mean applying TODAY's ratio to a past movement, restating history. After a 10:1 split every earlier entry would appear to jump tenfold. This means a ledger amount will NOT match a `rebased` read of the same movement from `GET /v2/deposits`, `GET /v2/withdrawals` or `GET /v2/orders` for a tokenised equity. Neither is wrong — they are different representations. To reconcile, read the movement with `assetFormat=base`, or compare against its `base` block, which every one of those resources publishes in both representations. `GET /v2/balances/{assetSymbol}/history` is settled-only for the same reason. ## `GET /v2/ledger` List ledger entries The double-entry record of how a balance changed, for one asset. Every entry carries a `source` back-pointer to the withdrawal, deposit or fill that caused it; filter by `sourceId` to fetch exactly those entries, or by `orderId` to fetch every entry an order produced across all of its fills. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `assetSymbol` | query | `string` | yes | Asset to list entries for. Required. | | `type` | query | `BUY` \| `SELL` \| `DEPOSIT` \| `WITHDRAWAL` | no | Filter by entry type. | | `sourceId` | query | `string` | no | Return only the entries caused by this resource. Accepts a withdrawal, deposit or fill id — the same value the resource publishes as `id` and the entry echoes as `source.id`. | | `orderId` | query | `string` | no | Return every entry produced by an order, across all of its fills. The ledger does not store an order id, so this resolves the order to its fills first — one extra internal hop. Note an order settles in two assets, and `assetSymbol` selects which leg you see. | | `startDate` | query | `string` | no | ISO-8601 start date. | | `endDate` | query | `string` | no | ISO-8601 end date. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`LedgerEntriesResponse`](#ledgerentriesresponse) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/ledger/{id}` Get a ledger entry by id A single double-entry movement. `source.id` points at the resource that caused it — the deposit, withdrawal or fill — so you can go from a ledger row straight to the movement behind it. Amounts are SETTLED quantities and this endpoint takes no `assetFormat`. For a tokenised equity that means a ledger amount will not match a `rebased` read of the same movement; compare against that resource's `base` block instead. See the Ledger section for why. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`LedgerEntry`](#ledgerentry) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### LedgerEntriesResponse | Field | Type | Description | |---|---|---| | `startDate` | `string` \| `null` | The start of the window this page was filtered by — the `startDate` you sent, echoed back. `null` when you sent none, which means the page is unbounded at that end. e.g. `2026-07-01T00:00:00.000Z`. | | `endDate` | `string` \| `null` | The end of the window this page was filtered by — the `endDate` you sent, echoed back. `null` when you sent none. e.g. `2026-07-31T23:59:59.000Z`. | | `data` | [`LedgerEntry`](#ledgerentry)[] | | | `pagination` | [`PaginationMeta`](#paginationmeta) | | ### LedgerEntry | Field | Type | Description | |---|---|---| | `id` | `string` | Id of this ledger entry. | | `source` | `any` | The resource that caused this entry. | | `type` | `string` | e.g. `DEPOSIT`. | | `subType` | `string` \| `null` | What KIND of entry this is within its type. Without it a DEPOSIT booked by an earn distribution is indistinguishable from a customer deposit. e.g. `INTERNAL_EARN_DISTRIBUTION`. | | `assetSymbol` | `string` | e.g. `BTC`. | | `detail` | `string` | | | `amount` | `string` | Positive magnitude; direction is given by `type`. e.g. `500`. | | `fee` | `string` | Positive magnitude. e.g. `0`. | | `total` | `string` | Positive magnitude. e.g. `500`. | | `balance` | `string` | Running balance after this entry. Signed — this is a state, not a movement. e.g. `500.62`. | | `createdAt` | `string` | | ### LedgerEntrySource | Field | Type | Description | |---|---|---| | `type` | `withdrawal` \| `deposit` \| `fill` \| `other` | e.g. `withdrawal`. | | `id` | `string` | Id of the causing resource. Matches that resource’s `id`. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Markets {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Markets Supported markets, and indicative pricing. `GET /v2/markets/{market}/price` is a NON-BINDING price: nothing is reserved, it has no expiry and it carries no order id, so it cannot be executed. It is the read-only counterpart to the Orders tag’s `POST /v2/quotes`. ## `GET /v2/markets` List supported markets Reference data, identical for every key. A market id is `BASE-QUOTE` and is what `POST /v2/quotes` and `GET /v2/markets/{market}/price` take. Being listed here means the pair is tradable, not that it is currently quotable — a quote can still be refused for an amount outside the market's limits. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`MarketResource`](#marketresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/markets/{market}` Get a supported market by id The id is the market code returned by `GET /v2/markets` (e.g. `BTC-CLP`). For a tokenised equity the rebased spelling also resolves — `NVDAX-CLP` returns `NVDASPV-CLP`. Reference data is identical for every key, so this response does not depend on who asks. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `market` | path | `string` | yes | | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`MarketResource`](#marketresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/markets/{market}/price` Get an indicative price for a market A NON-BINDING price. Nothing is reserved and there is no expiry — the response carries no order id, so it cannot be executed. To obtain a confirmable quote use `POST /v2/quotes` (scope `trading:write`), then execute it with a signed `POST /v2/orders`. Skipo prices a dealer spread, so the rate depends on size: pass `amount` + `amountAsset` to price a specific ticket, or omit both to price at the market minimum. `amount` is capped by that asset’s `maxIndicativeQuoteAmount` (see `GET /v2/assets`); above the cap the request is rejected rather than silently repriced at a smaller size. Either spelling of a tokenised equity works, in `market` and in `amountAsset` alike — `NVDAX-CLP` with `amountAsset=NVDAX` prices the same ticket as `NVDASPV-CLP` with `amountAsset=NVDASPV`. The representation of `amount` is set by `assetFormat`, never by which spelling you used. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `market` | path | `string` | yes | | | `side` | query | `BUY` \| `SELL` | yes | Which direction to price. Required — buying and selling the same market are different prices, so there is no sensible default. | | `amount` | query | `string` | no | Amount to price, denominated in `amountAsset`. A decimal string. Requires `amountAsset`. Omit both to price at the market minimum. Capped by the `maxIndicativeQuoteAmount` of `amountAsset` from `GET /v2/assets` — an amount above the cap is rejected rather than silently reduced, because a price for an amount you did not ask for is worse than an error. | | `amountAsset` | query | `string` | no | Which leg `amount` is denominated in — must be one of the market's two assets. Requires `amount`. | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts — BOTH the `amount` you send and the amounts and rate you get back. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens. No effect on other assets. `market` is unaffected either way: a market id names an instrument pair, not a quantity, and is spelled the same in both. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`PriceResource`](#priceresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### MarketResource | Field | Type | Description | |---|---|---| | `id` | `string` | e.g. `BTC-CLP`. | | `baseAsset` | `string` | Base asset code. Matches a `GET /v2/assets` assetSymbol. e.g. `BTC`. | | `rebasedBaseAsset` | `string` | The REBASED ticker for the base leg — the name a holder recognises. `NVDAX` where `baseAsset` is `NVDASPV`, and the same value as `baseAsset` for everything else. ⚠️ Display only: `baseAsset` is the code `GET /v2/assets` is keyed by, and `id` keeps the settled spelling because a market id never rebases. e.g. `BTC`. | | `quoteAsset` | `string` | Quote asset code. Matches a `GET /v2/assets` assetSymbol. e.g. `CLP`. | | `type` | `CRYPTO-CRYPTO` \| `CRYPTO-FIAT` \| `FIAT-FIAT` | The asset classes this market pairs. It does NOT identify a tokenised-equity market — those are typed `CRYPTO-FIAT` too. Read `assetClass` on the asset for that. e.g. `CRYPTO-FIAT`. | | `minBaseAmount` | `string` | Smallest amount you may request when you denominate the trade in the BASE asset. Identical for BUY and SELL. e.g. `0.0001`. | | `minQuoteAmount` | `string` | Smallest amount you may request when you denominate the trade in the QUOTE asset. Identical for BUY and SELL. e.g. `1000`. | | `baseIncrement` | `string` | Amount step on the BASE leg. A finer amount is truncated, not rejected. Equivalent to Coinbase's `base_increment` and Binance's `stepSize`. e.g. `0.00000001`. | | `quoteIncrement` | `string` | Amount step on the QUOTE leg — e.g. `"1"` on a CLP market, which settles in whole pesos. Equivalent to Coinbase's `quote_increment`. e.g. `1`. | | `tradingDays` | `number[]` \| `null` | Days of the week this market trades, as ISO weekday numbers (1 = Monday … 7 = Sunday), evaluated in UTC. `null` means it trades every day — which is the case for every crypto-only market. Tokenised-equity markets trade Monday to Friday. e.g. `1,2,3,4,5`. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### PriceBaseRepresentation | Field | Type | Description | |---|---|---| | `rate` | `string` | Rate in settled units — quote asset per SETTLED base unit. Moves INVERSELY to the base amount under a rebase: the base leg divides going back to settled, so the rate multiplies. e.g. `59700000`. | | `baseAmount` | `string` | Base-asset amount in settled units. Subject to the last-place caveat above — converts rounds to 8dp upstream, so treat this as indicative, like the price itself. e.g. `0.2`. | | `quoteAmount` | `string` | Quote-asset amount. Never rebases — an xStock market quotes in fiat or a stablecoin — so this always equals the top-level `quoteAmount`. e.g. `11940000`. | ### PriceResource | Field | Type | Description | |---|---|---| | `market` | `string` | The market this price is for, echoed from the request path. e.g. `BTC-CLP`. | | `side` | `BUY` \| `SELL` | The side priced. Buying and selling the same market are different prices — the spread is real — so a price is only meaningful together with its side. e.g. `BUY`. | | `rate` | `string` | Quote asset per one unit of base asset. A decimal string. This is the rate for `baseAmount` specifically: Skipo prices a dealer spread, so the rate is a function of size and does NOT scale linearly to a larger amount. e.g. `59700000`. | | `baseAmount` | `string` | Base-asset amount this price was calculated for. When the request omitted `amount`, this is the market's minimum — not a limit-free rate. e.g. `0.2`. | | `quoteAmount` | `string` | Quote-asset amount corresponding to `baseAmount` at `rate`. e.g. `11940000`. | | `indicative` | `boolean` | Always `true`. Present so a client never has to infer non-bindingness from missing fields. This price is not reserved, not held, and cannot be executed — obtaining a confirmable quote is `POST /v2/quotes`, which requires the `trading:write` scope. e.g. `true`. | | `pricedAt` | `string` | When Skipo calculated this price, ISO-8601. There is no expiry because there is nothing to expire; treat the price as a point-in-time observation and re-read it when it matters. e.g. `2026-07-30T04:36:02.451Z`. | | `assetFormat` | `any` | Which representation `baseAmount` and `rate` above are in — the value you asked for, or the default. Always present, so a client never has to infer the units it was given. `quoteAmount` is unaffected: an xStock market quotes in fiat or a stablecoin, which does not rebase. | | `multiplier` | `string` | This market's base-leg rebase factor as it stands NOW, published whether or not it was applied. `"1"` when the base asset does not rebase. It is NOT stamped on anything — a price is a calculation, not a record — so an order placed later carries the factor in force at ITS creation, which may differ. e.g. `1`. | | `base` | `any` | The same price in SETTLED units. Present in BOTH representations, so a client comparing an indicative price against a ledger entry or an existing position never has to re-request it. On a `base` read it repeats the figures above rather than disappearing. Indicative, like everything else on this resource — see the schema notes for the last-place rounding caveat that applies here and not to `POST /v2/quotes`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Orders {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Orders Quotes, orders and fills. `POST /v2/quotes` mints a short-lived, single-use CONFIRMABLE quote (scope `trading:write`) which `POST /v2/orders` then executes as a Tier-2 signed request. An order is filled by one or more fills. For a non-binding price with no confirmation step, use `GET /v2/markets/{market}/price` instead. ## `GET /v2/fills` List fills Every fill on the account, newest first, across all orders. Filter by `orderId`, `market`, asset (either leg), side or date range. Cursor-paginated: follow `pagination.nextCursor` until it is null, and do not infer the end from a short page. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | Default: `25`. | | `cursor` | query | `string` | no | Opaque cursor from the previous page's `pagination.nextCursor`. | | `orderId` | query | `string` | no | Restrict to the fills of one order (its public id). | | `market` | query | `string` | no | Filter to one market, by the `id` published by `GET /v2/markets` — the same value this endpoint returns as `market`. | | `assetSymbol` | query | `string` | no | Matches either leg — fills that touched this asset. | | `side` | query | `BUY` \| `SELL` | no | | | `startDate` | query | `string` | no | ISO-8601 start date (inclusive). | | `endDate` | query | `string` | no | ISO-8601 end date (exclusive). | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moved on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`FillResource`](#fillresource)[] · `pagination`: [`CursorMeta`](#cursormeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/orders` List orders One order per confirmed quote. An order is completed by one or more fills, so `filledBaseAmount`/`filledQuoteAmount` are the running totals and only equal the requested amounts once `status` is `FILLED`. An on-credit order (`onCredit: true`) fills over time as the debt is paid down. For a tokenised equity only the BASE leg rebases — the quote leg is unaffected and `rate` moves inversely — so the field-by-field relation that holds on a deposit does NOT hold here. Compare against the `base` block rather than deriving it. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `status` | query | `NEW` \| `PARTIALLY_FILLED` \| `FILLED` \| `FAILED` | no | | | `baseAsset` | query | `string` | no | Filter by base asset code. | | `quoteAsset` | query | `string` | no | Filter by quote asset code. | | `market` | query | `string` | no | Filter to one market, by the `id` published by `GET /v2/markets` — the same value this endpoint returns as `market`. | | `side` | query | `BUY` \| `SELL` | no | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity; `base` = settled SPV tokens. No effect on other assets. | | `startDate` | query | `string` | no | ISO-8601 start date. | | `endDate` | query | `string` | no | ISO-8601 end date. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`OrderResource`](#orderresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `POST /v2/orders` Place an order (execute a quote) Money movement — requires a Tier-2 signed request (X-API-Key header + signed Authorization JWT), not a bare bearer key. Executes the quote identified by `orderId`. Returns 201 Created with `status: "FILLED"` when the balance move is booked, or 202 Accepted with `status: "PROCESSING"` when the trade executed but the balance credit/debit is still being reconciled internally. On 202 the order is accepted — poll GET /v2/orders/{id} for the final state and do NOT retry (a retry places a new order and double-executes). **Authentication:** **Signed request (Tier-2)** **Request body** — [`PlaceOrderDto`](#placeorderdto) **Responses** | Code | Description | Body | |---|---|---| | `201` | Order placed and filled — the balance move is booked (`status: "filled"`). | [`PlacedOrderResource`](#placedorderresource) | | `202` | Order accepted; settlement is pending (`status: "PROCESSING"`). The trade executed but the balance move is still reconciling internally — poll GET /v2/orders/{id}; do NOT retry. | [`PlacedOrderResource`](#placedorderresource) | | `400` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`validation_error`](https://docs.skipo.com/errors/validation_error) (not retryable). | — | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable), [`invalid_signature`](https://docs.skipo.com/errors/invalid_signature) (not retryable), [`clock_skew`](https://docs.skipo.com/errors/clock_skew) (retryable), [`nonce_reused`](https://docs.skipo.com/errors/nonce_reused) (retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable), [`two_factor_required`](https://docs.skipo.com/errors/two_factor_required) (not retryable). | — | | `422` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unprocessable`](https://docs.skipo.com/errors/unprocessable) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/orders/{id}` Get an order by id Takes the id returned by `POST /v2/orders` (the client order id). Use `GET /v2/orders/{id}/fills` for the individual executions behind the running totals here. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moved on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`OrderResource`](#orderresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/orders/{id}/fills` List the fills of an order The fills of one order, newest first. Cursor-paginated: follow `pagination.nextCursor` until it is null. A normal convert has exactly one fill; an on-credit (capacity) order is filled by several as the debt is paid down. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | | | `limit` | query | `integer` | no | Default: `25`. | | `cursor` | query | `string` | no | Opaque cursor from the previous page's `pagination.nextCursor`. | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moved on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`FillResource`](#fillresource)[] · `pagination`: [`CursorMeta`](#cursormeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `POST /v2/quotes` Create a conversion quote Creates a short-lived, single-use CONFIRMABLE quote; execute it via POST /v2/orders (a signed request). Requires the write scope because the call mints server-side state that an order later consumes, and is balance- and capacity-gated — not because funds move here. It is bearer-authenticated rather than signed precisely because the quote itself moves no money. For a NON-BINDING price with no confirmation step and no write scope, use GET /v2/markets/{market}/price. **Request body** — [`CreateQuoteDto`](#createquotedto) **Responses** | Code | Description | Body | |---|---|---| | `201` | Created. | [`QuoteResource`](#quoteresource) | | `400` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`validation_error`](https://docs.skipo.com/errors/validation_error) (not retryable). | — | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `422` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unprocessable`](https://docs.skipo.com/errors/unprocessable) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### CreateQuoteDto | Field | Type | Description | |---|---|---| | `baseAsset` | `string` | Base asset code of the market. Together with `quoteAsset` it must name a market `GET /v2/markets` lists — the pairing is its `id`, and it is DIRECTIONAL, so the legs cannot be swapped. An unknown pairing is rejected before anything is priced. For a tokenised equity either spelling works: `NVDAX` names the same market as `NVDASPV`. e.g. `BTC`. | | `quoteAsset` | `string` | Quote asset code of the market. e.g. `CLP`. | | `amountAsset` | `string` | Which leg `amount` is denominated in — it must be `baseAsset` or `quoteAsset`, and anything else is rejected. It selects which minimum applies: `minBaseAmount` or `minQuoteAmount` on `GET /v2/markets`. Either spelling of a tokenised equity is accepted, and it names the leg ONLY — see `assetFormat` for what sets the units. e.g. `BTC`. | | `side` | `BUY` \| `SELL` | | | `amount` | `string` | Amount to convert, denominated in `amountAsset`. A decimal string. e.g. `0.5`. | | `assetFormat` *(optional)* | `any` | How to express tokenised-equity (xStock) amounts — BOTH the `amount` above and the amounts and rate in the response. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moves on chain and in the ledger. No effect on other assets. `market` is unaffected either way: a market id names an instrument pair, not a quantity, and is spelled the same in both. ⚠️ This field is the ONLY thing that decides units. Either spelling of a tokenised equity is accepted in `baseAsset`, `quoteAsset` and `amountAsset` — `NVDAX` and `NVDASPV` name the same asset — and which one you use does NOT change how `amount` is read. Sending `amountAsset: "NFLXX"` with `assetFormat: "base"` prices settled tokens, because you asked for `base`. A symbol is a name; the representation is this field. | ### CursorMeta | Field | Type | Description | |---|---|---| | `count` | `number` | Number of items in THIS page. e.g. `25`. | | `nextCursor` | `string` \| `null` | Opaque token for the next page, or null on the last page. Pass it back as `cursor`. Treat it as opaque — its encoding is not part of the contract and may change. e.g. `MjAyNi0wNy0yNiAxNzo0MjowMS4wMDMzMDl8OWY4Zi00YQ`. | ### FillBaseOrderState | Field | Type | Description | |---|---|---| | `filledBaseAmount` | `string` | Cumulative filled base amount on the order, in settled units. | | `filledQuoteAmount` | `string` | Cumulative filled quote amount. Unscaled, so identical to the top level. | ### FillBaseRepresentation | Field | Type | Description | |---|---|---| | `baseAsset` | `string` | Settled base asset — the SPV code for a tokenised equity, the ordinary code otherwise. e.g. `BTC`. | | `quoteAsset` | `string` | Quote asset. Never rebases, so it always equals the top-level `quoteAsset`. e.g. `CLP`. | | `baseAmount` | `string` | Base amount filled by this fill, in settled units — what actually moved on chain and in the ledger. | | `quoteAmount` | `string` | Quote amount. Unscaled, so identical to the top level. | | `rate` | `string` | Rate in settled units — quote per SETTLED base unit. | | `order` | [`FillBaseOrderState`](#fillbaseorderstate) | | ### FillOrderState | Field | Type | Description | |---|---|---| | `status` | `NEW` \| `PARTIALLY_FILLED` \| `FILLED` \| `FAILED` | | | `filledBaseAmount` | `string` | Cumulative filled base amount on the order. Positive magnitude. | | `filledQuoteAmount` | `string` | Cumulative filled quote amount on the order. Positive magnitude. | ### FillResource | Field | Type | Description | |---|---|---| | `id` | `string` | Public fill id. This is the same value the ledger entry carries as `source.id`, so a fill and its ledger movements join with no extra lookup. | | `orderId` | `string` | Id of the order this fill belongs to. | | `sequence` | `number` | 1-based position of this fill within its order. A normal convert has exactly one fill; an on-credit (capacity) order has several. e.g. `1`. | | `side` | `string` | e.g. `BUY`. | | `market` | `string` | e.g. `BTC-CLP`. | | `baseAsset` | `string` | | | `quoteAsset` | `string` | | | `baseAmount` | `string` | Base amount filled by this fill. Positive magnitude. | | `quoteAmount` | `string` | Quote amount filled by this fill. Positive magnitude. | | `rate` | `string` | | | `multiplier` | `string` | Rebase factor applied to `baseAmount` and `rate`, as it stood AT THIS FILL — not today. "1" when no scaling applies. Divide `baseAmount` by it to recover the settled (SPV) figure, which is what moved on chain and in the ledger. e.g. `1`. | | `assetFormat` | `any` | Which representation the amounts and `baseAsset` above are in — the value you asked for, or the default. Always present, so a client never has to infer the units it was given. | | `onCredit` | `boolean` | Whether the owning order was funded on credit (capacity). | | `executedAt` | `string` | | | `order` | [`FillOrderState`](#fillorderstate) | | | `base` | `any` | The settled (SPV) figures for this fill. Present on every read, in either `assetFormat`. These are the amounts the ledger entries carrying this fill id will show, so a fill and its ledger movements reconcile without converting anything. | ### OrderBaseRepresentation | Field | Type | Description | |---|---|---| | `baseAsset` | `string` | Settled base asset — the SPV code for a tokenised equity, the ordinary code otherwise. e.g. `BTC`. | | `quoteAsset` | `string` | Quote asset. Never rebases, so it always equals the top-level `quoteAsset`. e.g. `CLP`. | | `baseAmount` | `string` | Ordered base amount in settled units. e.g. `0.2`. | | `filledBaseAmount` | `string` | Cumulative filled base amount in settled units. e.g. `0.2`. | | `quoteAmount` | `string` | Ordered quote amount. Unscaled, so identical to the top level. e.g. `11940000`. | | `filledQuoteAmount` | `string` | Cumulative filled quote amount. Unscaled, so identical to the top level. e.g. `11940000`. | | `rate` | `string` | Rate in settled units — quote per SETTLED base unit. e.g. `59700000`. | ### OrderResource | Field | Type | Description | |---|---|---| | `id` | `string` | Public order id (the client order id). e.g. `clord_01HZY3K8QWERTY`. | | `status` | `NEW` \| `PARTIALLY_FILLED` \| `FILLED` \| `FAILED` | | | `side` | `string` | e.g. `BUY`. | | `market` | `string` | e.g. `BTC-CLP`. | | `baseAsset` | `string` | | | `quoteAsset` | `string` | | | `baseAmount` | `string` | Ordered base amount. Positive magnitude. | | `filledBaseAmount` | `string` | Cumulative filled base amount. Positive magnitude. | | `quoteAmount` | `string` | Ordered quote amount. Positive magnitude. | | `filledQuoteAmount` | `string` | Cumulative filled quote amount. Positive magnitude. | | `rate` | `string` | | | `multiplier` | `string` | Rebase factor applied to the base amounts and rate, as it stood when the order was placed. "1" when no scaling applies. Divide a base amount by it to recover the settled (SPV) figure. e.g. `1`. | | `assetFormat` | `any` | Which representation the amounts and `baseAsset` above are in — the value you asked for, or the default. Always present, so a client never has to infer the units it was given. | | `onCredit` | `boolean` | Whether the order was funded on credit (capacity). | | `createdAt` | `string` | | | `base` | `any` | The settled (SPV) figures for this order. Present on every read, in either `assetFormat`, so reconciling an order against the ledger or against its webhook never needs a second call with different parameters. On a `base` read it repeats the figures above. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### PlaceOrderDto | Field | Type | Description | |---|---|---| | `orderId` | `string` | The orderId of the quote to execute (returned by POST /v2/quotes). e.g. `ord_abc123`. | ### PlacedOrderResource | Field | Type | Description | |---|---|---| | `id` | `string` | Public order id (the client order id). | | `status` | `FILLED` \| `PROCESSING` | Placement outcome. `FILLED` — the fill executed and its balance move is booked. `PROCESSING` — the fill executed but the balance credit/debit is still being reconciled internally; the order is accepted, not yet booked. Poll GET /v2/orders/{id} for the final state, and do NOT retry a `PROCESSING` order (a retry places a new order and double-executes). | | `transactionId` *(optional)* | `string` | Id of the first fill, when the order fills at placement (balance orders). | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | ### QuoteBaseRepresentation | Field | Type | Description | |---|---|---| | `rate` | `string` | Rate in settled units — quote asset per SETTLED base unit. Note the rate moves INVERSELY to the base amount under a rebase: the base leg divides going back to settled, so the rate multiplies. e.g. `59700000`. | | `baseAmount` | `string` | Quoted base amount in settled units. e.g. `0.2`. | | `quoteAmount` | `string` | Quoted quote amount. Never rebases — an xStock market quotes in fiat or a stablecoin — so this always equals the top-level `quoteAmount`. Repeated rather than omitted, which is what keeps the block one shape. e.g. `11940000`. | ### QuoteResource | Field | Type | Description | |---|---|---| | `clientOrderId` | `string` | DEPRECATED alias of `orderId`, carrying the same value. Use `orderId`. Kept for one release so a client reading this field does not break. | | `orderId` | `string` | The id of this quote — pass it to POST /v2/orders to execute. It is also the `id` the resulting order will carry, and the value `GET /v2/orders`, `/v2/orders/{id}/fills`, `GET /v2/fills` and `GET /v2/ledger?orderId=` all key on. One id, learned once, used through the whole flow. | | `market` | `string` | The market this quote priced, spelled exactly as `GET /v2/markets` publishes it — so it can be passed straight back to `GET /v2/markets/{market}`. Without it a stored quote is not self-describing: the numbers below mean nothing without knowing what was priced. This is the market Skipo RESOLVED, which is authoritative over the `baseAsset`/`quoteAsset` you sent — those are matched case-insensitively, so the casing here may differ from your request. e.g. `BTC-CLP`. | | `rate` | `string` | Quoted exchange rate. e.g. `59700000`. | | `baseAmount` | `string` | Quoted base amount. Positive magnitude. e.g. `0.2`. | | `quoteAmount` | `string` | Quoted quote amount. Positive magnitude. e.g. `11940000`. | | `quotedAt` | `string` \| `null` | When the quote was produced, ISO-8601. `null` if the upstream time was unusable. e.g. `2026-07-28T12:34:56.789Z`. | | `expiresAt` | `string` \| `null` | When this quote stops being confirmable, ISO-8601 — about five seconds after `quotedAt`. Execute it with POST /v2/orders before this instant; afterwards expect `quotation_expired` and request a fresh quote. `null` means the expiry could not be determined — treat that as unknown, never as already expired. e.g. `2026-07-28T12:35:01.789Z`. | | `multiplier` | `string` | This asset's rebase factor as it stands NOW, published whether or not it was applied — so a client can convert between the two representations without a second read. `"1"` when the asset does not rebase. It is also the factor the resulting order and its fills will carry, because it is stamped when the order is created from this quote. e.g. `1`. | | `assetFormat` | `any` | Which representation `rate` and `baseAmount` above are in — the value you asked for, or the default. Always present, so a client never has to infer the units it was given. `quoteAmount` is unaffected: an xStock market quotes in fiat or a stablecoin, which does not rebase. | | `base` | `any` | The same quote in SETTLED units — what the resulting order and its fills will move on chain and in the ledger. Present in BOTH representations, so a client reconciling a quote against the order it becomes never has to re-request it with a different `assetFormat`. On a `base` read it repeats the figures above rather than disappearing, which is what keeps the shape stable. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## System {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # System Health, server time, and the OpenAPI spec ## `GET /v2/health` Service health Always answers `200`, even during maintenance — the body carries the state. A `MAINTENANCE` status means every other route is returning `503`, so poll this to know when to resume instead of probing a real endpoint. **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`HealthResponse`](#healthresponse) | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | --- ## `GET /v2/time` Server time for request signing and clock synchronization Unauthenticated. Use it to measure your clock offset against Skipo before signing a Tier-2 request: a signed JWT carries `iat` and an `exp` at most 60 s later, so a client whose clock drifts past that window has every money-movement call rejected as expired — which looks like an auth failure rather than a clock problem. Poll it at start-up, not per request. **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`TimeResponse`](#timeresponse) | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | --- ## Schemas ### HealthResponse | Field | Type | Description | |---|---|---| | `status` | `UP` \| `MAINTENANCE` | `UP` when serving normally. `MAINTENANCE` when the API is in maintenance — every other route is returning `503` with the `maintenance` problem type, so retry later rather than treating it as an outage. e.g. `UP`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | ### TimeResponse | Field | Type | Description | |---|---|---| | `serverTime` | `number` | Server time, milliseconds since Unix epoch. e.g. `1783003522123`. | | `iso` | `string` | The same instant as ISO-8601 UTC. e.g. `2026-07-28T12:34:56.789Z`. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Webhooks {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Webhooks Signed HTTP callbacks Skipo sends when your transactions change, so you do not have to poll. Endpoints are registered from the Skipo dashboard, not through this API — the one operation in this section is the public JWKS you verify deliveries against. ## The envelope Every delivery has the same outer shape; the resource snapshot is in `data`. | Field | Type | What it is | |---|---|---| | `id` | string | Logical event id. **Your idempotency key** — stable across retries and resends. | | `webhookId` | string | The endpoint that received the delivery. | | `eventType` | string | One of the seven below. | | `resourceId` | string \| null | Id of the affected resource. | | `createdAt` | **number** | Event time, **epoch milliseconds** — not ISO-8601. Timestamps *inside* `data` are ISO-8601. | | `data` | object | Snapshot of the affected resource. | Each delivery also carries `skipo-webhook-delivery-id`, `skipo-webhook-event` and `skipo-webhook-signature` headers. ## The seven events | Event | When | `data` shape | |---|---|---| | `withdrawal.created` | A withdrawal is created and the balance frozen. | Movement | | `withdrawal.status.updated` | The withdrawal changes status, terminal included. | Movement | | `deposit.created` | A deposit is detected and booked. | Movement | | `deposit.status.updated` | The deposit changes status, terminal included. | Movement | | `order.created` | A convert order is placed at confirm, with `status: "NEW"`. | Order | | `order.status.updated` | The order changes status. | Order | | `fill.created` | A fill executes against an order. | Fill | Subscribe exactly (`withdrawal.created`), by category (`withdrawal.*`) or globally (`*`). The subscribable categories are `withdrawal.*`, `deposit.*`, `order.*` and `fill.*`. **Ignore event types you do not recognise** — new ones can appear at any time, and new fields can appear inside `data`. ## The three `data` shapes - **Movement** (`withdrawal.*`, `deposit.*`) — mirrors the **WithdrawalResource** / **DepositResource** schemas in this reference, including the top-level `transactionHash` and `bankReference`. Status values: `PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED`. - **Order** (`order.*`) — mirrors **OrderResource**: `baseAmount`/`quoteAmount` requested, `filledBaseAmount`/`filledQuoteAmount` executed so far, plus `onCredit`. Status values: `NEW`, `PARTIALLY_FILLED`, `FILLED`, `FAILED`. - **Fill** (`fill.created`) — mirrors **FillResource**, with `orderId` pointing at the parent order. A fill has no status: if it reached you, it executed. All three always carry `assetFormat`, `multiplier` and a `base` block, so one amount reader works for all of them. **Amounts at the top level are always `rebased`** — a webhook carries no query string, so it cannot honour `assetFormat`. Comparing them against a REST read taken with `assetFormat=base` will not reconcile: the two are in different units and neither is wrong. ## Two things that break integrations - **A fill is not ordered against its order.** Deliveries are causally ordered per `(endpoint, resourceId)`, and an order and its fills have different `resourceId`s — so a `fill.created` can arrive **before** its own `order.created`. Store it and reconcile; do not treat an orphan fill as an error. - **The status channel carries transitions, not a log.** You are not guaranteed one event per internal transition, and a resource can be born terminal (`.created` and `.status.updated` both `COMPLETED`, sometimes in the same millisecond). Treat each payload as current state. Full detail, worked payloads and verification code: https://docs.skipo.com/concepts/webhooks ## `GET /v2/.well-known/webhook-jwks.json` Webhook signing JWKS Public JSON Web Key Set for verifying the EdDSA signature on webhook deliveries. Select the key by the `kid` in the delivery signature header. **Responses** | Code | Description | Body | |---|---|---| | `200` | JWKS document — `{ "keys": [...] }`. | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | --- ## Schemas ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Withdrawals {/* AUTO-GENERATED by scripts/gen-api-reference.mjs — do not edit; run `yarn gen:api`. */} # Withdrawals Withdrawal history ## `GET /v2/withdrawals` List withdrawals Every debit from the account, newest first. One withdrawal is one row, fee included: the fee is reported as `fee` ON the withdrawal it belongs to, never as a separate row, so summing `total` over this list is the account’s outflow with no double-counting and no filtering needed. The `NETWORK_FEE` and `TRANSFER_FEE` sub-types exist in the enum but are Skipo’s own accounting for the gas it pays, booked against a Skipo-internal account — they never appear on a customer’s list. Amounts default to `rebased`; the always-present `base` block carries the settled figures. `fee` is the flat fee Skipo charges, NOT the blockchain network fee, which Skipo absorbs. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `page` | query | `integer` | no | Default: `1`. | | `limit` | query | `integer` | no | Default: `25`. | | `assetSymbol` | query | `string` | no | | | `status` | query | `string` | no | | | `subType` | query | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `EXTERNAL_FIAT_BANK` \| `EXTERNAL_FIAT_REDPAY` \| `NETWORK_FEE` \| `TRANSFER_FEE` | no | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moved on chain and in the ledger. No effect on other assets. | | `startDate` | query | `string` | no | ISO-8601 start date. | | `endDate` | query | `string` | no | ISO-8601 end date. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | `data`: [`WithdrawalResource`](#withdrawalresource)[] · `pagination`: [`PaginationMeta`](#paginationmeta) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `POST /v2/withdrawals` Create a withdrawal Money movement — requires a Tier-2 signed request (X-API-Key header + signed Authorization JWT), not a bare bearer key. **Fees.** By default `amount` is the total DEBITED and the destination receives `amount` − `fee`, as on Binance, Kraken, Coinbase and OKX — so sweeping a balance is one call: send the balance. Set `feeMode: "add"` to make `amount` the figure that ARRIVES, debiting `amount` + `fee`. The response reports `amount` (sent), `fee` and `total` (debited) either way. **The fee is per chain, and this body does not name one.** The chain is whatever the destination contact is registered on. Read the fee from `GET /v2/assets` → `networks[].withdrawalFee` matched on the contact’s `crypto.networkId` — the asset-level `withdrawalFee` reports the cheapest chain and will understate any other. Rejections move nothing: the balance is checked before any funds are held. If a withdrawal fails after it is accepted, the full `total` — amount and fee — returns to the balance. **Authentication:** **Signed request (Tier-2)** **Request body** — [`CreateWithdrawalDto`](#createwithdrawaldto) **Responses** | Code | Description | Body | |---|---|---| | `201` | Created. | [`WithdrawalResource`](#withdrawalresource) | | `400` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`validation_error`](https://docs.skipo.com/errors/validation_error) (not retryable). | — | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable), [`invalid_signature`](https://docs.skipo.com/errors/invalid_signature) (not retryable), [`clock_skew`](https://docs.skipo.com/errors/clock_skew) (retryable), [`nonce_reused`](https://docs.skipo.com/errors/nonce_reused) (retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable), [`two_factor_required`](https://docs.skipo.com/errors/two_factor_required) (not retryable). | — | | `422` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unprocessable`](https://docs.skipo.com/errors/unprocessable) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## `GET /v2/withdrawals/{id}` Get a withdrawal by id Takes the same `assetFormat` as the list. A `PENDING` withdrawal has no `transactionHash` YET — it appears on a later read once the transaction is broadcast, so poll this or subscribe to `withdrawal.status.updated` rather than treating the initial `null` as final. **Parameters** | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | | | `assetFormat` | query | [`AssetFormat`](#assetformat) | no | How to express tokenised-equity (xStock) amounts. `rebased` (default) = underlying equity (real shares); `base` = settled SPV tokens, i.e. what moved on chain and in the ledger. No effect on other assets. | **Responses** | Code | Description | Body | |---|---|---| | `200` | Success. | [`WithdrawalResource`](#withdrawalresource) | | `401` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`unauthorized`](https://docs.skipo.com/errors/unauthorized) (not retryable), [`key_expired`](https://docs.skipo.com/errors/key_expired) (not retryable), [`key_revoked`](https://docs.skipo.com/errors/key_revoked) (not retryable). | — | | `403` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`insufficient_scope`](https://docs.skipo.com/errors/insufficient_scope) (not retryable), [`ip_not_allowed`](https://docs.skipo.com/errors/ip_not_allowed) (not retryable), [`api_access_disabled`](https://docs.skipo.com/errors/api_access_disabled) (not retryable). | — | | `404` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`not_found`](https://docs.skipo.com/errors/not_found) (not retryable). | — | | `429` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`rate_limited`](https://docs.skipo.com/errors/rate_limited) (retryable). | — | | `500` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`internal_error`](https://docs.skipo.com/errors/internal_error) (retryable). | — | | `502` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`downstream_unavailable`](https://docs.skipo.com/errors/downstream_unavailable) (retryable). | — | | `503` | `application/problem+json` (RFC 9457) — branch on `code`, not on the status: [`maintenance`](https://docs.skipo.com/errors/maintenance) (retryable). | — | --- ## Schemas ### CreateWithdrawalDto | Field | Type | Description | |---|---|---| | `assetSymbol` | `string` | Asset to withdraw, as the SETTLED asset code. ⚠️ Unlike every read endpoint, this one does NOT accept the rebased ticker of a tokenised equity: `amount` here is in settled units and the body declares no `assetFormat`, so accepting `NVDAX` would move a different quantity than the one you signed. Sending it is rejected with the settled code and the conversion. e.g. `BTC`. | | `amount` | `string` | Amount to withdraw, as a positive decimal string. How it is read against the fee depends on `feeMode`, which defaults to `deduct`: `amount` is the total DEBITED from the balance and the destination receives `amount` − `fee`. To sweep a balance, send the balance. The fee is per CHAIN and the chain comes from the contact, not from this body — read it from `GET /v2/assets` → `networks[].withdrawalFee`, matched on the contact’s `crypto.networkId`. The asset-level `withdrawalFee` is the cheapest chain’s and will understate a withdrawal over any other one. e.g. `0.05`. | | `feeMode` *(optional)* | `any` | How `amount` relates to the withdrawal fee. `deduct` (default) — `amount` is the total debited; the destination receives `amount` − `fee`. This is what Binance, Kraken, Coinbase and OKX do, and it is what makes sweeping a balance a single call. `add` — `amount` is what the destination receives; the balance is debited `amount` + `fee`. Use it when the arriving figure must be exact — settling an invoice, a payroll line, topping an address to a specific number. ⚠️ `add` was the behaviour of this endpoint before `feeMode` existed. Either value is fully supported; the response is identical in shape and always reports all three of `amount` (sent), `fee` and `total` (debited), so neither mode requires the caller to re-derive anything. No effect on internal transfers or bank payouts, which carry no fee — set it unconditionally rather than branching on the destination type. | | `contactId` *(optional)* | `string` | Destination contact id (a whitelisted address/account). | | `contactReference` *(optional)* | `string` | Destination contact reference/alias. | ### PaginationMeta | Field | Type | Description | |---|---|---| | `page` | `number` | e.g. `1`. | | `limit` | `number` | e.g. `25`. | | `totalItems` | `number` | e.g. `100`. | | `totalPages` | `number` | e.g. `4`. | ### Problem | Field | Type | Description | |---|---|---| | `type` | `string` | Stable problem-type URI. Resolves to docs for this error. e.g. `https://docs.skipo.com/errors/rate_limited`. | | `title` | `string` | Short, human-readable summary (stable, English). e.g. `Rate limit exceeded`. | | `status` | `number` | HTTP status code. e.g. `429`. | | `code` | `string` | Stable machine-readable error code (equals the last path segment of `type`). e.g. `rate_limited`. | | `retryable` | `boolean` | Whether retrying the identical request may succeed. e.g. `true`. | | `detail` *(optional)* | `string` | Human-readable, possibly localized detail. | | `instance` *(optional)* | `string` | The request path that produced the error. | | `traceId` *(optional)* | `string` | Trace id — joins BigQuery api_request and Cloud Logging. | ### WithdrawalBaseRepresentation | Field | Type | Description | |---|---|---| | `asset` | `string` \| `null` | Asset code of the settled token. Populated on every read, including a rebased one — the rebased ticker maps back to the settled code through the maintainers snapshot. `null` only if that lookup misses, which means the asset is unknown to the snapshot rather than that the code was unobtainable. It is never the underlying ticker: a plausible-looking wrong code is worse than an obvious gap when reconciling against a webhook. e.g. `USDT`. | | `amount` | `string` | Amount withdrawn excluding the fee, in settled units. Positive magnitude. e.g. `1954.78138064`. | | `fee` | `string` | Skipo withdrawal fee, in settled units. Positive magnitude. e.g. `0.5`. | | `total` | `string` | Total debited (`amount` + `fee`), in settled units. Positive magnitude. e.g. `1955.28138064`. | ### WithdrawalResource | Field | Type | Description | |---|---|---| | `id` | `string` | Withdrawal id. Use it to re-fetch this withdrawal and to match its ledger entry (`source.id`). e.g. `9bba6d71-5966-4045-962f-77a737e14226`. | | `type` | `string` | e.g. `WITHDRAWAL`. | | `subType` | `INTERNAL` \| `EXTERNAL_CRYPTO` \| `EXTERNAL_FIAT_BANK` \| `EXTERNAL_FIAT_REDPAY` \| `NETWORK_FEE` \| `TRANSFER_FEE` | | | `assetSymbol` | `string` | Asset code of the withdrawal. e.g. `USDT`. | | `amount` | `string` | Amount withdrawn, excluding the fee. Positive magnitude, in `assetSymbol`. e.g. `1954.78138064`. | | `fee` | `string` | The withdrawal fee charged by Skipo, in `assetSymbol`. A flat fee — NOT the blockchain network fee, which Skipo absorbs. Positive magnitude. e.g. `0.5`. | | `total` | `string` | Total debited from the balance (`amount` + `fee`). Positive magnitude, in `assetSymbol`. e.g. `1955.28138064`. | | `status` | `string` | e.g. `COMPLETED`. | | `createdAt` | `string` | e.g. `2026-07-16T16:58:54.216Z`. | | `transactionHash` | `string` \| `null` | On-chain transaction hash, when this withdrawal had one. `null` otherwise — an off-chain withdrawal (bank payout, internal transfer) never has one, and an on-chain withdrawal that has not been broadcast yet does not have one YET, so a `PENDING` withdrawal reports `null` and the hash appears on a later read. Top-level so that reading it never requires branching on `subType`. It is the same value as `withdrawalData.transactionHash`, which remains for the sub-types that already published it; this field additionally covers on-chain sub-types that carry a hash but publish no `withdrawalData` block. NOT a bank reference: on a fiat payout this is always `null`, even though the underlying record may hold the bank's own transaction id. Do not assume a `0x` prefix — BTC, Solana and Tron hashes have none. e.g. `0x8b3275b467d90d99bd99bb7bfec9402e393ce870d741131a9d69a7d42640ac5b`. | | `bankReference` | `string` \| `null` | The bank's or payment provider's own reference for this payout, when one was recorded. `null` on every non-fiat withdrawal, and `null` on a fiat withdrawal that carries no reference (the large majority — 73 of 14,502 bank payouts on prod have one). It is recorded when the payout is confirmed, so a `PENDING` bank withdrawal reports `null`. The counterpart of `transactionHash`: a withdrawal settles either on a chain or through a bank, so at most one of the two fields is ever populated. Free-form and short (3-19 characters, not always numeric) — it is whatever the bank supplied, so treat it as an opaque string for reconciliation, never parse it. e.g. `null`. | | `assetFormat` | `any` | Which representation the top-level money fields are expressed in. Echoes what the request RESOLVED to, so a response that took the default is as self-describing as one that asked. `POST /v2/withdrawals` always answers `base`: a creation returns the figures as booked. e.g. `rebased`. | | `multiplier` | `string` | Rebase factor as it stood when the withdrawal was created — not today. `"1"` when no scaling applies. `base.amount x multiplier = amount` when `assetFormat` is `rebased`. `"1"` is NOT a claim that the asset does not rebase; read `GET /v2/assets` for its class. e.g. `1`. | | `base` | [`WithdrawalBaseRepresentation`](#withdrawalbaserepresentation) | | | `withdrawalData` | `object` | Sub-type-specific details. The keys depend on `transactionSubType`, so branch on that rather than probing for fields. For `EXTERNAL_CRYPTO` this carries the CANONICAL chain identity, the same five fields `GET /v2/assets[].networks` and `GET /v2/contacts` publish — `networkId` (`BSC`, `ETH`, `TRON`), `networkName`, `networkNativeAsset`, `evmChainId` and `assetType` (the token standard, `BEP20`/`ERC20`/`TRC20`). **Join on `networkId`**: it is the one field that means the same thing on every v2 resource, so a movement, an asset and a saved destination line up without parsing prose. ⚠️ `networkSymbol` has been REMOVED from this block. It was the raw stored value and it disagreed with itself across the estate — 164,192 movements say `BEP20` for the same chain 1,614 call `BSC`, and it also carried values that are not networks (`INTERNAL`, `FIAT_NETWORK`) and prose (`Dogecoin`, `XRP Ledger`). `networkId` replaces it and is a stable key. `networkName` above is the human-readable form. The canonical fields are always present on an `EXTERNAL_CRYPTO` movement and are `null` when the chain cannot be resolved, so the shape never changes underneath you. | --- Back to the [endpoint reference](/api). See also: [Pagination](/concepts/pagination) · [Errors](/concepts/errors) · [Ids and correlation](/concepts/ids-and-correlation). --- ## Authentication import Tabs from '@theme/Tabs' import TabItem from '@theme/TabItem' # Authentication The Skipo API uses a **two-tier** scheme. Which tier applies depends on the sensitivity of the operation: - **Tier 1 — _bearer_ key:** reads and non-sensitive writes. - **Tier 2 — per-request signed JWT:** moving money. ## The three pieces of a key A key puts **three** values in play, and they go in different places. Skipo issues only the first two; you generate the third, and its private half never leaves your side. | Value | Goes in | Secret? | Origin | |---|---|---|---| | **Key secret** | `Authorization: Bearer ` (Tier 1) | Yes — shown **once** | Issued by Skipo when you create the key | | **Prefix** | `X-API-Key` header and the JWT `sub` claim (Tier 2) | No, public | Issued by Skipo — and **derivable** from the secret | | **Signing key** | Signs the Tier-2 JWT | Yes — the private half is never sent | You generate it; you upload **only the public** half (SPKI) | :::info[The prefix is the first 21 characters of the secret] The secret is `skp_live_` + 32 random characters + a 6-character checksum. The prefix is `skp_live_` + the **first 12** of those 32 — that is, exactly the first 21 characters of the secret: ``` skp_live_aB3dE5gH7jK9mN2pQ4rS6tU8vW0xY1zA9bC3dE ← secret (Tier 1) skp_live_aB3dE5gH7jK9 ← prefix (Tier 2, public) ``` You do not need to store it separately: derive it from the secret when you need it. ::: ## Tier 1 — _Bearer_ key Most operations authenticate with a secret key in the `Authorization` header: ```bash curl https://api.skipo.com/v2/balances \ -H "Authorization: Bearer $SKIPO_BEARER_KEY" ``` ```js const res = await fetch(`${base}/v2/balances`, { headers: { Authorization: `Bearer ${process.env.SKIPO_BEARER_KEY}` }, }) console.log(await res.json()) ``` ```python import os, urllib.request, json req = urllib.request.Request( f"{base}/v2/balances", headers={"Authorization": f"Bearer {os.environ['SKIPO_BEARER_KEY']}"}, ) with urllib.request.urlopen(req) as resp: print(json.load(resp)) ``` About _bearer_ keys: - Format `skp_live_…` / `skp_test_…`: 32 random characters (`0-9A-Za-z`) generated with a CSPRNG, plus a 6-character _checksum_ that lets you discard a mistyped key without calling the API. - Shown **only once** when created; Skipo stores only their SHA-256 hash. - Each key has **scopes** that limit which operations it can access (see below). - Each key **optionally** takes an **IP allowlist**. If you set one, requests from any other source are rejected with [`ip_not_allowed`](/errors/ip_not_allowed), on both the _bearer_ and the signed path. If you don't set one, no IP restriction applies. ## Tier 2 — Per-request signed JWT **Only these two operations require a signature. Everything else is Tier 1:** | Operation | Endpoint | Scope | |---|---|---| | Execute a quote | `POST /v2/orders` | `trading:write` | | Create a withdrawal | `POST /v2/withdrawals` | `transfers:write` | :::warning["Write" does not imply signed] The tier is decided by **money movement**, not by the HTTP verb. For example, `PATCH /v2/contacts/{contactId}` only edits an alias: it is **Tier 1** and authenticates with the _bearer_ key. The same goes for `POST /v2/quotes`, which prices a trade but does not execute it. If you sign a Tier-1 endpoint, the API answers `401 unauthorized` with `reason: "signed_jwt_on_bearer_route"`. Your key is fine — the signature is what does not belong. Resend the request with `Authorization: Bearer ` and no `X-API-Key`. ::: These operations require, instead of the _bearer_ key, two headers: the **public prefix** of your key and a **signed JWT** that proves you generated _this_ specific request and that no one tampered with it: ``` X-API-Key: skp_live_aB3dE5gH7jK9 # the PREFIX (21 characters), not the secret Authorization: Bearer # the JWT, not the key secret ``` :::caution[Neither header carries the key secret] On Tier 2 the secret never travels: `X-API-Key` carries the **prefix** and `Authorization` carries the **JWT**. Put the full secret in `X-API-Key` and it resolves no key, returning `401` [`unauthorized`](/errors/unauthorized) with `detail: "Unknown API key."` — not `invalid_signature`. When a signature does fail, the response carries a `reason` field naming the first check that failed ([full table](/errors/invalid_signature)). ::: The JWT is signed with your **private key** (Ed25519 by default, `RS256` as an alternative) and includes these claims: | Claim | Value | |---|---| | `sub` | The key's **prefix** (identical to the `X-API-Key` value). | | `uri` | `"METHOD /path?query"` — method, a single space, and the path with its query **exactly** as sent (e.g. `"POST /v2/withdrawals"`). | | `nonce` | A per-request unique value (e.g. a UUID v4). Prevents _replay_. | | `iat` | Issued at (epoch, seconds). | | `exp` | Expires at. Must satisfy **`exp − iat ≤ 60`**. | | `bodyHash` | **SHA-256 in hex** of the **raw bytes** of the body. | :::warning[Raw body] The `bodyHash` is computed over the raw bytes of the body, exactly as they are transmitted. Serialize the body **only once**, compute the hash over those bytes, and send **those same bytes**. If you re-serialize the JSON after signing, the `bodyHash` no longer matches and the request is rejected with [`invalid_signature`](/errors/invalid_signature). ::: ### Example — signing a withdrawal ```js import { SignJWT, importPKCS8 } from 'jose' import { createHash, randomUUID } from 'node:crypto' const method = 'POST' const path = '/v2/withdrawals' // Serializa el cuerpo UNA vez; estos bytes se hashean y se envían. const body = JSON.stringify({ assetSymbol: 'BTC', amount: '0.05', contactId }) const bodyHash = createHash('sha256').update(Buffer.from(body, 'utf8')).digest('hex') const now = Math.floor(Date.now() / 1000) const key = await importPKCS8(process.env.SKIPO_PRIVATE_KEY_PEM, 'EdDSA') const jwt = await new SignJWT({ uri: `${method} ${path}`, nonce: randomUUID(), bodyHash }) .setProtectedHeader({ alg: 'EdDSA' }) .setSubject(keyPrefix) // === X-API-Key .setIssuedAt(now) .setExpirationTime(now + 55) // exp − iat ≤ 60 .sign(key) const res = await fetch(base + path, { method, headers: { 'X-API-Key': keyPrefix, Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json', }, body, // los MISMOS bytes cubiertos por bodyHash }) ``` ```python import hashlib, json, time, uuid, jwt from cryptography.hazmat.primitives.serialization import load_pem_private_key method, path = "POST", "/v2/withdrawals" # Serializa el cuerpo UNA vez; estos bytes se hashean y se envían. body = json.dumps({"assetSymbol": "BTC", "amount": "0.05", "contactId": contact_id}).encode() body_hash = hashlib.sha256(body).hexdigest() now = int(time.time()) private_key = load_pem_private_key(os.environ["SKIPO_PRIVATE_KEY_PEM"].encode(), password=None) token = jwt.encode( { "sub": key_prefix, # === X-API-Key "uri": f"{method} {path}", "nonce": str(uuid.uuid4()), "iat": now, "exp": now + 55, # exp − iat ≤ 60 "bodyHash": body_hash, }, private_key, algorithm="EdDSA", ) # Envía body como los MISMOS bytes cubiertos por bodyHash, con: # X-API-Key: key_prefix + Authorization: Bearer ``` :::note Complete, runnable versions of these examples are in the [`examples/`](https://gitlab.com/skipo-engine/apps/api-public-docs/-/tree/main/examples) directory. ::: ## Generating the key pair **You always generate the signing private key**: Skipo only ever receives the public half (SPKI) and never sees the private one. There are two ways to do it, and the difference matters. Either way, you can register the public key **when you create the key** — both are stored in the same operation — or add it later to a key that already exists. ### Option A — on your machine (recommended) The private key never passes through a browser. ```bash # Private key (Ed25519, PKCS#8) — keep it secret openssl genpkey -algorithm ed25519 -out skipo-signing-key.pem # Public key (SPKI) — this is the one you upload to the dashboard openssl pkey -in skipo-signing-key.pem -pubout -out skipo-signing-key.pub.pem ``` :::note[macOS] The system `openssl` on macOS is LibreSSL and does not support `-algorithm ed25519`. Install OpenSSL with Homebrew (`brew install openssl`) or generate the pair with your language's utility (`crypto.generateKeyPairSync('ed25519')` in Node, `cryptography` in Python). ::: ### Option B — in the browser, from the dashboard The dashboard can generate the pair for you using the browser's **Web Crypto API**. The public half is uploaded and the private half is shown to you **once** so you can store it; it is never transmitted to or stored by Skipo. The trade-off: the private key exists in the page's memory for the duration of the process, so it inherits the security of that browser and its extensions. It is the convenient route to get started, or for a `skp_test_` key; for `skp_live_` keys that move money, prefer Option A. :::note[Browser support] Ed25519 in Web Crypto is not available in every browser. The dashboard checks before offering this option; if it is unavailable, use Option A. ::: ## Clocks and `/v2/time` Signed requests are sensitive to clock skew: if your `iat` runs too far ahead of the server's time, the request is rejected with [`clock_skew`](/errors/clock_skew) (retryable). Check the server time with `GET /v2/time` and sync before signing if you suspect a skew. ## Key rotation The two tiers rotate differently. **_Bearer_ key — 7-day grace period.** When you rotate it, Skipo issues a new secret and the previous one **keeps working for 7 days**; after that it returns [`key_expired`](/errors/key_expired). Deploy the new secret within that window. **Signing key — no grace period.** A signing key goes from active to revoked immediately: the moment you revoke it, it stops verifying. You control the overlap yourself. The verifier tries **every** active signing key you have, so upload the new public key **alongside** the current one: both verify in parallel for as long as you leave both active. Migrate your signing to the new key, and only then revoke the old one. ## Authentication errors | Situation | `code` | |---|---| | Missing or unknown credential | [`unauthorized`](/errors/unauthorized) | | Invalid signature or claim | [`invalid_signature`](/errors/invalid_signature) | | Clock running ahead | [`clock_skew`](/errors/clock_skew) | | Reused `nonce` | [`nonce_reused`](/errors/nonce_reused) | | Missing scope | [`insufficient_scope`](/errors/insufficient_scope) | | IP not allowed | [`ip_not_allowed`](/errors/ip_not_allowed) | ## Scopes Scopes are set when the key is created and limit what it can do: | Scope | Allows | |---|---| | `accounts:read` | Read account, balances, and movements. | | `market_data:read` | Read reference data (currencies, markets). | | `transfers:read` | Read withdrawals. | | `transfers:write` | Create withdrawals (requires signing). | | `trading:read` | Read conversions and orders. | | `trading:write` | Request quotes and confirm conversions (confirming requires signing). | | `contacts:read` | Read contacts. | | `contacts:write` | Edit a contact's alias/reference. | | `webhooks:read` | **Reserved — not usable yet.** No endpoint requires it today: webhook management is dashboard-only. Ticking it on a key enables nothing. | --- ## Errors The API returns errors in **`application/problem+json`** format ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)). Every error shares a stable, predictable structure: ```json { "type": "https://docs.skipo.com/errors/unauthorized", "title": "Unauthorized", "status": 401, "detail": "This operation requires a signed request (X-API-Key header + signed JWT).", "code": "unauthorized", "retryable": false, "instance": "/v2/withdrawals" } ``` ## Fields | Field | Description | |---|---| | `type` | Stable URI documenting the error (points to `docs.skipo.com/errors/{code}`). | | `title` | Human-readable summary of the error type. | | `status` | HTTP status code. | | `detail` | Description specific to _this_ occurrence. | | `code` | **Stable, machine-readable code** — use it to branch your logic. | | `retryable` | `true` if retrying the same request may succeed. | | `instance` | Path of the request that failed. | :::tip Branch your error handling on `code`, not on `title` or `detail` (which may be reworded or translated). The `code` is a stable contract. ::: ## Common codes | `code` | HTTP | Retryable? | |---|---|---| | [`unauthorized`](/errors/unauthorized) | 401 | no | | [`invalid_signature`](/errors/invalid_signature) | 401 | no | | [`insufficient_scope`](/errors/insufficient_scope) | 403 | no | | [`not_found`](/errors/not_found) | 404 | no | | [`validation_error`](/errors/validation_error) | 400 | no | | [`unprocessable`](/errors/unprocessable) | 422 | no | | [`rate_limited`](/errors/rate_limited) | 429 | yes | | [`internal_error`](/errors/internal_error) | 500 | yes | The **[full error catalog](/errors)** documents all 22 codes, each with its own page at `docs.skipo.com/errors/{code}` — the same URI that appears in the response's `type` field. --- ## Funding your account There are two rails for getting money in, each with its own endpoint: | Rail | Endpoint | Returns | |---|---|---| | Crypto | `GET /v2/deposit-addresses` | one address per (asset, chain) pair | | Fiat (CLP) | `GET /v2/deposit-instructions` | Skipo's bank accounts | Both are read-only and take a Tier-1 key with the `transfers:read` scope. :::danger Not to be confused with `GET /v2/contacts` `/v2/contacts` lists your **withdrawal destinations** — addresses Skipo can send money *to*. The endpoints on this page list Skipo addresses and accounts that **you** send to. Sending funds to a contact's address does **not** credit your account. ::: ## Crypto deposits ```http GET /v2/deposit-addresses?assetSymbol=USDT ``` ```json [ { "assetSymbol": "USDT", "networkId": "TRON", "networkName": "Tron (TRC20)", "address": "TDunhSa7jkTNuKrusUTU1MUHtqXoBPKETV", "tag": null, "tagRequired": false, "tagType": null, "tagLabel": null } ] ``` Addresses are **stable**: Skipo does not rotate or retire them, and reading this endpoint never mints a new address, so re-reading is free and safe. Key any cache on the pair **`(assetSymbol, networkId)`**, never on the address string — it is not unique across chains and is the one field with no identifier beside it. An (asset, chain) pair with no address yet is simply **absent** from the list rather than listed empty. It appears once provisioning completes. ### Only assets you have added are listed :::info An empty list almost never means "you have no address" Skipo **pre-provisions** an address for effectively the whole catalogue before you ask for any of it, so that it is ready when you want it. An address existing therefore does not mean you use that asset — and listing all of them would bury the two or three you actually fund. So this endpoint returns **only the assets the account has added**. An asset you have not added answers `[]`, even if you name it with `?assetSymbol=`. That is deliberate: asking for one asset must not reveal an address the listing hides. **How to enable it:** add the asset in the Skipo app, or open the deposit screen and select it. That is all it takes. The address **already exists and does not change**, so it appears here on the next call — nothing new is minted and nothing you already had is lost. ::: This is the same set `GET /v2/balances` returns, so the two endpoints always agree about which assets are yours. A useful rule: if the asset appears in `/v2/balances`, its address appears here. ### Three ways a deposit is lost **1. Sending over the wrong chain.** An address is only valid on the `networkId` it is listed under. Several chains share an address format — every EVM chain does — so the same string can appear under more than one `networkId`. Skipo credits only the chains it sweeps: funds sent over another one are not recoverable. **2. Omitting the tag.** Where `tagRequired` is `true`, the tag is **mandatory**. On those chains Skipo shares one address across accounts and the tag is the only thing identifying yours; a deposit without it does not credit automatically. **3. Sending a different asset.** An EVM address is shared by **every** token on that chain. Sending USDC to the address you read for USDT — same string, different token — parks the funds for manual review, again with no row, no webhook and no error. Send only the exact `assetSymbol` on that row, and if a pair you expect is missing, do **not** use a neighbouring row's address. Read `tagRequired`, not `tag !== null` — it is a property of the **chain**, not of that row. `tagType` tells you which of the two names the chain uses (`MEMO` or `DESTINATION_TAG`) as a machine key; `tagLabel` is the display text. ### Minimums and fees Not on this resource. They live on `GET /v2/assets`: - `networks[].minimumDeposit` — the smallest amount that will be credited - asset-level `minimumDeposit` — the platform floor :::danger A deposit below the minimum is not rejected — it is lost It is not rejected, not returned and not queued. It is swept to a Skipo internal account: **you will see no deposit, no webhook and no error**, and recovery is a manual support case. Read `minimumDeposit` from `GET /v2/assets` before every send and leave a margin. This resource publishes no amounts, deliberately. And do not send dust as a connectivity test. ::: Deposits carry **no fee**. The `fee` returned by `GET /v2/deposits` is always `"0"` and exists only for shape parity with withdrawals. ## Fiat deposits (CLP) ```http GET /v2/deposit-instructions ``` ```json [ { "assetSymbol": "CLP", "accountHolder": "Skipo Chile SpA", "accountHolderNationalId": "77.777.777-7", "bankName": "Banco de Chile", "accountType": "Cuenta Corriente", "accountNumber": "00-123-45678-90", "accountEmail": "deposits@skipo.com", "mustMatchSenderNationalId": true } ] ``` :::danger The transfer must come from an account in your own name Skipo matches an incoming transfer to an account by the **originator's national id** (in Chile, the RUT) as the bank reports it. There is no reference code, no memo and no per-customer account number to fall back on. A transfer sent from someone else's account — a company account for a personal balance, a partner, a friend, a payment processor — **cannot be matched and is not credited**. Recovering it is a manual support case, not an automatic return. Check which id Skipo holds for you with `GET /v2/account` (`nationalId`). ::: `accountHolderNationalId` is **Skipo's** id — it identifies the destination. It is not the id the money has to come from; that one is yours. ### More than one account is returned, and they are interchangeable A deposit into any of them credits the same balance. They exist to spread volume across banks, not to segregate funds. Prefer the one at the bank you already use: in Chile a same-bank transfer settles faster and costs less than an interbank one. ### CLP only There is no USD bank account. `GET /v2/deposit-instructions` never returns one, and `GET /v2/deposit-addresses?assetSymbol=CLP` returns `[]` — fiat has no chain. To fund in USD, use the crypto rail. ## Reconciling a deposit that has arrived `GET /v2/deposits` publishes, at the top level, who sent the money and when it moved: ```json { "id": "dp_01HZY0...", "assetSymbol": "CLP", "amount": "500000", "status": "COMPLETED", "createdAt": "2026-08-20T14:04:42.000Z", "senderName": "JANE DOE", "senderNationalId": "12.345.678-9", "senderBankId": "BANCO_DE_CHILE", "senderAccount": "00-123-45678-90", "bankReference": "987654321", "bankDescription": "transferencia", "valueDate": "2026-08-20T14:03:11Z" } ``` All are `null` on a crypto deposit, and on a fiat deposit an operator entered by hand. ### `senderNationalId` means "this was you" — and its `null` means nothing It is published **only when it matches your own**. So a populated value is positive confirmation that the deposit was **self-funded**, and is never a counterparty's identifier. :::danger[A `null` does not say "a third party sent this"] The match is a **necessary, not a sufficient** condition. The field arrives `null` in all of these, and the response cannot tell them apart: - a sender who genuinely is a third party; - **every** crypto and internal deposit; - a fiat deposit an operator entered; - the originator's id missing from the bank's message; - **Skipo being unable to read your own `nationalId`** — the lookup is per request and fails soft, so a transient outage leaves the field `null` on **every** row, self-funded ones included. Never surface a `null` as "someone else sent this", and do not branch reconciliation or compliance logic on it. ::: If you compare it to `GET /v2/account`'s `nationalId`, **normalise first**: strip every character outside `[0-9kK]` and upper-case. What is published is the sender's **raw** string in the bank's own formatting — a byte-for-byte comparison fails on real rows. ### `valueDate` is not `createdAt` `valueDate` is when the money moved; `createdAt` is when Skipo credited it. On production the median gap between the two is 91 seconds, and they fall in different months about once in 27 months. To close a month, use `valueDate` and join to the ledger on `source.id`. :::caution Date filters bound `createdAt` `startDate` and `endDate` on `GET /v2/deposits` and `GET /v2/ledger` filter on creation time, not value date, and **there is no filter or sort on `valueDate` at all**. A month-end query has to read `valueDate` back and bucket client-side. Two further traps in the same parameters: - **Both bounds are inclusive.** Use the next period's start as your end and exclude it yourself, or a deposit landing exactly on the boundary is counted in both months. - **Send full timestamps with an explicit `Z`.** A date-only `2026-08-31` is read as midnight and silently drops the whole 31st. Use `2026-08-01T00:00:00Z` … `2026-09-01T00:00:00Z`. ::: ### `bankReference` The operation number the bank assigned the movement. Present on nearly every automated deposit, absent on operator-entered ones. Free-form and short — treat it as an opaque string for reconciliation, never parse it. `bankDescription` is a different thing: the free text the sender put on the transfer, and it is empty on most deposits. ## Finding out without polling A deposit emits `deposit.created`. The webhook payload carries **the same fields** as `GET /v2/deposits`, sender attribution included. ⚠️ An automated fiat deposit emits exactly **one** webhook: it is born `COMPLETED`, so no `deposit.status.updated` follows. See also: [Webhooks](/concepts/webhooks) · [Withdrawal fees](/concepts/withdrawal-fees) · [Deposits](/api/deposits) · [Assets](/api/assets). --- ## Ids and correlation Every v2 resource exposes an **`id`**, and that same value both re-fetches it and locates it in the ledger. This page is the whole contract: what id each resource returns, and how to hop between them. ## The rule, in one line > A withdrawal's, deposit's or fill's `id` is **exactly** the value its ledger entry > publishes as `source.id`. Which is why you never scan the ledger looking for something — you filter by it. ## Resources and their ids | Resource | `id` | Re-fetch it with | |---|---|---| | Withdrawal | withdrawal id | `GET /v2/withdrawals/{id}` | | Deposit | deposit id | `GET /v2/deposits/{id}` | | Order | order id (your client order id) | `GET /v2/orders/{id}` | | Fill | fill id | `GET /v2/fills?orderId=…` | | Ledger entry | entry id | `GET /v2/ledger/{id}` | ## The ledger points back Every `GET /v2/ledger` entry carries a **`source`** object naming what caused it: ```json { "id": "lm_998877", "source": { "type": "withdrawal", "id": "wd_123" }, "type": "WITHDRAWAL", "subType": "EXTERNAL_CRYPTO", "assetSymbol": "USDT", "amount": "150", "fee": "0.5", "total": "150.5", "balance": "849.5", "createdAt": "2026-07-09T14:11:05.501Z" } ``` `source.type` is one of `withdrawal`, `deposit`, `fill` or `other`, and `source.id` is that resource's `id`. It is the same pattern as Stripe's `balance_transaction.source`. ## The hops you will actually make ### From a withdrawal or deposit to its ledger entries ```bash GET /v2/ledger?assetSymbol=USDT&sourceId=wd_123 ``` One call. `sourceId` filters on the same value the resource publishes as its `id`. ### From an order to every entry it produced An order is completed by **1..N fills**, and each fill books ledger entries. Rather than chaining requests, the `orderId` filter resolves the whole chain for you: ```bash GET /v2/ledger?assetSymbol=CLP&orderId=clord_01H… ``` :::note[An order settles in two assets] A conversion moves two assets (say `BTC` and `CLP`), and `assetSymbol` is required on the ledger: it selects **which leg** you are looking at. To see both, make one call per asset. ::: ### From an order to its fills ```bash GET /v2/orders/{id}/fills # nested GET /v2/fills?orderId={id} # equivalent, filtering the global collection ``` Both return the same resource. Both exist because Coinbase and Binance both have them and different clients reach for different ones. Both are [cursor-paginated](/concepts/pagination). ### From a fill back to its order A fill carries `orderId`, and also **embeds the order's cumulative state** so you do not have to re-read the order after every execution: ```json { "id": "1096473", "orderId": "clord_01H…", "sequence": 2, "side": "BUY", "market": "BTC-CLP", "baseAmount": "0.004", "quoteAmount": "260000.00", "rate": "65000000.00", "multiplier": "1", "onCredit": true, "executedAt": "2026-07-09T14:07:11.000Z", "order": { "status": "PARTIALLY_FILLED", "filledBaseAmount": "0.008", "filledQuoteAmount": "520000.00" } } ``` `sequence` is the fill's position within its order, starting at 1. ## Orders and fills An **order** is what you create by confirming a quote. A **fill** is each execution against it. - A normal conversion → **exactly one fill**. - An on-credit order (`onCredit: true`) → **several fills**, as the debt is paid down. That is why `filledBaseAmount` can sit below `baseAmount` for a while, and why the `fill.created` webhook can arrive more than once for the same `orderId`. ## Amount conventions | Convention | What it means | |---|---| | **Positive magnitudes** | `amount`, `fee` and `total` are always positive on every resource. Direction comes from the resource type or the entry's `type`, never from the sign. | | **`balance` IS signed** | It is a **state** (the balance after the entry), not a movement, so it is not converted to a magnitude. | | **UPPERCASE** | `status`, `type`, `subType` and `side` are always uppercase: `FILLED`, `WITHDRAWAL`, `EXTERNAL_CRYPTO`, `BUY`. | | **Decimals as strings** | Amounts travel as strings so no precision is lost. Parse them with a decimal type, never a `float`. | --- ## Pagination Almost every collection returns the same wrapper: `data` (the items) and `pagination` (the navigation metadata). The exception is `GET /v2/balances`, which returns a **bare array**: you hold one position per asset, so there is nothing to paginate. What changes is **what `pagination` contains**, because v2 uses two strategies. | Strategy | Where | Metadata | |---|---|---| | **Offset** (default) | Everything except fills | `page`, `limit`, `totalItems`, `totalPages` | | **Cursor** | `GET /v2/fills` and `GET /v2/orders/{id}/fills` | `count`, `nextCursor` | ## Offset — the general case ```json { "data": [ { "id": "wd_123", "status": "COMPLETED" }, { "id": "wd_124", "status": "PENDING" } ], "pagination": { "page": 1, "limit": 25, "totalItems": 132, "totalPages": 6 } } ``` | Parameter | Description | |---|---| | `page` | Page number. Starts at **1**. Defaults to `1`. | | `limit` | Items per page. Defaults to **25**, maximum **100**. | ## Cursor — fills The two fills endpoints paginate by **cursor**, not offset: ```json { "data": [ { "id": "1096473", "orderId": "clord_01H…", "baseAmount": "0.01" } ], "pagination": { "count": 25, "nextCursor": "MjAyNi0wNy0yNiAxNzo0MjowMS4wMDMzMDl8OWY4Zi00YQ" } } ``` | Parameter | Description | |---|---| | `limit` | Items per page. Defaults to **25**, maximum **100**. | | `cursor` | The previous page's `pagination.nextCursor`. Omit it on the first request. | The correct walk: request a page, process `data`, and if `nextCursor` is **not `null`**, request again passing it as `cursor`. Repeat until it is `null`. :::warning[Do not infer the end from a short page] `count` can be **lower** than the `limit` you asked for even when more results remain. The only end-of-collection signal is `nextCursor: null`. A client that stops on a short page silently misses data. ::: :::info[Why fills are the exception] A fill is an execution record: append-only, never reordered. With `OFFSET n` the database re-scans and discards `n` rows on every page and — what matters more in a money API — **the window shifts under you**: a fill landing mid-walk pushes a row from page 2 onto page 3, and a client paging through its own history **silently skips it**. A keyset cursor is stable against concurrent inserts and costs the same on page 1 as on page 500. It is also what the market does for this specific resource: Coinbase's `/fills` is cursor-paged and Binance's `myTrades` walks by `fromId`. What you give up is the totals, and for an append-only log "how many fills have I ever had" is not worth a full `COUNT` on every page. ::: :::caution[The cursor is opaque] `nextCursor` is an opaque token: pass it back verbatim. Its encoding is not part of the contract and may change. Do not parse it, do not construct one, and do not store it as if it were a stable identifier. ::: ## Filters In v2, filters are expressed as _query_ parameters instead of special routes. For example, to list the fills of an order use `GET /v2/fills?orderId=…` rather than a dedicated route. See the [API Reference](/reference) for the filters available on each collection. --- ## Rate limits Limits use a **point budget per 60-second window**, based on your key's **tier**. Each operation consumes points by its _weight_: a standard read costs **1 point**; heavier or fan-out operations cost more. ## Enforced in two dimensions The tier budget is checked **simultaneously** per account and per key, and the stricter one wins: - **Per account** — the primary limit. The tier budget is shared across **all** the keys on your account, so minting extra keys does **not** multiply your total limit. - **Per key** — each key uses the tier budget by default, and can be given a tighter **sub-limit** to cap an individual key. ## Tiers New keys start on **`basic`**. Tiers are **limit bands**, not billing plans. | Tier | Budget | Approx. | |---|---|---| | `basic` (default) | 300 points / min | ~5 req/s | | `standard` | 600 points / min | ~10 req/s | | `premium` | 6000 points / min | ~100 req/s | :::info[Need a higher limit?] To move to a higher tier, **contact Skipo support**. Tiers are adjusted by Skipo; they are not self-serve. ::: ## MCP tool costs The [MCP server](/mcp) draws on the **same budget** as the REST API: a tool call costs the points listed below, not a flat 1 point. | Points | Tools | |---|---| | 1 | `check_api_health`, `get_server_time` | | 2 | `get_account`, `get_balances`, `get_contacts`, `get_deposits`, `get_withdrawals`, `get_orders`, `get_assets`, `get_markets` | | 3 | `get_fills`, `get_order_fills`, `get_ledger`, `get_balance_history` | | **10** | `get_indicative_price` | :::caution[`get_indicative_price` costs 10 points] It is the only tool that reaches an **external pricing provider**, which is why it costs 10× a plain read. On the `basic` tier (300 points/min) that is roughly **30 calls per minute** before you are throttled — even if the rest of your budget is untouched. If you plan to poll for prices, space the calls out or ask for a higher tier. ::: ## Headers Every authenticated response includes the standard (IETF draft) headers along with their legacy equivalents: | Header | Meaning | |---|---| | `RateLimit-Limit` · `X-RateLimit-Limit` | Budget for the current window. | | `RateLimit-Remaining` · `X-RateLimit-Remaining` | Points remaining in the window. | | `RateLimit-Reset` · `X-RateLimit-Reset` | Seconds until the window resets. | ## `429` response If you exceed the limit, you receive a `429 Too Many Requests` with an [`application/problem+json`](/concepts/errors) body and a `Retry-After` header: ```http HTTP/1.1 429 Too Many Requests Retry-After: 3 Content-Type: application/problem+json ``` ```json { "type": "https://docs.skipo.com/errors/rate_limited", "title": "Rate limit exceeded", "status": 429, "code": "rate_limited", "retryable": true } ``` **Always honor `Retry-After`** and apply exponential _backoff_ when you get a `429`. --- ## Tokenized equities (xStocks) Skipo's tokenized equities (`NVDASPV`, `TSLASPV`, `AMDSPV`, `GLDSPV`…) are **rebasing** assets. The settled quantity never moves; corporate actions — dividend reinvestment, splits, reverse splits — are expressed by raising a **multiplier**: ``` underlying shares = settled quantity × multiplier price per share = rate / multiplier ``` Both representations describe the same money: `quantity × rate` yields the same quote-currency amount either way. The rebase applies to the **base leg only** — an xStock market is `-CLP` or `-USDT`, so the other leg is fiat or a stablecoin. ## `assetFormat` The **order**, **fill**, **deposit**, **withdrawal**, **balance**, **indicative price** and **quote** endpoints accept an `assetFormat` parameter: | Value | What you get | |---|---| | `rebased` **(default)** | Amounts in terms of the **underlying equity** — what a holder thinks of as "their shares". | | `base` | Amounts in terms of the **settled SPV tokens** — what moved on chain and in the ledger. | Where it is accepted: | Resource | `assetFormat` | |---|---| | Orders and fills — `GET /v2/orders`, `/v2/orders/{id}`, `/v2/orders/{id}/fills`, `/v2/fills` | yes — defaults to `rebased` | | Withdrawals — `GET /v2/withdrawals` | yes — defaults to `rebased` | | Deposits — `GET /v2/deposits` | yes — defaults to `rebased` | | Balances — `GET /v2/balances` | yes — defaults to `rebased` | | Indicative price — `GET /v2/markets/{market}/price` | yes — defaults to `rebased` | | Quotes — `POST /v2/quotes` | yes, **in the body** — defaults to `rebased` | | Ledger — `GET /v2/ledger` | **no** — always settled ([why](#the-ledger-does-not-accept-assetformat)) | | Balance history — `GET /v2/balances/{assetSymbol}/history` | **no** — always settled ([why](#nor-does-balance-history)) | | Webhooks | **no** — always `rebased` ([why](/concepts/webhooks#amounts-are-always-rebased)) | The authoritative parameter list for each endpoint is in the [endpoint reference](/api), which is generated from the OpenAPI specification itself. Remember that the API rejects parameters it does not know: sending `assetFormat` to an endpoint that does not accept it is a **400**, not something silently ignored. ```bash GET /v2/fills?market=NVDASPV-CLP # rebased (default) GET /v2/fills?market=NVDASPV-CLP&assetFormat=base # settled figures GET /v2/deposits?assetSymbol=COPXX # rebased (default) GET /v2/withdrawals?assetFormat=base # settled figures GET /v2/balances # rebased (default) GET /v2/balances?assetFormat=base # settled SPV quantities GET /v2/markets/NVDASPV-CLP/price?side=BUY # price per share (default) ``` :::info[On quotes the parameter goes in the BODY] `POST /v2/quotes` takes it in the request body, not the query string. That is not arbitrary: `assetFormat` is **the unit of the `amount` you send**, and a value and its unit should not travel by different routes. ```json { "baseAsset": "NVDASPV", "quoteAsset": "CLP", "amountAsset": "NVDASPV", "side": "BUY", "amount": "10", "assetFormat": "base" } ``` On both quoting routes — the confirmable one and the indicative price — it governs **both directions**: how the `amount` you send is read, and which representation `baseAmount` and `rate` come back in. `market` changes in neither: it names an instrument pair, not a quantity. ::: :::info[The default is `rebased`, on all of them] This is a deliberate change from the previous behaviour, which returned `base`. A holder thinks in shares, not SPV tokens, and it is what comparable venues do (Kraken's `rebase_multiplier` defaults the same way). If your integration depends on the settled figures, ask for `assetFormat=base` explicitly. The default is the same across **every** resource that accepts it: there are **no** endpoints that still return `base` implicitly. ::: For every other asset nothing changes: the multiplier is `1` and both modes are identical. ## The two names: `NVDASPV` and `NVDAX` A tokenized equity has **two codes** for the same asset: | Code | What it is | Where it appears | |---|---|---| | `NVDASPV` | The **settled** SPV token — what moved on chain and in the ledger | `assetSymbol` in reference data, `base.asset`, the ledger, the market id | | `NVDAX` | The **underlying share** — what a holder thinks they own | `baseAsset` on orders and fills, `assetSymbol` on balances, and all UI | Reference data publishes both, so there is no mapping to hardcode: ```json // GET /v2/assets/NVDASPV { "assetSymbol": "NVDASPV", "rebasedSymbol": "NVDAX", "assetClass": "STOCK" } // GET /v2/markets/NVDASPV-CLP { "id": "NVDASPV-CLP", "baseAsset": "NVDASPV", "rebasedBaseAsset": "NVDAX", "quoteAsset": "CLP" } ``` For an asset that does not rebase both fields carry the same value, so a UI can read `rebasedSymbol` unconditionally. Them being equal is **not** a claim that the asset does not rebase: `TSLASPV` sits at a factor of `1` today and is a `STOCK`. Class comes from `assetClass`. ### Either spelling is accepted on input Wherever an endpoint takes an asset, either spelling resolves. `NVDAX` selects the same asset as `NVDASPV`: ```bash GET /v2/assets/NVDAX # returns the NVDASPV asset GET /v2/markets/NVDAX-CLP # returns the NVDASPV-CLP market GET /v2/fills?assetSymbol=NVDAX # the same fills GET /v2/balances/NVDAX/history # the same series POST /v2/quotes {"baseAsset":"NVDAX", ...} # the same market ``` Two things to be clear about: **The response always uses the canonical spelling.** Asking for `NVDAX-CLP` returns `id: "NVDASPV-CLP"`. A market id names an instrument pair, not a quantity, and **never** rebases. **The spelling does not set the units.** Only `assetFormat` does. Sending `amountAsset: "NVDAX"` with `assetFormat: "base"` prices settled tokens, because you asked for `base`. A symbol is a name; the representation is that field. :::danger[`POST /v2/withdrawals` is the exception: settled code only] It is the one place the rebased spelling is **rejected**, and that is deliberate. That body carries no `assetFormat`, so the representation of `amount` is implicit: **settled tokens**. If we accepted `NVDAX` by translating the symbol without converting the amount, we would move a different quantity than you asked for — with `NFLXSPV`, whose factor is `10`, asking to withdraw 1 share would take out 10. And the trap is easy to fall into: `GET /v2/balances` answers rebased by default and hands you `assetSymbol: "NFLXX"`. Sending that value here is the natural next step. So it is rejected with a **400** that states the units and the conversion, not just the spelling: ```json { "code": "validation_error", "detail": "This endpoint takes the settled asset code and SETTLED amounts. 'NFLXX' is the rebased ticker for 'NFLXSPV' (1 NFLXSPV = 10 NFLXX). Resend with assetSymbol 'NFLXSPV' and 'amount' converted to settled units — divide by 10.", "extensions": { "assetSymbol": "NFLXSPV", "rebasedSymbol": "NFLXX", "multiplier": "10" } } ``` A signed instruction executes as written: the amount is yours, not ours to reinterpret. ::: ## The `multiplier` field Orders, fills, deposits, withdrawals, balances and quotes publish the factor that was applied: ```json { "id": "1096473", "baseAsset": "NVDASPV", "baseAmount": "10.000658218334353", "rate": "18499.8", "multiplier": "1.0000658218334353" } ``` The fundamental relationship, in one line: ``` base amount × multiplier = rebased amount ``` With it you can reconstruct the other representation without another request: - **base → rebased:** `baseAmount × multiplier`, `rate ÷ multiplier` - **rebased → base:** `baseAmount ÷ multiplier`, `rate × multiplier` This is what lets you reconcile an API amount against an on-chain balance. In webhook payloads you do not even need to multiply: **every** event — withdrawals, deposits, orders and fills — arrives with **both representations already computed** — a rebased top level and a `base` block with the settled figures — plus the `multiplier` that relates them. See [Webhooks](/concepts/webhooks#amounts-are-always-rebased). Careful applying the formulas above to a whole order: they hold for the **base leg** only. The And no surface needs you to multiply any more: balances, orders, fills, withdrawals and deposits all arrive with the top level in the representation you asked for and a `base` block holding the settled figures. Reconciling a holding or an execution against the ledger or against a webhook never needs a second read with different parameters. On an **order** the block carries both legs and the filled/ordered pair; on a **fill**, both legs plus its order's settled cumulative totals: ```json // GET /v2/orders (assetFormat=rebased, the default) { "market": "NVDASPV-CLP", "baseAsset": "NVDAX", "baseAmount": "0.3214865", "rate": "207290.19029004", "multiplier": "1.000103090792305", "assetFormat": "rebased", "base": { "baseAsset": "NVDASPV", "quoteAsset": "CLP", "baseAmount": "0.32145337", "filledBaseAmount": "0.32145337", "quoteAmount": "66641", "filledQuoteAmount": "66641", "rate": "207311.56" } } ``` :::warning[The `base` block is READ, not derived — and you should not derive it either] The formulas above are there to explain the relationship, not to reconstruct the `base` block from the top level. Two reasons, and both bite: The rebase is **asymmetric**. The base leg multiplies, `rate` divides, the quote leg is untouched. A uniform division corrupts the quote leg. And the top level is already **truncated** to the asset's precision, so dividing back re-manufactures a tail that is not what settled. With real figures: that order's settled `baseAmount` is `0.32145337`, and dividing the published `0.3214865` back gives `0.32145336` — one digit of real value, lost silently. That is why we publish both: we are the ones holding the unscaled row. Read `base`; do not compute it. ```json { "assetSymbol": "NVDAX", "balance": "125.57296048", "multiplier": "1.0009180758490996", "assetFormat": "rebased", "base": { "asset": "NVDASPV", "balance": "125.45778073" } } ``` `base.asset` is populated on **every** surface and in either `assetFormat`, withdrawals and deposits included. It used to come back `null` there on a rebased read, because the SPV code was no longer on that response; the rebased ticker now resolves back to the settled code, so the value is always there. It is `null` only if that lookup misses, which means the asset is unknown to the registry — not that the value was unobtainable. It is **never** the underlying ticker: a plausible-looking wrong code is worse than an obvious gap when you are reconciling against a webhook. quote-leg amount (`quoteAmount`) is **untouched**, which is why `rate` is divided — see [an order rebases only one of its two legs](/concepts/webhooks#an-order-rebases-only-one-of-its-two-legs). ## The multiplier is **point-in-time** This is the part that matters most and is easiest to get wrong: > The factor we publish is the one in effect **at that movement** — that fill, that deposit, > that withdrawal — read off the row itself. It is **never** today's factor, and it is never > recomputed. A corporate action changes the factor **going forward**; it does not change what a past transaction delivered. Applying today's multiplier to last year's fill **restates history**: if an asset does a 10:1 split, your entire past history would appear multiplied by ten. Practical consequence: two movements in the same asset can carry **different multipliers**, and that is correct. Do not cache "the multiplier for `NVDASPV`" as though it were reference data — it is **per-row** data. This is rule 3 of Solana's Scaled UI Amount extension guidance: a historical amount must be rendered with the multiplier that was in effect when the transaction happened. ### A balance or a price uses TODAY's factor The rule above is about **movements**: a fill, a deposit, a withdrawal each carry the factor that was in effect when they happened, and publishing them with any other would restate history. A **balance**, an **indicative price** and a **quote** are not history — they describe the state right now. Their factor is by definition today's, which is precisely what the issuer calls the _scaled balance_: what you hold on chain times the current multiplier. Applying a historical factor to them would be as wrong as applying today's to a past fill. In one line: **a row's factor comes from the row; a current state's factor comes from the clock.** Practical consequence, and it is not a bug: `GET /v2/balances` and `GET /v2/fills` can publish **different multipliers for the same asset** at the same instant. The balance carries today's; the fill carries the one from the day it executed. :::caution[`multiplier: "1"` does not mean "not a tokenized equity"] A factor of exactly `1` is simply a factor that has not moved yet — `TSLASPV` sits at `1` today. **Do not use it to classify assets.** Asset class is reference data and is read from `GET /v2/assets/{assetSymbol}`. ::: ## The ledger does not accept `assetFormat` `GET /v2/ledger` deliberately exposes **settled** amounts, with no `assetFormat`. The ledger is the double-entry record of what actually moved, and its entries do not currently carry the multiplier that was in effect when they were booked. Honouring `assetFormat` there would mean resolving the factor **live**, which is exactly the restatement bug described above. A correct unscaled figure beats a scaled, possibly false one. To see a fill in share terms, read it from `/v2/fills`, which does carry its point-in-time factor. ### Nor does balance history `GET /v2/balances/{assetSymbol}/history` publishes **settled** quantities and takes no `assetFormat` either, for the same underlying reason as the ledger. A rebased figure needs the factor that was in force **when that figure was true**. This series is computed from ledger movements, and each point arrives with only its date and its balance: there is no per-point factor, and none can be recovered. Honouring `assetFormat` would mean applying **today's** factor to every past point — after a 10:1 split, every earlier point would jump tenfold. That is exactly the history-rewriting the rule above forbids. We prefer a correct unscaled series to a scaled and possibly false one. It is a real gap and not a preference: it closes when each point carries its own factor. The **request** does accept either spelling — `NVDAX` reads `NVDASPV`'s series — because that only selects which asset is read. And `assetSymbol` in the response always carries the settled code, so the label matches the units you are reading. ## The precision of a rebased amount Multiplying by the factor **manufactures** decimals. An 8-decimal settled balance times a 16-decimal factor is a 24-decimal product, and that tail is arithmetic, not information. Every rebased amount is truncated to the precision Skipo operates that asset at — the `amountIncrement` published by `GET /v2/assets`, documented as the step below which an amount is truncated. It truncates down, never rounds up: on a published balance it never overstates what you hold, and on an amount travelling out to be executed it never sells more than you asked for. The `multiplier` published beside it is **not** truncated: it is the factor, and you need it whole to convert. --- ## Webhooks(Concepts) Instead of polling, you can register a URL to receive **webhooks** when your transactions change. Every delivery is **signed** so you can verify it came from Skipo and was not tampered with. ## Event envelope Every webhook has a stable envelope modeled on Fireblocks v2: ```json { "id": "evt_01H…", "webhookId": "whk_01H…", "eventType": "fill.created", "resourceId": "1096473", "createdAt": 1783003522123, "data": { "id": "1096473", "orderId": "clord_01H…", "side": "BUY" } } ``` | Field | Type | Description | |---|---|---| | `id` | string | **Logical** event id. This is your idempotency key. | | `webhookId` | string | The endpoint the delivery went to. | | `eventType` | string | One of the [catalog](#event-catalog). | | `resourceId` | string \| null | Id of the affected resource. | | `createdAt` | **number** | When the event happened, in **milliseconds since epoch**. | | `data` | object | Snapshot of the affected resource. It does **not** match the REST representation of that same resource — neither in field names nor in the **units** of the amounts (see [below](#amounts-are-always-rebased)). | :::warning[`createdAt` is a number, not an ISO-8601 string] The envelope uses **millisecond epoch** (`1783003522123`), not `"2026-07-10T12:34:56.000Z"`. Earlier versions of this page showed ISO-8601: **the documentation was wrong, not the payload**. If your integration parses that field as a string, fix it. The asymmetry is **deliberate**. The envelope is produced by the webhooks platform and uses epoch — an integer, with no timezone or format ambiguity. The timestamps **inside `data`** (`createdAt`, `executedAt`) are ISO-8601, because they are the exact same values the REST API returns. Rule of thumb: **outside `data`, epoch; inside `data`, ISO-8601.** ::: :::warning[`data` is not the REST response] `data` is a snapshot of the affected resource, but it is **not** guaranteed to match the REST representation of that same resource. The divergences come in two kinds — and the first is the one that actually breaks reconciliations, because it does not change a field's **name** but an amount's **units**: **Representation (the values differ):** - the top level of the payload is **always `rebased`**, whereas a REST response is in whatever representation you asked for with `assetFormat`. If you request `assetFormat=base` over REST and compare it against the webhook for the same movement, the amounts **will not match** — they are expressed in different units, and **neither one is wrong**. See [Amounts are always rebased](#amounts-are-always-rebased). **Field names (the values are the same):** - the webhook payload emits `asset` where the REST resource publishes `assetSymbol`. Network identity **no longer differs**: the webhook's `depositData` / `withdrawalData` block publishes `networkId`, `networkName`, `networkNativeAsset`, `evmChainId` and `assetType` — the same fields with the same values as the REST resource. Join on `networkId`. A fill payload also omits fields the REST fill resource carries. Code against the webhook payload itself — do not reuse your REST response parser. ::: ### Idempotency and resends `id` is the **logical** event id and is **stable across resends**: if Skipo retries the delivery, or the event is resent, the **same** `id` arrives. Deduplicate on it and a money-movement webhook will never be processed twice. Every delivery also carries these headers: | Header | Contents | |---|---| | `skipo-webhook-delivery-id` | Id of **this delivery**. It is the same across all automatic retries of one delivery; only a resend gets a different one. | | `skipo-webhook-event` | The `eventType`, so you can route without parsing the body. | | `skipo-webhook-signature` | The detached JWS signature (see [below](#verifying-the-signature)). | ## Amounts are always rebased A webhook **does not accept `assetFormat`**, and cannot: Skipo initiates the delivery, not you, so there is no query string in which to ask for a representation. The representation is therefore **fixed**, and we fix it to the same one REST returns by default — the top level of the payload is always **`rebased`**, the underlying equity, carrying the rebased symbol. So that you can reconcile without a second call, **all seven events in the catalog** carry **both representations at once**: the top level in `rebased`, and a `base` block holding the settled figures. Start with a movement (`withdrawal.*` and `deposit.*`). Here is its `data` object (the `id` in here is the **movement's**, not the event's — that one lives on the envelope): ```json { "id": "…", "type": "WITHDRAWAL", "subType": "INTERNAL", "asset": "COPXX", "amount": "0.18929052835346211008", "fee": "0", "total": "0.18929052835346211008", "status": "COMPLETED", "createdAt": "2026-08-14T18:51:31.066Z", "transactionHash": "0x8b3275b467d90d99bd99bb7bfec9402e393ce870d741131a9d69a7d42640ac5b", "bankReference": null, "assetFormat": "rebased", "multiplier": "1.002370480441", "base": { "asset": "COPXSPV", "amount": "0.18884288", "fee": "0", "total": "0.18884288" }, "withdrawalData": { "…": "…" } } ``` | Field | What it is | |---|---| | `asset`, `amount`, `fee`, `total` | Top level: **always `rebased`**. `asset` is the rebased symbol (`COPXX`), not the settled one (`COPXSPV`). | | `assetFormat` | The literal `"rebased"`. **Always present**, so you never have to infer which units you are being told about. | | `multiplier` | The factor in effect **at the time of that movement**, not today's — see [Tokenized equities](/concepts/tokenized-equities). It is `"1"` outside xStocks. | | `base` | The **settled** values (the SPV tokens): what moved on chain and in the ledger. **Always present.** | | `transactionHash` | The on-chain hash of the movement, when there was one. `null` on a movement that never touched a chain (internal transfer, bank payout), and `null` **yet** on one that has not been broadcast — the hash arrives on a later event. **Do not assume a `0x` prefix**: BTC, Solana and Tron hashes have none. | | `bankReference` | The **bank's** or payment provider's own reference, when one was recorded. `null` on every non-fiat movement. The counterpart of `transactionHash`: a movement settles either on a chain **or** through a bank, so at most one of the two is ever populated. Short and free-form (1-19 characters, not always numeric) — treat it as an opaque string for reconciliation, never parse it. | On a movement, field by field, `base.amount × multiplier = amount`, and likewise for `fee` and `total`: all three figures are denominated in the same asset, so they all scale alike. A `deposit.*` payload is identical except that the trailing block is called `depositData`. On an **order** or a **fill** that field-by-field relation does **not** hold — and that is not an oversight, it is what has to happen. It gets its own section: [an order rebases only one of its two legs](#an-order-rebases-only-one-of-its-two-legs). :::caution[The shape does **not** vary by asset] For a non-xStock asset (`USDT`, say), `multiplier` is `"1"`, `asset` equals `base.asset`, and the `base` block mirrors the top level exactly. The `assetFormat` and `multiplier` literals and the `base` block **never disappear**: you can parse one shape for every asset, with no branching on asset type. ::: :::warning[This **breaks** the previous behaviour] Until now the top level carried the **settled** figures with the SPV symbol (`"asset": "COPXSPV"`). It now carries the **rebased** figures with the rebased symbol (`"asset": "COPXX"`). If your integration reconciles against on-chain balances, read the `base` block: it holds exactly the values you used to receive at the top level. ::: ### An order rebases only one of its two legs An order has **two legs**, and the multiplier only touches one of them. The base leg is denominated in the token; the quote leg (`quoteAsset` — CLP, USDT…) knows nothing about tokenized equities. So when rebasing: - `baseAmount` and `filledBaseAmount` are **multiplied** by the multiplier; - `quoteAmount` and `filledQuoteAmount` stay the **same**: the money you pay does not change because the unit you count shares in changed; - `rate` is **divided**, because it is a price _per unit of the base leg_, and that unit just got smaller. That division is exactly what preserves the notional: `baseAmount × rate = quoteAmount` holds in **both** representations. It is also why `base` is not "the top level divided by the multiplier" — compute it that way and your CLP figure comes out wrong. ```json { "id": "…", "status": "FILLED", "side": "BUY", "market": "NVDASPV-CLP", "baseAsset": "NVDAX", "quoteAsset": "CLP", "baseAmount": "2.5059262011025", "filledBaseAmount": "2.5059262011025", "quoteAmount": "50000", "filledQuoteAmount": "50000", "rate": "19952.702508957424797", "multiplier": "1.002370480441", "assetFormat": "rebased", "base": { "baseAsset": "NVDASPV", "quoteAsset": "CLP", "baseAmount": "2.5", "filledBaseAmount": "2.5", "quoteAmount": "50000", "filledQuoteAmount": "50000", "rate": "20000" }, "onCredit": false, "createdAt": "2026-08-14T18:51:31.066Z" } ``` | Field | Top level vs `base` | |---|---| | `baseAsset` | the **rebased** symbol (`NVDAX`) vs the settled one (`NVDASPV`) | | `baseAmount`, `filledBaseAmount` | `base × multiplier` | | `quoteAsset`, `quoteAmount`, `filledQuoteAmount` | **identical**: the quote leg does not rebase | | `rate` | `base.rate ÷ multiplier` | | `market` | **never rebases**. It is the pair identifier, and REST spells it the same way (`NVDASPV-CLP`), so it works as-is for re-fetching. | A `fill.created` carries the same `base` block in the smaller shape a fill emits: `baseAsset`, `quoteAsset`, `baseAmount`, `quoteAmount` and `rate`. A fill has no `filled…` figures — the fill **is** the execution. :::tip[Reconcile against `base`] The `base` block comes out **exactly as stored**: we read it, we do not derive it by dividing. So it is exact. The top level is a product — and for `rate`, a quotient — rounded to 20 significant figures, so on an xStock `baseAmount × rate` can drift from `quoteAmount` by one unit in the last decimal. If you reconcile to the cent, use `base` and the multiplier. ::: ## Event catalog There are **7 events**. Types use dot notation and support wildcards when subscribing: exact (`withdrawal.created`), by category (`withdrawal.*`), or global (`*`). | Event | When | `resourceId` | `data` shape | |---|---|---|---| | `withdrawal.created` | A withdrawal is created and the balance frozen. | Withdrawal id | [Movement](#movement-withdrawal-and-deposit) | | `withdrawal.status.updated` | The withdrawal changes status, terminal included. | Withdrawal id | [Movement](#movement-withdrawal-and-deposit) | | `deposit.created` | A deposit is detected and booked. | Deposit id | [Movement](#movement-withdrawal-and-deposit) | | `deposit.status.updated` | The deposit changes status, terminal included. | Deposit id | [Movement](#movement-withdrawal-and-deposit) | | `order.created` | A convert order is placed (at confirm), with `status: "NEW"`. | `clOrdId` | [Order](#order-order) | | `order.status.updated` | The order changes status. | `clOrdId` | [Order](#order-order) | | `fill.created` | A fill executes against an order. | Fill id | [Fill](#fill-fill) | The subscribable categories are exactly `withdrawal.*`, `deposit.*`, `order.*` and `fill.*`. #### The `status` values you can receive `status` travels **inside `data`**. These are every possible value, per resource: | Resource | Values | Terminal | |---|---|---| | Withdrawal and deposit | `PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED` | `COMPLETED`, `FAILED` | | Order | `NEW`, `PARTIALLY_FILLED`, `FILLED`, `FAILED` | `FILLED`, `FAILED` | | Fill | — a fill has no status: existing **is** having executed | — | Order statuses are the **exchange-standard** ones, not the internal ones: converts-service stores `PENDING`/`STARTED`/`IN_PROGRESS`/`COMPLETED` internally and translates them to `NEW`/`PARTIALLY_FILLED`/`PARTIALLY_FILLED`/`FILLED` before publishing. `GET /v2/orders` applies the same translation, so the event and the REST resource agree. :::caution[The status channel carries **transitions**, not a complete log] A `*.status.updated` event tells you the resource **is now** in that status. It does not guarantee one event per **every** internal transition: a movement can pass through intermediate states without emitting anything, and a withdrawal that is born and finishes in one operation may give you only the terminal event. The practical consequence: **do not reconstruct history from the event sequence.** Treat each payload as the current state and keep the last one you saw for that `resourceId`. If you need the history, read it over REST. ::: :::note[A resource can be born terminal] A deposit detected already confirmed, or an instant internal withdrawal, produces `.created` and `.status.updated` with the **same** `status: "COMPLETED"` — and sometimes with the **same millisecond** in `createdAt`. It is not a duplicate: they are two events with different `id`. If your logic assumes `.created` always arrives in a non-terminal state, revisit it. ::: :::info[Orders and fills] An **order** (one per confirmation) is completed by **1..N fills**. A normal conversion has exactly one fill; an on-credit (capacity) order is filled by several as the debt is paid down. That is why `fill.created` can arrive more than once for the same `orderId` — see [Ids and correlation](/concepts/ids-and-correlation). ::: :::note[`webhook.test` is not subscribable] A test ping arrives with `test: true` so you can verify your endpoint wiring. It is **not** a catalog event and you cannot subscribe to it: a test is something you trigger, not something that happens in your account. There is no public endpoint to trigger it yet — v2 exposes no webhook management, only the verification JWKS at `/.well-known`. ::: :::caution[Ignore types you do not know] New events may appear at any time. A correct consumer **silently discards** an `eventType` it does not recognize rather than failing. Subscribing with `*` means accepting that contract. ::: ### The shape of `data`, per resource There are **three** `data` shapes, one per resource type. All three share the same representation contract (`assetFormat`, `multiplier` and a `base` block, always present), so you can write a single amount reader for all of them. #### Movement (`withdrawal.*` and `deposit.*`) This is the payload shown [above](#amounts-are-always-rebased). The trailing block is called `withdrawalData` on a withdrawal and `depositData` on a deposit, and its contents depend on the `subType`: only the on-chain and RedPay sub-types carry detail; the rest send `{}`. `transactionHash` and `bankReference` live at the **top level**, so you never have to branch on `subType` to read them. ##### Sender attribution, on `deposit.*` only A deposit additionally carries, at the top level, who sent the money and when it moved — the **same fields** `GET /v2/deposits` returns, with the same meaning: | Field | What it is | |---|---| | `senderName` | Holder of the originating account, as the bank reports it. | | `senderNationalId` | The originator's national id, **only when it matches your own**. A populated value confirms the deposit was self-funded; it is never a counterparty's identifier. ⚠️ A `null` does **not** mean "a third party sent this" — it also arrives `null` on every crypto and internal deposit, on operator-entered ones, and whenever Skipo could not read your own `nationalId`. See [Funding your account](/concepts/funding-your-account#sendernationalid-means-this-was-you--and-its-null-means-nothing). | | `senderBankId` | Originating bank, as a stable key. | | `senderAccount` | Originating account number. | | `bankDescription` | The free text the sender put on the transfer. Empty on most. Different from `bankReference`, which the bank assigns. | | `valueDate` | When the money **moved**, as opposed to `createdAt`, which is when Skipo credited it. | All six are `null` on a withdrawal, on a crypto deposit, and on a fiat deposit an operator entered by hand. The full detail is in [Funding your account](/concepts/funding-your-account). :::caution[An automated fiat deposit emits exactly **one** event] It is born `COMPLETED`, so no `deposit.status.updated` follows the `deposit.created`. If you wait for the second one before crediting in your own system, a bank-transfer deposit waits forever. ::: #### Order (`order.*`) ```json { "id": "clord_01H…", "status": "PARTIALLY_FILLED", "side": "BUY", "market": "COPXX-USDT", "baseAsset": "COPXX", "quoteAsset": "USDT", "baseAmount": "10.023704804410", "filledBaseAmount": "4.009481921764", "quoteAmount": "250.00", "filledQuoteAmount": "100.00", "rate": "24.94", "multiplier": "1.002370480441", "assetFormat": "rebased", "base": { "baseAsset": "COPXSPV", "quoteAsset": "USDT", "baseAmount": "10", "filledBaseAmount": "4", "quoteAmount": "250.00", "filledQuoteAmount": "100.00", "rate": "25.00" }, "onCredit": true, "createdAt": "2026-08-14T18:51:31.066Z" } ``` | Field | What it is | |---|---| | `id` | The `clOrdId`: the **stable public** order id. It is what each fill's `orderId` points at. | | `status` | `NEW`, `PARTIALLY_FILLED`, `FILLED` or `FAILED`. | | `side` | `BUY` or `SELL`, **from your perspective**. It is inverted in the supplier's event. | | `market` | The pair, `BASE-QUOTE`. **Never rebased**: it identifies an instrument, not a quantity. | | `baseAmount` / `quoteAmount` | What was **requested** on each leg. | | `filledBaseAmount` / `filledQuoteAmount` | What has **executed so far**. On `FILLED` they equal the requested amounts. | | `rate` | The order's price. Note it **rebases inversely** — see [below](#an-order-rebases-only-one-of-its-two-legs). | | `onCredit` | `true` for an on-credit (capacity) order, which is filled by several fills. | #### Fill (`fill.*`) ```json { "id": "1096473", "orderId": "clord_01H…", "side": "BUY", "baseAsset": "COPXX", "quoteAsset": "USDT", "baseAmount": "4.009481921764", "quoteAmount": "100.00", "rate": "24.94", "multiplier": "1.002370480441", "assetFormat": "rebased", "base": { "baseAsset": "COPXSPV", "quoteAsset": "USDT", "baseAmount": "4", "quoteAmount": "100.00", "rate": "25.00" }, "executedAt": "2026-08-14T18:51:33.212Z" } ``` | Field | What it is | |---|---| | `id` | The fill id. | | `orderId` | The parent order's `clOrdId`. **It can be `null`** — do not assume you can always link. | | `baseAmount` / `quoteAmount` | What executed **in this fill**, not the order's running total. | | `executedAt` | When it executed, ISO-8601. A fill has no `createdAt`. | A fill carries **no `status`**: if it reached you, it executed. The order's running total lives in the order payload's `filledBaseAmount` / `filledQuoteAmount`, not in the fill's. ### Typical sequences None of these is guaranteed field by field — they are the shape the lifecycle takes in practice, useful for knowing what to expect: | Scenario | Events, in causal order | |---|---| | Normal crypto withdrawal | `withdrawal.created` (`PENDING`) → `withdrawal.status.updated` (`COMPLETED`, with `transactionHash` by then) | | Withdrawal that fails before broadcast | `withdrawal.created` (`PENDING`) → `withdrawal.status.updated` (`FAILED`, `transactionHash: null`) | | Crypto deposit | `deposit.created` → `deposit.status.updated` (`COMPLETED`) | | Automated fiat deposit | `deposit.created` (`COMPLETED`) — **and nothing else**: it is born terminal | | Simple conversion | `order.created` (`NEW`) → `fill.created` → `order.status.updated` (`FILLED`) | | On-credit conversion | `order.created` (`NEW`) → `fill.created` × N, interleaved with `order.status.updated` (`PARTIALLY_FILLED`) → `order.status.updated` (`FILLED`) | ### Delivery order Deliveries for the **same resource** to the **same endpoint** are causally ordered: they are grouped by `(endpoint, resourceId)` and delivered serially, by `occurredAt`, breaking ties with `.created` first. So an `order.status.updated` can never overtake its own `order.created`, not even when the source stamps both in the same millisecond. :::caution[A fill is **not** ordered against its order] That grouping is **by `resourceId`**, and an order and its fills have different `resourceId`s — so they land in different groups and can be delivered in any order relative to each other. It is entirely possible to receive a `fill.created` **before** the `order.created` of its own order. Do not treat an orphan fill as an error: store it by `orderId` and reconcile when the order arrives, or read the order over REST. The same applies across different resources: there is no ordering at all between a withdrawal and a conversion. ::: ### Change policy New fields can appear in `data` at any time. Treat it as an extensible object: parse the fields you care about and ignore the rest. ## Suppliers are consumers too A **supplier** — the counterparty that executes a conversion — is a **first-class** API consumer, symmetric with any other: same keys, same REST endpoints, same webhooks, for its own reconciliation and automation. What it sees is **its own side**: - its own withdrawals and deposits; - the orders and fills for which **it was the selected supplier**. A conversion is bilateral: one execution produces an event for the customer and another for the supplier, each from **its** perspective (`side` is flipped). This is not an internal or hidden capability — it is the same contract documented on this page. ## Verifying the signature Every delivery includes a **detached JWS** signature (EdDSA/Ed25519) in the `skipo-webhook-signature` header, with a signed timestamp in the critical header `skipo.io/iat`. Verify it against the public key published in the **JWKS**: ``` GET https://api.skipo.com/v2/.well-known/webhook-jwks.json ``` The header value is a JWS in **detached** form — the payload segment is empty, so it has two dots with nothing between them: ``` eyJhbGciOiJFZERTQSIsImtpZCI6IndoaS….. ``` The protected header carries the key id and the signed timestamp: ```json { "alg": "EdDSA", "kid": "whk_939492a5e222", "crit": ["skipo.io/iat"], "skipo.io/iat": 1786030000 } ``` Verification steps: 1. Split the header on `.` into ``, an empty segment, and ``. 2. Look up the JWK whose `kid` matches the protected header's (cache the JWKS). 3. Rebuild the signing input by **re-inserting the payload**: ` + "." + base64url(rawBody)`. 4. Verify the Ed25519 signature over that input. 5. Check `skipo.io/iat` against your clock — reject anything more than **300 seconds** (5 minutes) out, in either direction. This is what stops a captured delivery being replayed. :::warning Always verify against the **raw bytes** of the body, before parsing the JSON. Re-serializing the JSON changes the bytes and the signature will not validate — even when the parsed object is identical. ::: ### Node.js No dependencies — `node:crypto` verifies Ed25519 and accepts the JWK directly. ```js import crypto from 'node:crypto' const MAX_SKEW_SECONDS = 300 function verifySkipoWebhook(rawBody, signatureHeader, jwks) { const [protectedB64, empty, signatureB64] = signatureHeader.split('.') if (empty !== '' || !protectedB64 || !signatureB64) throw new Error('malformed signature header') const header = JSON.parse(Buffer.from(protectedB64, 'base64url').toString('utf8')) if (header.alg !== 'EdDSA') throw new Error(`unexpected alg ${header.alg}`) const jwk = jwks.keys.find((k) => k.kid === header.kid) if (!jwk) throw new Error(`unknown kid ${header.kid}`) // Detached JWS: re-insert the base64url payload to rebuild the signing input. const signingInput = `${protectedB64}.${Buffer.from(rawBody).toString('base64url')}` const key = crypto.createPublicKey({ key: jwk, format: 'jwk' }) const ok = crypto.verify(null, Buffer.from(signingInput), key, Buffer.from(signatureB64, 'base64url')) if (!ok) throw new Error('bad signature') const iat = header['skipo.io/iat'] if (typeof iat !== 'number') throw new Error('missing skipo.io/iat') if (Math.abs(Math.floor(Date.now() / 1000) - iat) > MAX_SKEW_SECONDS) { throw new Error('stale signature (replay?)') } return JSON.parse(Buffer.from(rawBody).toString('utf8')) } ``` In Express, get the raw bytes with `express.raw({ type: 'application/json' })` — `express.json()` parses and discards them, and the signature can no longer be checked. ### Python ```python import base64, json, time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature MAX_SKEW_SECONDS = 300 def _b64u_decode(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _b64u_encode(b: bytes) -> str: return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def verify_skipo_webhook(raw_body: bytes, signature_header: str, jwks: dict) -> dict: parts = signature_header.split(".") if len(parts) != 3 or parts[1] != "": raise ValueError("malformed signature header") protected_b64, _, signature_b64 = parts header = json.loads(_b64u_decode(protected_b64)) if header.get("alg") != "EdDSA": raise ValueError(f"unexpected alg {header.get('alg')}") jwk = next((k for k in jwks["keys"] if k.get("kid") == header.get("kid")), None) if jwk is None: raise ValueError(f"unknown kid {header.get('kid')}") # Detached JWS: re-insert the base64url payload to rebuild the signing input. signing_input = f"{protected_b64}.{_b64u_encode(raw_body)}".encode() key = Ed25519PublicKey.from_public_bytes(_b64u_decode(jwk["x"])) try: key.verify(_b64u_decode(signature_b64), signing_input) except InvalidSignature: raise ValueError("bad signature") iat = header.get("skipo.io/iat") if not isinstance(iat, int): raise ValueError("missing skipo.io/iat") if abs(int(time.time()) - iat) > MAX_SKEW_SECONDS: raise ValueError("stale signature (replay?)") return json.loads(raw_body) ``` ### Key rotation `kid` exists so the signing key can be rotated without breaking you. Select the JWK by the `kid` in the protected header rather than assuming a single key, cache the JWKS, and re-fetch it when you see an unknown `kid`. Do not pin the key material itself. ## Retries If your endpoint does not respond `2xx`, Skipo retries on a backoff schedule. An endpoint that fails persistently is automatically **suspended**, and you can re-enable it from the dashboard. Deliveries for the same resource are serialized on a best-effort basis, but **ordering is not guaranteed**: use `createdAt` and the resource's own `status` to decide which state is most recent, rather than assuming arrival order. ### Resends A resent event carries the **same** logical `id`, so if you deduplicate on it (as you should) a resend of something you already processed is a no-op. It arrives with a **new** `skipo-webhook-delivery-id`, which is how you tell a redelivery from the original. :::info[Endpoint management lives in the dashboard] Registering, editing or suspending a webhook endpoint is done from the Skipo dashboard, not from the key-authenticated API. This is deliberate: a leaked key must not be able to redirect your notifications. ::: --- ## Withdrawal fees An external crypto withdrawal pays a **flat** fee, denominated in the asset being withdrawn. It is not a percentage: withdrawing 10 USDT and withdrawing 284,000 USDT cost the same. **Internal** withdrawals (Skipo account to Skipo account) and **bank payouts** pay no fee. :::info This is not the network fee The fee you see in the API is the one **Skipo** charges. The on-chain gas cost is absorbed by Skipo and is never billed to you or shown in your movements. ::: ## `feeMode` `POST /v2/withdrawals` takes an optional `feeMode` field that decides how `amount` is read: | `feeMode` | `amount` is… | Debited | Arrives at destination | |---|---|---|---| | `deduct` **(default)** | the total to **debit** | `amount` | `amount − fee` | | `add` | the amount that must **arrive** | `amount + fee` | `amount` | `deduct` is what Binance, Kraken, Coinbase and OKX do. It is the one you want for **sweeping a balance**: send the balance and you are done. `add` is for when the arriving figure has to be exact — settling an invoice, a payroll line, topping an address up to a specific number. Both modes are equally supported and the response is identical in shape: it always carries all three numbers, so you never have to re-derive anything. ```json { "amount": "999.5", // what is sent to the destination "fee": "0.5", // Skipo's fee "total": "1000" // what leaves the balance } ``` `total = amount + fee` holds in both modes. The only thing that changes is which of the two you supplied. ### Example A balance of 1,000 USDT on BNB Smart Chain, fee 0.5 USDT. ```http POST /v2/withdrawals { "assetSymbol": "USDT", "amount": "1000", "contactId": "..." } ``` With the `deduct` default, 1,000 USDT is debited and 999.5 arrives. For exactly 1,000 to arrive, send `"amount": "1000"` with `"feeMode": "add"` — 1,000.5 will be debited. :::caution Coming from v1, or integrated before August 2026 This endpoint previously behaved like `add`. If your code already sends `balance − fee`, add `"feeMode": "add"` and it keeps working exactly as before, with no change to your arithmetic. ::: ## The fee depends on the NETWORK, and the body does not name one `POST /v2/withdrawals` takes `assetSymbol`, `amount` and the contact. It does **not** take a network. The chain — and therefore the fee — is whichever one the destination contact is registered on. This matters because the same asset can cost very different amounts per chain. USDT is the live case: 0.5 USDT on BNB Smart Chain, 6 USDT on Tron. Same `assetSymbol`, twelve times the fee. :::danger Do not use the asset-level `withdrawalFee` In `GET /v2/assets`, the `withdrawalFee` hanging off the asset is the **cheapest chain's**. For USDT that is `0.5`, and sizing a Tron withdrawal with it understates the cost by 5.5 USDT. The authoritative value is `networks[].withdrawalFee`. ::: ### How to read the right fee ```js // 1. Which chain is this destination on? const contact = await get(`/v2/contacts/${contactId}`) const networkId = contact.crypto.networkId // "BSC" | "TRON" | "ETH" | ... // 2. The fee and minimum for THAT chain. const asset = await get('/v2/assets?assetSymbol=USDT') const network = asset.networks.find((n) => n.networkId === networkId) network.withdrawalFee // what Skipo charges on this chain network.minimumWithdrawal // the effective minimum on this chain ``` `networkId` means the same thing on `/v2/assets`, `/v2/contacts` and on movements, so they join without parsing prose. ## Minimums `networks[].minimumWithdrawal` is the **effective** minimum for that chain: it already resolves to the larger of the platform floor and the chain's own floor. Validate against it rather than the asset-level `minimumWithdrawal`, which reports only the platform half and can sit below what the chain will accept. The minimum applies to **what is sent**. Under the `deduct` default that is `amount − fee`, so the smallest `amount` a chain accepts is `minimumWithdrawal + withdrawalFee`. On some assets the fee sits near or above the minimum — DOGE charges 5 and permits a minimum of 5 — so compute it rather than assuming. ## Related errors | Situation | Response | |---|---| | Balance does not cover `total` | `422` [`unprocessable`](/errors/unprocessable) — *Insufficient available balance for this withdrawal.* | | With `deduct`, `amount` does not cover the fee | `422` [`unprocessable`](/errors/unprocessable) — nothing would reach the destination. The account may be perfectly well funded: what is missing is amount, not funds. | | Amount falls below the chain's minimum | `422` [`unprocessable`](/errors/unprocessable) | A rejection moves nothing: the balance is checked before any funds are held. And if an accepted withdrawal later **fails**, the full `total` — amount and fee — is returned. Skipo does not keep the fee on a failed send. ## Where the fee shows up afterwards `GET /v2/withdrawals` and `GET /v2/withdrawals/{id}` return `amount`, `fee` and `total` on every withdrawal, the same as the creation response. The `withdrawal.status.updated` webhook publishes the same three fields. A withdrawal is **one** row, fee included: the fee is never emitted as a separate movement, so summing `total` over the list gives the account's real outflow with no filtering. See also: [Withdrawals](/api/withdrawals) · [Assets](/api/assets) · [Contacts](/api/contacts). --- ## api_access_disabled {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `api_access_disabled` API access for this account has been disabled. | HTTP | `code` | Retryable? | |---|---|---| | 403 | `api_access_disabled` | no | ## When it happens A Skipo operator suspended the account's access to the public API. It applies to every key and every authentication method, not to one particular key. ## How to resolve it Contact support. The credential itself is valid: re-authenticating or creating a new key will not restore access — only an operator can lift it. ## Example ```json { "type": "https://docs.skipo.com/errors/api_access_disabled", "title": "API access disabled", "status": 403, "code": "api_access_disabled", "retryable": false, "detail": "API access for this account has been disabled." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## clock_skew {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `clock_skew` The client clock is too far ahead of the server. | HTTP | `code` | Retryable? | |---|---|---| | 401 | `clock_skew` | yes | ## When it happens The `iat` claim is ahead of the server time by more than the allowed tolerance. ## How to resolve it Sync your clock with `GET /v2/time` and re-sign. This is retryable once the skew is corrected. ## Example ```json { "type": "https://docs.skipo.com/errors/clock_skew", "title": "Clock skew too large", "status": 401, "code": "clock_skew", "retryable": true, "detail": "The client clock is too far ahead of the server." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## conflict {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `conflict` The request conflicts with the resource's current state. | HTTP | `code` | Retryable? | |---|---|---| | 409 | `conflict` | no | ## When it happens A change that contradicts the present state (for example a duplicate name). ## How to resolve it Re-read the resource, adjust the request to the current state, and retry. ## Example ```json { "type": "https://docs.skipo.com/errors/conflict", "title": "Conflict", "status": 409, "code": "conflict", "retryable": false, "detail": "The request conflicts with the resource's current state." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## downstream_unavailable {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `downstream_unavailable` A service the operation depends on is unavailable. | HTTP | `code` | Retryable? | |---|---|---| | 502 | `downstream_unavailable` | yes | ## When it happens An internal component or provider is down or not responding. ## How to resolve it Retry with backoff. This usually resolves on its own within a short time. ## Example ```json { "type": "https://docs.skipo.com/errors/downstream_unavailable", "title": "Downstream service unavailable", "status": 502, "code": "downstream_unavailable", "retryable": true, "detail": "A service the operation depends on is unavailable." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## idempotency_conflict {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `idempotency_conflict` An idempotency key was reused with a different body. | HTTP | `code` | Retryable? | |---|---|---| | 409 | `idempotency_conflict` | no | ## When it happens You sent the same idempotency key with different parameters than the original request. ## How to resolve it Use a new idempotency key for a different request, or resend the exact same body. ## Example ```json { "type": "https://docs.skipo.com/errors/idempotency_conflict", "title": "Idempotency conflict", "status": 409, "code": "idempotency_conflict", "retryable": false, "detail": "An idempotency key was reused with a different body." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## Error catalog {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # Error catalog Every error response uses the [`application/problem+json` (RFC 9457)](/concepts/errors) format. The `type` field points to the catalog page for its `code`: `https://docs.skipo.com/errors/{code}`. The `code` is a **stable contract** — branch your logic on it. | `code` | HTTP | Retryable? | Title | |---|---|---|---| | [`validation_error`](/errors/validation_error) | 400 | no | Validation error | | [`unauthorized`](/errors/unauthorized) | 401 | no | Unauthorized | | [`invalid_signature`](/errors/invalid_signature) | 401 | no | Invalid signature | | [`clock_skew`](/errors/clock_skew) | 401 | yes | Clock skew too large | | [`nonce_reused`](/errors/nonce_reused) | 401 | yes | Nonce already used | | [`key_expired`](/errors/key_expired) | 401 | no | API key expired | | [`key_revoked`](/errors/key_revoked) | 401 | no | API key revoked | | [`insufficient_scope`](/errors/insufficient_scope) | 403 | no | Insufficient scope | | [`ip_not_allowed`](/errors/ip_not_allowed) | 403 | no | IP address not allowed | | [`two_factor_required`](/errors/two_factor_required) | 403 | no | Two-factor required | | [`permission_denied`](/errors/permission_denied) | 403 | no | Permission denied | | [`api_access_disabled`](/errors/api_access_disabled) | 403 | no | API access disabled | | [`not_found`](/errors/not_found) | 404 | no | Resource not found | | [`conflict`](/errors/conflict) | 409 | no | Conflict | | [`idempotency_conflict`](/errors/idempotency_conflict) | 409 | no | Idempotency conflict | | [`key_limit_reached`](/errors/key_limit_reached) | 409 | no | API key limit reached | | [`quotation_expired`](/errors/quotation_expired) | 410 | yes | Quotation expired | | [`unprocessable`](/errors/unprocessable) | 422 | no | Unprocessable request | | [`rate_limited`](/errors/rate_limited) | 429 | yes | Rate limit exceeded | | [`internal_error`](/errors/internal_error) | 500 | yes | Internal server error | | [`downstream_unavailable`](/errors/downstream_unavailable) | 502 | yes | Downstream service unavailable | | [`maintenance`](/errors/maintenance) | 503 | yes | Service under maintenance | | [`service_degraded`](/errors/service_degraded) | 503 | yes | Service degraded | --- ## insufficient_scope {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `insufficient_scope` The key does not have the scope required for the operation. | HTTP | `code` | Retryable? | |---|---|---| | 403 | `insufficient_scope` | no | ## When it happens You called an endpoint that requires a scope the key does not include (see `requiredScopes` in the response). ## How to resolve it Create a key with the required scopes. Scopes are fixed when the key is created and cannot be expanded afterward. ## Example ```json { "type": "https://docs.skipo.com/errors/insufficient_scope", "title": "Insufficient scope", "status": 403, "code": "insufficient_scope", "retryable": false, "detail": "The key does not have the scope required for the operation." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## internal_error {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `internal_error` An unexpected error occurred on Skipo's side. | HTTP | `code` | Retryable? | |---|---|---| | 500 | `internal_error` | yes | ## When it happens An unhandled server-side failure. ## How to resolve it Retry with backoff. If it persists, contact Skipo with the `traceId` carried in this response body. ## Example ```json { "type": "https://docs.skipo.com/errors/internal_error", "title": "Internal server error", "status": 500, "code": "internal_error", "retryable": true, "detail": "An unexpected error occurred on Skipo's side." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## invalid_signature {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `invalid_signature` The signed JWT does not verify against the registered public key, or one of its claims does not match the request. | HTTP | `code` | Retryable? | |---|---|---| | 401 | `invalid_signature` | no | ## When it happens Incorrect signature, `sub`/`uri`/`bodyHash` that do not match, `exp - iat > 60s`, or a missing `nonce`. ## `reason` values The response carries a `reason` field naming **the first check that failed**. Checks run in the order of the table below, so a `bad_body_hash` means `sub` and `uri` already matched. Branch on `reason`, not on `detail`. | `reason` | Meaning | |---|---| | `no_signing_key` | The key has no active signing key. Upload the public key (SPKI) from the panel. | | `bad_jwt` | The JWT does not verify against any active signing key, or it is malformed or expired. We verify against **all** active keys, so this is not a rotation overlap. | | `bad_sub` | The `sub` claim is not identical to the `X-API-Key` header. Both are the key **prefix**. | | `bad_uri_claim` | The `uri` claim is not `"METHOD /path?query"` exactly as sent, query string included. | | `missing_iat_exp` | `iat` or `exp` is missing, or is not a number. | | `exp_too_far` | `exp - iat > 60` seconds. | | `bad_body_hash` | `bodyHash` is not the SHA-256 hex of the raw bytes you sent. Serialize the body once and send **those** bytes. | | `missing_nonce` | The `nonce` claim is missing. A repeated `nonce` is a different error: [`nonce_reused`](/errors/nonce_reused). | ## How to resolve it Rebuild the JWT: `sub` = the key prefix, `uri` = the exact `"METHOD /path?query"`, `bodyHash` = SHA-256 hex of the raw request body bytes, `exp ≤ iat + 60`. Sign with the private key whose public counterpart you uploaded. ## Example ```json { "type": "https://docs.skipo.com/errors/invalid_signature", "title": "Invalid signature", "status": 401, "code": "invalid_signature", "retryable": false, "detail": "The signed JWT does not verify against the registered public key, or one of its claims does not match the request.", "reason": "bad_sub" } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## ip_not_allowed {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `ip_not_allowed` The source IP is not in the key's list of allowed IPs. | HTTP | `code` | Retryable? | |---|---|---| | 403 | `ip_not_allowed` | no | ## When it happens The key has an allowed-IP list and the request came from a different address. ## How to resolve it Add your server's outbound IP to the key's allowlist, or call from an authorized IP. ## Example ```json { "type": "https://docs.skipo.com/errors/ip_not_allowed", "title": "IP address not allowed", "status": 403, "code": "ip_not_allowed", "retryable": false, "detail": "The source IP is not in the key's list of allowed IPs." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## key_expired {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `key_expired` The API key has reached its expiration date. | HTTP | `code` | Retryable? | |---|---|---| | 401 | `key_expired` | no | ## When it happens The key had an expiration configured and it has now passed. ## How to resolve it Create a new key from the dashboard and update your integration. ## Example ```json { "type": "https://docs.skipo.com/errors/key_expired", "title": "API key expired", "status": 401, "code": "key_expired", "retryable": false, "detail": "The API key has reached its expiration date." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## key_limit_reached {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `key_limit_reached` The account has reached its maximum number of API keys. | HTTP | `code` | Retryable? | |---|---|---| | 409 | `key_limit_reached` | no | ## When it happens You tried to create a key beyond the account's limit. ## How to resolve it Revoke a key you no longer use before creating another, or contact Skipo to raise the limit. ## Example ```json { "type": "https://docs.skipo.com/errors/key_limit_reached", "title": "API key limit reached", "status": 409, "code": "key_limit_reached", "retryable": false, "detail": "The account has reached its maximum number of API keys." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## key_revoked {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `key_revoked` The API key has been revoked. | HTTP | `code` | Retryable? | |---|---|---| | 401 | `key_revoked` | no | ## When it happens Someone revoked the key from the dashboard (or it was revoked for security reasons). ## How to resolve it Create a new key and replace it in your integration. If you did not expect the revocation, contact Skipo. ## Example ```json { "type": "https://docs.skipo.com/errors/key_revoked", "title": "API key revoked", "status": 401, "code": "key_revoked", "retryable": false, "detail": "The API key has been revoked." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## maintenance {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `maintenance` The API is under maintenance. | HTTP | `code` | Retryable? | |---|---|---| | 503 | `maintenance` | yes | ## When it happens A planned maintenance window; almost all routes return 503 except `/health` and `/v2/time`. ## How to resolve it Retry later, respecting `Retry-After`. ## Example ```json { "type": "https://docs.skipo.com/errors/maintenance", "title": "Service under maintenance", "status": 503, "code": "maintenance", "retryable": true, "detail": "The API is under maintenance." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## nonce_reused {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `nonce_reused` The JWT `nonce` has already been used (anti-replay protection). | HTTP | `code` | Retryable? | |---|---|---| | 401 | `nonce_reused` | yes | ## When it happens You resent a JWT that was already seen, or generated the same `nonce` twice within the retention window. ## How to resolve it Generate a unique `nonce` per request (for example a UUID v4) and re-sign. ## Example ```json { "type": "https://docs.skipo.com/errors/nonce_reused", "title": "Nonce already used", "status": 401, "code": "nonce_reused", "retryable": true, "detail": "The JWT `nonce` has already been used (anti-replay protection)." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## not_found {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `not_found` The requested resource does not exist or does not belong to your account. | HTTP | `code` | Retryable? | |---|---|---| | 404 | `not_found` | no | ## When it happens A nonexistent identifier, or a resource owned by another account (Skipo does not reveal its existence). ## How to resolve it Check the identifier. Remember that you can only access your own resources. ## Example ```json { "type": "https://docs.skipo.com/errors/not_found", "title": "Resource not found", "status": 404, "code": "not_found", "retryable": false, "detail": "The requested resource does not exist or does not belong to your account." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## permission_denied {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `permission_denied` The account is not authorized for this operation. | HTTP | `code` | Retryable? | |---|---|---| | 403 | `permission_denied` | no | ## When it happens Account-level restrictions block the action, regardless of the key's scope. ## How to resolve it Contact Skipo if you believe your account should have access. ## Example ```json { "type": "https://docs.skipo.com/errors/permission_denied", "title": "Permission denied", "status": 403, "code": "permission_denied", "retryable": false, "detail": "The account is not authorized for this operation." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## quotation_expired {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `quotation_expired` The quote you are trying to confirm has already expired. | HTTP | `code` | Retryable? | |---|---|---| | 410 | `quotation_expired` | yes | ## When it happens You confirmed a conversion too late; quotes have a short lifetime. ## How to resolve it Request a new quote (`POST /v2/quotes`) and confirm it right away. ## Example ```json { "type": "https://docs.skipo.com/errors/quotation_expired", "title": "Quotation expired", "status": 410, "code": "quotation_expired", "retryable": true, "detail": "The quote you are trying to confirm has already expired." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## rate_limited {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `rate_limited` You exceeded the rate limit or the key/account quota. | HTTP | `code` | Retryable? | |---|---|---| | 429 | `rate_limited` | yes | ## When it happens Too many requests in the window, or the daily quota is exhausted. ## How to resolve it Respect the `Retry-After` header and apply exponential backoff. See the Rate limits guide. ## Example ```json { "type": "https://docs.skipo.com/errors/rate_limited", "title": "Rate limit exceeded", "status": 429, "code": "rate_limited", "retryable": true, "detail": "You exceeded the rate limit or the key/account quota." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## service_degraded {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `service_degraded` The service is operating in a degraded mode and temporarily rejected the request. | HTTP | `code` | Retryable? | |---|---|---| | 503 | `service_degraded` | yes | ## When it happens Load-shedding protection or a degraded dependency. ## How to resolve it Retry with backoff, respecting `Retry-After`. ## Example ```json { "type": "https://docs.skipo.com/errors/service_degraded", "title": "Service degraded", "status": 503, "code": "service_degraded", "retryable": true, "detail": "The service is operating in a degraded mode and temporarily rejected the request." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## two_factor_required {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `two_factor_required` The operation requires second-factor verification (2FA). | HTTP | `code` | Retryable? | |---|---|---| | 403 | `two_factor_required` | no | ## When it happens Sensitive account-management operations that require 2FA. ## How to resolve it Complete the 2FA step from the dashboard. Sensitive management (creating keys, changing destinations) is dashboard-only, not available via the API. ## Example ```json { "type": "https://docs.skipo.com/errors/two_factor_required", "title": "Two-factor required", "status": 403, "code": "two_factor_required", "retryable": false, "detail": "The operation requires second-factor verification (2FA)." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## unauthorized {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `unauthorized` The credential is missing or invalid. | HTTP | `code` | Retryable? | |---|---|---| | 401 | `unauthorized` | no | ## When it happens You did not send `Authorization: Bearer`, the key is unknown, or a signed operation arrived without `X-API-Key` + JWT. ## `reason` values On a signed operation, `X-API-Key` takes the key **prefix**, not the full secret. Sending the full secret resolves no key and returns this error with `detail: "Unknown API key."` — not `invalid_signature`. | `reason` | Meaning | |---|---| | `signed_jwt_on_bearer_route` | You signed a Tier-1 endpoint. Your key is fine — the signature is what does not belong. Resend with `Authorization: Bearer ` and no `X-API-Key`. | ## How to resolve it Make sure you are sending the correct key for the environment (`skp_live_` vs `skp_test_`). For money-moving operations, use the signed flow (level 2). ## Example ```json { "type": "https://docs.skipo.com/errors/unauthorized", "title": "Unauthorized", "status": 401, "code": "unauthorized", "retryable": false, "detail": "The credential is missing or invalid." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## unprocessable {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `unprocessable` The request is syntactically valid but cannot be processed due to a business rule. | HTTP | `code` | Retryable? | |---|---|---| | 422 | `unprocessable` | no | ## When it happens For example, insufficient balance or a disallowed destination. ## How to resolve it Check `detail` for the specific rule that failed and adjust the operation. ## Example ```json { "type": "https://docs.skipo.com/errors/unprocessable", "title": "Unprocessable request", "status": 422, "code": "unprocessable", "retryable": false, "detail": "The request is syntactically valid but cannot be processed due to a business rule." } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## validation_error {/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */} # `validation_error` The request does not match the expected schema (missing field, wrong type, or invalid value). | HTTP | `code` | Retryable? | |---|---|---| | 400 | `validation_error` | no | ## When it happens The body, query parameters, or headers fail input validation. This includes one case that surprises many clients: **a parameter we do not recognise is rejected, not ignored**. Send a misspelled or retired parameter name and you get a 400 rather than a silently unfiltered response — deliberate on a money API, where an ignored filter returns the wrong answer without telling you. ## How to resolve it Read `detail` (every failure, joined with `; `) and the `errors` array, which carries the same messages one per entry for programmatic handling. They name the exact field: `property asset should not exist` means that parameter does not exist — check its name in the endpoint reference. Do not retry without changing the request. ## Example ```json { "type": "https://docs.skipo.com/errors/validation_error", "title": "Validation error", "status": 400, "code": "validation_error", "retryable": false, "detail": "property asset should not exist; assetSymbol must be a valid asset symbol" } ``` --- Back to the [error catalog](/errors) · See the [error format](/concepts/errors). --- ## Environments Skipo exposes **a single environment**: production. | Environment | Base URL | |---|---| | Production | `https://api.skipo.com/v2` | :::warning There is no test environment. With any key, whatever its prefix, you read real balances, quote against real prices, and execute real withdrawals and conversions. The prefix (`skp_live_…` or `skp_test_…`) is only a key format: the API does not read it and it does not change the behavior of any operation. ::: ## Standard headers The API does **not** return an `X-Request-Id` header. The trace used for support and debugging travels in the body of error responses, but you supply it: - Send your own `X-Request-Id` header on every request. Skipo reuses it as that request's `traceId`, so you can correlate it with your own logs. - `traceId` — a field in the error body (`application/problem+json`). It appears only if you sent that header; quote it when reporting issues. ## Time zone and clocks Signed operations (level 2) are sensitive to clock skew. Check the server time with `GET /v2/time` before signing if you suspect a mismatch. --- ## Quickstart In this guide you'll make your first authenticated call to the Skipo API using a read-only _bearer_ key. ## 1. Get an API key Keys are generated from the Skipo dashboard (self-service, with 2FA verification). Each key is created with a set of **scopes** and is shown **only once**. Store the key somewhere safe; Skipo only keeps its hash. :::warning[There is no test environment] Every key operates on real balances and real pricing: a withdrawal or an order really executes. For your first call, use a read-only operation such as `GET /v2/balances`. ::: ## 2. Make a read call Reads and non-sensitive writes are authenticated with the key in the `Authorization: Bearer` header: ```bash curl https://api.skipo.com/v2/balances \ -H "Authorization: Bearer skp_live_your_key_here" ``` Response (abbreviated): ```json [ { "assetSymbol": "USDC", "balance": "1250.00", "balanceFrozen": "0", "balancePending": "0", "balancePrefCurrency": "1187500", "balanceUSD": "1250.00" } ] ``` ## 3. Explore the reference Every available operation is documented in the **[API Reference](/reference)**, with code examples in several languages generated from the OpenAPI specification. The always-current specification is served by the API itself at [`https://api.skipo.com/v2/openapi.json`](https://api.skipo.com/v2/openapi.json). ## Next steps - **[Authentication](/concepts/authentication)** — how to sign operations that move money (withdrawals, conversions). - **[Webhooks](/concepts/webhooks)** — get notified when your transactions change instead of _polling_. - **[Rate limits](/concepts/rate-limits)** — how to read the `RateLimit-*` headers and avoid the `429`. --- ## Skipo API Welcome to the documentation for the **Skipo public API** (`v2`). With the API you can look up balances and transactions, retrieve market data, create withdrawals and conversions, and receive signed **webhooks** whenever your transactions change. :::info[Version] This documentation covers version **v2**, served under `https://api.skipo.com/v2`. Version v1 remains available until **September 10, 2026**, when it is retired — see [Migrating from v1](/migration-from-v1). ::: ## Start here - **[Getting started → Quickstart](/getting-started/quickstart)** — your first authenticated call in under five minutes. - **[Authentication](/concepts/authentication)** — the two-tier scheme: _bearer_ keys for reads and a per-request signed JWT to move money. - **[API Reference](/reference)** — every operation, schema, and example, rendered from the OpenAPI specification. The always-current version is served by the API itself at [`api.skipo.com/v2/openapi.json`](https://api.skipo.com/v2/openapi.json). - **[Endpoint reference](/api)** — the same surface in text, one page per section, made to be read and searched. ## For AI agents and tools The documentation is also published in a language-model-friendly format: - [`/llms.txt`](pathname:///llms.txt) — a curated index of the documentation. - [`/llms-full.txt`](pathname:///llms-full.txt) — all content in a single file. - [`/openapi.json`](pathname:///openapi.json) — the raw OpenAPI specification. Prefer to connect your account directly to an agent? Use the [read-only MCP server](/mcp). ## Key concepts | Concept | Description | |---|---| | [Authentication](/concepts/authentication) | `skp_live_…` / `skp_test_…` keys and EdDSA signing for sensitive operations. | | [Rate limits](/concepts/rate-limits) | Per-key limits, `RateLimit-*` headers, and monthly quotas. | | [Errors](/concepts/errors) | `application/problem+json` responses (RFC 9457) with a stable `code`. | | [Pagination](/concepts/pagination) | A consistent wrapper: offset by default, cursor on fills. | | [Ids and correlation](/concepts/ids-and-correlation) | How withdrawals, deposits, orders, fills and the ledger link together. | | [Webhooks](/concepts/webhooks) | The 7 events, the envelope, and the JWS (EdDSA) signature verifiable via JWKS. | | [Funding your account](/concepts/funding-your-account) | Crypto deposit addresses, the CLP bank accounts, and the sender national-id rule. | | [Withdrawal fees](/concepts/withdrawal-fees) | `feeMode`, sweeping a balance, and why the fee depends on the network. | | [Tokenized equities](/concepts/tokenized-equities) | `assetFormat`, the multiplier, and why it is point-in-time. | --- ## MCP Server The **Skipo MCP server** exposes the public v2 API over the [Model Context Protocol](https://modelcontextprotocol.io), so you can connect your account to **Claude** (Desktop, Code, or any compatible client) and ask about your balances, transactions, and FX activity in natural language. The agent translates your questions into read calls against the API. :::info[Read-only] The Skipo MCP server **only issues `GET` requests**. It cannot move money, create withdrawals, or execute conversions — even if asked. There is no write tool at all. ::: ## How to connect Skipo hosts the server; there is nothing to install and you handle no API keys. - **[Remote connector (OAuth)](/mcp/remote-connector)** — add `https://api.skipo.com/mcp` as a custom connector in your client, sign in with your Skipo account (OAuth 2.1), and approve read-only access. You can revoke it at any time from the dashboard, under **Connected apps**. ## Tools There are **17 tools**, all read-only, mapping to `GET` operations of the [v2 API](/reference). Amounts are returned as **decimal strings** (preserve precision) and dates in **ISO-8601**. Paginated collections arrive as `{ items, pagination }`. Note this is **not the same wrapper as the REST API**, which uses [`{ data, pagination }`](/concepts/pagination) — the MCP server renames `data` to `items` so every tool returns one consistent shape. The `pagination` block is identical. `get_balances` is the exception: it returns the full list as an array, with no wrapper and no pagination. ### Account & balances Requires the `accounts:read` scope. | Tool | Returns | |---|---| | `get_account` | Account profile: name, national ID, verification status, preferred currency. | | `get_balances` | Per-currency balances (available, frozen, pending, USD value). | | `get_balance_history` | Historical balance snapshots for one currency (day/week/month buckets). | | `get_ledger` | Ledger entries for **one** currency, each with its `source` — the reconciliation surface. | ### Transfers Requires the `transfers:read` scope. | Tool | Returns | |---|---| | `get_withdrawals` | Withdrawal history with filters; or one by `transaction_id`. | | `get_deposits` | Deposit history with filters; or one by `transaction_id`. Carries sender attribution. | | `get_deposit_addresses` | The crypto addresses to send to in order to fund the account, one per (asset, chain) pair. | | `get_deposit_instructions` | Skipo's CLP bank accounts. The transfer must come from an account in the holder's own name and national id. | ### Convert (orders & fills) Requires the `trading:read` scope. | Tool | Returns | |---|---| | `get_orders` | Convert orders, with filters. It has no fetch-one-by-id mode — narrow with the filters. | | `get_fills` | Fills (executions) across **all** orders — when the question spans orders. | | `get_order_fills` | The fills of **one** order, when you already have its id. | ### Contacts Requires the `contacts:read` scope. | Tool | Returns | |---|---| | `get_contacts` | Saved transfer destinations (crypto/bank); or one by `contact_id`. | ### Market data Requires the `market_data:read` scope. | Tool | Returns | |---|---| | `get_assets` | Supported assets (fiat/stablecoin/crypto/stock) and their constraints; or one by `asset_symbol`. | | `get_markets` | Supported markets (pairs, minimums); or one by `market`. | | `get_indicative_price` | An **indicative** (non-binding) price for a market: the rate and the converted amount, for buying or selling. Nothing is reserved, there is no expiry and no order id is returned, so it **cannot be executed**. | :::caution[An indicative price is not a quote] `get_indicative_price` answers "what rate would I get?", not "hold this rate for me". Skipo prices a dealer spread, so the rate **depends on size and does not scale linearly**: pass `amount` + `amount_asset` for a specific size, or omit both and the price is for the **market minimum** (do not multiply it up to estimate a larger amount). `amount` is capped by the asset's `maxIndicativeQuoteAmount` (see `get_assets`); above the cap the call is rejected and the error states the limit. Obtaining a **confirmable** quote is `POST /v2/quotes` (scope `trading:write`), and executing it requires a signed request — **neither is reachable through this connector**. It is also the **most expensive** tool: **10 points** per call versus 1–3 for the rest, because it reaches an external pricing provider. See [tool costs](/concepts/rate-limits). ::: ### Service status No scope; useful for diagnostics and anchoring relative dates. | Tool | Returns | |---|---| | `check_api_health` | API reachability/health check. | | `get_server_time` | Skipo server time (clock skew / relative dates). | ## Errors The server surfaces the v2 API's [RFC 9457](/concepts/errors) errors to the agent: the stable `code`, the human-readable `detail`, and a link to the [error catalog](/errors). When the rate limit is reached, the error also states how long to wait. The assistant can then decide to try again itself: **Skipo does not retry the call for you**. --- ## Remote connector (OAuth) The remote connector is the fastest way to connect your account: you **install nothing**. Your client (for example Claude) talks to the MCP server hosted by Skipo at `https://api.skipo.com/mcp`, and you authorize access with your own account via **OAuth 2.1** — no API keys to handle or paste. :::info[Read-only] The access granted is **read-only**. The connector cannot move money, create withdrawals, or execute conversions; it only reads information from your account. ::: ## Connect 1. In your MCP client, add a **custom connector** with the URL: ``` https://api.skipo.com/mcp ``` 2. The client redirects you to **sign in with Skipo**. Authenticate with your usual account (with 2FA verification where applicable). 3. Skipo shows a **consent screen** with the read-only access about to be granted. Review it and click **Approve**. 4. You return to your client and the connector is **ready**. You can now ask about your balances, transactions, and markets in natural language. ## What it can access The connector grants **read-only** access to the same set of `GET` operations the [MCP server](/mcp#tools) exposes: - Account, balances, balance history, and ledger movements. - Withdrawals and deposits, with the sender attribution on each deposit. - Deposit addresses and the fiat deposit instructions — where to send funds to top the account up. - Convert orders and their fills (executions). - Contacts. - Market data: assets, markets, and **indicative** (non-binding) **prices**. No write access is granted: the connector can never initiate money movement. An indicative price is not a quote either: it reserves nothing, does not expire, and cannot be executed. ## Revoke access You can cut off the connector's access at any time from the Skipo dashboard, under **Connected apps**: find the connection and click **Revoke**. From then on the connector can no longer read your account, and you'll need to authorize it again to use it. --- ## Migrating from v1 The **v2** API is a ground-up rebuild. It runs alongside v1 until **September 10, 2026**, when v1 is retired. ## Key changes ### Authentication (breaking change) The old scheme (`X-API-KEY` + RSA x509 signature) is **gone** in v2. In its place, we use the [two-tier scheme](/concepts/authentication): `skp_live_…` _bearer_ keys for reads, and EdDSA-signed JWTs to move money. Onboarding is self-service (no more certificate sent by email). ### Errors v2 always returns [`application/problem+json` (RFC 9457)](/concepts/errors) with a stable `code`, instead of inconsistent error formats. ### Route renames | v1 | v2 | |---|---| | `/convert_orders` | `/orders` | | `/converts` | ⚠️ `/fills` — **one row per execution**, not per order (see the warning below) | | `/converts/quotations` | `/quotes` | | `/converts/quotations:confirm` | `POST /orders` | | `/currencies` | `/balances` | | `/historical_balances/{currencyId}` | `/balances/{assetSymbol}/history` | | `/ledger_movements/{currencyId}` | `/ledger?assetSymbol=` | | `/supported_currencies` | `/assets` | | `/supported_markets` | `/markets` | | `/users/current` | `/account` | | `/convert_orders/id/{id}` | `GET /orders/{id}` | | `/converts/id/{id}` | ⚠️ no 1:1 equivalent — see [Lookups by id](#lookups-by-id-that-change-shape) | | `/…/clOrdId/{clOrdId}` | ⚠️ no equivalent — see [Lookups by id](#lookups-by-id-that-change-shape) | :::warning[`/converts` maps to `/fills`, not `/orders`] In v1, `/converts` returns **one row per execution** and `/convert_orders` one row per order. Their v2 equivalents are `/fills` and `/orders` respectively. If you point what today reads `/converts` at `/orders`, your row counts change: an order with several executions returns a single row from `/orders` where v1 returned several. ::: The resource is called an **order**, not a "conversion": Convert is the product brand, but the model is exchange-shaped (an order carrying a `type`), so future order types can be added without a breaking restructure. ### Lookups by id that change shape Three v1 routes have no 1:1 equivalent in v2: | v1 | v2 | What changes in your code | |---|---|---| | `/convert_orders/id/{id}` | `GET /v2/orders/{id}` | Nothing: still one row. | | `/converts/id/{id}` | `GET /v2/orders/{id}/fills` or `GET /v2/fills?orderId={id}` | **The cardinality changes**: you get a _list_ of executions, not a row. There is no `GET /v2/fills/{id}`. | | `/convert_orders/clOrdId/{clOrdId}` | — | **There is no lookup by `clientOrderId` in v2.** That field is returned only on the `POST /v2/quotes` response, alongside the `orderId` you *can* query later. Persist that mapping on your side when you quote. | :::note[`?sourceId=` exists only on the ledger] `?orderId=` is available on `/v2/fills` and `/v2/ledger`. `?sourceId=` is on `/v2/ledger` **only** — it does not filter orders or fills. See [ids and correlation](/concepts/ids-and-correlation). ::: ### Parameter and field renames Route renames announce themselves with a `404`. Query-parameter renames and response-field renames do not behave the same way as each other, so they are split below. #### Query parameters — v2 rejects the ones it does not recognize v2 does **not** ignore an unknown parameter: it returns `400` [`validation_error`](/errors/validation_error) naming the offending parameter (`property take should not exist`). This is deliberate — on a money API an ignored filter returns the wrong answer with no warning — and in practice it means a rename you miss **shows up on the first call**, not in the month-end reconciliation. | v1 | v2 | |---|---| | `take=` | `limit=` | | `currencyId=` | `assetSymbol=` | | `currencyBase=` / `currencyQuote=` | `baseAsset=` / `quoteAsset=` | #### Response fields — these do fail silently No safety net here: reading a field that no longer exists yields `undefined`, not an error. | v1 | v2 | If you miss it | |---|---|---| | `meta.currentSize` | `pagination.limit` | All four change at once, so a pagination loop written for v1 finds no totals and stops after the first page. | | `meta.totalCount` | `pagination.totalItems` | ⬑ | | `meta.totalPages` | `pagination.totalPages` | ⬑ | | `meta.currentPage` | `pagination.page` | ⬑ | | `quantity` | `amount` | Null amount. Watch the sign too — see below. | | `date` | `createdAt` | Null timestamp. | | `currencyId` | `assetSymbol` | Null asset. | | `transactionId` | `source.id` (with `source.type`) | ⚠️ **Not `id`.** `id` exists in both v1 and v2 and is a different field: the entry's own. See [ids and correlation](/concepts/ids-and-correlation). | | `qtyCumBase` / `qtyCumQuote` | `filledBaseAmount` / `filledQuoteAmount` | Null filled quantity. | | `currencyBase` / `currencyQuote` | `baseAsset` / `quoteAsset` | Unidentified market. | ### Changes of meaning, not of name Two changes keep a reasonable name but **change the value**. No error announces them — check for them explicitly. #### Public amounts are positive magnitudes In v1, `quantity` carried a **sign** (a withdrawal was negative). In v2 `amount`, `fee` and `total` are always **positive**: direction comes from the entry's `type`, and from the resource itself (a withdrawal debits, a deposit credits). If you sum movements to compute a net, the same code **produces a different answer**: in v2 debits add instead of subtracting. Branch on `type` before you accumulate. #### `assetFormat` defaults to `rebased` `/orders`, `/fills` and `/withdrawals` accept `assetFormat`, and its default is **`rebased`** — a deliberate change from the previous behaviour, which returned `base`. For [tokenised equities](/concepts/tokenized-equities) that changes the amounts you see relative to v1, with no error signal. For every other asset the multiplier is `1` and nothing changes. This is an accounting decision before it is a technical one: if your integration depends on settled figures, ask for `assetFormat=base` explicitly. ### New in v2 - **Fills as a first-class resource** — `GET /v2/fills` and `GET /v2/orders/{id}/fills`. - **A traceable ledger** — `GET /v2/ledger`, with `source: {type, id}` and `?sourceId=` / `?orderId=` filters ([guide](/concepts/ids-and-correlation)). - **Indicative pricing** — `GET /v2/markets/{market}/price`, a **non-binding** rate that reserves nothing and needs no signature. (Market and asset listings already existed in v1 as `/supported_markets` and `/supported_currencies`; what is new here is the price.) - **Signed webhooks** ([guide](/concepts/webhooks)). - **Per-account and per-key rate limits** with `RateLimit-*` headers. ## Timeline **v1 is retired on September 10, 2026** (`2026-09-10`). From that date its endpoints stop serving traffic — complete your migration to v2 before then. Ahead of the shutdown, v1 will start returning `Deprecation` / `Sunset` headers announcing that same date, and we may run _brownouts_ — short, announced windows where v1 returns errors — so you can confirm your migration is complete.