Skip to main content

State machine

States

The POST /api/orders/place and place-batch responses report a partially filled resting order as OPEN (filledQty carries the fill). The read endpoints — GET /api/orders/open, /history and /{id} — return the stored status, so the same order reads PARTIAL there. Each order is one row with its current status: a partially filled order that is then cancelled reads CANCELLED with a non-zero filledQty.

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.)
All four are optional; combine freely. The response is always paginated: without 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 to FILLED / 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 from GET /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/user typed channels (recommended). On an API key, every channel below needs the portfolio:read scope — a key with only orders:write is refused with api_key_scope_missing:
    • user_orders — order_placed / order_cancelled / order_rejected / order_expired. Use for orderbook UI updates. It also carries trade_failed (see below).
    • user_fills — user_fill fires at match time (source: "matcher"), before on-chain settlement. It carries no fee, txHash or blockNumber.
    • vault_activity (subscribe with ids: [<your vault address>]; any other vault address is refused) — trade_fill fires when the on-chain OrderFilled event for your vault is indexed. It carries txHash, blockNumber, orderHash, fee, both vault addresses (makerVault / takerVault) and the filled amounts. side ("maker" / "taker") is your vault’s position in that OrderFilled event, not your order’s role in the match: a taker order that fills against N resting orders produces N frames with side: "taker" plus one side: "maker" frame for the taker order’s own fill.
  • REST balance polling — available + locked totals derive from on-chain-confirmed state, so polling /api/me/balances is a backstop if WS is unavailable.
  • Settlement-leg failures — when the chain rejects a leg (MatchFailed), the taker receives a trade_failed frame on user_orders (orderId there is the taker order’s EIP-712 hash). The maker side gets no trade_failed, so also poll GET /api/orders/{id}: the failed quantity comes off filledQty, and the order moves back to OPEN / PARTIAL or ends SETTLEMENT_FAILED (a SETTLEMENT_FAILED order’s locked balance is refunded automatically — the trade is treated as if it never settled).
See WebSocket — User events for the full payload shapes of each push.

What actually happens on POST /api/orders/place

  1. Authn — X-Api-Key is verified (partner lookup → SHA-256 hash compare → key lifecycle checks). The acting wallet is your key’s associatedWallet (single_wallet keys) or the X-User-Wallet header (multi_wallet keys). orders:write scope is enforced before the handler runs — see API keys.
  2. Market guard — the market must be OPEN, or PRE_MARKET for 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 normal 201 response with status: REJECTED and code: market_not_open (market not open, or its close cut-off has passed), pre_market_post_only (non-LIMIT-GTC order in pre-market) or pre_market_crossing_order (pre-market order would cross).
  3. Pre-verify enrichment — the platform resolves:
    • your vault via VaultFactory.vaultOf(signer)
    • the on-chain tokenId for the requested outcome
    • makerAmount / takerAmount from price × quantity per side
  4. EIP-712 verify — the platform rebuilds the on-chain Order struct (11 fields) itself. It does not take maker or signatureType from you:
    • salt = body nonce
    • maker = your vault (VaultFactory.vaultOf(signer))
    • signer = the acting wallet
    • taker = 0x0000000000000000000000000000000000000000
    • tokenId = the token of the requested outcome
    • makerAmount / takerAmount from price × quantity at 6 decimals (BUY notional rounded up, SELL rounded down)
    • expiration = body expiry
    • feeRateBps = the market’s current feeTakerBps
    • side = 0 BUY / 1 SELL
    • signatureType = 1 (VAULT), always
    The digest uses the binary or neg-risk exchange domain, depending on the market. Your signature must recover to the acting wallet over exactly this struct. If it does not, the order comes back REJECTED with code: bad_signature, and details.recovered, details.expected_signer and details.platform_canonical_struct let you diff your struct against ours. A BUY signed with a rounded-down notional is rejected as order_signed_with_floor_notional instead (details.expectedCeilMakerAmountWei is the value to sign). A non-zero expiry that has already passed also reports bad_signature; if the signature itself is correct, recovered then equals expected_signer. The body maker only has to be your wallet or your vault (anything else is 401 maker_mismatch); either way, the vault is what gets signed over and stored.
  5. Balance / position lock — atomic:
    • BUY: available -= notional, locked += notional
    • SELL: position lookup is keyed by vault address (not EOA); fails with insufficient_position if the vault doesn’t hold enough of the outcome token
    • the order row is recorded as PENDING
  6. Match — sent to the matching engine. Returns {status, filledQty, trades[]} synchronously.
  7. Trade persistence — trade rows + fee ledger written, balance deltas applied for both sides of every trade — atomically.
  8. Settlement enqueue — matched trades are broadcast on-chain via batchMatchOrders on CTFExchange. See the trade-state table above.
  9. Response — shape documented below.
If step 6 fails after retries (the matching engine cannot be reached), step 5 is compensated: order status → 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

The synchronous 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

Include clientOrderId 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 CANCELLED and the locked collateral for its unfilled remainder returns to available.
  • 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.