Skip to main content
See API keys for format, scope taxonomy, associatedWallet requirement, and rate-limit buckets.

Request body

The request body is whitelisted (forbidNonWhitelisted). Any extra field returns 400 invalid_request, and error.details.fields[] names the offending property, for example: { "field": "foo", "constraint": "whitelistValidation", "message": "property foo should not exist" }. Send feeRateBps with the value you signed. If the market fee changed after you fetched it, the order is rejected with status: REJECTED, code: stale_fee_quote and a message giving the current rate. Re-fetch feeTakerBps, then re-sign. If you omit the field, the signature is still checked against the current fee, and a stale fee comes back as bad_signature. The platform never receives the struct you signed. It rebuilds the on-chain Order from your request fields and recovers your signature against that rebuild. So the struct you sign must always contain:
  • signer = the wallet you authenticate as (your key’s associatedWallet, or the X-User-Wallet for a multi_wallet key)
  • maker = that wallet’s vault (VaultFactory.vaultOf(signer)), never the EOA
  • signatureType = 1 (VAULT). 0 (EOA) is deprecated. The platform and the exchange contract both reject it, even when maker == signer.
  • salt = the body nonce, expiration = expiry, feeRateBps = the market’s feeTakerBps, taker = 0x0, tokenId = the token for outcome, side = 0 BUY / 1 SELL
  • makerAmount / takerAmount derived from price / quantity (CEIL on BUY notional, FLOOR on SELL)
  • the EIP-712 domain of the binary or the neg-risk exchange, according to the market’s negRiskEligible
See EIP-712 signing for the typed-data definition. The body maker field is only an ownership check. It must be your wallet or its vault (anything else is rejected with maker_mismatch, an HTTP 401 on /place), and the platform verifies against your vault in both cases. If the signature does not recover to your wallet, or a non-zero expiry has already passed, the order comes back as status: REJECTED, code: bad_signature, not as an HTTP 400. Its details show the exact struct the platform verified and the address it expected to recover; see Response shape.

Validation rules

Except where a row shows an HTTP status, every rule in this table is checked as a business rule. A failure comes back as a REJECTED envelope, not an HTTP error: an HTTP 201 body { status: "REJECTED", code, message } on /place, or an entry with success: false on /place-batch. Branch on status / code, not on the HTTP status. (Shape errors, such as a non-numeric price or an unknown field, fail request validation first with 400 invalid_request.)

Worked example — BUY 10 YES at 0.42

Response shape

On code: bad_signature, details contains recovered (the address your signature recovers to against the platform’s struct), expected_signer (the acting wallet: your key’s associatedWallet, or the X-User-Wallet on multi_wallet keys) and platform_canonical_struct (the exact Order the platform verified). Compare it field by field with the struct you signed. The field that differs is the cause. If recovered equals expected_signer, the struct matched and your non-zero expiry has already passed. See bad_signature diagnostic hints. The clientOrderId field is echoed verbatim when the request supplied one — present on every shape (success, REJECTED, idempotent replay). Use it to correlate the server-issued orderId to your own client-side handle without a follow-up GET /api/orders/{id}. The same value is also carried on the order_placed and order_cancelled frames of the user_orders channel on /ws/user, so partners that drive their state machine off the WS feed get the correlation key on both surfaces.

Batch placement

