Core Concepts
The core ideas the Native Core Python SDK is built on — read this before wiring a bot.
The SDK is a thin, typed, synchronous client over the two Native Core endpoints. Info wraps POST /info (market data, balances, order status); Exchange wraps POST /trade (one signed action per call) and owns an internal Info as exchange.info. Both return the API's raw JSON as plain dicts. This page expands the core ideas every integration leans on. It does not re-document the wire fields — for those, see the POST /trade and POST /info references.
Accepted is not filled
order() and market_order() return {"submission_status": "accepted", "tx_hash": "…"} once the transaction has landed and executed. But accepted does not say whether the order rested or filled — it also covers a benign IOC/FOK cancel or a no-liquidity market order. The /trade response omits the oid and fill amount, so read the order's status to learn the lifecycle and fill.
from native_core import Exchange, is_accepted
exchange = Exchange.from_bundle("bundle.json") # your connection bundle
resp = exchange.order("ETH/USDT", is_buy=True, sz="0.01", limit_px="1000.00", tif="gtc")
assert is_accepted(resp) # landed & executed — but not necessarily filled
# Read the order's status once for the oid and fill state:
snapshot = exchange.info.wait_for_open(exchange.effective_account, "ETH/USDT", resp["cloid"])Pick the wait that matches the order's time-in-force:
info.wait_for_open(user, market, cloid)
a resting order (gtc / alo)
the order is open (or terminal)
info.wait_for_order(user, market, cloid)
an order that finishes (ioc / fok / market, or after a cancel)
a terminal state such as filled or cancelled
exchange.place(...) runs the submit and the matching wait in one call, returning {cloid, submission, status, state}, so you rarely call either wait directly. Do not call wait_for_order on a resting order — a resting order has no terminal state, so the call just times out; that is what wait_for_open is for.
Every order is reconcilable by cloid
Every order carries a client order id (cloid). order() and market_order() return the cloid and the nonce they used; batch() returns cloids (one per order leg) plus the shared nonce. Pass your own cloid or let the SDK generate one — either way the response echoes it, so an order is never handle-less.
If a write times out on the wire, the SDK raises SubmissionUncertain — the single most important exception. The order may still be live. It carries .cloid and .nonce; reconcile the outcome, and never resubmit under a fresh nonce (that risks a double-fill).
info.reconcile_by_cloid(user, market, cloid) is the one-call recovery path. It returns {state, undetermined, is_filled, filled_qty, status}. It never reports "definitely never landed": order status cannot distinguish a not-yet-indexed order from one that never arrived, so an unconfirmed order stays undetermined rather than inviting the forbidden resubmit. The same rule applies to a submission_status of "timeout" on a response body — reconcile, never resend.
Survive a restart. An SDK-generated cloid is only known after the call returns; a crash after sending but before recording it cannot be reconciled. For crash safety, generate the cloid yourself and persist {intent, cloid} durably before you send:
This is the idempotency-key pattern: the cloid is the key, and reconciling it on restart is what makes a retry safe.
One Exchange per API wallet
The nonce is a per-instance, lock-guarded, monotonic millisecond counter. A single Exchange is therefore safe to share across threads — but two instances (or two processes) signing with the same API-wallet key hand out colliding nonces and draw seemingly random rejections. Construct one Exchange per API wallet and share it; never one per worker.
The client is synchronous (blocking). If you need concurrency, wrap calls in your own executor and still share the one Exchange.
Numbers are strings, never floats
Pass sz and limit_px (and every price/quantity) as str or Decimal — "0.01", not 0.01. Each market has a fixed precision: decimal places, significant figures, and a minimum notional. The SDK validates against that metadata before signing and raises LocalValidationError rather than silently round your number down.
info.snap_price(market, price) and info.min_order_size(market, price) round a value to what the market accepts and return a string. Native executes on integer atoms under the hood — see Decimals & Units for the raw/display conversion model that makes floats unsafe.
Errors live in the response body
Error handling keys on the response body, not the HTTP status. The API returns the same trade-response shape for HTTP 200/400/429/503/504, with submission_status in {accepted, rejected, timeout}. A business rejection — {"submission_status": "rejected", "error": {"code": …}} — is data, not an exception. Read it with the top-level helpers: is_accepted, is_rejected, is_timeout, error_code, retry_after_ms, is_retryable.
next_action(response) collapses any trade response into one verdict to branch on (and returns None for a non-trade response):
next_action
Situation
What to do
READ_ORDER_STATUS
accepted — landed & executed
Read the order's status once (wait_for_open / wait_for_order) for the oid and fill — the outcome is already settled
BACKOFF_AND_RETRY
RateLimited — never admitted
Sleep retry_after_ms, then resend the same order (the only safe resend)
RECONCILE_BY_CLOID
timeout — indeterminate
reconcile_by_cloid; never resubmit
FIX_AND_RESUBMIT
other rejection
Fix the input or account state, then submit fresh
Order-side helpers read an order-status snapshot: order_state, is_terminal, is_undetermined, is_filled, filled_quantity. as_problem_details(failure) renders any exception or rejected/timeout body into one flat envelope, and retry_on_rate_limit(action) wraps a write to resend only on RateLimited. The SDK raises only for a transport failure (NetworkError), an uncertain write (SubmissionUncertain), a non-trade body (ClientError / ServerError), or a problem caught before signing (LocalValidationError). For the full response shapes and the rejection error.code catalog, see Transaction signing.
Markets by symbol or id
Anywhere a market argument appears, pass a "BASE/QUOTE" symbol ("ETH/USDT") or its integer market id — they are interchangeable. Symbols and precision are fetched once when Info is constructed.
info.resolve_market_id(market) resolves a symbol (or passes an id through); an unknown symbol raises LocalValidationError, not a bare KeyError. Per-market precision comes from info.markets(); snap_price / min_order_size apply it for you.
Agent vs owner mode
The SDK trades with an API wallet — a protocol-level agent key scoped only to placing and cancelling orders; it can never move funds. Exchange.from_bundle(bundle) builds an agent-mode Exchange: the bundle's agentPrivateKey signs, and its accountAddress is the owner the orders act on. The owner address is used only locally — it never goes on the wire; the API recovers the signer from the signature.
The agent epoch is resolved live at construction from userAgents (not taken from the bundle, which may be stale), and refreshed once automatically if a write is rejected with AgentEpochMismatch. Check approval any time without side effects:
If the API wallet is not an approved agent on that owner, the constructor raises LocalValidationError; agent_info() reports it as data instead of throwing. Deposits, withdrawals, and approveAgent / revokeAgent are signed by your main wallet in the web app, never in the SDK.
Networks
The SDK runs on both mainnet (https://api.native.org) and testnet (https://api-test.native.org). The bundle's network field selects which, and from_bundle picks the endpoint from it. An API wallet is created and approved on that network's site and is bound to it — a key signed for one network is rejected on the other.
Polling & freshness
Reads are poll-only over POST /info. Block time is ~50ms; wait_for_open / wait_for_order poll internally (starting at 50ms and backing off) against a ~5s default deadline. For a standing reconcile loop — open orders, fills, balances — poll on a fixed interval tuned to the strategy, not the block rate: a few hundred milliseconds for a tight quoting loop, low seconds for slower reconciliation. Polling faster than block time only burns requests without seeing new state.
Every read view carries query_height and app_hash — the committed height the answer reflects. queryStatus exposes the retained recent-height window (oldest_available_height, latest_available_height, recent_query_window_blocks), which bounds how far back a windowed read such as userFills can reach. Treat precision and symbol metadata (markets, assets) as slowly-changing — fetch it once at startup (the SDK does this when Info is constructed) — and re-fetch balances, orders, and fills every loop.
Cancel on disconnect
There is no server-side scheduled-cancel (dead-man switch) today, and because the API is poll-only it never observes a dropped bot — a crashed or partitioned process leaves its resting orders on the book. Run the switch client-side: cancel every open order on shutdown, on any unhandled exception, and on a heartbeat timeout.
exchange.cancel_open(...) finds where the effective account actually rests (via open_orders_all) and issues one cancel_all per market with orders; cancels are always admitted, even for a frozen account. Pass markets=[...] to bound the scan when you already know where you traded. examples/market_maker_bot.py wires this in a finally: shutdown pass — see examples.md for the runnable pattern.
Next
Getting StartedTransaction SigningDecimals & UnitsLast updated