Trading

Orders

An order spends a quote and returns an exact instruction for your ledger: what to debit, and what to give back. Every surface — a mobile sheet, a desktop panel, your API client — enters the same pipeline at the same point.

The pipeline

  1. 1Quote
  2. 2Confirm
  3. 3Order
  4. 4Route
  5. 5Fill
  6. 6Position

Confirmation is an input to this pipeline, never a shortcut around it. The order row is written before anything is sent, because an execution that succeeds upstream and then fails to be recorded is the one outcome with no recovery path.

The lifecycle

states
accepted → submitted → filled | partially_filled | rejected
              └→ unknown after timeout → 409 reconciliation_required
statussettlementStateWhat your ledger does
filledsettledDebit actualDebit, release releaseAmount (zero on a full fill). Done.
partially_filledsettledDebit actualDebit and release the non-zero releaseAmount. Debiting the full stake keeps money that is not ours to keep.
rejectedsettledactualDebit is 0.00 and the whole authorization comes back. rejectReason says why.
pendingpendingNot resolved. Hold the authorization and poll.
submittedpendingNot resolved. Hold the authorization and poll. This is the reconciliation case.
cancelledsettledTerminal, nothing debited.

settlementState collapses six statuses to the only question a wallet has to answer right now: is the money final, or must I hold and come back? Switch on it, and submitted can never be mistaken for terminal.

Submit an order

POST/api/v1/ordersAPI key
Spends a quote. orders:write, plus X-Predicta-User.
request
{
  "quoteId": "fc3be731-98dc-4c03-80ac-23576a708d9c",
  "clientOrderId": "ticket-9031"    // YOUR id. The idempotency key.
}
201 application/json
{
  "orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
  "clientOrderId": "ticket-9031",
  "status": "filled",
  "contractId": "ctr_9f31c2…",
  "side": "YES",
  "action": "buy",
  "filledQuantity": 68.6111,
  "averagePrice": 0.72,
  "predictaFee": 0.6,
  "predictaFeeBps": 120,
  "notional": 49.4,
  "stake": 50,
  "rejectReason": null,
  "simulated": true,
  "createdAt": "2026-08-20T04:43:40.282Z",
  "idempotentReplay": false,
  "routing": { "reason": "only_venue", "consideredVenues": 1 },

  "operatorMoney": {
    "currency": "USD",
    "authorizationAmount": "50.00",   // what you reserved
    "actualDebit": "50.00",           // TAKE THIS
    "releaseAmount": "0.00",          // GIVE THIS BACK
    "tradeAmount": "49.40",
    "platformFee": "0.60",
    "venueFee": "0.00",
    "venueFeeKnown": true
  },
  "operatorExitMoney": null
}

operatorMoney is what your ledger acts on

The float fields above — stake, notional, predictaFee, averagePrice — are for display. The money object carries exact decimal strings and two identities that hold on every buy:

authorizationAmount = actualDebit + releaseAmount
actualDebit = tradeAmount + platformFee + venueFee

Assert both before you post. Parsing "12.30" as a float has already left the exact world before your own ledger sees the figure.

A sell is a credit, and carries a different object

On an exit operatorMoney is null — deliberately — and operatorExitMoney.sellProceeds is what to credit, already net of the platformFee and venueFee reported beside it. A single object for both directions would hand a wallet an authorizationAmount on a trade that pays cash out, and a partner applying the contract as written would debit the player for selling their own position.

FieldMeaning
statusThe state machine above. A partial fill is a real state, not an error.
filledQuantityContracts held. This, not the quote’s contracts, is what the player owns.
averagePriceVolume-weighted fill price, 0–1. Display only.
predictaFeeBpsThe rate this order was struck at, snapshotted from its quote — so you can reconcile the charge against the terms you agreed without asking what our config currently says.
routingreason and consideredVenues: a count and a cause, useful when debugging why an order went where it did. The venue itself is never named.
idempotentReplaytrue when this response replayed an order that already existed.
rejectReasonPopulated when status is rejected. Null otherwise.

201, or a 200 replay

Status codeWhen
201The order was created by this call.
200This call replayed an existing order: idempotentReplay: true, and the body is the original order verbatim — including its original averagePrice and its original money. Never a re-quote.

clientOrderId is unique per operator and player, enforced by a database index rather than by a check in application code. Two requests arriving together produce one order; the loser of the race re-reads the winner's.

  • Derive the key from the reservation in your own ledger. A random value per attempt gives you no protection at all, because a retry has to present the same string. A clock gives you a key you cannot reproduce after a crash.
  • A rejected order still consumes its key. Retrying a spent key after a rejection must not open a second live order, so the rejected record stays.

When the venue does not answer

409 reconciliation_required is not a rejection

The venue took the order or it did not, and Predicta does not yet know which. Collateral stays committed. Hold the authorization. Do not release it.

Releasing on an order that did in fact execute leaves a real position with no cash behind it, and you have no way to discover the mistake because you were told it failed. That is the most expensive lie this API could tell, so it declines to tell it: “unknown” is the only true answer until reconciliation resolves it durably.

