> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ondoperps.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Spot API guide

> Build a Spot client with REST orders, transfers, market data, and WebSocket reconciliation.

Build a client that discovers Spot markets, funds its Spot wallet, places and cancels orders, and reconciles executions over REST and WebSocket.

<Note>
  **Use WebSocket for ongoing data updates.** Keep a persistent connection and subscribe to market data, orders, fills, and balances where available. Maintain local state from those updates instead of repeatedly polling REST.
  Use REST to place and cancel orders, transfer funds, load initial state, fetch data without a WebSocket channel, and reconcile after reconnects or uncertain results.
</Note>

Spot trading uses a central limit order book. Orders spend the available balance in the **spot** wallet. Standard Spot trading is fully funded: a buy spends the quote asset, and a sell spends an existing holding of the base asset.
Examples use `SPY-USDC` with illustrative prices, sizes, and fees. Select an enabled market and its increments from your authenticated market configuration before submitting orders.

## 1. Connect and authenticate

Use credentials issued for the environment and account you intend to trade.

| Environment | REST base URL | WebSocket URL |
| :- | :- | :- |
| Production | `https://api.ondoperps.xyz` | `wss://api.ondoperps.xyz/ws` |
| Sandbox | `https://api.ondoperps-sandbox.xyz` | `wss://api.ondoperps-sandbox.xyz/ws` |

Market availability and account access can differ between environments. An endpoint being reachable does not establish that a market is enabled for your account.
Create your API key through the authenticated account interface. API keys cannot create or manage other API keys. Keep the full key ID and secret, including their prefixes. Store the secret on the server and exclude it from logs.

| Permission | Use |
| :- | :- |
| Read access | Any valid API key can use endpoints requiring `view`. |
| `trade` | Place and cancel orders. |
| `transfer` | Move funds between supported wallets. |

A key identifies one account. To trade a subaccount, use a key created for that subaccount; do not add a subaccount override header to an API-key request. An IP allowlist, if configured on the key, must include your client's egress IP.

### REST signature

Send these headers on authenticated requests:

| Header | Value |
| :- | :- |
| `ONDO-KEY-ID` | Full API key ID. |
| `ONDO-TIMESTAMP` | Unix timestamp in **milliseconds**, formatted as a decimal string. |
| `ONDO-SIGN` | Hex-encoded HMAC-SHA256 signature. |
| `Content-Type` | `application/json` when sending JSON. |

Construct the signed bytes without separators:

```text theme={null}
message = timestamp + HTTP_METHOD + path_with_query + body
signature = hex(HMAC_SHA256(secret_as_UTF8_bytes, message_as_bytes))
```

Use the uppercase HTTP method. Include the exact encoded path and query string, including `?`, parameter order, and percent encoding. Exclude the scheme and host. Sign the exact body bytes you send; a bodyless request contributes zero bytes, not `null` or `{}`. Do not hex-decode or Base64-decode the secret.
The timestamp may be at most **10 seconds behind** or **1 second ahead** of server time. Synchronize your clock, and generate a fresh timestamp and signature for each request. A signature does not make a write idempotent.

### Python client

This example uses the Python standard library. Save it as `spot_client.py`. Set `ONDO_API_BASE`, `ONDO_KEY_ID`, and `ONDO_API_SECRET` in your environment. Running the file only reads market configuration and balances.

```python theme={null}
import hashlib
import hmac
import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener


class APIError(Exception):
    def __init__(self, status, payload):
        self.status = status
        self.payload = payload
        super().__init__(f"HTTP {status}: {payload}")


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


BASE = os.environ["ONDO_API_BASE"].rstrip("/")
KEY = os.environ["ONDO_KEY_ID"]
SECRET = os.environ["ONDO_API_SECRET"].encode("utf-8")
if urlsplit(BASE).scheme != "https":
    raise ValueError("ONDO_API_BASE must use HTTPS")
http = build_opener(NoRedirect())


def sign(timestamp, method, path="", body=b""):
    message = (timestamp + method + path).encode("utf-8") + body
    return hmac.new(SECRET, message, hashlib.sha256).hexdigest()


def api(method, path, query=None, payload=None):
    method = method.upper()
    if query:
        path += "?" + urlencode(query)
    body = b"" if payload is None else json.dumps(
        payload, separators=(",", ":"), ensure_ascii=False
    ).encode("utf-8")
    timestamp = str(time.time_ns() // 1_000_000)
    request = Request(
        BASE + path,
        data=None if payload is None else body,
        method=method,
        headers={
            "ONDO-KEY-ID": KEY,
            "ONDO-TIMESTAMP": timestamp,
            "ONDO-SIGN": sign(timestamp, method, path, body),
            "Content-Type": "application/json",
        },
    )
    try:
        with http.open(request, timeout=15) as response:
            result = json.load(response)
            status = response.status
    except HTTPError as exc:
        raw = exc.read().decode("utf-8", errors="replace")
        try:
            payload = json.loads(raw)
        except ValueError:
            payload = {"error": raw}
        raise APIError(exc.code, payload) from exc
    if result.get("success") is not True:
        raise APIError(status, result)
    return result


def websocket_login():
    timestamp = str(time.time_ns() // 1_000_000)
    return {
        "op": "login",
        "args": {
            "key": KEY,
            "time": timestamp,
            "sign": sign(timestamp, "ondo_perps_ws_login"),
        },
    }


if __name__ == "__main__":
    markets = api("GET", "/v1/markets")["result"]
    print(json.dumps(markets["spot"], indent=2))
    print(json.dumps(api("GET", "/v1/spot/balances"), indent=2))
```

