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
- 1Quote
- 2Confirm
- 3Order
- 4Route
- 5Fill
- 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
accepted → submitted → filled | partially_filled | rejected
└→ unknown after timeout → 409 reconciliation_required| status | settlementState | What your ledger does |
|---|---|---|
filled | settled | Debit actualDebit, release releaseAmount (zero on a full fill). Done. |
partially_filled | settled | Debit actualDebit and release the non-zero releaseAmount. Debiting the full stake keeps money that is not ours to keep. |
rejected | settled | actualDebit is 0.00 and the whole authorization comes back. rejectReason says why. |
pending | pending | Not resolved. Hold the authorization and poll. |
submitted | pending | Not resolved. Hold the authorization and poll. This is the reconciliation case. |
cancelled | settled | Terminal, 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
/api/v1/ordersAPI keyorders:write, plus X-Predicta-User.{
"quoteId": "fc3be731-98dc-4c03-80ac-23576a708d9c",
"clientOrderId": "ticket-9031" // YOUR id. The idempotency key.
}{
"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 + releaseAmountactualDebit = 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.
| Field | Meaning |
|---|---|
status | The state machine above. A partial fill is a real state, not an error. |
filledQuantity | Contracts held. This, not the quote’s contracts, is what the player owns. |
averagePrice | Volume-weighted fill price, 0–1. Display only. |
predictaFeeBps | The 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. |
routing | reason and consideredVenues: a count and a cause, useful when debugging why an order went where it did. The venue itself is never named. |
idempotentReplay | true when this response replayed an order that already existed. |
rejectReason | Populated when status is rejected. Null otherwise. |
201, or a 200 replay
| Status code | When |
|---|---|
201 | The order was created by this call. |
200 | This 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.
# 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 }clientOrderIdis the identifier that survives a crash mid-request: you chose it before the request was sent. Predicta'sorderIdonly 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 is400 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
actualDebitof"0.00"with the whole authorization asreleaseAmount. That is a description of an unfinished order, not an instruction to release:settlementStateis what tells you whether to act.
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
/api/v1/ordersAPI keyorders:read.| Parameter | Notes |
|---|---|
externalUserId | Your own id for a player. |
clientOrderId | Your own id for one order. Requires externalUserId; resolves to that single order. |
status | pending | submitted | partially_filled | filled | cancelled | rejected. |
since / until | Inclusive ISO-8601 bounds on createdAt. |
limit | 1–200, default 50. |
cursor | Opaque. 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.
/api/v1/orders/{id}API keyorderId, 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_orderas 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
| Status | error | What happened | What to do |
|---|---|---|---|
404 | quote_not_found | No such quote. | Quote again. |
409 | quote_expired | The quote passed expiresAt before this call landed. | Re-quote and re-confirm the new price with the player. |
409 | quote_already_used | That 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. |
409 | price_stale | The upstream price is older than the router will trade on. | Wait for the next tick and re-quote. |
409 | reconciliation_required | The venue did not answer in time. | Hold the authorization and poll. Never release, never re-quote. |
403 | quote_not_yours | The quote belongs to another operator. | A quote is an offer to one party. Quote it yourself. |
403 | market_not_offered | The contract exists; your operator is not offered it. | A commercial question, not a code one. Escalate. |
422 | market_unavailable | The contract closed or resolved between quote and submit. | Stop offering it. |
422 | no_eligible_venue | No source is configured to execute this contract. A configuration problem. | Escalate: this does not resolve itself on the next tick. |
422 | insufficient_position | The sell is larger than the position held. | Re-read the position and size the exit in contracts. |
422 | insufficient_collateral | The execution layer has no collateral for this order. | Release the authorization and escalate. |
422 | rejected | The 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.