recovery
# 1. You have an orderId — poll it.
GET /api/v1/orders/{orderId}
→ { "status": "submitted", "settlementState": "pending",
    "operatorMoney": { "actualDebit": "0.00", "releaseAmount": "50.00", … } }

# 2. You never received a response at all — look it up by YOUR id.
GET /api/v1/orders?externalUserId=user-4471&clientOrderId=ticket-9031
→ { "orders": [ … ], "count": 1, "nextCursor": null }
  • clientOrderId is the identifier that survives a crash mid-request: you chose it before the request was sent. Predicta's orderId only ever existed in a response you may not have received.
  • It is unique within a user, so it must be sent with externalUserId. Alone it is 400 invalid_request — a question with no single answer, and returning the first match would silently pick one.
  • The lookup is a filter on the list route rather than a second endpoint, so a reconciler uses one call for “this one order” and “everything since 09:00”.
  • While unresolved, the read reports actualDebit of "0.00" with the whole authorization as releaseAmount. That is a description of an unfinished order, not an instruction to release: settlementState is what tells you whether to act.
typescript
import { ReconciliationRequiredError } from '@predicta/sdk';

try {
  const order = await player.submitOrder({ quoteId, clientOrderId: 'ticket-9031' });
  ledger.settle(order.operatorMoney);
} catch (err) {
  if (err instanceof ReconciliationRequiredError) {
    // err.holdAuthorization === true
    const found = await player.findOrderByClientOrderId('ticket-9031');
    if (found?.settlementState === 'pending') return schedulePoll();
  }
  throw err;
}

Reading orders back

GET/api/v1/ordersAPI key
Order history, newest first, keyset-paged. orders:read.
ParameterNotes
externalUserIdYour own id for a player.
clientOrderIdYour own id for one order. Requires externalUserId; resolves to that single order.
statuspending | submitted | partially_filled | filled | cancelled | rejected.
since / untilInclusive ISO-8601 bounds on createdAt.
limit1–200, default 50.
cursorOpaque. Echo nextCursor from the previous page.

Paging is keyset for the same reason as the catalogue: orders arrive while you page, and an offset would let one placed between two requests push an older order across the page boundary where you never see it — a missing trade, reported as a successful page.

GET/api/v1/orders/{id}API key
One order, by Predicta's orderId, with settlementState. orders:read. Add ?fills=true for the individual executions.
  • Every order leaves the API through the same serialiser the POST response uses. A stored POST response and a later GET describe the order identically — the fields most likely to differ between two shapes are exactly the ones that decide money.
  • An order belonging to another partner returns the same 404 unknown_order as one that does not exist. Distinguishing them would make the route an oracle for whether a guessed id is real.

Why fills are nested under an order

There is no top-level fills feed, and that is deliberate. A fill carries no money contract of its own: operatorMoney belongs to the order, computed once over the whole execution. A partner booking per fill double-counts the moment a venue fills one order in two pieces. Fills are a reconciliation detail — fillId, quantity, price, filledAt — and are off by default because the order already carries the average price, the filled quantity and the money.

When an order is refused

StatuserrorWhat happenedWhat to do
404quote_not_foundNo such quote.Quote again.
409quote_expiredThe quote passed expiresAt before this call landed.Re-quote and re-confirm the new price with the player.
409quote_already_usedThat quote has already been spent by an order.Do not retry. If this was a retry, resubmit with the same clientOrderId to get the original order back.
409price_staleThe upstream price is older than the router will trade on.Wait for the next tick and re-quote.
409reconciliation_requiredThe venue did not answer in time.Hold the authorization and poll. Never release, never re-quote.
403quote_not_yoursThe quote belongs to another operator.A quote is an offer to one party. Quote it yourself.
403market_not_offeredThe contract exists; your operator is not offered it.A commercial question, not a code one. Escalate.
422market_unavailableThe contract closed or resolved between quote and submit.Stop offering it.
422no_eligible_venueNo source is configured to execute this contract. A configuration problem.Escalate: this does not resolve itself on the next tick.
422insufficient_positionThe sell is larger than the position held.Re-read the position and size the exit in contracts.
422insufficient_collateralThe execution layer has no collateral for this order.Release the authorization and escalate.
422rejectedThe execution layer refused. detail carries the reason: price_moved when the book passed the accepted price.Release the authorization and re-quote.

price_stale and no_eligible_venue are different problems

One resolves itself on the next tick; the other needs a human. They used to report identically, which sent anybody debugging it to the wrong place.

Price protection

The quoted price is a ceiling. If the book has moved past it by the time the order is submitted, the order is rejected rather than filled: the player agreed to a number, and a fill at a worse one is not the trade they confirmed.

After the order

A filled order opens or extends a position, readable at GET /api/v1/positions and GET /api/v1/positions/{id}. A position carries operatorMoney.costBasis and operatorMoney.realisedPnl, which are facts, not instructions — cash already debited, and P&L already booked. There is no mark-to-market: a number that moves on its own is not something a ledger can reconcile against, and a partner who wants the mark quotes a sell.

Every money fact an order produces also lands on the reconciliation feed — order.state_changed, order.filled, fee.assessed, collateral.moved — totally ordered and resumable. See Reconciliation.