Transport errors, timeouts, and non-JSON responses need handling in your application. The helper does not automatically retry writes or follow redirects. Before retrying an order or transfer whose outcome is unknown, reconcile it as described below.

## 2. Discover markets and units

`GET /v1/markets` supports public and authenticated requests. **Use an authenticated request for trading**: the response reflects account-specific market access and maker/taker fees.
Read `result.spot.tradingPairs`. A market entry has this shape; these values are illustrative:

```json theme={null}
{
  "market": "SPY-USDC",
  "pair": {"base": "SPY", "quote": "USDC"},
  "baseIncrement": "0.01",
  "quoteIncrement": "0.01",
  "makerFee": "0",
  "takerFee": "0"
}
```

| Field | Meaning |
| :- | :- |
| `market` | Exact identifier used in orders, filters, and subscriptions. Preserve its spelling and case. |
| `pair.base`, `pair.quote` | Asset bought/sold, and asset used to price it. |
| `baseIncrement` | Order size must be a positive multiple of this increment. |
| `quoteIncrement` | Limit price must be a positive multiple of this increment. |
| `makerFee`, `takerFee` | Decimal fractions, not percentages or basis points. `0.0001` means 1 basis point. |
| `disabled` | When `true`, the market is disabled. This field can be omitted when false. |
| `tags` | Optional market categories. |

Use decimal arithmetic for prices, sizes, balances, and fees. Send them as JSON strings. Do not use binary floating point to align an order to an increment. Preserve IDs as strings, even when they contain only digits. Accept additional response fields for forward compatibility.
For GM assets, inspect the corresponding entry in `result.tokenConfig`: `id`, `decimals`, `ledgerUnit`, `custodiedAs`, and `sharesMultiplier`. When `ledgerUnit` is `shares`, order sizes, fills, balances, and internal transfers use underlying share-equivalent units. `custodiedAs` names the external wrapper token.
For example, a `SPY-USDC` order with `size: "2"` trades two SPY-equivalent units at a price in USDC per unit. It does not request two SPYon wrapper tokens. If the applied multiplier is 1.02 shares per token, 10 wrapper tokens correspond to 10.2 share units before applicable precision handling. Internal wallet transfers do not apply the multiplier again. Deposit and withdrawal conversion occurs at the custody boundary; use the credited or executed amounts for reconciliation rather than recomputing past events using a later multiplier.
Refresh configuration on reconnect, before starting a trading session, and after a market-disabled or increment-related rejection. A listed asset, a tradable pair, a supported funding network, and eligibility as Perps collateral are distinct capabilities.

## 3. Fund the Spot wallet

The wallet identifiers are `spot` for Spot trading, `margin` for Perps collateral, and `main` for the main wallet. Funds in another wallet do not automatically fund a Spot order.

### Read balances

`GET /v1/spot/balances` requires authentication and returns an array in `result`:

```json theme={null}
{
  "success": true,
  "result": [
    {"coin": "USDC", "total": "1000", "reserved": "600", "free": "400", "usdValue": "1000"},
    {"coin": "SPY", "total": "2", "reserved": "0", "free": "2", "usdValue": "1200"}
  ]
}
```

`total` is the held quantity. `reserved` is the quantity held by open Spot orders. `free = total - reserved` is available to fund additional orders, subject to account restrictions. `usdValue` is an optional estimate; it is not an executable price and can be zero when valuation is unavailable. Key rows by `coin`, not array position. Zero-balance rows can be returned.
For a limit buy, the funding requirement is `price × size` in the quote asset. For a sell, it is `size` in the base asset. Open-order reservations prevent those funds from being reused. A completed cancellation releases the reservation for the unfilled portion. Withdrawal availability is subject to its own checks and is not established by this endpoint.

### Transfer funds

`POST /v1/spot/transfers` requires `transfer`. It accepts `main` ↔ `spot` and `margin` ↔ `spot` transfers on the same account. Margin transfers also require asset eligibility and sufficient withdrawable margin.
The following example moves 1,000 USDC from Perps collateral to Spot. Execute it only after selecting the amount to move:

