native-core-python-sdk
A Python SDK for trading on Native Core — read market data, place and cancel orders, and run trading bots against the public gateway.
Mainnet moves real funds. Develop against testnet first, use a scoped API wallet (never your main wallet), and start with small sizes. Mainnet is
api.native.org(chain 696969), testnet isapi-test.native.org(chain 969696);from_bundlepicks the gateway from the bundle'snetwork.
Info handles reads (POST /info), Exchange handles writes (POST /trade), and WsClient streams live data over a WebSocket. Everything is synchronous (built on requests and websocket-client) and returns the gateway's JSON as plain dicts annotated with TypedDict; stream frames arrive on a background thread, so no part of the SDK asks you to write async code. Spot order-book markets only — no perpetuals — and trading and market data only: deposits, withdrawals and account administration stay in the web app. Requires Python 3.10+ (requests, eth-account, eth-utils, websocket-client).
Rate limits are counted per client IP, not per address, and they are tighter than most people expect. On mainnet an IP gets one order per second and one read per second; those are two separate budgets, and each is shared between HTTP and the WebSocket. Two bots on one machine, or two behind one office NAT, share a single budget no matter how many addresses they sign with. See §7 Rate limits.
1. Install
pip install native-core-python-sdk
Pin a version for reproducible installs: pip install native-core-python-sdk==2.0.0.
2. Get an API wallet
You need a funded account and an API wallet (your bot's key), set up once in the app. In the Native app (mainnet or testnet): connect your main wallet, deposit to fund it (testnet has no faucet — bring Arbitrum Sepolia; mainnet takes real assets), then Create API wallet. You sign one approval and the app shows the connection bundle once — copy it.
Save it (e.g. bundle.json, what the quickstart loads) and pass it to the SDK as a file path, dict, or JSON string:
{
"network": "testnet", // "mainnet" or "testnet" — picks the gateway
"accountAddress": "0x…", // your main wallet, the account the bot trades on
"agentPrivateKey": "0x…" // the API wallet key the SDK signs with
}
A leaked API wallet key can trade your balance but can never withdraw or move funds off Native, so it is safe in a bot; revoke it in the app to rotate.
3. Quickstart
Place a resting limit order, confirm it rests, then cancel it. Point BUNDLE at a testnet bundle to try it risk-free:
from decimal import Decimal
from native_core import Exchange, is_order_failed, leaf_error_code, order_oid
BUNDLE = "bundle.json" # the file you saved, or the dict / JSON string itself
MARKET = "ETH/USDT"
exchange = Exchange.from_bundle(BUNDLE) # picks the gateway, loads the key, sets owner
info = exchange.info
# Check the API wallet is approved before you trade.
print(exchange.agent_info()) # {"approved": True, "slot_id": 0, "epoch": 10}
# Price a bid well below the market so it rests instead of filling.
book = info.l2_book(MARKET, depth=1)
reference = book["asks"] or book["bids"] # whichever side has liquidity
px = info.snap_price(MARKET, Decimal(reference[0]["price"]) / 2)
sz = info.min_order_size(MARKET, px)
resting = exchange.order(MARKET, is_buy=True, sz=sz, limit_px=px, tif="gtc")
# An accepted write can still carry a rejected order, so check before trusting it.
if is_order_failed(resting):
raise SystemExit(f"rejected: {leaf_error_code(resting)}")
oid = order_oid(resting) # the oid comes back on the write itself
cancel = exchange.cancel(MARKET, oid)
print("cancelled", order_oid(cancel)) # the write reports what it removed
4. Core concepts
A write's submission_status is exactly one of three values: accepted, rejected or timeout.
accepted is a verdict on the transaction, not on your order. Only two kinds of problem reject at the top level: the six envelope-level codes (bad nonce, bad signature, expired tx, malformed tx, bad batch length, feature disabled), and the node's admission refusals (RateLimited, PlaceOrderSuspended, WrongChainId and friends). A tick-size violation, a lot-size violation, an ALO that would cross, an insufficient balance found at execution all come back accepted, with the reason on the failing action's leaf inside the response. Checking submission_status alone will report those as successful orders:
resp = exchange.order(MARKET, is_buy=True, sz="0.01", limit_px="1700.005", tif="alo")
is_accepted(resp) # True — the transaction landed
is_order_failed(resp) # True — but the order did not: the price is off-tick
leaf_error_code(resp) # "tick"
The response carries the outcome itself, so the oid, the fill amount and each batch leg's result are all available without a second call: order_oid(resp) is the assigned order id, fill(resp) is {total_sz, avg_px, oid} in the same display format /info uses, batch_legs(resp) is one entry per leg in request order, and next_action(resp) folds all of them into one verdict string to branch on. Reading these off the response instead of /info saves a read, and reads are the scarce resource (§7).
When you do need to poll, wait_for_open is for an order you expect to rest (gtc / alo) and wait_for_order for one you expect to finish (ioc / fok / market, or after a cancel); a resting order never reaches a terminal state, so wait_for_order on one just times out. exchange.place(...) picks the right one by tif and skips the read entirely when the response already settled the order.
Every order is reconcilable. order() and market_order() return the cloid and nonce they used; batch() returns cloids (one per leg) plus the shared nonce. Pass your own cloid or let the SDK generate one — either way the response carries it. If a write times out on the wire, the SDK raises SubmissionUncertain with the cloid attached: reconcile with reconcile_by_cloid, and do not resubmit under a fresh nonce unless is_safe_to_resend says the transaction never left the gateway (§7).
One Exchange per API wallet. The nonce is a per-instance, lock-guarded monotonic counter, so a single Exchange is safe to share across threads. Two instances — or two processes — signing with the same key hand out colliding nonces and draw seemingly random rejections. Construct one and share it.
Numbers are strings, never floats. Pass sz and limit_px as str or Decimal ("0.01", not 0.01). Each market has a fixed precision; info.snap_price(market, price) and info.min_order_size(market, price) round a value to what it accepts. The SDK validates before signing and raises LocalValidationError rather than silently round your number down.
Errors live in the response body. A business rejection is data, not an exception: {"submission_status": "rejected", "error": {"code": …}}, read with the response helpers. The SDK raises only for transport failures, non-trade responses, pre-sign problems, unanswered writes and refused subscriptions — see Exceptions.
5. Running the examples
The examples ship in the source distribution (the .tar.gz on PyPI), under examples/. They read examples/config.json — copy the template and fill in your key:
cp examples/config.json.example examples/config.json
{ "secret_key": "0x<agentPrivateKey>", "account_address": "0x<accountAddress>" }
This is the connection bundle flattened: secret_key is the bundle's agentPrivateKey, account_address its accountAddress. There is no network field — the examples hardcode constants.TESTNET_API_URL, so they always run on testnet; edit that argument to constants.MAINNET_API_URL only to trade real funds.
Run one with python <path>. The read-only tours place no orders but still need a valid secret_key (account_address is derived from it when blank); the trading tours place real orders on testnet in ETH/USDT (edit MARKET to change); ws_feeds.py needs no key at all and takes an optional gateway URL, e.g. python examples/ws_feeds.py https://api.native.org.
| Script | What it does |
|---|---|
examples/info/query_markets_info.py |
Tradable markets and their precision |
examples/info/query_orderbook_info.py |
The L2 order book for a market |
examples/info/query_balances_info.py |
Your spot balances |
examples/info/query_open_order_info.py |
Your open orders in a market |
examples/basic_order.py |
Resting limit order: place, confirm, cancel |
examples/basic_market_order.py |
Market order with a protection price |
examples/basic_batch.py |
Several orders and cancels under one nonce |
examples/ws_feeds.py |
Stream the book, top of book, and trades |
6. Using it from an AI agent
The SDK returns each outcome as structured fields, so an agent branches on a field instead of parsing prose. One safety contract, two integration paths.
The safety contract
acceptedis a verdict on the transaction, not on your order (§4). Checkis_order_failed(resp), readleaf_error_code(resp)for the cause, and useis_benign_cancel(resp)to tell a routine unfilled IOC from a real fault — both arrive on the same error leaf.- A failed order barely exists anywhere else. It is never assigned an oid and writes nothing to the fills feed; a lookup by cloid may surface a null-oid early-failure row, but the leaf in the
/traderesponse is the evidence to rely on. - Never resubmit an indeterminate write.
submission_status: "timeout"or a raisedSubmissionUncertainmeans the order MAY be live; reconcile bycloid. The one exception is the handoff family (HandoffTimeout,HandoffBufferFull:*,HandoffMultipleActive), which the gateway guarantees never reached the chain —is_safe_to_resend(resp)tests exactly that set. - Numbers are strings. Size with
min_order_sizeand validate withbuild_order(a dry run that sends nothing). - Survive a restart. Generate the
cloidyourself withrandom_cloid(), persist{intent, cloid}durably before callingorder(..., cloid=cloid), and reconcile every persisted cloid on restart. An agent that crashes after sending but before recording an SDK-generated cloid cannot reconcile and may double-fill.
next_action(response) collapses any trade response into one verdict (None if it is not a trade response):
next_action |
Situation | What the agent does |
|---|---|---|
USE_RESPONSE_OUTCOME |
Succeeded | Nothing more to fetch — order_oid and fill are already on the response |
ORDER_CLOSED_UNFILLED |
Benign cancel (unfilled IOC, STP) | Order is over, nothing filled, nothing wrong |
FIX_AND_RESUBMIT |
Rejected, at either level | Read leaf_error_code or error.code, fix, submit fresh |
BACKOFF_AND_RETRY |
Never reached the chain | Sleep retry_after_ms, resend the same order |
RECONCILE_BY_CLOID |
Indeterminate timeout | reconcile_by_cloid; never resubmit |
READ_ORDER_STATUS |
Accepted, no outcome envelope | A gateway older than read-node 119d935; read order_status once |
as_problem_details(failure) renders any exception, any rejection, and any accepted-but-failed order into one flat {type, title, retryable, next_action, cloids, …} envelope, and returns None when what you passed is not a failure — so if (p := as_problem_details(x)) is not None: is the intended shape.
Option A — the MCP server (no glue code)
An optional MCP server lets an AI assistant that speaks the Model Context Protocol (such as Claude Desktop) read the market and place orders through the SDK. Install the extra:
pip install "native-core-python-sdk[mcp]"
Configure it from the environment (never from the assistant). Reads are always available; the write tools are registered only when you turn trading on, so a read-only setup physically cannot place an order. If NATIVE_CORE_ENABLE_TRADING=1 but the agent key is missing, malformed, or unapproved, the server starts read-only and prints the reason instead of crashing.
| Variable | Purpose |
|---|---|
NATIVE_CORE_BUNDLE |
Path to a connection bundle JSON. Supplies everything on its own. |
NATIVE_CORE_NETWORK |
testnet (default) or mainnet, when not using a bundle. |
NATIVE_CORE_BASE_URL |
A gateway URL to use directly. Takes precedence over NATIVE_CORE_NETWORK. |
NATIVE_CORE_ACCOUNT |
Your account address, for account-scoped reads. |
NATIVE_CORE_AGENT_KEY |
The API wallet's private key, required for trading. |
NATIVE_CORE_ENABLE_TRADING |
Set to 1 to register the write tools. Off by default. |
Run it over stdio with native-core-mcp. To connect it to Claude Desktop, add to its MCP config:
{
"mcpServers": {
"native-core": {
"command": "native-core-mcp",
"env": {
"NATIVE_CORE_BUNDLE": "/path/to/your/bundle.json",
"NATIVE_CORE_ENABLE_TRADING": "1"
}
}
}
}
Read tools: whoami, list_markets, get_orderbook, get_balances, list_open_orders, get_order, get_fills, get_min_order_size, reconcile_order. Write tools (trading only): preview_order, place_limit_order, place_market_order, cancel_order, cancel_all_orders. Every result is normalized, so a timed-out order comes back telling the assistant to reconcile by cloid, never to resubmit. Use a dedicated, revocable API wallet — these tools are convenience, not a security boundary.
Option B — drive the SDK from your agent code
If your agent does its own tool-calling, call the SDK directly and hand the model the same fields:
exchange = Exchange.from_bundle(BUNDLE)
order = exchange.place(MARKET, is_buy=True, sz=sz, limit_px=px, tif="gtc")
# Resolve the real outcome by cloid — never resubmit on an uncertain one.
verdict = exchange.info.reconcile_by_cloid(exchange.effective_account, MARKET, order["cloid"])
# verdict["undetermined"] — not confirmed yet: keep reconciling, do not re-place.
# verdict["is_filled"] — fully filled; verdict["filled_qty"] != "0" — partially filled, still resting.
# otherwise the order is resting and unfilled, at verdict["state"] (e.g. "open").
7. API reference
Info (reads)
Build with Info(base_url) or Info.from_bundle(bundle); symbols and precision are fetched once at construction. Any market argument takes a "BASE/QUOTE" symbol or an integer market id.
| Method | Returns |
|---|---|
markets() / assets() / quote_assets() |
Tradable markets and their assets, with precision |
resolve_market_id(market) |
The integer market id for a "BASE/QUOTE" symbol |
l2_book(market, depth=20) |
Order book, up to 100 levels |
mark_prices(asset_ids=None) / oracle_status() |
Mark prices; oracle health |
query_status() |
Current query height and the available block-height window |
user_balances(address) |
Spot balances |
open_orders(address, market) |
Resting orders in one market. The gateway caps the list and sets truncated when it cut one, and there is no offset to page past that, so check the flag |
open_orders_all(address, markets=None, *, per_market=False) |
Open orders across markets, each tagged with its market id. One read by default; per_market=True falls back to one read per market, and truncation is logged as a warning |
order_status(oid=None, user=None, market=None, cloid=None) |
One order, by oid or by (user, market, cloid) |
batch_order_status(orders) |
Up to 20 order lookups in one read, answered in request order |
user_fills(address, from_height, to_height, limit) |
Fills in a raw block-height window (≤10,000 blocks) |
recent_fills(address, blocks=10000) |
Every fill in roughly the last N blocks, window resolved for you |
iter_user_fills(address, start_height=None, end_height=None, window=10000, page_limit=500, *, clamp=False) |
Iterate all fills since a height, paged and deduplicated by tid. page_limit is clamped to the server's cap of 500, and clamp=True starts at the oldest retained height instead of raising when the advancing window has already pruned the blocks it reached for |
account_status(address) |
Whether an account exists and its state |
spot_credit_account(address) / spot_credit_positions(address) |
Credit-account details |
credit_trading_allowed(account, market) |
Whether that account may trade that market on credit |
user_agents(address) / agent_status(owner, agent_address) |
Approved API wallets; whether one is approved |
snap_price(market, price, rounding=ROUND_DOWN) / min_order_size(market, price, margin="1.1") |
Round a price or size to what the market accepts. margin is the headroom over the quote asset's minimum notional, so "1.1" asks for 10% more than the bare minimum |
protection_price(market, is_buy, slippage_bps, ref_price=None) |
Worst acceptable price for a market order, derived from the book |
wait_for_open(user, market, cloid, timeout=5.0) |
Poll until the order is resting or terminal |
wait_for_order(user, market, cloid, timeout=5.0) |
Poll until the order is terminal |
reconcile_by_cloid(user, market, cloid, timeout=5.0) |
Did an order land? A one-call verdict (state, undetermined, is_filled, filled_qty, plus the raw status) |
Exchange (writes)
Build with Exchange.from_bundle(bundle), or Exchange(wallet, base_url, owner=<account_address>) where wallet is an eth_account account from your API wallet key.
| Method | Description |
|---|---|
place(market, is_buy, sz, limit_px, tif, cloid=None, *, confirm=True, timeout=5.0) |
Submit an order and wait for the outcome that matches its tif; returns {cloid, submission, status, state, oid} |
order(market, is_buy, sz, limit_px, tif, cloid=None) |
Limit order. tif is gtc / ioc / fok / alo |
market_order(market, is_buy, sz, protection_px=None, tif="ioc", cloid=None, *, slippage_bps=None) |
Market order (ioc / fok). Pass protection_px or slippage_bps to derive it from the book |
build_order(market, is_buy, sz, limit_px, tif, cloid=None, order_type="limit") |
Dry run: validate and build an order without signing or sending it |
sign_action(action) |
Sign a built action into a ready-to-send /trade body. Consumes a nonce, so the body is single-use |
cancel(market, oid) / cancel_by_cloid(market, cloid) |
Cancel one order |
cancel_all(market) |
Cancel every open order in a market |
cancel_open(markets=None) |
Cancel every open order across markets (only where orders rest) |
modify(market, oid_or_cloid, replacement) |
Atomically cancel and replace one order |
batch(items) |
Up to 10 mixed order / cancel / cancelAll / modify actions under one nonce |
set_expires_after(expires_after_ms) |
Attach an expiry to every signed action |
agent_info() |
Whether this API wallet is approved on the owner: {approved, slot_id, epoch} |
Each write returns the raw gateway response with the client handle echoed in: submission_status, tx_hash, error, cloid (or cloids for a batch), nonce, and response — the settled outcome envelope holding the oid, the filled size, the average price and one leaf per batch action. Reach into it with trade_envelope, order_oid, fill and batch_legs rather than by hand. Also on Exchange: the agent_address property (the signing wallet), effective_account (the account orders act on: the owner in agent mode, else the wallet), and the static Exchange.random_cloid().
WsClient (live data)
Build with WsClient(base_url), or Info.ws() / Exchange.ws() to reuse a market table already loaded. Call connect() before subscribing, and close() when done (it is also a context manager). Nine feeds stream over one connection, and examples/ws_feeds.py is a runnable tour that needs no key.
ws = WsClient("https://api.native.org")
ws.connect()
ws.subscribe_l2_book("ETH/USDT", lambda data: print(data["levels"])) # callback style
ws.subscribe_trades("ETH/USDT") # iterator style
for message in ws.stream():
print(message["channel"], message["data"])
| Method | Feed |
|---|---|
subscribe_trades(market, cb=None) |
Every trade printed on a market |
subscribe_l2_book(market, cb=None) |
Full book snapshot, ≤10 levels a side, ≤2/s |
subscribe_bbo(market, cb=None) |
Top of book, only when it changes |
subscribe_all_mids(cb=None) |
Mid price for every market that has liquidity on both sides, ≤1 per 5s |
subscribe_user_fills(address, cb=None) |
An account's fills; first message replays the recent 100 |
subscribe_order_updates(address, cb=None) |
Order lifecycle: open / filled / canceled |
subscribe_open_orders(address, cb=None) |
All resting orders, as a full replacement |
subscribe_spot_state(address, cb=None) |
Spot balances, as a full replacement |
subscribe_spot_credit_state(address, cb=None) |
Credit positions and credit line |
subscribe(body, cb=None) / unsubscribe(sub) |
Raw subscription body; stop a feed |
stream(timeout=None) |
Iterate feeds subscribed without a callback |
post_info(payload) / post_action(signed_body) |
Run an /info read or submit a signed order over the socket |
A callback receives the payload and runs on the reading thread, so it must return quickly. What a slow one costs depends on the feed: the six snapshot feeds are conflated, so you silently miss intermediate frames but stay connected, while the three event feeds (trades, userFills, orderUpdates) queue and the server drops the connection once that queue fills. Staying connected is therefore not evidence you are keeping up. Subscribing without a callback routes the feed to stream(), which buffers (queue_maxsize, default 10,000, dropping oldest and counting them in dropped_messages).
The socket names the same data differently than HTTP does, and one pair is a trap. These are the gateway's Hyperliquid-compatible names, passed through rather than renamed. Check this table before swapping a polling loop for a feed:
HTTP (Info) |
socket (WsClient) |
|
|---|---|---|
| Book level | price, quantity, order_count |
px, sz, n |
| Balance | asset_id, symbol, available, locked |
token, coin, total, hold |
available and total are not the same quantity. available is what you can spend; total is everything you hold, available + locked. Reading total where you used to read available overstates your free balance by exactly the amount sitting in resting orders — silently, and only once you have orders on the book. Use total - hold:
# Info.user_balances # spotState feed
free = Decimal(row["available"]) free = Decimal(row["total"]) - Decimal(row["hold"])
Subscribing blocks until the server answers, so a refusal raises SubscriptionError at the call rather than surfacing later. Reconnects and resubscribes are automatic; on_reconnect fires once subscriptions are restored. Snapshot feeds repair themselves and userFills replays on resubscribe, but trades and orderUpdates gap across a disconnect — re-read those through Info from on_reconnect. Event frames can also go missing without a disconnect: when read-node's broadcast thread falls behind the block ring it drops that gap's trades, userFills and orderUpdates outright and re-pushes only the snapshot feeds. The connection stays up and on_reconnect never fires, so userFills does not self-heal here the way it does on resubscribe. Anything that needs complete fills must poll Info.recent_fills periodically and reconcile by tid. Note that orderUpdates frames carry no address, so subscribing several accounts on one connection delivers every account's updates to every callback; tell them apart by oid.
To submit an order over the socket, Exchange.build_order validates and builds it, Exchange.sign_action signs it (consuming a nonce, so the body is single-use), and post_action sends it:
built = exchange.build_order("ETH/USDT", True, "0.01", "1900", "gtc")
response = ws.post_action(exchange.sign_action(built["action"]))
A write reports less here than over HTTP. When the gateway answers a write with anything other than HTTP 2xx, the server renders the reply as a bare string and discards the trade response, so submission_status, tx_hash and retry_after_ms are gone on exactly the paths where you would want them. post_action turns those into exceptions instead: a refusal before the node (400 bad signature or nonce, 429 rate limit) raises ClientError, and everything else — including the routing failures HTTP reports as a timeout (HandoffTimeout, HandoffBufferFull, node_unreachable) — raises SubmissionUncertain, because the order may still have landed. Reconcile those by cloid. A write is also never retried automatically, including on a rate limit. The socket caps a message at 64 KiB where HTTP /trade accepts 256 KiB, so an oversized request is refused locally rather than silently closing the connection. None of this is faster: the socket shares its rate budget with HTTP and the round trip is the same either way, so prefer Exchange.order.
Response helpers
The order's own outcome, from the write response, no extra read: order_oid (assigned id), fill (total_sz / avg_px / oid), batch_legs (one entry per batch leg, in request order), is_order_failed (did any leg genuinely fail), leaf_error_code and leaf_errors (the lowercase per-action codes), is_benign_cancel (is that code a normal no-fill rather than a fault), trade_envelope and trade_outcomes for the raw shapes.
The transaction's status: is_accepted, is_rejected, is_timeout, error_code, retry_after_ms, is_retryable, plus is_safe_to_resend for the handoff timeouts that never reached the chain. next_action folds all of it into one verdict.
An order_status snapshot: order_state, is_terminal, is_undetermined, is_filled, filled_quantity. Everything else: as_problem_details renders any failure into one flat envelope, info_error reads the error object off an /info response that returned HTTP 200, retry_on_rate_limit(action) resends a write only on RateLimited, and random_cloid returns a fresh client order id.
Exceptions
Everything inherits from native_core.Error, and the WebSocket raises the same set the HTTP path does (its write-path mapping is under WsClient), so one except block covers both transports. Business rejections are not exceptions; they arrive in the response body (above).
| Exception | Raised when |
|---|---|
LocalValidationError |
Before signing: bad precision, below minimum notional, unknown tif/market, or (at construction) an API wallet that is not an approved agent |
NetworkError |
A transport failure (timeout, connection, DNS) before any response arrived |
SubmissionUncertain |
A write was signed and sent, then timed out. Carries cloid and nonce to reconcile |
ClientError |
An HTTP 4xx whose body is not a trade response. Has status_code, error_code, error_message |
ServerError |
An HTTP 5xx whose body is not a trade response. Has status_code |
SubscriptionError |
A WebSocket subscribe or unsubscribe was refused or went unanswered. Has reason and subscription |
Two vocabularies live at two levels. The top-level error.code is CamelCase, and ErrorCode is a convenience enum of the known ones: RateLimited, ExpiredTx, WrongChainId, DirectSignerIsActiveAgent, AgentEpochMismatch, InsufficientSpotBalance. Read it with error_code(resp), which returns anything outside the enum as a raw string. Per-action failures live one level down, on the leaf inside the response envelope, and are lowercase: tick, lotsize, badalopx, insufficientspotbalance, mintradespotntl, missingorder. Read those with leaf_error_code(resp), never with error_code. The case worth memorizing: a post-only (alo) order that would cross comes back accepted with badalopx on its leaf, meaning the transaction succeeded and the order never rested, so is_order_failed(resp) is True with no second call.
Rate limits and retries
The gateway can rate-limit requests: an HTTP 429 with error.code RateLimited and a suggested retry delay. Because a 429 never reached the node, the SDK retries it automatically — a bounded rate_limit_retries (default 3), backing off by the server's delay. Pass rate_limit_retries=0 to surface a 429 at once; when retries are exhausted a read raises ClientError.
Budget your bot against these numbers, and note the unit: client IP. The gateway keys every limit on the source IP it sees, never on the signing address. Writes and reads have separate budgets, and each is shared between HTTP and the WebSocket, so sending a request through the socket buys no extra headroom. Mainnet, as deployed:
| Per client IP | |
|---|---|
| Orders per second | 1 |
| Reads per second | 1 |
| Subscriptions per connection | 10 |
| Posts outstanding at once | 1 |
| Connections | 1 |
| New connections per minute | 30 |
Two consequences people get wrong in both directions. Running several addresses from one host does NOT multiply your budget — they contend for the same one, and the second bot starts collecting 429s. Running one address from several hosts is NOT limited to the single-IP rate — each host gets its own budget. The 30-connections-per-minute ceiling matters if you reconnect aggressively: WsClient backs off from 2 seconds and doubles, which by itself tops out at exactly 30 attempts a minute, so a second client on the same IP can push you over.
The platform sets these and may change them, so the SDK does not hard-code the per-second rates; it reads the delay out of each rejection. WsClient defaults max_subscriptions=10 and max_inflight_posts=1 to match, and both are constructor arguments an integration granted higher limits can raise.
A RateLimited on a /trade write can instead arrive as a trade body (rejected with error.retry_after_ms); branch on it with is_retryable(resp) (true only for RateLimited, safe to resend) and back off with retry_after_ms(resp), or wrap the write in retry_on_rate_limit(...). A timeout never satisfies is_retryable, so read it with is_safe_to_resend instead. Under load, two other write-plane families appear:
| Response | What it means | What to do |
|---|---|---|
PlaceOrderSuspended (503, a clean rejected before the node) |
The chain is degraded and only cancels are admitted | Back off by retry_after_ms and resubmit once it clears |
HandoffTimeout, HandoffBufferFull:*, HandoffMultipleActive (503, arriving as a timeout) |
The write plane never handed the transaction to a node, so it definitely did not land | Safe to resend after backing off; is_safe_to_resend(resp) tests exactly this set |
Any other timeout, including node_unreachable (504) and a plain wait-budget expiry carrying no error code |
Indeterminate: the order may be live | Reconcile by cloid, never resubmit |
Transport controls
Info and Exchange (and their from_bundle) accept timeout (per-request deadline in seconds, default 30; None for no deadline), pool_maxsize (connection pool, default 100), rate_limit_retries (default 3; 0 to disable), and hooks:
on_request(url_path, body, trace_id)— before each request.on_response(url_path, status, body, elapsed_ms, trace_id)— after each;trace_idis thex-trace-idthe gateway returned, orNone.
The gateway accepts an x-trace-id request header and echoes it back, so you can line up a request with its logs. You supply the id via trace_id_factory (once per request) or trace_id= (per call to post); the SDK never generates one.
import uuid
exchange = Exchange.from_bundle(bundle, trace_id_factory=lambda: str(uuid.uuid4()))
Constants
constants.TESTNET_API_URL, constants.MAINNET_API_URL, and constants.NETWORK_URLS (network name → gateway URL). The chain id is derived from the URL, and it is part of every signed payload, so an API wallet is bound to one network: signing against the other gateway is rejected with WrongChainId.
8. Troubleshooting
| You see | Meaning and fix |
|---|---|
LocalValidationError: … is not an active agent for owner (at construction) |
The API wallet is not approved on that account. Check the owner, or create a fresh one — the old key may have been revoked. info.agent_status(owner, agent) shows the current slots. |
SubmissionUncertain |
A write timed out on the wire. Reconcile with reconcile_by_cloid(user, market, e.cloid). Do not resubmit under a new nonce, unless it is a handoff timeout (§7). |
NetworkError |
A transport failure on a read. Retry the read. |
DirectSignerIsActiveAgent |
You built Exchange with an API wallet key but no owner. Pass owner=<accountAddress>, or use from_bundle. |
WrongChainId |
The key's network does not match the gateway. from_bundle avoids this. |
LocalValidationError: … decimal places / significant figures |
sz or limit_px exceed the market's precision. Snap with info.snap_price / info.min_order_size. |
submission_status: "accepted" but the order is not on the book |
A per-action failure. is_order_failed(resp) is True and leaf_error_code(resp) names it; is_benign_cancel(resp) separates a routine unfilled IOC or STP cancel from a real fault. |
insufficientspotbalance (on the leaf, under an accepted write) |
The account is not funded for that market's quote asset. is_order_failed(resp) is True. |
submission_status: "rejected" |
A hard write failure — admission reject or execution failure. Read error_code(resp). Data, not an exception. |
submission_status: "timeout" |
The order may still land. Reconcile by cloid, and resubmit only when is_safe_to_resend(resp) says so (§7). |
RateLimited / HTTP 429 |
The gateway is throttling you. The SDK already retried with backoff, so slow down; see §7. |
AgentEpochMismatch |
The SDK refreshes and retries once. If it persists, the API wallet was revoked or re-approved; create a new one. |
9. Development
make install # uv sync
make lint # ruff + mypy --strict
make test # pytest
Release files for native-core-python-sdk 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| native_core_python_sdk-2.0.0.tar.gz | 164.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| native_core_python_sdk-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 265.9 kB
Release files / native_core_python_sdk-2.0.0.tar.gz
| Download URL | native_core_python_sdk-2.0.0.tar.gz |
|---|---|
| Size | 164.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5cfdd4c475d7a301d7018a7da01ac6e1127df83551167010102db6d73f2f437b
|
|
BLAKE2b-256 checksum How to use checksums |
06879eddd63e76662eb362e715111a81ba32ae2b0b19348b036488567d75c27f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / native_core_python_sdk-2.0.0-py3-none-any.whl
| Download URL | native_core_python_sdk-2.0.0-py3-none-any.whl |
|---|---|
| Size | 101.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a88c822a2e61f525a24138377ef0ba39dfe1e35a66f53f98f00ac7d5be569e42
|
|
BLAKE2b-256 checksum How to use checksums |
1c108964fc0ff0308cc92377431857600b74f6bc68e2ada61cb6977acb81cd90
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|