Skip to main content
POST /api/orders/place
X-Api-Key: ps_live_<keyId>_<secret>   # needs `orders:write` scope
Content-Type: application/json
See API keys for format, scope taxonomy, associatedWallet requirement, and rate-limit buckets.

Request body

interface PlaceOrderReq {
  marketId: string;          // app-level symbol, e.g. "NC26-BIN-83479265"
  side: 'buy' | 'sell';      // lowercase
  outcome: string;           // "0" = YES (first), "1" = NO (second)
  price: string;             // decimal USDC, e.g. "0.42"
  quantity: string;          // decimal outcome qty, e.g. "2"
  nonce: string;             // decimal-string of the salt used in the EIP-712 struct
  expiry: number;            // unix seconds (= Order.expiration); use 0 for no on-chain expiry
  maker: string;             // VAULT address (= VaultFactory.vaultOf(signer))
  signature: string;         // 0x-prefixed EIP-712 signature
  clientOrderId?: string;    // idempotency key (optional, scoped per associatedWallet)
  type?: 'limit' | 'market'; // default 'limit'
  timeInForce?: 'gtc' | 'ioc' | 'fok'; // default 'gtc' for limit, 'ioc' for market
}
The request body is whitelisted (forbidNonWhitelisted); any extra field returns 400 validation_failed. maker is the vault address. The signing EOA is recovered from signature and must satisfy vaultFactory.vaultOf(signer) == maker. The backend re-runs that check before storing the order, so passing the EOA in maker instead of the vault returns 400 bad_signature.

Validation rules

FieldRuleFailure
price0 < p < 1 (strict — p >= 1 is rejected to block the fee = k × P × (1−P) sign flip at P > 1). Tick size is 0.01 — at most 2 decimal places (integer cents, 99 distinct levels). 0.505 returns invalid_amounts with “price tick size is 0.01”.400 invalid_amounts
quantity> 0, at most 6 decimal places (USDC scale).400 invalid_amounts
type + timeInForceMARKET orders require ioc or fok; MARKET+GTC rejected400 invalid_tif
signatureEIP-712 recover (full on-chain Order struct) must equal an EOA whose vault == maker400 bad_signature
expiry0 (no expiry, recommended) or unix-seconds in the future. There is no minimum TTL.400 expired
outcome"0" or "1". The platform maps this to the on-chain tokenId for the market.400 invalid_outcome / 409 market_outcomes_not_seeded
Balanceavailable >= price × quantity (BUY)200 REJECTED, code: insufficient_funds
Positionvault.erc1155(tokenId) >= quantity (SELL)200 REJECTED, code: insufficient_position
Marketmarket.status === 'OPEN'409 market_not_open

Worked example — BUY 2 YES at 0.42

import { Wallet, randomBytes, parseUnits } from 'ethers';

// 1. Resolve vault, outcome tokenId, AND the per-market taker fee
const vault    = await vaultFactory.vaultOf(wallet.address);
const market   = await fetch(`${BASE}/api/markets/NC26-BIN-83479265`).then(r => r.json());
const tokenId  = market.yesTokenId;       // outcome '0'
const feeBps   = BigInt(market.feeTakerBps);  // CRITICAL — see callout below

// 2. Build + sign EIP-712 (see /concepts/trading/eip712-signing)
const salt     = BigInt('0x' + randomBytes(32).toString('hex'));
const priceWei = parseUnits('0.42', 6);
const qtyWei   = parseUnits('2',    6);
// CEIL on BUY notional. FLOOR-signed BUY orders are rejected at
// placement (code: order_signed_with_floor_notional) because they
// overflow the matcher's per-fill CEIL math on the closing tail
// fill. See /concepts/trading/eip712-signing#rounding-rule-ceil-on-buy-floor-on-sell-notional
const notionalWei = (priceWei * qtyWei + 999_999n) / 1_000_000n;

const orderStruct = {
  salt, maker: vault, signer: wallet.address,
  taker: '0x0000000000000000000000000000000000000000',
  tokenId: BigInt(tokenId),
  makerAmount: notionalWei,        // BUY → USDC notional
  takerAmount: qtyWei,             // BUY → outcome qty
  expiration: 0n,
  feeRateBps: feeBps,              // MUST equal market.feeTakerBps
  side: 0, signatureType: 1,       // BUY, VAULT
};
const signature = await wallet.signTypedData(domain, types, orderStruct);

// 3. POST
const resp = await fetch(`${BASE}/api/orders/place`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.PS_API_KEY! },
  body: JSON.stringify({
    marketId:      'NC26-BIN-83479265',
    side:          'buy',
    outcome:       '0',
    price:         '0.42',
    quantity:      '2',
    nonce:         salt.toString(),
    expiry:        0,
    maker:         vault,
    signature,
    clientOrderId: `mm-bot-${Date.now()}`,
    type:          'limit',
    timeInForce:   'gtc',
  }),
}).then(r => r.json());

Response shape

interface PlaceOrderResp {
  orderId: string;             // server-issued UUID, '' when status=REJECTED
  status: 'PENDING' | 'OPEN' | 'FILLED' | 'CANCELLED' | 'REJECTED' | 'EXPIRED' | 'SETTLEMENT_FAILED';
  filledQty: string;           // cumulative fill at response time (decimal)
  remainingQty: string;        // quantity - filledQty
  trades: TradeFill[];         // synchronous fills produced by IOC/FOK or aggressive LIMIT
  code?: string;               // present when status=REJECTED (insufficient_funds, …)
  message?: string;            // human-readable explanation; pairs with code
  clientOrderId?: string;      // echoed from request when supplied
}
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 events.order_placed and events.order_cancelled frames over /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.
POST /api/orders/place-batch
X-Api-Key: ps_live_<keyId>_<secret>   # needs `orders:write` scope
Content-Type: application/json

{ "orders": [ /* 1–10 PlaceOrderReq objects, same shape as single place */ ] }
The response is an array aligned to the request by index. Each item is a PlaceOrderResp (identical to single place) plus a success flag:
[
  { "success": true,  "orderId": "0x1f...", "status": "OPEN",     "filledQty": "0", "remainingQty": "2", "trades": [], "clientOrderId": "mm-1" },
  { "success": false, "orderId": "",        "status": "REJECTED", "filledQty": "0", "remainingQty": "0", "trades": [], "code": "insufficient_funds", "message": "..." }
]
Per-wallet limits accumulate across the batch. KYC deposit-tier, responsible-gambling, and partner trade caps are charged against the running total of accepted entries, 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, …) is a per-entry success: false with HTTP 200 — the rest of the batch still processes. An infrastructure failure (matcher / verifier / DB unavailable) fails the whole request with a 5xx. On a 5xx, retry the whole batch: every forwarded entry carries a clientOrderId (the server mints one when you omit it), so the replay returns the already-committed result via idempotency rather than placing duplicates.
Rate limited at 5 req/sec/wallet (up to 50 orders/sec). 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 or accidentally crossing their own quotes.
POST /api/orders/place
X-Api-Key: ps_live_<keyId>_<secret>   # needs `orders:write` scope
Content-Type: application/json

{ /* ...single-place body... */ "marketId": "UAE-CUP-FINAL-20260425", "type": "limit", "timeInForce": "gtc", "postOnly": true }
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 (HTTP 400) — postOnly was sent with a MARKET order or an ioc / fok time-in-force. The combination is invalid; nothing is placed.
  • 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.

Sell orders

Same shape; side: 'sell'. Balance is not locked for SELL orders — the outcome token (ERC-1155) must already sit in the vault, not the EOA. 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.