```python theme={null}
from spot_client import api

transfer = api("POST", "/v1/spot/transfers", payload={
    "from": {"wallet": "margin"},
    "to": {"wallet": "spot"},
    "amount": "1000",
    "symbol": "USDC",
})
print(transfer)
```

The optional `id` inside `from` and `to` defaults to the authenticated account ID. If supplied, both must identify that same account. Amount must be positive and conform to the asset's `decimals`. The shared `GET /v1/account` endpoint returns the authenticated `accountID` when you need it.
The response `result` is a transfer record containing `id`, `from`, `to`, `amount`, `symbol`, `time`, and a numeric `type`. For a margin/spot move, the returned record represents the leg crediting your destination wallet; its `from.wallet` can therefore be `main`, even when the request starts from `margin` or `spot`. Preserve the response and read the destination balance to reconcile the move.
`GET /v1/spot/transfers` returns paginated Spot movement history. Supported filters are `startTime`, `endTime`, `cursor`, and `limit`. It includes Spot transfers, subaccount transfers involving Spot, and share adjustments; it excludes trading-fee transfers. Use fills to account for trading fees.
Transfer creation has no client-supplied idempotency key. After a timeout, inspect balances and transfer history before resubmitting. If the move cannot be reconciled, retain the account, asset, amount, time, and any returned transfer ID for support.

## 4. Read market data

| Method and path | Authentication | Parameters and result |
| :- | :- | :- |
| `GET /v1/spot/ticker` | Public | All Spot tickers. Optional `sparkline=true`. No market filter; select the row by `market`. |
| `GET /v1/spot/depth` | Public | Required `market`; optional integer `depth`, default 10, range 1–100. One book snapshot. NOTE: do NOT spam this endpoint for orderbook updates. use the websocket instead. |
| `GET /v1/spot/trades` | Public | Required `market`; optional `cursor`, `limit`. Paginated public trades. |
| `GET /v1/spot/candles` | Authenticated | Required `market`, `resolution`, `to`, and either `from` or `countback`. Array of candles. |

Book request:

```text theme={null}
GET /v1/spot/depth?market=SPY-USDC&depth=10
```

Illustrative response:

```json theme={null}
{
  "success": true,
  "result": {
    "market": "SPY-USDC",
    "time": "2026-09-14T12:00:00Z",
    "bids": [["599.99", "10"]],
    "asks": [["600.01", "8"]]
  }
}
```

Bids are buys and asks are sells. Each level is a two-element array `[price, size]` of decimal strings. Level size is aggregate base quantity. Bids run from highest to lowest price; asks run from lowest to highest. An empty side can be represented by an empty array or `null`; treat either as no displayed liquidity. Snapshot quantities are absolute values.
Ticker rows include `market`, `disabled`, `baseCurrency`, `quoteCurrency`, `lastPrice`, `bid`, `ask`, `baseVolume`, `quoteVolume`, `high`, `low`, and `priceChangePercent`. Volume and high/low describe the trailing 24-hour window. A ticker or last trade does not guarantee a currently available execution price.
Public trades contain `id`, `market`, `price`, `size`, `cost`, `aggressor_side`, and `time`. `aggressor_side` is the taker's side. These are market-wide trades; they do not identify your account's fills.
For REST candles, `from` and `to` are Unix **seconds**. Supported resolutions are `1`, `5`, `15`, `60`, `240`, `1D`, and `1W` (`D` and `W` are accepted aliases). `countback` requests a positive number of bars ending at `to`. Align time ranges to bar boundaries; the server can round the requested boundaries. `to` is inclusive. Each candle has `startTime`, `open`, `high`, `low`, `close`, and base-asset `volume`. Prices and volume are decimal strings; `startTime` is an RFC 3339 timestamp.

### Minimum size and price protection

Order size must be a positive multiple of `baseIncrement`. The smallest permitted quantity is one `baseIncrement`; there is no separate minimum-notional requirement in the standard Spot order validator. For example, a market with `baseIncrement: "0.01"` accepts quantities such as `0.01`, `0.02`, and `1.23`, but not `0.001` or `0.015`.

A limit price must be positive, less than 10,000,000, and a multiple of `quoteIncrement`. Market orders use base-denominated `size`; omit `price` and `timeInForce`. Positive `quoteSize` is not supported. Meeting the increments does not guarantee acceptance: available funds, market access, liquidity, and [price protection](/mark-price-protection) are checked separately.

Spot price protection uses the underlying oracle price. The configured percentage can vary; 10% is the fallback. GTC orders use directional price bounds, while market and IOC orders are checked using simulated execution. The rules below describe the boundaries and IOC partial-fill behavior.

### Price protection rules

#### Protection percentage

The protection percentage can vary by market and market type. The default fallback is 10%; it is not a fixed promise for every market. A missing or zero reference price prevents an order from passing price validation.

#### GTC limit orders

The check is directional: it prevents a buy priced too high or a sell priced too low. With reference price `R` and configured protection fraction `f`, the ordinary bounds are:

