State machine
States
Filtering /api/orders/open
/open accepts these filters — useful for MMs with thousands of
resting orders who want to scope server-side instead of streaming the
full open-set every poll. (/history filters by marketId, status,
from and to, pages with limit / cursor, and rejects any other
query parameter, such as side, with 400 invalid_request.)
limit you get the first 500 rows; pass limit
(1–500) for a smaller page. While more orders remain, nextCursor is
non-null — pass it back as cursor for the next page. nextCursor is
null only on the last page.
Trade-level settlement (after match)
Order state flips toFILLED / PARTIAL as soon as the matching
engine returns fills — but the underlying trade rows then go
through a separate on-chain settlement pipeline:
The most common cause of
settlement_failed is MatchFailed(reason = 0xc56873ba) = OrderExpired() — a resting maker order whose
expiration slipped between off-chain match and on-chain submit. Sign
new orders with expiration = 0 to avoid this; see
CTFExchange — MatchFailed reason codes.
This is why PredictStreet uses a strict settlement model:
partner-visible balances reflect only on-chain-confirmed state.
Expect a few seconds (p99 < 30s on testnet) between order FILLED
and the locked notional clearing. If settlement_pending exceeds
5 minutes, contact support — most likely chain congestion or a
known stuck-trade case.
How partners observe settlement
Every trade row fromGET /api/orders/{id}/fills and
GET /api/me/trades carries its settlement state: status
(matched → settlement_pending → settled), txHash (set once the
settlement transaction is broadcast, null before) and settledAt
(set once the chain confirms). Legs that fail on-chain are left out of
both feeds. For push notifications:
- WebSocket
/ws/usertyped channels (recommended). On an API key, every channel below needs theportfolio:readscope — a key with onlyorders:writeis refused withapi_key_scope_missing:user_orders—order_placed/order_cancelled/order_rejected/order_expired. Use for orderbook UI updates. It also carriestrade_failed(see below).user_fills—user_fillfires at match time (source: "matcher"), before on-chain settlement. It carries nofee,txHashorblockNumber.vault_activity(subscribe withids: [<your vault address>]; any other vault address is refused) —trade_fillfires when the on-chainOrderFilledevent for your vault is indexed. It carriestxHash,blockNumber,orderHash,fee, both vault addresses (makerVault/takerVault) and the filled amounts.side("maker"/"taker") is your vault’s position in thatOrderFilledevent, not your order’s role in the match: a taker order that fills against N resting orders produces N frames withside: "taker"plus oneside: "maker"frame for the taker order’s own fill.
- REST balance polling —
available+lockedtotals derive from on-chain-confirmed state, so polling/api/me/balancesis a backstop if WS is unavailable. - Settlement-leg failures — when the chain rejects a leg
(
MatchFailed), the taker receives atrade_failedframe onuser_orders(orderIdthere is the taker order’s EIP-712 hash). The maker side gets notrade_failed, so also pollGET /api/orders/{id}: the failed quantity comes offfilledQty, and the order moves back toOPEN/PARTIALor endsSETTLEMENT_FAILED(aSETTLEMENT_FAILEDorder’s locked balance is refunded automatically — the trade is treated as if it never settled).
What actually happens on POST /api/orders/place
-
Authn —
X-Api-Keyis verified (partner lookup → SHA-256 hash compare → key lifecycle checks). The acting wallet is your key’sassociatedWallet(single_walletkeys) or theX-User-Walletheader (multi_walletkeys).orders:writescope is enforced before the handler runs — see API keys. -
Market guard — the market must be
OPEN, orPRE_MARKETfor resting LIMIT GTC orders that don’t cross the book. It is checked before the signature is verified and re-checked when funds are locked (step 5). A failure comes back in the normal201response withstatus: REJECTEDandcode: market_not_open(market not open, or its close cut-off has passed),pre_market_post_only(non-LIMIT-GTC order in pre-market) orpre_market_crossing_order(pre-market order would cross). -
Pre-verify enrichment — the platform resolves:
- your vault via
VaultFactory.vaultOf(signer) - the on-chain
tokenIdfor the requestedoutcome makerAmount/takerAmountfromprice × quantityper side
- your vault via
-
EIP-712 verify — the platform rebuilds the on-chain
Orderstruct (11 fields) itself. It does not takemakerorsignatureTypefrom you:salt= bodynoncemaker= your vault (VaultFactory.vaultOf(signer))signer= the acting wallettaker=0x0000000000000000000000000000000000000000tokenId= the token of the requestedoutcomemakerAmount/takerAmountfromprice × quantityat 6 decimals (BUY notional rounded up, SELL rounded down)expiration= bodyexpiryfeeRateBps= the market’s currentfeeTakerBpsside=0BUY /1SELLsignatureType=1(VAULT), always
REJECTEDwithcode: bad_signature, anddetails.recovered,details.expected_signeranddetails.platform_canonical_structlet you diff your struct against ours. A BUY signed with a rounded-down notional is rejected asorder_signed_with_floor_notionalinstead (details.expectedCeilMakerAmountWeiis the value to sign). A non-zeroexpirythat has already passed also reportsbad_signature; if the signature itself is correct,recoveredthen equalsexpected_signer. The bodymakeronly has to be your wallet or your vault (anything else is401 maker_mismatch); either way, the vault is what gets signed over and stored. -
Balance / position lock — atomic:
- BUY:
available -= notional,locked += notional - SELL: position lookup is keyed by vault address (not EOA);
fails with
insufficient_positionif the vault doesn’t hold enough of the outcome token - the order row is recorded as
PENDING
- BUY:
-
Match — sent to the matching engine. Returns
{status, filledQty, trades[]}synchronously. - Trade persistence — trade rows + fee ledger written, balance deltas applied for both sides of every trade — atomically.
-
Settlement enqueue — matched trades are broadcast on-chain via
batchMatchOrdersonCTFExchange. See the trade-state table above. - Response — shape documented below.
REJECTED, locked balance
refunded, and the call returns 503 trading_temporarily_unavailable.
The same code answers an order the exchange was too busy to take: nothing
was admitted, retryable is true, and you resend the same signed order
unchanged, under the same clientOrderId (on place-batch that entry
comes back success: false with code: journal_intent_not_durable
instead). A 503 shard_owner_unavailable means the exchange node that
serves your account is restarting and refused the order before recording
anything; the platform has already re-sent it for about 6 seconds, so
resend it after Retry-After.
If the matching engine errors or returns an unusable answer, the same
compensation runs and the call returns the REJECTED envelope with
code: matcher_error. A 503 placement_unknown means the answer from
the exchange was lost and the order may have been placed: re-send the
same signed order under the same clientOrderId (details.clientOrderId
carries it, including a server-generated one if you sent none) and do
not re-sign it. A 503 placement_not_sent means nothing was sent or
recorded.
Response shape
trades[] is the match-time view; on-chain settlement
happens after the response. To observe on-chain confirmation, re-read
the trade from
GET /api/orders/{id}/fills or GET /api/me/trades (status,
txHash, settledAt), or subscribe to trade_fill on the
vault_activity WebSocket channel — see
How partners observe settlement
above.
Idempotency
IncludeclientOrderId in the request body to make the call idempotent.
If a network retry hits the server with the same (wallet, clientOrderId),
the original response is replayed. To check an order’s state without
re-sending, look it up by the same key with
GET /api/orders/lookup?clientOrderId= (or by its EIP-712 hash with
?orderHash=); see
Finding an order after a timeout.
Cancellation
orderId is the UUID returned by POST /api/orders/place (or
place-batch). It is not the EIP-712 order hash; cancelling by any
other value answers status: "not_found".
- On success the order ends as
CANCELLEDand the locked collateral for its unfilled remainder returns toavailable. - Cancelling an order that has already ended answers
already_terminal, so a repeated cancel is safe. See Cancelling orders.
Next
EIP-712 signing
Struct reference + signing examples.
Placing orders
Full request schema + validation.
Fees
Quadratic taker-fee curve explained.
Error codes
Complete code reference.