Submit up to 10 orders in one request with POST /api/orders/place-batch. Each entry is the exact same body as single placement; the batch is processed sequentially in array order with independent per-order outcomes — one rejected order never aborts the rest, and there is no all-or-nothing atomicity. Request validation is the exception: if any entry is malformed (for example a non-numeric price, an unknown field or a missing signature), or the array holds 0 or more than 10 entries, the whole request fails with 400 invalid_request and no entry is processed.
The response is an array aligned to the request by index. Each item is a PlaceOrderResp (identical to single place) plus a success flag:
Partner trade limits accumulate across the batch. For users who trade through a partner sign-in (partner_sso), the partner trade cap is charged against the running total of the entries accepted earlier in the same batch, so a batch can never admit more exposure than the same orders sent one at a time.
Business reject vs infrastructure failure. A rejected order (bad signature, insufficient funds, …) comes back as a per-entry success: false inside an HTTP 201 response, and the rest of the batch still processes. An infrastructure failure (matcher / verifier / DB unavailable) fails the whole request with a 5xx. Entries processed before the failure may already be placed, and the 5xx body does not say which ones.An entry the exchange was too busy to take comes back success: false with code: "journal_intent_not_durable" and details.retryable: true, and the other entries keep their own results. Nothing was admitted for that entry: resend the same signed order unchanged, under the same clientOrderId, shortly. Do not sign a new one.Always set your own clientOrderId on every entry. Then you can resend the same batch with the same keys: entries that were already placed return their original result, and the rest are processed normally.If you left clientOrderId out, the keys the server generated are not returned to you, so a resend is not linked to the first attempt. Resending the identical signed orders still cannot place anything twice: an entry that was already placed comes back success: false with code: "order_already_placed". You won’t get its original result, though, so look it up by its order hash (see Finding an order after a timeout). Don’t re-sign an entry with a new nonce until you’ve confirmed the first one was not placed.

Finding an order after a timeout

When a place request times out or answers 5xx you don’t have the orderId, but you hold two keys for the order: the clientOrderId you sent, and its orderHash, the EIP-712 digest of the struct you signed (see EIP-712 signing). Look the order up by either one:
  • Pass exactly one of the two; neither or both answers 400 invalid_request.
  • The answer is the same Order object as GET /api/orders/{id}.
  • clientOrderId is matched exactly (case-sensitive, not trimmed). URL-encode it: a raw + in a query string reads as a space. orderHash is accepted in any case.
  • An unknown key and an order of another wallet both answer 404 order_not_found.
  • The server-generated srv-* key comes back (in details.clientOrderId) only on a single POST /api/orders/place 503. A place-batch 5xx carries no keys, so set your own clientOrderId on every batch entry, or look the order up by orderHash.
  • Orders that ended with no trade are archived after 30 days and are then not found here or by GET /api/orders/{id}: a 404 for an old order says nothing about it.
A 404 right after a timeout does not prove the order was never placed. It may not be recorded yet. The authoritative answer is still to re-send the identical request under the same clientOrderId: that returns the original result if the order was placed, and places it if it never reached us. It never creates a second order. Don’t sign a new order until you know the first one was not placed. If you sent no clientOrderId, look the order up by orderHash. Re-send only in that window, never an old order.
Rate limited at 8 req/sec/wallet (token bucket, so up to 80 orders/sec). Single /place has its own limit of 20 req/sec/wallet. For the full table, see Rate limits. KYC deposit-tier gating applies, same as single place.

Post-only orders

Set postOnly: true to guarantee an order only ever rests on the book and never matches on entry. It’s the maker-side guarantee market makers need to avoid paying taker fees. (No order of yours ever trades against your own quotes, post-only or not: a limit order that would reach them is rejected, and a market order cancels the ones it comes to; see Self-trade prevention.)
Only valid for LIMIT orders with gtc time-in-force (optionally with expiry). Omit it (or send false) for today’s behavior.
Two rejection modes.
  • post_only_invalid_order_type — postOnly was sent with a MARKET order or an ioc / fok time-in-force. The combination is invalid and nothing is placed. On /place this is an HTTP 400. On /place-batch it comes back as that entry’s own { "success": false, "status": "REJECTED", "code": "post_only_invalid_order_type" } inside a successful batch response, and the other entries still process. In a batch, check the entry, not the HTTP status.
  • post_only_would_cross — the order was marketable at entry: its price meets or crosses the best price on the opposite side of the book (at-touch equality counts as crossing). It returns { "status": "REJECTED", "code": "post_only_would_cross" } with no fills — reprice inside the spread and resubmit. If its price also reaches one of your own resting orders, you get self_cross_blocked instead, even when another participant’s order sits in front of yours: self-trade prevention is checked first.