* Buy: `limit price <= R × (1 + f)`.
* Sell: `limit price >= R × (1 - f)`.

Equality at the boundary passes this check. A passive buy below the reference or a passive sell above it is not rejected merely for being more than `f` away, though other validation rules still apply.

For example, if a Spot asset's oracle price is 100 USDC and its configured fraction is 10%, a GTC buy limit above 110 or a sell limit below 90 is rejected by this check. Perpetual price-discovery bounds, when active, can tighten the corresponding boundary; those bounds do not apply to Spot.

#### Market and IOC limit orders

Market orders and IOC limit orders are checked by simulating execution against available liquidity. A market order is rejected if the simulation cannot fill the requested quantity within the system's protective boundary.

For an IOC limit order, execution is bounded by the tighter of your limit and the system boundary. When your limit is at least as restrictive as the system boundary, a partial fill is allowed and the remainder is canceled. A limit outside the system boundary is not automatically rejected solely for that limit; it must be fully executable within the system boundary during validation.

Validation is not a fill guarantee. Review the returned order status and fills. Use an IOC limit order when you need to specify your own execution-price bound. See [Order types](/order-types).

## 5. Place an order

`POST /v1/spot/orders` requires `trade`. Requests and responses use `Content-Type: application/json`.

| Field | Requirement |
| :- | :- |
| `market` | Required enabled market identifier. |
| `side` | Required: `buy` or `sell`. |
| `type` | Use `limit` or `market`. |
| `size` | Required positive base quantity, aligned to `baseIncrement`. |
| `price` | Required positive limit price, aligned to `quoteIncrement`. Omit for a market order. |
| `timeInForce` | Limit orders only: `GTC` or `IOC`, uppercase. Defaults to `GTC`. Omit for a market order. |
| `postOnly` | Optional boolean, default false. Use with a `GTC` limit order. |
| `clientOrderId` | Optional but recommended. At most 64 ASCII characters: letters, digits, underscore, or hyphen. Use a unique value for every new order. |

`quoteSize` orders are unsupported and a positive `quoteSize` is rejected. Use base-denominated `size` for both buys and sells. Spot does not provide borrowing, a leverage field, or a reduce-only position workflow. Do not send `builderCode` or `rpi`, which are Perps-only. Do not send Perps stop-loss/take-profit fields or submit `stopMarket` or `takeProfitMarket` directly.

### Limit order

This submits a buy for up to two SPY-equivalent units at 600 USDC per unit or better:

```python theme={null}
from spot_client import api
from uuid import uuid4

client_id = "spot-" + uuid4().hex
order = api("POST", "/v1/spot/orders", payload={
    "market": "SPY-USDC",
    "side": "buy",
    "type": "limit",
    "price": "600",
    "size": "2",
    "timeInForce": "GTC",
    "clientOrderId": client_id,
})["result"]
print(order)
```

A GTC order may execute immediately. Its unfilled remainder can rest on the book. Set `postOnly: true` to require that it rest; a post-only order that would immediately match is rejected. Post-only and IOC cannot be combined.

### Immediate-or-cancel limit order

Use `timeInForce: "IOC"` to execute available quantity at the limit price or better and cancel any remainder. A partial fill is possible; IOC is not fill-or-kill.

```json theme={null}
{
  "market": "SPY-USDC",
  "side": "sell",
  "type": "limit",
  "price": "600",
  "size": "1",
  "timeInForce": "IOC",
  "clientOrderId": "spot-ioc-001"
}
```

### Market order

A market order consumes available liquidity. It may fill at multiple prices or partially fill. Use an IOC limit order when your instruction requires an explicit execution-price bound.

```json theme={null}
{
  "market": "SPY-USDC",
  "side": "buy",
  "type": "market",
  "size": "1",
  "clientOrderId": "spot-market-001"
}
```

Omit `price` and `timeInForce`. A returned market-order `price` of `"0"` is an order-type marker, not the execution price. Read its fills or divide `filledCost` by `filledSize` when `filledSize` is positive. Market orders can be disabled independently; handle `market_orders_disabled` without assuming the order was placed.

### Delayed placement

Some accounts or order flows may require the delayed placement paths:

* `POST /v1/spot/orders/delayed`

* `POST /v1/spot/orders/batch/delayed`
  They accept the same bodies as their ordinary counterparts. If the server rejects placement with a message directing you to a delayed path, sign a new request using that exact path and a fresh timestamp. Do not reuse a signature from the original URL. Allow time for the server's delay; an ambiguous timeout still requires reconciliation.

## 6. Track orders and fills

Order placement returns `success: true` and an order object in `result`. It can already be partially filled, fully filled, or canceled when you receive the response. An acknowledgement does not guarantee that an order remains open.
Illustrative resting-order response:

