# MCP server Source: https://docs.numofx.com/ai-tools/mcp Connect these docs to Claude Code, Cursor, and other AI tools Numo Docs is available as a [Model Context Protocol](https://modelcontextprotocol.io) server. Connect it and your AI assistant can search this site directly instead of guessing at endpoint shapes, market symbols, or signed-payload fields. The server is read-only and scoped to the published content of this site. ``` https://docs.numofx.com/mcp ``` ## Connect your client ```bash theme={null} claude mcp add --transport http --scope user numo-docs https://docs.numofx.com/mcp ``` Use `--scope user` to make the server available in every project, or drop the flag to scope it to the current directory. Verify with `claude mcp get numo-docs`. ```bash theme={null} npx mint-mcp add docs.numofx.com ``` The installer writes the server into your Cursor MCP config. Restart Cursor afterwards. ```bash theme={null} npx mint-mcp add docs.numofx.com ``` Pick Windsurf when the installer asks which client to configure, then restart it. Any client that speaks streamable HTTP can connect to the endpoint directly: ```json theme={null} { "mcpServers": { "numo-docs": { "type": "http", "url": "https://docs.numofx.com/mcp" } } } ``` ## What you get Search this site for guides, endpoint references, and code examples. Returns ranked excerpts with direct page links. Read whole pages by path and run exact keyword or regex matches across the docs. Report a page that is wrong, outdated, or incomplete straight to the docs team. ## Try it Once connected, ask your assistant questions it can only answer from these docs: * "What fields does a signed Numo order payload need, and which ones must match `action_json`?" * "Show me the WebSocket subscribe message for the cNGN book channel." * "How do I resume a Numo WebSocket stream after a reconnect without refetching a snapshot?" * "What settles to whom when a Numo cNGN-USDC trade crosses?" ## Plain text for other tools Tools that read a flat corpus rather than connecting to a server can use: * [`/llms.txt`](https://docs.numofx.com/llms.txt) — index of every page * [`/llms-full.txt`](https://docs.numofx.com/llms-full.txt) — full site content in one file New to the API? Read the book and submit your first signed order. # Get order book Source: https://docs.numofx.com/api-reference/endpoint/book GET /v1/book # Get OHLCV candles Source: https://docs.numofx.com/api-reference/endpoint/candles GET /v1/candles Buckets are returned **oldest first**, so a chart can render them directly. Periods with no trades are **absent**, not zero-filled. This venue trades sparsely, and emitting flat synthetic candles would invent price action that never happened. If you need a continuous axis, carry the previous close forward yourself. # Create order Source: https://docs.numofx.com/api-reference/endpoint/create POST /v1/orders # Cancel order Source: https://docs.numofx.com/api-reference/endpoint/delete POST /v1/orders/cancel # Create a deposit Source: https://docs.numofx.com/api-reference/endpoint/deposits POST /v1/deposits Opens a perp margin account (subaccount_id 0) and funds it, or tops one up, with one signed DepositModule action. The venue verifies the action, reads the nonce, ownership and the owner's token balance and allowance, the issuer's freezes and pause, and the cNGN cap from the chain, simulates, and submits it through Matching.verifyAndMatch, paying the gas. Opens a perp margin account and funds it, or tops up one you already have, with one signed `Action` for the `DepositModule`. The venue verifies it and submits it through `Matching.verifyAndMatch`, paying the gas; the module pulls your USDC or cNGN, credits it to the account, and Matching records you as the account's owner. One account trades both `cNGN-USDC` spot and `cNGN-PERP`, so a deposit funds both. For USDC, either approve the `DepositModule` once, on chain, or [sign a permit](#or-sign-a-permit-instead) with the deposit and never send a transaction yourself. cNGN has no permit, so for cNGN you approve once, on chain. Either way every deposit is this endpoint, and the venue pays its gas. **Wallets with an EIP-7702 delegation cannot sign for Matching with a plain signature.** Matching verifies signatures with OpenZeppelin's `SignatureChecker`, which treats any address that has code — including an EOA with a 7702 delegation — as a contract wallet and asks it via ERC-1271 instead of recovering the signature. The deposit is refused with `OV_InvalidSignature` unless the delegate contract implements `isValidSignature`. Use an undelegated EOA, or a wallet that supports ERC-1271. The same applies to orders and withdrawals. ## Before your first deposit Approve the `DepositModule` to move at least the amount you will deposit, from the wallet that will own the account: ```text theme={null} USDC.approve(0x6540f8d9Eb599b045C05E45cb6a5B1730a806658, amount) cNGN.approve(0x6540f8d9Eb599b045C05E45cb6a5B1730a806658, amount) ``` USDC on Base is `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`; cNGN is `0x46C85152bFe9f96829aA94755D9f915F9B10EF5F`. Changing an existing non-zero allowance to another value is fine for both; there is no need to reset it to zero. ### Or sign a permit instead For USDC only: USDC on Base supports EIP-2612 permits, so you can skip the on-chain approve entirely: sign a `Permit` with USDC's domain (`name` `USD Coin`, `version` `2`, chain `8453`) for `spender` = the `DepositModule`, a `value` of at least the deposit amount, and your current USDC `nonce`. Send it alongside the action: ```json theme={null} "permit": { "value": "1000000000", "deadline": "1789400600", "signature": "0x…" } ``` The venue submits the permit, then the deposit, and pays the gas for both. If your allowance already covers the deposit — you approved earlier, or someone else submitted your permit first — the permit is skipped, not failed. A permit USDC refuses (expired, already used, or signed for another spender) comes back as a **400**. cNGN implements neither EIP-2612 nor EIP-3009, so a cNGN deposit carrying a `permit` is refused with a **400** before anything is sent. ## Signing the action Sign an [`Action`](/signing) against the same `Matching` domain as an order, with: | Field | Value | | - | - | | `subaccountId` | `0` to open a new perp account; your perp account's id to top it up | | `nonce` | any unused `uint256`; nonces are per owner and per module | | `module` | the DepositModule: `0x6540f8d9Eb599b045C05E45cb6a5B1730a806658` on Base | | `data` | `abi.encode(uint256 amount, address asset, address managerForNewAccount)` | | `expiry` | unix seconds, in the future and at most one hour ahead | | `owner` | your address; the tokens are pulled from here, and you own the account | | `signer` | the same as `owner` (session keys are not supported for deposits) | In `data`: * `asset` is what you deposit into, one of: | Token | `asset` | Minimum | | - | - | - | | USDC | the cash asset, `0xA74E49b4Ed7cb176bc02ef4D8a1A3240C9aD4272` (`quote_asset_address` on `GET /v1/markets`) | 10 USDC | | cNGN | the cNGN escrow, `0x37c976bb5d4887a714ef19AF6B83e34fe2f37c98` (`asset_address` under the perp's `collateral_assets`) | 15,000 cNGN | No other asset is accepted. * `amount` is in the **token's 6-decimal base units**: `1000000000` is 1,000 USDC, or 1,000 cNGN. It must be explicit — the "whole balance" sentinel `type(uint256).max` is refused — and at least the minimum above. * `managerForNewAccount` is the perp risk manager, `0xDE0423D0a1E15536265C9513d2e0c10DAb5835D4`. For a top-up it is ignored; send the same address or zero. The account is credited at 18 decimals, so 1,000 USDC appears on chain as `1000000000000000000000` of cash, and 1,000 cNGN as the same number of the cNGN escrow. The response states the amount in all three forms. cNGN counts toward margin at its `margin_factor` (50%), and all cNGN under the perp risk manager shares one cap, `cap` in `collateral_assets`. A deposit that would cross it is refused with a **400** that says how much room is left. Send the action's seven fields as strings (numbers in base 10, `data` as hex) with the signature: ```json theme={null} { "action": { "subaccount_id": "0", "nonce": "7", "module": "0x6540f8d9Eb599b045C05E45cb6a5B1730a806658", "data": "0x000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000a74e49b4ed7cb176bc02ef4d8a1a3240c9ad4272000000000000000000000000de0423d0a1e15536265c9513d2e0c10dab5835d4", "expiry": "1789400600", "owner": "0xYourAddress", "signer": "0xYourAddress" }, "signature": "0x…" } ``` ## What is checked In order, and every check fails closed: 1. The request: module, signer equals owner, expiry, `data` exactly three words, an accepted asset, an explicit amount at or above that asset's minimum, no permit on cNGN, and the perp risk manager for a new account. Refused with **400**. 2. At most one deposit in progress per owner, three requests a minute, and six submitted deposits an hour. Refused with **429**; the hourly limit sends `Retry-After`. A request refused for any other reason does not count toward the hour. 3. The signature authorizes the action. Refused with **401**. 4. The token's issuer: if USDC or cNGN has frozen your wallet, the deposit is refused with **400** saying so. If the issuer has paused the token, or frozen the venue's `DepositModule` or custody contract, deposits of that token are suspended for everyone: **503**. 5. Read from the chain before anything is submitted, each refused with **400** and what to do: the nonce is unused; for a top-up, you own the account and it is a perp account; your wallet holds the tokens and has approved the `DepositModule` for them; the asset still accepts deposits under the perp risk manager, and the deposit fits under its cap. 6. The executor's simulation. The reverts above, if they still happen (a race), come back as **400** with `revert` naming them; any other revert is **422**. **503** means deposits are off or paused: the venue's gas wallet is below its reserve, the hour's budget of sponsored gas is spent, or the token's issuer has paused it or frozen a venue contract. `error` says which; retry later. ## The outcome A **200** carries the transaction and what it credited: ```json theme={null} { "action_hash": "0x1a818d05…", "status": "confirmed", "accepted": true, "tx_hash": "0x…", "receipt_status": "success", "block_number": "52386004", "subaccount_id": "27", "asset": "0xa74e49b4ed7cb176bc02ef4d8a1a3240c9ad4272", "asset_symbol": "USDC", "amount": "1000.000000", "amount_units": "1000000000", "credited_e18": "1000000000000000000000", "amount_usdc": "1000.000000", "credited_cash_e18": "1000000000000000000000" } ``` `amount`, `amount_units` and `credited_e18` are in the deposited token, named by `asset_symbol`. `amount_usdc` and `credited_cash_e18` are the same numbers under their original names, and appear on USDC deposits only: a cNGN deposit carries `"asset_symbol": "cNGN"` and no `amount_usdc`. With a permit that was needed, the response also carries `permit_tx_hash`. `subaccount_id` is your new account when you sent `0`. Use it as `subaccount_id` on your orders. `action_hash` identifies the deposit: it is the EIP-712 hash of your signed action (`Matching.getActionHash`). `status` is one of: | `status` | Meaning | | - | - | | `pending` | Being checked and submitted. Answered with **202**. | | `submitted` | Broadcast, but the receipt was not known yet (`receipt_status: "timeout"`). | | `confirmed` | Mined successfully. | | `reverted` | Mined and reverted. | | `rejected` | Refused before anything was sent. You may resubmit the same request. | | `unknown` | The venue could not confirm whether it was submitted. | ## Checking a deposit later ```text theme={null} GET /v1/deposits/{action_hash} ``` Returns the deposit's record. A `submitted` deposit is re-read from the chain first, so a receipt that timed out resolves to `confirmed` (with `subaccount_id`) or `reverted` on the next read. Sending the **same signed request** again is also safe at any point: the venue answers it from its record and never submits it twice — after a timeout, a dropped connection or a restart. A **502** means the venue could not confirm whether the deposit was submitted: look it up by `action_hash`, or resend the same request, before signing a new one. A second signed deposit is a second deposit. # List fills Source: https://docs.numofx.com/api-reference/endpoint/fills GET /v1/fills The fills on the authenticated owner's orders, newest first. Takes the same X-Numo-Auth frame as GET /v1/orders; the owner is taken from it, never from a parameter. A trade between two of the owner's orders is listed once per order. Returns the fills on your orders — every execution, at the price and size it traded — newest first. Use it to reconcile balances trade by trade; [order history](/api-reference/endpoint/order-history) gives the same activity summed per order. Authentication is required, and the owner is taken from the signature, never from a parameter. The public [trades](/api-reference/endpoint/trades) feed lists every execution by order id but not by owner; this endpoint is what ties fills to an address. ## Authentication Send the same `X-Numo-Auth` frame as [order history](/api-reference/endpoint/order-history), signed over the same message. One frame authorizes both endpoints until it expires, so a client that already shows order history needs no second signature. ## Reading a fill * `liquidity` is `taker` when your order crossed the book, `maker` when it rested and was hit. * `side`, `price` and `size` are the engine's: your order's side, the price in USDC per cNGN, and the size in cNGN. `buy` means your order acquired cNGN and paid USDC. * `spot_contract.ui_intent` restates the fill at display scales — the same `side`, `price` to 10 decimal places and `size` in cNGN to 6 — and `balance_delta` is what the fill moved: cNGN by the size, USDC by size times price, signed by side. * A trade between two of your own orders, on different subaccounts, is listed twice — once for each order — because each moved that account's balances. Both rows share a `trade_id`. ## Fees `fee` is what your order paid on the fill, in USDC. Makers are never charged, so a maker fill's `fee` is `0`. A taker fill carries the fee the venue charged: recorded when the fill executes, and filled in exactly for fills from before fees were recorded. `fee` is absent only when the fee is not known. `tx_hash` is the Base transaction that settled the fill. It is absent for fills recorded before transaction hashes were stored. ## Retention Fills are kept indefinitely. An order that traded is never pruned, even after it is cancelled or expires, so its fills stay listed. ## Paging Pages hold up to `limit` fills (default 50, maximum 100). When a page is full, the response includes `next_before`; pass it back as `before` to fetch older fills. The last page has no `next_before`. # List markets Source: https://docs.numofx.com/api-reference/endpoint/get GET /v1/markets # Get orderbook Source: https://docs.numofx.com/api-reference/endpoint/integrations-orderbook GET /v1/integrations/orderbook Resting orders aggregated into price levels in the advertised pair orientation. On cNGN-USDC and cNGN-PERP, bids are orders buying cNGN and asks are orders selling it, priced in USDC per cNGN with quantities in cNGN. Bid prices round down and ask prices round up to 10 decimal places, and quantities round down to 6, so no level shows a better price or more size than is resting. Orders whose prices round to the same level are summed; a level whose quantity rounds to 0 is dropped. At most the first 1000 resting orders per side are read. Responses are cached for up to 5 seconds and carry Cache-Control: public, max-age=5, so polling faster than that returns the same data. Levels are `[price, quantity]` with the price in **USDC per cNGN** and the quantity in **cNGN**. `bids` are orders buying cNGN, highest price first; `asks` are orders selling it, lowest first. This is the same orientation as `GET /v1/book`, so nothing needs inverting between the two. Prices are rounded to 10 decimal places away from the touch — bids down, asks up — and quantities round down to 6 places, so no level shows a better price or more size than is resting. Orders whose prices round to the same level are summed into one; a level whose quantity rounds to `0` is dropped. The service reads at most the first 1000 resting orders per side when building levels. Responses are cached for up to 5 seconds and carry `Cache-Control: public, max-age=5`. The cache key is `ticker_id` and `depth` only; other query parameters do not bypass it. # List tickers Source: https://docs.numofx.com/api-reference/endpoint/integrations-tickers GET /v1/integrations/tickers One ticker per enabled market, in the pair orientation GET /v1/markets advertises: base_currency is the asset priced, target_currency the asset it is priced in. On cNGN-USDC and cNGN-PERP that is cNGN priced in USDC, the same orientation as GET /v1/book and GET /v1/trades; nothing is inverted. Prices are USDC per cNGN to 10 decimal places: last_price, high, low and bid round down, ask rounds up, so the ticker agrees with the orderbook's top levels. base_volume is cNGN to 6 decimal places, target_volume USDC to 18, both rounded down. Use this endpoint, not stats_24h, for a market data listing. Responses are cached for up to 5 seconds and carry Cache-Control: public, max-age=5, so polling faster than that returns the same data. Each ticker is in the orientation `GET /v1/markets` advertises: `base_currency` is `cNGN` and `target_currency` is `USDC`, so every price is **USDC per cNGN** (about `0.00073`), `base_volume` is cNGN and `target_volume` is USDC. The `ticker_id` is the market identifier, `cNGN-USDC` or `cNGN-PERP`; read the two currency fields for the orientation rather than parsing it. The old `USDCcNGN-SPOT` and `USDCcNGN-PERP` are accepted as deprecated aliases for `ticker_id` until 2027-01-06, answered under the canonical `ticker_id` with `Deprecation: true` and `X-Canonical-Market` headers. Prices are rounded to 10 decimal places the same way the orderbook rounds its top levels: `bid`, `last_price`, `high` and `low` round down, `ask` rounds up. Volumes round down, `base_volume` to 6 places and `target_volume` to 18. Responses are cached for up to 5 seconds and carry `Cache-Control: public, max-age=5`. Polling faster than that returns the same body. # List trades Source: https://docs.numofx.com/api-reference/endpoint/integrations-trades GET /v1/integrations/trades Fills newest first, in the advertised pair orientation. On cNGN-USDC and cNGN-PERP, price is USDC per cNGN to 10 decimal places, base_volume is the cNGN traded to 6 decimal places and target_volume the USDC it traded for to 18, all rounded to nearest. type is the taker's side in cNGN. Responses are cached for up to 5 seconds and carry Cache-Control: public, max-age=5, so polling faster than that returns the same data. Each trade is in the orientation `GET /v1/markets` advertises: `price` in **USDC per cNGN** to 10 decimal places, `base_volume` the cNGN traded to 6 places, and `target_volume` the USDC it traded for (`price x base_volume`) to 18 places, all rounded to nearest. `type` is the taker's side in cNGN: `buy` means the taker acquired cNGN and paid USDC. `trade_id` is the same id `GET /v1/trades` reports, so the two feeds can be reconciled row by row. Responses are cached for up to 5 seconds and carry `Cache-Control: public, max-age=5`. The cache key is `ticker_id`, `limit` and `before_trade_id` only. # List order history Source: https://docs.numofx.com/api-reference/endpoint/order-history GET /v1/orders The authenticated owner's orders in every status (active, matching, filled, cancelled, expired), newest first. The owner is taken from the signed X-Numo-Auth frame, never from a parameter. Filled orders, and any cancelled or expired order that traded, are retained indefinitely; cancelled and expired orders with no fills for 30 days. Returns your orders in every status — `active`, `matching`, `filled`, `cancelled` and `expired` — newest first. Authentication is required, and the owner is taken from the signature, never from a parameter. Resting orders are public on the book and fills settle on chain, but cancelled and expired orders are not published anywhere else. ## Authentication Build and sign this exact message with `personal_sign` (unix seconds, no trailing newline). The domain is the same one the WebSocket uses. ```text theme={null} wants you to view your Numo order history. Address:
Nonce: Issued At: Expiration Time: ``` Send the frame — `address`, `signature`, `nonce`, `issued_at`, `expiry` — as JSON, base64url-encoded, in the `X-Numo-Auth` header. One frame can be reused across requests until it expires; the service accepts a validity window of up to 24 hours. A frame signed for the WebSocket `orders` channel is not accepted here, and an order-history frame does not authenticate a WebSocket: the two messages differ, so each recovers a different address from the other's signature. ## What traded Each order that has filled carries `filled_quote`: the USDC that actually changed hands, summed over its fills at the prices they executed at. Use it to reconcile balances. Do not derive a size from the order's own amount and limit price — a marketable order is signed with slippage room past the touch, so that figure overstates what filled. `filled_quote` is absent for an order with no fills. ## Retention Filled orders are kept indefinitely, and so is any cancelled or expired order that traded before it ended, so its fills and `filled_quote` stay reportable. Cancelled and expired orders with no fills are kept for 30 days, then pruned. ## Paging Pages hold up to `limit` orders (default 50, maximum 100). When a page is full, the response includes `next_before`; pass it back as `before` to fetch older orders. The last page has no `next_before`. # Get order status Source: https://docs.numofx.com/api-reference/endpoint/order-status GET /v1/orders/{order_id} # List positions Source: https://docs.numofx.com/api-reference/endpoint/positions GET /v1/positions A subaccount's perpetual positions and margin accounts, read from chain at request time. Positions are public on chain, so no signature is required. Everything is in the cNGN-PERP orientation: prices in USDC per cNGN, sizes in cNGN, long means long cNGN and is a positive engine_position. positions holds one entry per perp market with a non-zero position; accounts one entry per enabled perp market whether or not a position is held. Both are [] rather than null when empty, and both are empty when the deployment serves no perp. Market prices and margin rates are cached for 10 seconds; the account's balances are read fresh on every request. Returns a subaccount's perpetual positions and margin accounts, read from chain at request time. Positions are public on chain, so no signature is required: pass the `subaccount_id` and read. The response always has two arrays, each `[]` rather than `null` when empty: * `positions` — one entry per perp market where the account holds a non-zero position. * `accounts` — one entry per enabled perp market, whether or not the account holds a position: its cash and margin surpluses are what a ticket shows as available before the first trade. Both are empty when this deployment serves no perp. ## Reading a position Everything is in the cNGN-PERP orientation: prices in USDC per cNGN, sizes in cNGN, and long means long cNGN. * `engine_position` is the signed cNGN balance of the perp on chain, as a decimal string with up to 18 places. Positive is a long. * `ui_side` is `long` when `engine_position` is positive, `short` when negative. `ui_size` is its magnitude in cNGN and `ui_notional_usdc` that size valued at the index, both to 6 decimal places. * `mark_price_ui` and `index_price_ui` are the market's mark and index to 10 decimal places, the same values as the `perp` block on [`GET /v1/markets`](/api-reference/endpoint/get). * `unrealized_pnl` is the position's unsettled and unrealized cash on chain, in USDC, signed. * `initial_margin_surplus` and `maintenance_margin_surplus` are the SRM's own figures for the account, in USDC. Below zero means the account is under that margin; a negative maintenance surplus means it can be liquidated. * `liquidation_price_ui` is an estimate: the price in USDC per cNGN at which the maintenance surplus reaches zero if nothing else changes, for an account holding only this position. It is **omitted** when no positive price gets there — a long at 1x, for example. ## Reading an account * `cash` is the account's balance in the perp's CashAsset, in USDC, signed. * `initial_margin_surplus` and `maintenance_margin_surplus` are as above. For an account with no position the initial surplus is its cash. * `collateral` lists each base asset the account has posted as margin: `symbol`, `asset_address`, `balance` in that asset, `value_usd` at the index and `margin_value_usd`, the share the SRM credits after its `margin_factor`. Empty when nothing is posted, or margin is cash only. Decimal fields read from chain are strings with up to 18 decimal places and no trailing zeros. ## Example A long of 137,400 cNGN with 40 USDC of margin, at a mark of 0.0007278: ```json theme={null} { "positions": [ { "market": "cNGN-PERP", "subaccount_id": "42", "engine_position": "137400", "ui_side": "long", "ui_size": "137400", "ui_notional_usdc": "100.0272", "mark_price_ui": "0.0007278", "index_price_ui": "0.000728", "unrealized_pnl": "-0.02748", "initial_margin_surplus": "6.63045", "maintenance_margin_surplus": "19.96708", "liquidation_price_ui": "0.00054615" } ], "accounts": [ { "market": "cNGN-PERP", "subaccount_id": "42", "cash": "40", "initial_margin_surplus": "6.63045", "maintenance_margin_surplus": "19.96708", "collateral": [] } ] } ``` ## Polling The market's mark, index and margin rates are cached for 10 seconds server-side; the account's balances and surpluses are read from chain on every request. The Numo app polls this endpoint every 15 seconds, which is ample: funding accrues continuously, so there is no settlement instant to catch. ## Errors | Status | Error | Cause | | - | - | - | | `400` | `subaccount_id is required` | The query parameter is missing. | | `400` | `subaccount_id must be a non-negative integer` | Not a base-10 unsigned integer. | | `502` | `could not read positions from chain` | The RPC read of the position failed. Retry. | | `502` | `could not read the account from chain` | The RPC read of the account's margin failed. Retry. | # Get trades Source: https://docs.numofx.com/api-reference/endpoint/trades GET /v1/trades # Execute match Source: https://docs.numofx.com/api-reference/endpoint/webhook POST /execute # Create a withdrawal Source: https://docs.numofx.com/api-reference/endpoint/withdrawals POST /v1/withdrawals Withdraws from a subaccount held by Matching with one signed WithdrawalModule action. The venue verifies the signature and that Matching records action.owner as the owner, simulates the withdrawal, and submits it through Matching.verifyAndMatch. Tokens are paid to action.owner. Every check fails closed. Withdraws from a subaccount held by Matching — the kind every account created through the Numo app is. You sign one `Action` for the `WithdrawalModule`; the venue verifies it and submits it through `Matching.verifyAndMatch`, which lends the account to the module for the call. The module pays the action's `owner`, and the account stays in Matching, so trading from it is not interrupted. Signing costs no gas: the venue's executor submits the transaction. It is simulated first, so a withdrawal that would revert is refused before anything is sent. ## Signing the action Sign an [`Action`](/signing) against the same `Matching` domain as an order, with: | Field | Value | | - | - | | `subaccountId` | the subaccount, deposited in Matching with you as its recorded owner | | `nonce` | any unused `uint256`; nonces are per owner and per module, so order nonces never collide with it | | `module` | the WithdrawalModule: `0x0a10AE2f5D2482cE1e43bC309D430B8861C2b5aB` on Base | | `data` | `abi.encode(address asset, uint256 amount)` | | `expiry` | unix seconds, in the future and at most one hour ahead | | `owner` | your address; the tokens are sent here | | `signer` | the same as `owner` (session keys are not supported yet) | `asset` is the wrapped asset your balance is held in, not the token it pays out: | Balance | `asset` | Paid out as | | - | - | - | | USDC | `0x364058aFF6f36E01505fB2Cc870f8B6BD4835e84` | USDC `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | | cNGN | `0x9D806fD040a719D27a8E5E77dc5aE0ED1e089493` | cNGN `0x46C85152bFe9f96829aA94755D9f915F9B10EF5F` | `amount` is in the token's native decimals — 6 for both, so 1 USDC is `1000000`. Send the action's seven fields as strings (numbers in base 10, `data` as hex) with the signature: ```json theme={null} { "action": { "subaccount_id": "19", "nonce": "7328734720000000", "module": "0x0a10AE2f5D2482cE1e43bC309D430B8861C2b5aB", "data": "0x000000000000000000000000364058aff6f36e01505fb2cc870f8b6bd4835e8400000000000000000000000000000000000000000000000000000000001e82d7", "expiry": "1789400600", "owner": "0xYourAddress", "signer": "0xYourAddress" }, "signature": "0x…" } ``` ## What is checked In order, and every check fails closed — a signature or owner that cannot be read is refused, not waved through: 1. The request: module, subaccount (not 0), signer equals owner, expiry, `data` exactly two words, asset, amount above 0, signature shape. Refused with **400**. 2. At most one withdrawal in progress per owner, and five a minute. Refused with **429**. 3. The signature authorizes the action. Refused with **401**. 4. Matching holds the subaccount (**400**) and records `owner` as its owner (**403**). 5. The token's issuer: if USDC or cNGN has frozen your wallet, it cannot receive the token and the withdrawal is refused with **400** saying so. If the issuer has paused the token or frozen the venue's custody contract, withdrawals of that token are suspended for everyone: **503**. 6. The executor's simulation. A withdrawal that would revert is refused with **422**, and `revert` names the revert — for example `WERC_CannotBeNegative` for more than the account holds. ## The outcome A **200** carries the transaction: `accepted`, `tx_hash`, `receipt_status` and `block_number`. `receipt_status: "timeout"` means it was broadcast and had not confirmed when the venue answered — watch the hash rather than treating it as failed. A **502** means the venue could not confirm whether the withdrawal was submitted. Check your balance before signing another: a second signed withdrawal is a second withdrawal. Resubmitting the *same* signed request is safe — its nonce can only be spent once. # Numo API Source: https://docs.numofx.com/api-reference/introduction REST endpoints and WebSocket streams for the Numo orderbook and execution stack. ## Welcome Numo's orderbook stack is split across a public orderbook service and a separate match executor. Use this reference for: * discovering enabled markets * reading the current book and recent trades over REST * streaming real-time book, trades, and order updates over the [WebSocket](/api-reference/websocket) * submitting signed orders to the matcher * checking the status of a submitted order * understanding the executor payload that clears matched orders onchain View the `openapi.json` file used to build the API reference. ## Services covered here * `markets-service` * `execution-service` The contracts and risk engine behind those services live in: * `execution-contracts` * `risk-core` ## Orderbook flow Read `GET /v1/markets` to find the canonical market symbol, `asset_address`, `sub_id`, and settlement metadata for instruments like `cNGN-USDC`. Read `GET /v1/book` and `GET /v1/trades` from `markets-service`, or subscribe to the `book` and `trades` [WebSocket](/api-reference/websocket) channels for real-time updates instead of polling. Post signed order payloads to `POST /v1/orders`, then track fills with `GET /v1/orders/{order_id}` or the authenticated `orders` WebSocket channel. Let the matcher send crossed-order payloads to `POST /execute` on `execution-service`. # WebSocket streams Source: https://docs.numofx.com/api-reference/websocket Real-time book, trades, and order streams from markets-service over a single WebSocket. ## Overview `markets-service` exposes a realtime feed at `GET /v1/ws`. Connect once and multiplex any number of channel subscriptions over the same socket. Every stream starts with a snapshot and then delivers incremental updates, so you never poll `GET /v1/book` or `GET /v1/trades` in a loop. * **Endpoint** — `wss://api.numofx.com/v1/ws` * **Framing** — JSON text frames * **Model** — snapshot, then a strictly increasing stream of deltas you can resume after a reconnect * **Delivery** — event-driven push on every state change, with no publish interval (see [Update delivery](#update-delivery)) Every server frame that belongs to a market carries the `seq` it reflects. The `snapshot` frame is taken at a specific `seq`; each following `update` is the next `seq`. A gap in the `seq` you receive means you missed an event — resume (see [Resume after reconnect](#resume-after-reconnect)). ## Update delivery Updates are event-driven, not interval-based. The server pushes an `update` frame the moment the underlying state changes — an order placed, cancelled, expired, or filled. There is no publish tick, no fixed cadence, and nothing for you to poll. That has a few consequences worth designing for: * **One frame per state change.** Updates are not batched, conflated, or rate-limited. A price level that changes five times produces five `update` frames, each with its own `seq`. * **No cadence to rely on.** A quiet market can go minutes without a frame; a single match can deliver a burst of frames in the same millisecond. Drive your UI off arriving frames rather than a timer, and buffer client-side if you need a fixed-cadence render. * **Silence is not a stalled connection.** The only periodic traffic the server sends is a WebSocket ping every 15 seconds. If the market is idle, you receive nothing but pings. * **Related deltas arrive together.** A fill emits its `trades`, `book`, and `orders` deltas as one contiguous block of `seq` values, so you never see a trade without the matching book and order updates. If an internal notification is ever lost, a background reconciliation delivers the affected events within a few seconds, in normal `seq` order. No client action is needed — you see a slightly later frame, never a gap. ## Channels | Channel | Access | Snapshot | Updates | | - | - | - | - | | `book` | Public | Top bids and asks (mirrors `GET /v1/book`) | Price-level size changes | | `trades` | Public | Recent fills (mirrors `GET /v1/trades`) | One frame per new fill | | `orders` | **Private** — requires auth | Your open orders | Status and fill changes for your address | The `orders` channel is scoped to the authenticated owner address. You must send a valid `auth` frame before subscribing to it, and you only ever receive your own order events. ## Client frames Send these as JSON text frames. The `op` field selects the operation. ### Subscribe ```json theme={null} { "op": "subscribe", "channel": "book", "market": "cNGN-USDC" } ``` One of `subscribe`, `unsubscribe`, `auth`, `ping`. `book`, `trades`, or `orders`. Market identifier such as `cNGN-USDC`, or an `asset_address:sub_id` pair. Required for `book` and `trades`. Optional for `orders`, where it scopes the stream to a single market. The deprecated `USDCcNGN-SPOT` and `USDCcNGN-PERP` are accepted until 2027-01-06; whichever identifier you subscribe with, every frame names the market by its canonical identifier, and an `unsubscribe` with any identifier for the market ends the subscription. Optional. The last `seq` you processed. When present, the server replays events after that point instead of sending a fresh snapshot. See [Resume after reconnect](#resume-after-reconnect). Subscriptions are idempotent — subscribing again to the same `(channel, market)` is a no-op. ### Unsubscribe ```json theme={null} { "op": "unsubscribe", "channel": "book", "market": "cNGN-USDC" } ``` The server replies with an `ack` frame. ### Ping ```json theme={null} { "op": "ping" } ``` The server replies with `{ "type": "pong" }`. The server also sends protocol-level WebSocket pings every 15 seconds and drops the connection on missed pongs, so most clients do not need to send `ping` themselves. ### Auth Required before subscribing to the private `orders` channel. See [Authentication](#authentication). ```json theme={null} { "op": "auth", "address": "0xC7bE60b228b997c23094DdfdD71e22E2DE6C9310", "signature": "0x…", "nonce": "b3f1…", "issued_at": 1745000000, "expiry": 1745000300 } ``` ## Server frames The `type` field identifies the frame. | `type` | Meaning | | - | - | | `snapshot` | Full state for a `(channel, market)` at `seq`. Sent first after subscribe. | | `update` | One incremental delta. Its `seq` is greater than the snapshot's. | | `ack` | Acknowledges `auth` or `unsubscribe`. | | `pong` | Reply to a client `ping`. | | `error` | A problem with the last frame or the stream. Carries `code` and `message`. | Frame shape: ```json theme={null} { "type": "update", "channel": "book", "market": "cNGN-USDC", "seq": 10441, "data": { "…": "…" } } ``` ### Update payloads The `data` on an `update` frame is a compact delta, not the whole book. ```json book theme={null} { "side": "buy", "price_ticks": "1390", "limit_price": "1390", "size_delta": "-0.001", "order_open": "0", "order_id": "…" } ``` ```json trades theme={null} { "trade_id": 4821, "price": "1390", "size": "0.001", "aggressor_side": "buy", "taker_order_id": "…", "maker_order_id": "…", "created_at": "2026-04-01T00:00:00Z" } ``` ```json orders theme={null} { "order_id": "…", "status": "filled", "side": "sell", "filled_amount": "0.001", "desired_amount": "0.001", "limit_price": "1390", "price_ticks": "1390" } ``` The `snapshot` payload mirrors the matching REST response: `book` snapshots use the same shape as `GET /v1/book`, and `trades` snapshots use the trade list from `GET /v1/trades`. ## Subscribe and snapshot consistency On subscribe, the server: Nothing is dropped between the read and the first frame. The snapshot data and its `seq` come from a single consistent read. Buffered deltas with `seq <= boundary` are dropped, so you never double-apply an event already reflected in the snapshot. ## Resume after reconnect Track the highest `seq` you have applied per `(channel, market)`. On reconnect, subscribe with `since_seq` set to that value: ```json theme={null} { "op": "subscribe", "channel": "book", "market": "cNGN-USDC", "since_seq": 10441 } ``` * If the events after `since_seq` are still retained, the server replays them and goes live — no snapshot, no gap. * If `since_seq` is older than the retention horizon, the server sends `error` with code `resume_too_old`, followed by a fresh `snapshot`. Discard your local state and rebuild from the snapshot. ## Authentication The `orders` channel requires a signed `auth` frame. Authentication uses an EIP-191 `personal_sign` signature over a canonical message, recovered to your owner address. Build and sign this exact message (unix seconds, no trailing newline): ```text theme={null} wants you to authenticate for the Numo markets WebSocket. Address:
Nonce: Issued At: Expiration Time: ``` Then send the fields in an `auth` frame: Your owner address, `0x` + 40 hex characters. Recovery must match this address. The `personal_sign` signature over the canonical message. A unique value per auth frame. Unix seconds when the frame was signed. Rejected if far in the future. Unix seconds when the frame stops being valid. Keep the window short; the server enforces a maximum TTL. On success the server replies `{ "type": "ack", "message": "authenticated" }` and the connection is authorized for that address until it closes. On failure it replies with an `error` frame, code `auth_failed`. The REST [order history](/api-reference/endpoint/order-history) endpoint takes the same frame fields but signs a different message, so a frame signed for one is never accepted by the other. ## Error codes `error` frames carry a `code` and a human-readable `message`. | Code | Cause | | - | - | | `bad_json` | Frame was not valid JSON. | | `bad_op` | Unknown `op`. | | `bad_channel` | Unknown channel. | | `unknown_market` | Market symbol or `asset:sub_id` did not resolve. | | `auth_required` | Subscribed to `orders` without authenticating first. | | `auth_failed` | The `auth` frame failed signature, domain, or expiry checks. | | `resume_too_old` | `since_seq` is older than retained history; a fresh snapshot follows. | | `stream_reset` | The server dropped the subscription; resubscribe with `since_seq`. | | `snapshot_failed` | The snapshot could not be loaded; resubscribe. | | `slow_consumer` | Your client fell behind and the connection was closed. Reconnect and resume. | The server enforces backpressure. If your client cannot keep up with the send rate, the connection is closed with `slow_consumer`. Reconnect and resume with `since_seq` rather than holding a slow socket open. # Changelog Source: https://docs.numofx.com/changelog Changes to the Numo API, newest first **The markets are identified as `cNGN-USDC` and `cNGN-PERP`.** These are the values `market`, `symbol` and `ticker_id` carry everywhere, matching `display_name`. `GET /v1/markets` and `GET /v1/integrations/tickers` list the canonical identifiers only. Every response and websocket frame names the market by its canonical identifier, whichever identifier the request used. **The old identifiers are deprecated aliases until 2027-01-06.** `USDCcNGN-SPOT` and `USDCcNGN-PERP` still resolve, by exact match, wherever an identifier is read: `symbol`, `market` and `ticker_id`, including websocket subscribe and unsubscribe. A request that uses one returns the same data as the canonical request plus two headers: `Deprecation: true` and `X-Canonical-Market: `. On 2027-01-06 the aliases are removed and such requests return `400 unknown_market`. **An unknown or missing market is now a 400.** `GET /v1/book`, `GET /v1/trades` and `GET /v1/candles` return `{"error": "unknown_market", "message": …, "markets": [...]}` when the `symbol` or `asset_address` + `sub_id` names no listed market (`no such market: `), or when the request names no market at all (`symbol or asset_address is required`). Before this change both cases silently returned the spot book, so a client that sent a wrong identifier read spot prices and never saw an error. See [Errors](/errors#instrument). Unchanged: `display_name`, `order_entry_spec` (`cngn_usdc_spot_v1` and `cngn_usdc_perp_v1`), every address, and the `asset_address` + `sub_id` form of naming a market. # Order lifecycle Source: https://docs.numofx.com/development How an order moves through markets-service, execution-service, execution-contracts, and risk-core ## System overview Numo's orderbook is split across four layers: Public REST API for market discovery, orderbook reads, trades, order entry, and cancellation. Backend executor that validates crossed-order payloads and submits `verifyAndMatch(...)`. Onchain matching and action-verification contracts used by the trusted backend executor. Margin engine, subaccounts, cash asset accounting, manager hooks, settlement, and liquidation. ## End-to-end flow Clients call `GET /v1/markets` on `markets-service` to discover enabled instruments and retrieve the canonical `asset_address` and `sub_id`. Clients poll `GET /v1/book` and `GET /v1/trades` on `markets-service` for top-of-book state, recent prints, and 24h stats. Clients submit signed order payloads to `POST /v1/orders`. The service validates that order metadata matches the embedded action payload before persisting the order. The matcher loop in `markets-service` scans for crossed orders, computes fill amounts, and builds an executor payload for the market. `execution-service` validates the payload, ABI-encodes `TradeModule.OrderData`, simulates `Matching.verifyAndMatch(...)`, and broadcasts the transaction through `execution-contracts`. `risk-core` validates account state through manager and asset hooks, updates margin state, and handles cash settlement, funding, and liquidation behavior. ## cNGN-USDC market The cNGN-USDC spot market exposed by the backend is: * `market`: `cNGN-USDC` * `contract_type`: `spot` * `settlement_type`: `spot` * `base_asset_symbol`: `cNGN`, `quote_asset_symbol`: `USDC` * `display_name`: `cNGN-USDC`, `display_label`: `USDC per cNGN` * `order_entry_spec`: `cngn_usdc_spot_v1` The settlement note in the matcher registry is: > Spot-style orderbook market on Base. Trades exchange WRAPPED\_CNGN against the quote asset of the configured TradeModule (TRADE\_MODULE\_ADDRESS / QUOTE\_ASSET\_ADDRESS): the internal USDC cash ledger under the cash-quoted module, or the wrapped USDC asset under the wrapped-quote module, in which case both legs are 1:1 token-backed. ## What each repo is responsible for ### `markets-service` * stores active orders and trade fills * exposes `GET /v1/markets`, `GET /v1/book`, `GET /v1/trades` * accepts `POST /v1/orders` and `POST /v1/orders/cancel` * resolves market metadata from exact `(asset_address, sub_id)` ### `execution-service` * accepts `POST /execute` * requires `actions.length === signatures.length` * requires `actions[0].subaccount_id === order_data.taker_account` * requires every `action.module === module_address` ### `execution-contracts` * verifies signed actions * owns the trusted `Matching` execution path * executes matched transfers against the core account system ### `risk-core` * stores balances in `SubAccounts` * lets managers validate final account state * lets assets track transfer semantics and settlement inputs * provides `CashAsset` and `PerpAsset` primitives used by managers during settlement # Order errors and validation Source: https://docs.numofx.com/errors Every rejection markets-service can return, and what to change to fix it Every error response has the same shape: ```json theme={null} { "error": "action_json.data.isBid must match side" } ``` | Status | Meaning | | - | - | | `400` | The request is malformed or internally inconsistent. Fix and resubmit. | | `403` | The cancel targets an order you do not own, or a protected namespace. | | `404` | No such order, or the order is no longer active. | | `409` | `order_id` has already been used. | | `500` | Server-side failure. Safe to retry. | Most rejections happen before the order reaches the book, so a `400` means nothing was placed. `markets-service` verifies your signature at submission — see [Authentication and signing](/signing#what-the-service-checks). An order whose signature does not authorize its action is rejected here rather than failing later onchain. ## Signature The signature did not recover to `signer_address`, and the signer is not a contract whose `isValidSignature` accepted it. The digest is over the `Action` struct exactly as described in [Authentication and signing](/signing) — domain `Matching` version `1.0`, bound to the chain and the Matching contract. A mismatch in any signed field produces a different digest and so a different recovered address. Check that `action_json` is byte-for-byte what you signed, and that `signer_address` is the key that signed it rather than the account that owns it. A signature that cannot be checked — for example an RPC failure while resolving a contract signer — is not a rejection. The order is accepted and the failure is logged, so a transient outage never blocks a valid order. ## Missing fields Every one of these is required on `POST /v1/orders`. | Error | Fix | | - | - | | `order_id is required` | Supply a unique client-side id. | | `asset_address is required` | Take it from `GET /v1/markets`. | | `nonce is required` | Supply a nonce unique per `(owner, nonce)`. | | `limit_price is required` | Supply the price in human decimals. | | `desired_amount is required` | Supply the size in human decimals. | | `signature is required` | Supply the EIP-712 signature. | | `invalid JSON body` | The body did not parse as JSON. | | `action_json must be valid JSON` | `action_json` is present but not valid JSON. | ## Action payload shape These come from decoding `action_json.data`, the ABI-encoded `TradeData` tuple. `data` is not exactly seven 32-byte words. After the `0x` prefix it must be 448 hex characters. See [Encoding data](/signing#encoding-data). `data` is the right length but not valid hex. A wrapper around the field-level failures below. The suffix names the specific problem. ## Field consistency The flat order fields and the signed `action_json` describe the same order, so they must agree. Rebuild both from one set of values rather than editing one of them. | Error | Compared against | | - | - | | `action_json.subaccount_id must match subaccount_id` | body `subaccount_id` | | `action_json.nonce must match nonce` | body `nonce` | | `action_json.owner must match owner_address` | body `owner_address` | | `action_json.signer must match signer_address` | body `signer_address` | | `action_json.data.asset must match asset_address` | body `asset_address` | | `action_json.data.subId must match sub_id` | body `sub_id` | | `action_json.data.isBid must match side` | `true` for `buy`, `false` for `sell` | ## Scaling These are the most common rejections, and they almost always mean the body was sent in wei. `data.limitPrice` must be a positive whole multiple of the body's `limit_price` after tick normalization. The body takes **human decimals**; only `data` carries wei. Same rule for size, normalized against the instrument's minimum size. `limitPrice` is a signed `int256` but zero and negative values are rejected. Same for `desiredAmount`. `desired_amount` is smaller than the instrument's minimum size, so it normalizes to nothing. `desired_amount` is not a whole multiple of the instrument's minimum size. If a price or amount error appears and the numbers look correct, check the scale first. See [Price and amount use two different scales](/signing#price-and-amount-use-two-different-scales). ## Amounts and expiry | Error | Cause | | - | - | | `desired_amount must be greater than zero` | Non-positive size in the body. | | `filled_amount cannot be negative` | Negative `filled_amount`. | | `filled_amount cannot exceed desired_amount` | Partial-fill state is inconsistent. | | `expiry must be in the future` | `expiry` is at or before now. Use unix seconds. | | `invalid integer value …` | An integer field is not parseable. | ## Instrument The `(asset_address, sub_id)` pair is not an enabled market. Read `GET /v1/markets` — both values must come from the same entry. `GET /v1/book`, `GET /v1/trades` and `GET /v1/candles` refuse a request that does not name a listed market. The body carries the `error` code, a `message`, and the identifiers the venue accepts: ```json theme={null} { "error": "unknown_market", "message": "no such market: NOPE", "markets": ["cNGN-USDC", "cNGN-PERP"] } ``` Two messages occur. `no such market: ` means the `symbol`, or the `asset_address` + `sub_id` pair, matched nothing: identifiers match exactly, with no prefix or case folding, and the deprecated `USDCcNGN-SPOT` and `USDCcNGN-PERP` still resolve until 2027-01-06. `symbol or asset_address is required` means the request named no market at all. Before 2026-10-08 both cases returned the spot book. These two fields apply to `cNGN-USDC` and `cNGN-PERP` only. Omit them on orders for any other market. ## Order translation (`ui_intent`) These apply to `cNGN-USDC` and `cNGN-PERP`. Both fields are optional — omit them and your raw engine values are used as sent. Send either one and the rules below apply. Since the cNGN-USDC orientation the translation is the identity: the engine order is the `ui_intent`, with the price in USDC per cNGN and the size in cNGN. See the [Spot market](/spot) guide for the units involved. You sent `ui_intent` without `order_entry_spec`, or with a spec that is not this market's own. The error names the spec the market expects: `cngn_usdc_spot_v1` on spot, `cngn_usdc_perp_v1` on the perp. A spot intent cannot be replayed on the perp or the other way round, and the retired `usdc_cngn_spot_v1` and `usdc_cngn_perp_v1` specs are refused with this error. The two fields travel together. Send both, or neither. You sent both `side` and `ui_intent`, and they disagree. The engine side equals `ui_intent.side`. Omit `side` to let the service derive it. You sent both `limit_price` and `ui_intent`, and they disagree. The engine price equals `ui_intent.price`, in USDC per cNGN. Omit `limit_price` to let the service derive it. You sent both `desired_amount` and `ui_intent`, and they disagree. The engine amount equals `ui_intent.size`, in cNGN. Omit `desired_amount` to let the service derive it. ## Duplicate orders `order_id` has already been submitted. Generate a fresh one; do not retry with the same id. Treat `order_id` as an idempotency key you control. A `409` means the first submission was accepted, so check its status with `GET /v1/orders/{order_id}` rather than resubmitting. ## Cancelling | Status | Error | Cause | | - | - | - | | `404` | `active order not found` | Already filled, already cancelled, or never existed. | | `403` | `service-tagged cancels are not allowed for protected namespace …` | The order belongs to a protected `order_id` namespace. | | `500` | `failed to resolve cancel target` | Lookup failed server-side. Retry. | ## Read endpoints | Endpoint | Error | | - | - | | `GET /v1/trades` | `limit must be between 1 and 100` | | `GET /v1/trades` | `before_trade_id must be a positive integer` | | `GET /v1/candles` | `interval must be one of 1m, 5m, 15m, 1h, 4h, 1d` | | `GET /v1/candles` | `limit must be between 1 and 1000` | | `GET /v1/candles` | `start must be an RFC3339 timestamp` | | `GET /v1/candles` | `end must be an RFC3339 timestamp` | | `GET /v1/candles` | `end must be after start` | | `GET /v1/orders/{order_id}` | `order not found` (`404`) | `unknown_market` applies to `GET /v1/book`, `GET /v1/trades`, and `GET /v1/candles` alike, and is the one error on those endpoints whose body carries more than `error`. Most rejections trace back to how the action payload was built. # Execution and risk Source: https://docs.numofx.com/essentials/navigation How execution-service, execution-contracts, and risk-core turn orders into settled trades ## Execution service `execution-service` is a thin HTTP wrapper around the trusted executor. It exposes: * `GET /healthz` * `POST /` * `POST /execute` The payload contains: * `market` * `asset_address` * `module_address` * `maker_order_id` * `taker_order_id` * `actions` * `signatures` * `order_data` The service validates payload shape with Zod before submitting anything onchain. ## Execution contracts `execution-contracts` contains the `Matching` and action-verification contracts used by the backend. These contracts are responsible for: * verifying signed user actions * constraining which modules can execute those actions * forwarding matched transfers into the account system The execution path is trusted-backend driven. The orderbook does not send raw orders directly onchain. ## Risk core `risk-core` is the account and margin layer underneath the orderbook. Important concepts: * `SubAccounts` stores `{asset, subId, balance}` state per account * managers validate final account state and can handle settlement and liquidation * assets define transfer semantics and settlement hooks * `CashAsset` is the main cash accounting rail * `PerpAsset` tracks unsettled PnL, funding, and price state for perpetual-style instruments ## Why this split exists This architecture keeps concerns separate: * `markets-service` handles order intake and market data * `execution-service` handles verified transaction submission * `execution-contracts` handles onchain match execution * `risk-core` handles solvency, settlement, and liquidation # Markets service Source: https://docs.numofx.com/essentials/settings Public orderbook and order-entry endpoints exposed by markets-service `markets-service` is the public-facing REST surface for Numo's orderbook. ## Public endpoints The service exposes these core routes: * `GET /healthz` * `GET /v1/markets` * `GET /v1/book` * `GET /v1/trades` * `POST /v1/orders` * `POST /v1/orders/cancel` ## Market discovery Use `GET /v1/markets` before you submit anything. The response contains the canonical metadata you need for order entry: * `market` * `asset_address` * `sub_id` * `contract_type` * `settlement_type` * `base_asset_symbol` and `quote_asset_symbol` — `cNGN` and `USDC` on both markets * `display_name` — `cNGN-USDC` for spot, `cNGN-PERP` for the perp * `tick_size` * `order_entry_spec` — `cngn_usdc_spot_v1` or `cngn_usdc_perp_v1`, the `ui_intent` contract the market accepts ## Reading the book `GET /v1/book` accepts either: * `symbol=cNGN-USDC`, or * `asset_address=0x...&sub_id=0` The response returns: * `market_presentation` * `bids` * `asks` The matcher currently returns the top 25 levels on each side. ## Reading trade history `GET /v1/trades` supports: * `symbol` * `asset_address` * `sub_id` * `limit` * `before_trade_id` The response includes: * `market_presentation` * `stats_24h` * `trades` * `next_before_trade_id` `stats_24h` covers the trailing 24 hours, recomputed from the market's fills on each request. Its prices are in USDC per cNGN, like every other price; `volume` is in cNGN and `quote_volume` in USDC. Every field is omitted when nothing traded in the window. For the fills on your own orders, with the fee paid and the settling transaction, use the signed [List fills](/api-reference/endpoint/fills) endpoint. ## Submitting an order `POST /v1/orders` persists a signed order after validating the embedded action payload. Required fields include: * `order_id` * `owner_address` * `signer_address` * `subaccount_id` * `recipient_id` * `nonce` * `side` * `asset_address` * `sub_id` * `desired_amount` * `limit_price` * `worst_fee` * `expiry` * `action_json` * `signature` The service rejects the order if the order metadata and `action_json` disagree on owner, signer, subaccount, or nonce. ## Cancelling an order Use `POST /v1/orders/cancel` with: * `owner_address` * `nonce` Cancellation is owner-and-nonce based, not order-id based. # Introduction Source: https://docs.numofx.com/index Numo is the global exchange for dollars. Numo Numo ## Start building with the Numo API Use these guides and references to integrate faster. Make your first market read and submit your first signed order. Read cNGN-USDC spot prices, quoted in USDC per cNGN. Trade cNGN-PERP: margin, funding, liquidation and positions. Build and sign the EIP-712 action payload that authenticated order requests require. Browse the Numo API reference for request and response formats. Subscribe to book, trades, and order channels, and resume cleanly after a reconnect. Every rejection markets-service can return, and what to change to fix it. Read OHLCV buckets, and handle the periods where nothing traded. Start from a working signed-order example and adapt it to your stack. Integrate the execution webhook route used by your match executor. # Perpetual market Source: https://docs.numofx.com/perp Trade cNGN-PERP, the USDC-settled cNGN perpetual: discovery, order entry, margin, funding and positions cNGN-PERP is a USDC-settled perpetual on cNGN. It is quoted exactly like [spot](/spot): every price is **USDC per cNGN** (about `0.00073`) and every size is a quantity of **cNGN**. A **long** is long cNGN — it gains when cNGN strengthens against the dollar — and is the on-chain long of the perp, so a positive position on chain is a long. Read this before integrating against the perp. ## What the market is The perp runs on Base under a CashAsset over real USDC that PnL and funding settle in, a StandardManager (SRM) that holds margin and liquidates, and a TradeModule that orders are signed for. Since 2026-10-04 spot trades on the same stack: both markets sign for the same `trade_module_address`, and a wallet has **one trading account** under the SRM for both. Spot fills and perp margin share that account's balances, so cNGN bought on spot counts as perp margin at its factor. The account is opened by the first deposit under the SRM. * `market = "cNGN-PERP"` — the identifier you pass as `symbol` on REST endpoints and `market` on websocket streams * `display_name = "cNGN-PERP"`, `display_label = "USDC per cNGN"` * `contract_type = "perpetual"`, `settlement_type = "cash_settled_perpetual"` * `base_asset_symbol = "cNGN"`, `quote_asset_symbol = "USDC"` * `order_entry_spec = "cngn_usdc_perp_v1"` * `ui_size_unit = "cNGN contracts"`, `contract_multiplier = "1"`: one contract is one cNGN * `taker_fee_bps = 25`, `maker_fee_bps = 0`, charged in the perp's cash **Deprecated identifier.** Until 2027-01-06 the old identifier `USDCcNGN-PERP` is accepted as an exact-match alias for `cNGN-PERP` everywhere an identifier is read: `symbol`, `market` and `ticker_id`. A request that uses it returns the same data as one that uses `cNGN-PERP`, names the market `cNGN-PERP` in the body, and carries two response headers: `Deprecation: true` and `X-Canonical-Market: cNGN-PERP`. Switch to `cNGN-PERP` before the removal date. ## Discover the market ```bash theme={null} curl "https://api.numofx.com/v1/markets" ``` The perp's entry carries everything spot's does — the `ui_*` and `engine_*` contract fields, with `engine_side_policy = "same_as_ui"` and the identity formulas — plus three addresses and a `perp` object read from chain: | Field | What it is | | - | - | | `trade_module_address` | The TradeModule a perp order's `action_json.module` must name. A spot module is refused. | | `quote_asset_address` | The perp's CashAsset: the USDC escrow you deposit margin into and that PnL settles in. | | `margin_manager_address` | The SRM: the manager the trading account, for spot and the perp alike, is created under. | The `perp` object is cached for 10 seconds, except `paused`, which is re-read on every request: | Field | Scale | Meaning | | - | - | - | | `mark_price`, `index_price` | up to 18 decimals | Mark and index in USDC per cNGN, at the engine's scale. | | `mark_price_ui`, `index_price_ui` | up to 10 decimals | The same prices at display scale. An empty string when the chain reports no positive price. | | `funding_rate_1h` | up to 18 decimals | The hourly funding rate from chain, signed. | | `ui_long_funding_rate_1h` | up to 18 decimals | The same number: positive means longs (long cNGN) pay shorts. | | `funding_interval_seconds` | integer | `3600`. | | `open_interest` | up to 18 decimals | Open interest in cNGN. | | `open_interest_usd` | up to 6 decimals | Open interest at the index, in USDC. | | `initial_margin_rate`, `maintenance_margin_rate` | up to 18 decimals | The SRM's margin requirements as fractions of notional, for example `0.33333` and `0.2`. | | `max_leverage` | up to 2 decimals | `1 / initial_margin_rate`, for example `3`. Empty when the initial rate is zero. | | `trading_enabled` | boolean | The chain's answer: the module is allowed on Matching, the position cap is above zero, and the SRM is not paused. Orders are accepted and rest while it is false; the matcher does not cross them. | | `paused` | boolean | The SRM guardian's pause. While it holds, every adjustment on perp accounts reverts and new perp orders are refused with `trading_paused`. | | `position_cap` | up to 18 decimals | The open-interest cap under the SRM, in cNGN. | | `trade_module_address`, `quote_asset_address`, `margin_manager_address` | hex | The same three addresses, repeated. | | `collateral_assets` | array | Base assets the SRM credits as margin besides cash. See [cNGN as collateral](#cngn-as-collateral). Empty when margin is cash only. | | `index_lag` | object | Present only when the index-lag gate is configured. See [Index lag](#index-lag). | | `updated_at` | unix seconds | When the block was read from chain. | ## Place an order Orders use the same `ui_intent` contract as spot, under the perp's own spec. A long of 137,400 cNGN at 0.0007278 USDC per cNGN: ```json theme={null} { "order_entry_spec": "cngn_usdc_perp_v1", "ui_intent": { "side": "buy", "price": "0.0007278", "size": "137400" }, "side": "buy", "limit_price": "0.0007278", "desired_amount": "137400" } ``` The engine order is the identity of the intent — `engine_side = ui_side`, `engine_price = ui_price`, `engine_amount = ui_size` — so the values you sign into `action_json.data` are the same numbers scaled to wei, and `action_json.module` is the perp's `trade_module_address`. `buy` opens or extends a long of cNGN; `sell` opens or extends a short. The order echoes with a `spot_contract` like spot's. For the perp, `balance_delta` is the exposure the fill opens — `cngn: "+137400"`, `usdc: "-99.99972"` for the long above — not a token movement: nothing is delivered, and the trade leg moves only the difference between the fill and the mark. Sending the spot spec, or a retired `usdc_cngn_*_v1` spec, on a perp order is rejected with `order_entry_spec must be "cngn_usdc_perp_v1" for this market's ui_intent`. Two perp-only behaviours on `POST /v1/orders`: * `reduce_only: true` asks the venue to let the order only shrink the account's position. It is refused on spot, and it needs this deployment to read positions from chain. * Before resting a perp order the venue runs a margin check on both sides — the initial-margin surplus now, minus the price-versus-mark leg, the taker fee and the initial margin the fill adds. Reducing a position is always allowed. The SRM remains the enforcement on chain. See [Authentication and signing](/signing) for building the signed payload, and [Create order](/api-reference/endpoint/create) for the full request body. ## Margin and liquidation Margin is held in the perp's CashAsset in USDC. The SRM revalues every position at the **mark** and charges margin on that notional: * **Initial margin** (`initial_margin_rate`) is what a new or larger position must leave in surplus; it caps leverage at `max_leverage`. * **Maintenance margin** (`maintenance_margin_rate`) is what an open position must keep. When the account's maintenance surplus falls below zero it is liquidated through the stack's auction. `GET /v1/positions` reports both surpluses as the SRM computes them, and an estimated `liquidation_price_ui`: the price at which the maintenance surplus reaches zero if nothing else changes, assuming the account holds only this position. For a single position the surplus is linear in price, so the estimate is `mark - surplus / (S - |S| x mm)` with `S` the signed size and `mm` the maintenance rate. Two consequences worth knowing: * A long at 1x — cash equal to the full notional — has **no finite liquidation price**: no positive price exhausts it, and the field is omitted. * A short at 1x liquidates when cNGN has strengthened to `entry x 2 / (1 + mm)`: with a 20% maintenance rate, about 67% above entry. Leverage brings it closer. ## Funding Funding accrues continuously on chain and settles into the account's cash; there is no funding timestamp to wait for. `ui_long_funding_rate_1h` is the chain's hourly rate as it is: positive means longs (long cNGN) pay shorts, negative means shorts pay longs. Annualise it as `rate x 24 x 365`; the `0.0000125` in the example below is about 11% a year. ## cNGN as collateral When the perp's cNGN escrow is whitelisted on the SRM, `perp.collateral_assets` lists it and a trader can post cNGN as margin beside USDC. Each entry is the asset as the SRM values it: | Field | Scale | Meaning | | - | - | - | | `symbol`, `asset_address` | | `cNGN` and the escrow's address — what a cNGN margin deposit approves and pays into. | | `margin_factor` | up to 18 decimals | The share of the asset's index value that counts as maintenance margin, for example `0.5`. | | `im_scale` | up to 18 decimals | Multiplies in again for initial margin. | | `cap`, `total` | up to 18 decimals | The escrow's collateral cap under the SRM and what is posted now, in cNGN. A deposit that would cross the cap reverts. | | `deposits_open` | boolean | Whether the escrow accepts deposits for the SRM yet. The SRM may credit the asset before that; a deposit offered in between reverts. | Read the haircut from `margin_factor` rather than assuming one. Note that cNGN posted as margin is itself long cNGN: the haircut is what liquidates a leveraged short when cNGN strengthens, so a short on a cNGN-collateralised account doubles the exposure. An account's posted cNGN appears under `accounts[].collateral` on `GET /v1/positions`, valued at the index with the margin credit beside it. ## Index lag The on-chain index is a TWAP republished on an interval, so after a real move the venue's quotes can rest at a price the market has left. When the index-lag gate is configured, `perp.index_lag` reports the venue's latest spot sample against the index — `enforced`, `max_bps`, `lag_bps` (absent when the venue has no fresh sample), `spot_sample_at`, `spot_usdc_per_cngn` and `sample_age_sec` — and, while `enforced` and the lag exceeds `max_bps` or the venue is blind, new perp orders are refused with an `index_lag` error until the index catches up. Resting orders, cancels and liquidations are untouched. ## Read a position ```bash theme={null} curl "https://api.numofx.com/v1/positions?subaccount_id=42" ``` Positions are public on chain, so the endpoint needs no signature. It returns the account's `positions` — `ui_side` is `long` when `engine_position` is positive, `ui_size` is the size in cNGN and `ui_notional_usdc` that size at the index — and its `accounts`, with cash, the two margin surpluses and any posted collateral, even before the first trade. The Numo app polls it every 15 seconds. See [List positions](/api-reference/endpoint/positions) for every field and an example. # Quickstart Source: https://docs.numofx.com/quickstart Read the orderbook and submit your first signed order Use this sequence to integrate against Numo's orderbook for stablecoins pegged to fiat currencies. ## Base URL `markets-service` is served from `https://api.numofx.com`. The examples below use `$MARKETS_SERVICE_URL`, so export it once: ```bash theme={null} export MARKETS_SERVICE_URL="https://api.numofx.com" ``` Read endpoints are public and require no API key or no allowlisting for server-side clients. ## 1. Discover supported markets Query `markets-service` first so you know which instruments are enabled in the matcher. ```bash theme={null} curl "$MARKETS_SERVICE_URL/v1/markets" ``` For the cNGN-USDC spot market, look for: * `market = "cNGN-USDC"` * `display_name = "cNGN-USDC"` * `contract_type = "spot"` * `settlement_type = "spot"` * `asset_address` and `sub_id` ## 2. Read the book and recent trades Once you have the market symbol or the `(asset_address, sub_id)` pair, fetch the current orderbook and recent prints. ```bash theme={null} curl "$MARKETS_SERVICE_URL/v1/book?symbol=cNGN-USDC" curl "$MARKETS_SERVICE_URL/v1/trades?symbol=cNGN-USDC&limit=50" ``` `markets-service` is the public read surface for: * market metadata * top-of-book bids and asks * recent trades and 24h stats * order submission and cancellation ## 3. Submit a signed order Orders are posted to `markets-service`, but the request must already contain a signed action payload that matches the contracts. See [Authentication and signing](/signing) for how to build `action_json.data` and produce the signature. `worst_fee` is the most a fill may charge you **per unit filled**, and it must cover the market's `taker_fee_bps` from `GET /v1/markets`. The value below is 30 bps of the example price (`0.00073 x 0.0030`), scaled to 1e18. Do not copy `0` from an older example. The fee is paid by whichever order arrived **later**, so resting on the book does not exempt you — see [Fees follow arrival order](/spot#fees-follow-arrival-order-not-identity). A zero bound against a live taker schedule reverts `TM_FeeTooHigh` on every fill you take. ```bash theme={null} curl -X POST "$MARKETS_SERVICE_URL/v1/orders" \ -H "Content-Type: application/json" \ -d '{ "order_id": "spot-order-1", "owner_address": "0xOWNER", "signer_address": "0xSIGNER", "subaccount_id": "10", "recipient_id": "10", "nonce": "1", "side": "buy", "asset_address": "0x37c976bb5d4887a714ef19AF6B83e34fe2f37c98", "sub_id": "0", "desired_amount": "1000", "limit_price": "0.00073", "worst_fee": "2190000000000", "expiry": 1893456000, "action_json": { "subaccount_id": "10", "nonce": "1", "module": "0xTRADE_MODULE", "data": "0x...", "expiry": "1893456000", "owner": "0xOWNER", "signer": "0xSIGNER" }, "signature": "0x..." }' ``` `markets-service` rejects the order unless: * the instrument is enabled * `action_json.subaccount_id` matches `subaccount_id` * `action_json.nonce` matches `nonce` * `action_json.owner` matches `owner_address` * `action_json.signer` matches `signer_address` `desired_amount` and `limit_price` in the body are **human decimals** — `markets-service` normalizes them against the instrument's minimum size and tick size. The wei values go in the signed `action_json.data`. See [Authentication and signing](/signing#price-and-amount-use-two-different-scales). ## 4. Let the executor clear crossed orders The matching loop in `markets-service` identifies crossed orders and emits a payload to `execution-service`. `execution-service` then: * validates the match payload shape * ABI-encodes `TradeModule.OrderData` * simulates `Matching.verifyAndMatch(...)` * submits the transaction to the onchain matching contracts Follow the full path across markets-service, execution-service, execution-contracts, and risk-core. Review the public orderbook, trades, and order-entry surface. ## Fund a perp account To trade `cNGN-PERP` or `cNGN-USDC` you need an account holding USDC, cNGN or both. Approve the `DepositModule` for your token once, on chain (or [sign a USDC permit](/api-reference/endpoint/deposits#or-sign-a-permit-instead) instead and pay no gas at all), then sign one `Action` and post it to [`POST /v1/deposits`](/api-reference/endpoint/deposits) with `subaccount_id` `0`. The venue opens the account, funds it and pays the gas, and the response's `subaccount_id` is your new account — the `subaccount_id` you put on your orders. cNGN has no permit, so it is always approved on chain. The amount is in the token's **6-decimal base units** (`1000000000` is 1,000 USDC, or 1,000 cNGN); the account is credited at 18 decimals, and the response gives both. # Authentication and signing Source: https://docs.numofx.com/signing Build and sign the EIP-712 action payload that POST /v1/orders requires Numo has no API keys. Every order is authenticated by an EIP-712 signature over an `Action` struct, verified onchain by the `Matching` contract when the trade settles. `markets-service` stores the signature and forwards it — it never signs on your behalf. An order request therefore carries three related things: 1. **Flat order fields** (`side`, `limit_price`, `desired_amount`, …) that `markets-service` uses to place you on the book 2. **`action_json`** — the `Action` struct you signed 3. **`signature`** — your EIP-712 signature over that struct The service rejects the order if the flat fields and `action_json` disagree, so the two must be built from the same values. ## The Action struct `Action` is defined in `IActionVerifier` and hashed with this type string: ```solidity theme={null} Action(uint256 subaccountId,uint256 nonce,address module,bytes data,uint256 expiry,address owner,address signer) ``` The subaccount placing the order. Unique per `(owner, nonce)`. The trade module tracks fills against it, so reusing a nonce reuses its fill state. The trade module address for your network — see [Contract addresses](#contract-addresses). ABI-encoded `TradeData`. See [Encoding data](#encoding-data). Unix seconds after which the action can no longer be matched. The account that owns the subaccount. The address whose key produced the signature. Equal to `owner` unless you are using a [session key](#session-keys). ## EIP-712 domain The domain is fixed by the `Matching` contract, which is the verifying contract: ```json theme={null} { "name": "Matching", "version": "1.0", "chainId": 8453, "verifyingContract": "0x9E90A9cD13d859Bd6a08168082FB1F6F7405F191" } ``` Use the `chainId` and `Matching` address for the network you are trading on. ## Encoding data `Action.data` is the ABI encoding of `TradeData`, defined in `ITradeModule`: | Field | Type | Order field | Scale in `data` | | - | - | - | - | | `asset` | `address` | `asset_address` | — | | `subId` | `uint256` | `sub_id` | — | | `limitPrice` | `int256` | `limit_price` | wei (1e18) | | `desiredAmount` | `int256` | `desired_amount` | wei (1e18) | | `worstFee` | `uint256` | `worst_fee` | raw, **per unit** | | `recipientId` | `uint256` | `recipient_id` | — | | `isBid` | `bool` | `side` | — | Every field is static, so the encoding is exactly seven 32-byte words — 448 hex characters after the `0x`. `markets-service` rejects any other length with `expected encoded TradeData tuple`. `limitPrice` and `desiredAmount` are signed `int256`, but both must be **positive**. The service rejects zero or negative values. `worstFee` is a bound **per unit filled**, not a total for the order: the contract checks `fee / amountFilled` against it. So it scales with price, and one hardcoded value is right at exactly one price. It is also not optional on a market that charges. The fee is paid by whichever order arrived **later**, so resting on the book does not exempt you — see [Fees follow arrival order](/spot#fees-follow-arrival-order-not-identity). A `worstFee` of `0` against a live taker schedule reverts `TM_FeeTooHigh` on every fill you take, quietly, until the order expires. ### Price and amount use two different scales This is the easiest thing to get wrong, and a mismatch produces an order that is accepted but never matches. * **`action_json.data`** carries the raw onchain values in **wei** — the numbers the contracts settle on. * **The order body** carries **human decimal** values. `markets-service` normalizes them itself: the price is divided by the instrument's tick size, and the amount by its minimum size. So a bid for 1,000 cNGN at 0.00073 is `limit_price: "0.00073"` and `desired_amount: "1000"` in the body, while `data` holds `730000000000000` and `1000000000000000000000`. Sending wei in the body is the classic failure: it parses fine and the order rests on the book at a tick level nothing else will ever cross. Compute the wei values first, then derive the body values with `formatUnits(value, 18)`. ## Sign and submit ```ts theme={null} import { encodeAbiParameters, formatUnits, parseAbiParameters } from 'viem' import { privateKeyToAccount } from 'viem/accounts' const MATCHING = '0x9E90A9cD13d859Bd6a08168082FB1F6F7405F191' const TRADE_MODULE = '0xDea968188598BA0E3F58A56C0fdfF338C74F699f' // trade_module_address from GET /v1/markets const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) // A cNGN-USDC buy of 1,000 cNGN at 0.00073 USDC per cNGN. const asset = '0x37c976bb5d4887a714ef19AF6B83e34fe2f37c98' // asset_address from GET /v1/markets const subId = 0n // sub_id from GET /v1/markets const limitPrice = 730000000000000n // 0.00073 x 1e18 const desiredAmount = 1000000000000000000000n // 1000 x 1e18 // The most this fill may charge you, PER UNIT FILLED -- the contract compares // fee / amountFilled against it and reverts TM_FeeTooHigh rather than filling. // // Derive it from the market's taker_fee_bps (GET /v1/markets) and this order's price, with a // little headroom. 0 is correct ONLY on a market that charges nothing: whoever arrives second is // the taker, so a zero bound on a fee-charging market reverts every fill you take. const takerFeeBps = 25n // from GET /v1/markets const headroomBps = 5n // so a schedule change does not brick orders already resting const worstFee = (limitPrice * (takerFeeBps + headroomBps)) / 10000n const recipientId = 10n const isBid = true const data = encodeAbiParameters( parseAbiParameters( 'address asset, uint256 subId, int256 limitPrice, int256 desiredAmount, uint256 worstFee, uint256 recipientId, bool isBid' ), [asset, subId, limitPrice, desiredAmount, worstFee, recipientId, isBid] ) const action = { subaccountId: 10n, nonce: 1n, module: TRADE_MODULE, data, expiry: 1893456000n, owner: account.address, signer: account.address, } const signature = await account.signTypedData({ domain: { name: 'Matching', version: '1.0', chainId: 8453, verifyingContract: MATCHING, }, types: { Action: [ { name: 'subaccountId', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'module', type: 'address' }, { name: 'data', type: 'bytes' }, { name: 'expiry', type: 'uint256' }, { name: 'owner', type: 'address' }, { name: 'signer', type: 'address' }, ], }, primaryType: 'Action', message: action, }) ``` Then post the same values, with `action_json` mirroring the struct you signed: ```ts theme={null} await fetch(`${MARKETS_SERVICE_URL}/v1/orders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: 'spot-order-1', owner_address: account.address, signer_address: account.address, subaccount_id: '10', recipient_id: '10', nonce: '1', side: 'buy', asset_address: asset, sub_id: '0', // human decimals here — the signed `data` above keeps the wei values desired_amount: formatUnits(desiredAmount, 18), limit_price: formatUnits(limitPrice, 18), worst_fee: worstFee.toString(), expiry: 1893456000, action_json: { subaccount_id: '10', nonce: '1', module: TRADE_MODULE, data, expiry: '1893456000', owner: account.address, signer: account.address, }, signature, }), }) ``` ## What the service checks `markets-service` verifies your signature and validates consistency before the order reaches the book. An order whose signature does not authorize its action is rejected at submission rather than resting on the book until it expires. The service rebuilds the same EIP-712 digest `ActionVerifier` checks onchain and recovers the signer, so the signature must be over exactly the `Action` described above — same domain, same typehash, same field values. Contract signers are supported: when local recovery does not match, the signer is checked for code and `isValidSignature` is called per ERC-1271, mirroring `SignatureChecker.isValidSignatureNow` onchain. Verification proves the signature authorizes the action. It does not guarantee settlement — margin can still fail, a [session key](#session-keys) can be deregistered between submission and match, and a nonce can collide. Those are still caught onchain. Metadata must agree with `action_json`: * `action_json.subaccount_id` matches `subaccount_id` * `action_json.nonce` matches `nonce` * `action_json.owner` matches `owner_address` * `action_json.signer` matches `signer_address` And the decoded `action_json.data` must agree with the flat fields: * `data.asset` matches `asset_address` * `data.subId` matches `sub_id` * `data.isBid` matches `side` — `true` for `buy`, `false` for `sell` * `data.limitPrice` is a positive whole multiple of the tick-normalized `limit_price` * `data.desiredAmount` is a positive whole multiple of the size-normalized `desired_amount` These last two are what tie the wei values in `data` to the human values in the body. Derive one from the other as shown above and they hold automatically. ## Session keys Set `signer` to a different address than `owner` to trade from a hot key without exposing the owner key. The session key must be registered onchain first: ```solidity theme={null} registerSessionKey(address toAllow, uint expiry) ``` `Matching` then accepts that signer for the owner's subaccounts until `expiry`. Deregistering is subject to a 10-minute cooldown (`DEREGISTER_KEY_COOLDOWN`). Contract signers are supported too — if `signer` is a contract, verification falls back to ERC-1271. ## Contract addresses | Network | Chain ID | `Matching` (verifying contract) | Trade module | | - | - | - | - | | Base | 8453 | `0x9E90A9cD13d859Bd6a08168082FB1F6F7405F191` | `0xDea968188598BA0E3F58A56C0fdfF338C74F699f` | | Base Sepolia | 84532 | `0x1599636347FD5bA1fBE21D58AfE0b8B9cbe283FF` | `0x0AAE65AaA66Fe7f54486cDbD007956d3De611990` | See the signed order in the context of the full integration flow. # Spot market Source: https://docs.numofx.com/spot Read cNGN-USDC spot prices, and the order contract behind them The cNGN-USDC spot market quotes cNGN in USDC. Every price the Numo API reports for it — on the book, in trades, in candles, and in the `ui_intent` you submit — is **USDC per cNGN**, about `0.00073`, and every size is a quantity of **cNGN**. Read this before integrating against spot. **What changed.** Since 2026-10-07 the pair is quoted as cNGN-USDC: base cNGN, quote USDC. Before that it was quoted the other way, as USDC priced in cNGN with sizes in USDC, and the API translated between the two. * The identifiers changed on 2026-10-08, after the orientation did: `cNGN-USDC` and `cNGN-PERP` are now what you pass as `symbol`, `market` and `ticker_id`, matching `display_name`. The old `USDCcNGN-SPOT` and `USDCcNGN-PERP` remain deprecated aliases until 2027-01-06 (see the note under [Discover the market](#discover-the-market)). * If you read `/v1/integrations/tickers`, `/orderbook` or `/trades`: `base_currency` is now `cNGN` and `target_currency` `USDC`. Prices are USDC per cNGN, `base_volume` and book quantities are cNGN, `target_volume` is USDC. A `buy` is a buy of cNGN. * If you submit `ui_intent`: send `order_entry_spec: "cngn_usdc_spot_v1"` (or `cngn_usdc_perp_v1` on the perp), a price in USDC per cNGN, a size in cNGN, and the side you mean in cNGN. The engine order is now identical to the intent — nothing is inverted, and no formula stands between them. The old `usdc_cngn_*_v1` specs are refused. * If you read `ui_intent` on orders, trades or fills: `price` is USDC per cNGN, `size` is cNGN, and `side` equals the engine side. * `stats_24h` and candles were already in this orientation and did not change. The remarks about reading `1/high` as a low no longer apply: `high` is the high. To show a naira rate, compute `1 / price` yourself. The Numo app shows it as a secondary line only. ## Discover the market ```bash theme={null} curl "https://api.numofx.com/v1/markets" ``` Look for: * `market = "cNGN-USDC"` * `display_name = "cNGN-USDC"` * `contract_type = "spot"` * `base_asset_symbol = "cNGN"` and `quote_asset_symbol = "USDC"` * `order_entry_spec = "cngn_usdc_spot_v1"` Prefer the `market` symbol over `asset_address` and `sub_id` when you call the read endpoints. The symbol is stable; the address pair can change between deployments. **Deprecated identifier.** Until 2027-01-06 the old identifier `USDCcNGN-SPOT` is accepted as an exact-match alias for `cNGN-USDC` everywhere an identifier is read: `symbol`, `market` and `ticker_id`. A request that uses it returns the same data as one that uses `cNGN-USDC`, names the market `cNGN-USDC` in the body, and carries two response headers: `Deprecation: true` and `X-Canonical-Market: cNGN-USDC`. Switch to `cNGN-USDC` before the removal date. ## Read the current price ```bash theme={null} curl "https://api.numofx.com/v1/book?symbol=cNGN-USDC" ``` Every order in the response carries a `spot_contract` object that restates it at the scales a display uses. A bid to buy 137,400 cNGN at 0.0007278 USDC each looks like this: ```json theme={null} { "side": "buy", "limit_price": "0.0007278", "desired_amount": "137400", "spot_contract": { "spec": "cngn_usdc_spot_v1", "ui_intent": { "side": "buy", "price": "0.0007278", "size": "137400" }, "engine_order": { "side": "buy", "price": "0.0007278", "amount": "137400" }, "balance_delta": { "usdc": "-99.99972", "cngn": "+137400" } } } ``` `ui_intent` and `engine_order` are the same order. The only difference is scale: `ui_intent` prints the price to 10 decimal places and the size to 6, while `engine_order` and the top-level `limit_price` and `desired_amount` carry the engine's full 18 places. Read whichever suits your parser; they never disagree. A price near `0.0007` is correct, not a broken response. The quote currency is USDC, so one cNGN costs a fraction of a dollar. If your display wants naira per dollar, compute `1 / price` — `1 / 0.0007278 = 1373.99` — and label it as a derived figure. Do not feed the reciprocal back into an order: `ui_intent.price` is always USDC per cNGN. `GET /v1/markets` publishes the contract inline on the spot entry, so you can assert it at runtime rather than hardcoding it: | Field | Value | | - | - | | `ui_price_unit` | `USDC per cNGN` | | `ui_size_unit` | `cNGN amount` | | `ui_side_meaning` | `BUY acquires cNGN and pays USDC; SELL delivers cNGN and receives USDC.` | | `engine_price_unit` | `USDC per cNGN` | | `engine_amount_unit` | `cNGN amount` | | `engine_side_policy` | `same_as_ui` | | `ui_price_to_engine` | `engine_price = ui_price` | | `ui_size_to_engine` | `engine_amount = ui_size` | ## Book sides read the normal way `bids` are orders buying cNGN and `asks` are orders selling it, and the trader's side is the engine's side: | Array | Side | What it means for you | | - | - | - | | `bids` | buy cNGN | the price at which you can **sell** cNGN for USDC | | `asks` | sell cNGN | the price at which you can **buy** cNGN with USDC | Each order also carries `spot_contract.ui_intent.side`, which is the same value as its top-level `side`. A mid price: ```bash theme={null} curl -s "https://api.numofx.com/v1/book?symbol=cNGN-USDC" | python3 -c ' import json, sys book = json.load(sys.stdin) bid = float(book["bids"][0]["spot_contract"]["ui_intent"]["price"]) ask = float(book["asks"][0]["spot_contract"]["ui_intent"]["price"]) mid = (bid + ask) / 2 print(f"bid {bid:.7f} USDC per cNGN") print(f"ask {ask:.7f} USDC per cNGN") print(f"mid {mid:.7f} USDC per cNGN ({1 / mid:.2f} cNGN per USDC)")' ``` ## Prefer the book over the last trade `GET /v1/trades` returns the most recent fills, each with its own `spot_contract`. That is the right source for a "last traded at" display, and every trade carries `created_at` so you can show its age. It is the wrong source for a current rate. In a quiet period the last fill can be hours or days old, and the endpoint returns it without any staleness signal — a stale price and a fresh one look identical. The book quotes continuously, so a mid price stays current even when nothing is trading. Check that `bids` and `asks` are both non-empty before computing a mid. If no maker is quoting, the arrays come back empty. ## Candles and 24h stats ```bash theme={null} curl "https://api.numofx.com/v1/candles?symbol=cNGN-USDC&interval=1h&limit=200" ``` Candle prices and `stats_24h` are raw engine values with no `spot_contract` wrapper. They are in the same unit as everything else — USDC per cNGN — so use them as they are: `high` is the highest price paid for a cNGN in the bucket, `volume` is cNGN traded and `quote_volume` the USDC it traded for. Buckets with no trades are absent rather than zero-filled. ## Polling and streaming No rate limit is enforced. Polling every one to five seconds is ample for a displayed rate; be considerate rather than aggressive. For anything latency-sensitive, subscribe to the [websocket stream](/api-reference/websocket) instead. Use the `book` and `trades` channels with `"market": "cNGN-USDC"`, seed from the `snapshot` frame, and apply `update` deltas. Public channels need no authentication, and server-side clients are unaffected by the browser origin allowlist. ## Submitting spot orders Send the order you mean in `ui_intent`, with `order_entry_spec` set to the market's own spec. The engine order is the identity of the intent: ```text theme={null} engine_side = ui_side engine_price = ui_price (USDC per cNGN) engine_amount = ui_size (cNGN) ``` So the values you sign into `action_json.data` are the same numbers as `ui_intent`, scaled to wei. There is no inversion and no derived size any more. When you also send `side`, `limit_price` or `desired_amount`, each must equal the corresponding `ui_intent` field or the order is rejected. A fill of the whole order moves balances by `balance_delta`: a buy of size `S` at price `P` is cNGN `+S` and USDC `-S x P`; a sell is cNGN `-S` and USDC `+S x P`. `order_entry_spec` and `ui_intent` are accepted on the two markets that carry the contract — `cNGN-USDC` with `cngn_usdc_spot_v1` and `cNGN-PERP` with `cngn_usdc_perp_v1` — and refused everywhere else. The spec must be the market's own: an intent sent with the other market's spec, or with a retired `usdc_cngn_*_v1` spec, is rejected with `order_entry_spec must be "cngn_usdc_spot_v1" for this market's ui_intent`. Omit both fields to submit raw engine values, which on these markets are the same values anyway. See [Authentication and signing](/signing) for building the signed payload, and [Create order](/api-reference/endpoint/create) for the full request body. ## Fees follow arrival order, not identity `GET /v1/markets` reports the schedule for each market as `taker_fee_bps` and `maker_fee_bps`. Read it from there — it is the only source, and the matcher charges exactly what it says. The fee is paid by the **taker**, and taker is decided per fill by which of the two orders was created **later**. It is not a property of an account, a strategy, or an order type. Resting a quote does not make you the maker. If your order arrives after the one it matches against, you are the taker on that fill and you pay the taker fee — even if you have been sitting on the book for hours, and even if you think of yourself as a market maker. A bot that requotes frequently is the later order most of the time, so it is usually the taker. The practical consequence is the bound you sign. `worstFee` caps what a fill may charge you, and the contract reverts `TM_FeeTooHigh` rather than filling when the charge exceeds it. Signing `0` is safe only while a market charges nothing; on a market with a taker schedule it means every fill where you arrive second reverts, silently and repeatedly, until the order expires. Two details that decide the number: * **`worstFee` is per unit filled, not a total.** The contract compares `fee / amountFilled` against it, so the correct bound is `feeRate x price`, and it changes with price. A single configured constant is right at exactly one price. * **Leave headroom above the published schedule.** Orders already resting were signed under the old number, and they cannot be re-signed atomically with a fee change. Our own clients sign 30 bps against a 25 bps schedule for this reason. See [Authentication and signing](/signing) for how to compute and encode it.