Self-trade prevention

A wallet never trades with itself. When a new order would meet one of your own resting orders, the platform stops that match. What it stops depends on the new order’s type: For a limit order, “reaches” means a BUY priced at or above your lowest resting SELL, or a SELL priced at or below your highest resting BUY; an equal price counts. Quoting both sides with a spread between them is always allowed.
A limit order is checked against your own price, not against the whole book. If its price reaches one of your resting orders, the whole order is rejected, even when other participants’ orders at better prices would have filled it first. Example: another trader asks 0.45 and your own ask rests at 0.55. A BUY limit at 0.60 is rejected with self_cross_blocked and takes nothing; a BUY at 0.50 fills against the 0.45 ask. To trade through your own quote, cancel it first, or send a market order with a price cap, which cancels your own orders it comes to and fills against everyone else’s.
  • YES and NO share one book. On a binary market, buying NO at p is the same as selling YES at 1 − p, so your orders on both outcomes are checked together. A BUY YES and a BUY NO reach each other when their prices add up to 1.00 or more; a SELL YES and a SELL NO when they add up to 1.00 or less. With your BUY NO at 0.40 resting, a BUY YES at 0.60 is self_cross_blocked and a BUY YES at 0.55 is not. Different markets never block each other, including the questions of one multi-outcome event.
  • Checked first. The check runs before the post-only and fill-or-kill checks. A postOnly order that reaches your own order gets self_cross_blocked, not post_only_would_cross, and a LIMIT + FOK order comes back REJECTED with self_cross_blocked, not CANCELLED. A LIMIT + IOC order that reaches your own order takes nothing, not even better-priced orders of other participants.
  • Batches. place-batch entries are processed in array order. A limit entry that reaches an order an earlier entry of the same batch left resting is rejected with self_cross_blocked, and the earlier entry is unaffected. A market entry cancels that order instead, as described above.
  • Pre-market. While a market is PRE_MARKET, an order that would cross any resting order, yours or anyone else’s, is rejected earlier with code: pre_market_crossing_order (see Market status). An entry that crosses an order placed by an earlier entry of the same place-batch is not caught by that check and comes back as self_cross_blocked instead.
  • Just-expired orders. One of your orders whose expiry has just passed can still count for a few seconds, until the platform removes it from the book.
  • Per wallet. Orders from two different wallets are never blocked or cancelled against each other, even when they belong to the same multi_wallet API key: they match like any two participants. Trading between wallets you control is still subject to market surveillance.
What you receive
  • Rejected limit order. POST /api/orders/place answers HTTP 201 with status: "REJECTED", code: "self_cross_blocked" and a real orderId (on place-batch, that entry with success: false). Only this response names the code. On the user_orders WebSocket channel you get the usual order_placed frame, then order_cancelled with reason: "matcher_rejected" (not order_rejected), and GET /api/orders/{id} reads REJECTED.
  • Resting order cancelled by your market order. An order_cancelled frame on user_orders with reason: "self_trade_prevention". It can arrive without clientOrderId, originalQuantity and remainingQty, so key it by orderId. GET /api/orders/{id} reads CANCELLED, and the order’s locked funds are released as with any cancel. The market order’s own response doesn’t list the orders it cancelled.

Sell orders

Same shape; side: 'sell'. No USDC is locked for a SELL order. Instead, the order reserves its quantity of the outcome token (ERC-1155), which must already sit in the vault, not the EOA. Tokens reserved by one open SELL cannot back another; cancelling or expiry releases the reservation for the unfilled remainder. Position resolution is keyed by maker (the vault), which is why on-chain splits (USDC → YES + NO) must be initiated by vault.splitPosition(...) rather than by the EOA directly. See Vaults for the split flow.

Next

Time-in-force

GTC vs IOC vs FOK behaviour.

Cancelling orders

Single-order and cancel-all flows.