```json theme={null}
{
  "success": true,
  "result": {
    "orderId": "order-example-001",
    "clientOrderId": "spot-limit-001",
    "market": "SPY-USDC",
    "side": "buy",
    "type": "limit",
    "price": "600",
    "size": "2",
    "filledSize": "0",
    "lastFillSize": "0",
    "filledCost": "0",
    "fee": "0",
    "status": "open",
    "timeInForce": "GTC",
    "createdAt": "2026-09-14T12:00:00Z"
  }
}
```

| Field | Interpretation |
| :- | :- |
| `orderId` | Exchange-assigned order ID. |
| `size` | Requested base quantity. |
| `filledSize` | Cumulative executed base quantity, before a buy fee is deducted. |
| `lastFillSize` | Quantity of the most recent fill. |
| `filledCost` | Cumulative executed quote amount. |
| `fee` | Cumulative fee in the asset received: base for buys, quote for sells. |
| `status` | Usually `open`, `fullyfilled`, or `canceled` for Spot. |
| `createdAt` | Creation timestamp. |
| `filledAt`, `canceledAt`, `cancelReason` | Optional completion/cancellation metadata. |

An `open` order can have nonzero `filledSize`; there is no separate partially-filled status. A `canceled` order can also have fills. In JSON responses the fully filled status is **`fullyfilled`**. The order-history query filter instead uses **`status=fullyFilled`**.

### Get one order

```text theme={null}
GET /v1/spot/orders/{orderId}
GET /v1/spot/orders/client:{clientOrderId}
```

Both require authentication. `client:` is a path lookup prefix; do not include it in the submitted `clientOrderId`.
After a timeout, query the original `clientOrderId` before submitting another order. Reusing an accepted ID returns `clientOrderID_collision`; it does not return the original response. A not-found result immediately after a timeout is not proof that the original request cannot still complete. Keep the original ID and reconcile the order and fills before deciding to submit a replacement with a new ID.

### List orders

`GET /v1/spot/orders` requires authentication. Supported parameters:

| Parameter | Meaning |
| :- | :- |
| `market` | Filter by exact market. Omit for all Spot markets. |
| `status` | For standard Spot orders: `open`, `fullyFilled`, or `canceled`. |
| `orderType` | `limit`, `market`, or a comma-separated list. |
| `hasFill` | Boolean; `true` selects orders with executions. |
| `activeOnly` | Boolean; selects active orders. For ordinary Spot orders, use `status=open` for the open-order set. |
| `startTime`, `endTime` | Creation-time bounds in Unix milliseconds. |
| `cursor`, `limit` | Pagination controls. |

```text theme={null}
GET /v1/spot/orders?market=SPY-USDC&status=open&limit=100
```

### Read fills

These authenticated endpoints return your executions:

```text theme={null}
GET /v1/spot/fills?market=SPY-USDC&limit=100
GET /v1/spot/orders/{orderId}/fills
GET /v1/spot/orders/client:{clientOrderId}/fills
```

The list endpoint supports `market`, `startTime`, `endTime`, `cursor`, and `limit`. Time filters are Unix milliseconds. The by-order endpoint returns an array without pagination.
Illustrative buy fill, as an item inside `result`:

```json theme={null}
{
  "id": "fill-example-001",
  "orderId": "order-example-001",
  "clientOrderId": "spot-limit-001",
  "market": "SPY-USDC",
  "price": "600",
  "size": "1",
  "side": "buy",
  "filledCost": "600",
  "fee": "0",
  "time": "2026-09-14T12:00:01Z",
  "isMaker": false
}
```

Deduplicate private executions by fill `id`. `isMaker` identifies whether your order supplied resting liquidity for that execution. One order can have multiple fills and may incur both maker and taker fees across its lifetime.

## 7. Calculate fees and reconcile balances

**Launch promotion: Spot maker and taker trading fees are 0% for the first 30 days after Spot launch.** This window starts at launch, not at your first trade. The fee calculations below explain how nonzero rates are applied; use the rate returned for your account. See [Fees](/fees).

Read your maker/taker rates from authenticated market configuration. Rates are captured when an order is accepted; a later configuration change does not reprice a resting order's captured rates. Fees are rounded per fill to the fee asset's precision. Use returned fee amounts for accounting.

| Side | Asset spent | Asset received | Fee asset | Net received |
| :- | :- | :- | :- | :- |
| Buy | Quote | Base | Base | `size - fee` |
| Sell | Base | Quote | Quote | `filledCost - fee` |

At an illustrative taker rate of `0.0002`, a buy of 1 SPY unit at 600 USDC spends 600 USDC and receives 0.9998 SPY units. A sell of 1 SPY unit at the same price receives 599.88 USDC after a 0.12 USDC fee, subject to fee rounding.
The buy fill's `size` remains `"1"`; it is not the net balance credit. Do not assume `fee` is always denominated in USDC, and do not subtract it twice when comparing against reported balances.
To reconcile an interval, account for fills and fees, wallet transfers, and share adjustments. Order events alone cannot explain every balance change.

## 8. Cancel orders and submit batches

All cancellation endpoints require `trade`.

| Action | Request |
| :- | :- |
| Cancel one order | `DELETE /v1/spot/orders/{orderId}` |
| Cancel using your ID | `DELETE /v1/spot/orders/client:{clientOrderId}` |
| Cancel all orders in a market | `DELETE /v1/spot/orders?market=SPY-USDC` |
| Cancel all Spot orders | `DELETE /v1/spot/orders` |
| Cancel up to 20 orders | `DELETE /v1/spot/orders/batch?orderIDs=id1,id2` |

The batch parameter is **`orderIDs`**, with capital `IDs`, in the query string. It accepts exchange IDs or `client:`-prefixed client IDs. URL-encode the query and sign the encoded form. Cancel-all is scoped to Spot; it does not cancel Perps orders.
A cancel cannot undo a fill. After an ambiguous cancellation result, reread the order and fills. An already-filled or already-canceled response requires reconciliation rather than repeated blind cancellation.

### Batch placement

`POST /v1/spot/orders/batch` accepts **1–20 orders**:

```json theme={null}
{
  "orders": [
    {"market": "SPY-USDC", "side": "buy", "type": "limit", "price": "599", "size": "1", "timeInForce": "GTC", "postOnly": true, "clientOrderId": "batch-bid-001"},
    {"market": "SPY-USDC", "side": "sell", "type": "limit", "price": "601", "size": "1", "timeInForce": "GTC", "postOnly": true, "clientOrderId": "batch-ask-001"}
  ]
}
```

The batch is **not all-or-nothing**. A valid batch request can return HTTP success while individual orders fail. Always inspect both `result.addedOrders` and `result.failedOrders`. Each failure contains `order`, `error`, and `errorCode`.
Batch cancellation similarly returns `result.successfulCancels` and `result.failedCancels`. Each cancellation failure contains `orderId`, `error`, and `errorCode`. Item-level `errorCode` uses camel case; the outer error envelope uses `error_code`.
A batch is not an atomic cancel-and-replace operation. Reconcile each order by ID, and resubmit only items whose outcomes are known.

## 9. Pagination and REST errors

Paginated endpoints put the result array and cursor metadata at the top level:

```json theme={null}
{
  "success": true,
  "result": [],
  "pageInfo": {"prevCursor": "", "nextCursor": ""}
}
```

`limit` must be an integer from 1 to 1,000; the request default is 1,000. The service may cap historical page size further. Start without a cursor, then pass `pageInfo.nextCursor` as the next request's `cursor`, keeping filters unchanged. Stop when it is empty. Treat cursors as opaque strings and URL-encode them; do not decode or construct them. Do not infer completion solely from a page being shorter than your requested limit.

```python theme={null}
from spot_client import api

query = {"market": "SPY-USDC", "limit": 100}
while True:
    page = api("GET", "/v1/spot/fills", query=query)
    for fill in page["result"] or []:
        print(fill["id"], fill["size"], fill["fee"])
    cursor = page.get("pageInfo", {}).get("nextCursor")
    if not cursor:
        break
    query["cursor"] = cursor
```

Persist fills by ID and reconcile an overlapping time window after interruptions. Separate REST reads do not provide a single atomic snapshot of orders, fills, and balances.
An error response normally has this shape:

```json theme={null}
{
  "success": false,
  "error": "Insufficient funds",
  "error_code": "insufficient_funds"
}
```

Check both HTTP status and `success`. `result` can be omitted on errors or successful actions with no result, such as cancel-all. Preserve `error_code` for programmatic handling and treat `error` as descriptive text whose wording can change. Gateways or connection failures can return non-JSON errors.

| Error code | Client action |
| :- | :- |
| `timestamp_too_far`, `signature_mismatch` | Check clock, secret, timestamp units, encoded URL, method, and exact body bytes. |
| `key_doesnt_have_scope`, `ip_not_permitted` | Correct key permissions or egress allowlist. |
| `invalid_market`, `trading_disabled`, `feature_disabled` | Refresh configuration and confirm account/market availability. |
| `insufficient_funds` | Read Spot free balances and reservations. |
| `order_invalid_price`, `order_invalid_size` | Align price and base size to market increments. |
| `order_price_outside_safe_bounds` | Review the oracle reference, limit, and executable liquidity; see [Order price protection](/mark-price-protection). |
| `mark_price_not_available` | The reference price is unavailable; wait for valid pricing before resubmitting. This code is also used for Spot oracle availability. |
| `deprecated_field` | Remove unsupported `quoteSize`. |
| `invalid_market_order_fields` | Remove market-order `timeInForce`; omit price as well. |
| `post_only_has_match` | Choose a price that can rest, or change the intended order behavior. |
| `clientOrderID_collision` | Look up the original order by client ID. |
| `insufficient_liquidity`, `market_orders_disabled` | Refresh liquidity or use a supported order type. |
| `order_not_found`, `order_already_canceled`, `order_already_fully_filled` | Reconcile the order and fills before another mutation. |
| `too_many_open_orders`, `too_many_requests` | Reduce outstanding orders or request rate as appropriate. |
| `invalid_cursor` | Start a new bounded query and deduplicate persisted records. |

Back off with jitter on HTTP 429 and transient read failures. Respect `Retry-After` if present. For timeouts or server errors on writes, determine the original outcome before retrying.

## 10. Rate limits

**REST rate limits are ceilings, not recommended polling rates.** Clients should use WebSocket subscriptions for ongoing updates and keep REST reads limited to initialization, necessary lookups, and reconciliation. Limits can vary by account and environment; the standard defaults below are not a promise of throughput.

| Endpoint/action | Sustained rate | Burst |
| :- | :- | :- |
| Single-order placement | 30 requests/second | 60 |
| Single-order cancellation | 30 requests/second | 60 |
| Batch placement | 30 requests/second | 60 |
| Batch cancellation | 10 requests/second | 20 |
| Cancel all Spot orders | 2 requests/second | 5 |
| List orders | 10 requests/second | 60 |
| List fills | 30 requests/second | 15 |
| Create Spot transfer | 20 requests/hour | 20 |
| Other REST endpoints without a specific override | 10 requests/second | 60 |

Additional account-level budgets apply separately to order additions and cancellations: standard defaults are **30 order actions/second with a burst of 60** for each action. These budgets are shared across markets, placement paths, and Spot and Perps. A batch of N orders consumes N actions. Additional API keys for the same account do not create new account budgets. Cancel-all has its own endpoint budget and is exempt from the shared cancellation-action budget.
Avoid continuous REST polling for order books, trades, orders, fills, or balances when the corresponding WebSocket channel is available. Apply incoming updates to local state; do not issue a REST refresh after every WebSocket message or fill. Use occasional, bounded REST reconciliation to detect drift, and reconcile after reconnects or ambiguous writes.
If a required channel is unavailable, use conservative fallback polling, coalesce reads across your application's consumers, and back off on failures or HTTP 429. Stop fallback polling when the subscription is restored.

## 11. WebSocket authentication and subscriptions

Connect to `/ws` on the same environment as REST. Public market-data subscriptions can be used without login. Private channels require a successful login.
For API-key login, sign:

```text theme={null}
message = timestamp + "ondo_perps_ws_login"
signature = hex(HMAC_SHA256(secret_as_UTF8_bytes, message_as_UTF8_bytes))
```

The literal `ondo_perps_ws_login` is used for **Spot as well as Perps**. There is no path or body in the signed message. The timestamp uses milliseconds and the same acceptance window as REST.
Send `json.dumps(websocket_login())` from the Python helper over your WebSocket connection. Its JSON shape is:

```json theme={null}
{
  "op": "login",
  "args": {"key": "YOUR_KEY_ID", "time": "TIMESTAMP_IN_MILLISECONDS", "sign": "HEX_SIGNATURE"}
}
```

Wait for `type: "loggedIn"` before subscribing to private channels. A connection logs into one account. Reconnecting requires a new login and new subscriptions.

### Channels

| Channel | Access | Data in an `update` message |
| :- | :- | :- |
| `topOfBooksSpot` | Public | Array of best-bid/best-ask book snapshots. |
| `depthBooksSpot` | Public | Array of depth snapshots. |
| `tradesSpot` | Public | Array of public trades. |
| `kLineSpot` | Public | Candle updates for one market and resolution. |
| `ordersSpot` | Login | Array of your order objects. |
| `fillsSpot` | Login | Array of your fill objects. |
| `balanceSpot` | Login; where enabled | Complete array of your Spot balance rows. |
| `cancelAllOrdersAfterSpot` | Login | Arms or disarms cancellation on connection inactivity. |

Subscribe using `op: "subscribe"`. For book, order, fill, and trade subscriptions, specify the markets you need. Omitting `markets` subscribes to the channel's default market set; an explicit list makes the scope predictable. Unsubscribe with `op: "unsubscribe"` and the same channel and subscription parameters.

```json theme={null}
{"op":"subscribe","channel":"depthBooksSpot","markets":["SPY-USDC"],"limit":20}
```

```json theme={null}
{"op":"subscribe","channel":"ordersSpot","markets":["SPY-USDC"]}
```

```json theme={null}
{"op":"subscribe","channel":"fillsSpot","markets":["SPY-USDC"]}
```

```json theme={null}
{"op":"subscribe","channel":"balanceSpot"}
```

For depth subscriptions, `limit` caps the number of returned levels per side. Optional `depthLevels` is a **price grouping increment**, expressed as a decimal string, not a count of levels. Omit it or use `"0"` for ungrouped prices. An unsubscribe for a custom depth subscription must match its `depthLevels` and `limit`.
For `kLineSpot`, specify exactly one market and a resolution from `1`, `5`, `15`, `1H`, `4H`, `1D`, or `1W`. WebSocket hour resolutions differ from REST's `60` and `240`:

```json theme={null}
{"op":"subscribe","channel":"kLineSpot","markets":["SPY-USDC"],"resolution":"1H"}
```

The candle update's `data` is an object with `m` (market), `t` (event time), `s` (bar start), `e` (bar end), `o`/`h`/`l`/`c` (open/high/low/close), `v` (base volume), and `x` (whether the bar is closed). `t`, `s`, and `e` are Unix seconds. Unlike REST candles, WebSocket candle prices and volume are JSON numbers. Decode these numbers with a decimal-aware JSON parser, such as Python's `json.loads(message, parse_float=Decimal)`. Update the current bar by market and start time until it closes.

### Message handling

Responses use `type`, optional `channel`, `timestamp`, and optional `data`, `code`, or `msg`. Handle `loggedIn`, `subscribed`, `unsubscribed`, `update`, `pong`, and `error`.
Illustrative depth update:

```json theme={null}
{
  "type": "update",
  "channel": "depthBooksSpot",
  "timestamp": "2026-09-14T12:00:00Z",
  "data": [{
    "market": "SPY-USDC",
    "time": "2026-09-14T12:00:00Z",
    "bids": [["599.99", "10"]],
    "asks": [["600.01", "8"]]
  }]
}
```

Book updates are **replacement snapshots**, not deltas. Replace the stored book for the relevant market and subscription when an update arrives. Do not add the incoming sizes to previous sizes. The feed does not expose a sequence-number/checksum contract for reconstructing missed changes.
`ordersSpot` and `fillsSpot` provide events, not a complete historical snapshot at subscription. Initialize open orders and historical fills through REST. `balanceSpot` provides complete balance snapshots and can coalesce multiple changes; it is not a transaction log. Replace your balance map when a new snapshot arrives. If the balance channel is unavailable, use REST balances alongside order and fill events.
Send an application-level heartbeat regularly, for example every 15 seconds, and monitor for a response:

```json theme={null}
{"op":"ping"}
```

The response has `type: "pong"`. Reconnect with backoff when the connection closes or heartbeats stop. A quiet market alone is not evidence of a broken connection.

### Recover after a disconnect

1. Reconnect, log in, and resubscribe. Discard book state from the old connection.
2. Buffer incoming private events while retrieving open orders, fills from an overlapping interval, balances, and transfers through REST.
3. Deduplicate fills by `id`. Merge order updates by `orderId`, preserving cumulative fills and terminal outcomes; requery an order if REST and buffered events disagree.
4. Refresh balances after reconciliation. These endpoints and channels do not share an atomic account snapshot or a global sequence number.
5. Resume order submission after the client has reconciled outstanding orders and funds.

## 12. Cancel on inactivity

Arm the Spot dead man's switch on the connection responsible for your trading session:

```json theme={null}
{"op":"subscribe","channel":"cancelAllOrdersAfterSpot","timeout_seconds":30}
```

It cancels **all open Spot orders for the authenticated account**, across markets, after the armed connection receives no client activity for the configured interval. Do not send `markets`. Inbound application messages, including `ping`, refresh the inactivity timer; outbound market updates do not. Keep heartbeats well inside the configured interval.
The timer remains armed if the connection closes. It is account-wide, so coordinate multiple clients: arming it on another connection replaces the account's previous Spot timer. It does not distinguish orders created by separate API keys or sessions. Do not rely on it as durable protection across service restarts; reconnect, reconcile, and re-arm it.
To disarm, send either:

```json theme={null}
{"op":"subscribe","channel":"cancelAllOrdersAfterSpot","timeout_seconds":0}
```

```json theme={null}
{"op":"unsubscribe","channel":"cancelAllOrdersAfterSpot"}
```

Disarming prevents future timer-triggered cancellation; it does not cancel existing orders. After a timeout or reconnect, read order status and fills to confirm which orders remain open. A fill can precede cancellation.

## 13. Market pauses and share adjustments

Affected markets can pause for corporate actions or other trading restrictions. Stop submitting new orders when the API reports that trading is unavailable. Preserve outstanding IDs and reconcile their final states before creating replacements.
Share adjustments can change a GM asset's balance without a fill. Read share-adjustment entries in Spot transfer history and refresh balances and market metadata after the event. Do not rescale historical fills using the current shares multiplier. Do not assume an old resting order was preserved, canceled, or resized solely because trading resumed; query it.

## 14. Additional REST exports

Authenticated CSV exports are available at `GET /v1/spot/orders/csv` and `GET /v1/spot/fills/csv`. These responses are CSV files, not the JSON envelope consumed by the Python helper. Order CSV export includes only orders with fills; it is not a source for the complete open-order set.
The trading integration covered here uses `/v1/spot/*`. Use the explicit Spot paths for order management, history, and market data.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.