Direct Link: Order Lifecycle — Fills, Polling, WebSocket, Cancel
This page covers what happens to an order after submit_order returns: how to find out whether it filled, what a partial fill looks like, what cancel_order returns depending on timing, and how the REST and WebSocket views relate. It assumes you've read Step 6 of the Direct Linking guide. Most Direct Link partners only send order_strategy: "fillOrKill", so that's the focus, but the mechanics apply to resting orders too.
1. The submit_order response is pre-match
submit_order response is pre-matchThe order is created and the HTTP response is returned before matching runs. So the order object you get back will show status: inactive, matching_status: unmatched, filled_quantity: 0 essentially every time, regardless of how much liquidity was sitting on the book. That's not a failed fill — it's just early. Matching normally completes well under a second later.
What the response is authoritative for: the order exists, and order_id (a partner_… string) is its identity from here on. Keep it alongside your external_id.
2. Three ways to learn the outcome
| Signal | Latency | Authoritative? | Use it for |
|---|---|---|---|
WebSocket orders event on the user's private channel | Sub-second typical | Driven by change events from the matching system (async, but no replication in the path) | Primary fill signal |
GET /v4/mm/get_order/{id} | Reads a replicated reporting store; usually seconds, can lag under load | Eventually consistent | Backstop / reconciliation |
POST /v4/mm/cancel_order | Synchronous | Yes — a direct write to the matching system | The hard stop before you route the user elsewhere |
Recommended pattern for a fillOrKill order where you'll fall back to another venue if it doesn't fill:
- Submit. Record
order_id+external_id. - Watch the WebSocket for
ordersevents with thatid(pollget_orderas a backstop). - Decide "done" when
open_quantityis0(see §3). - Before routing the user anywhere else, call
cancel_order. Its response is final no matter what the read side is doing (see §4). This is what makes double-booking impossible.
get_order is eventually consistent
get_order is eventually consistentget_order (and get_order_history, get_order_matched_detail, get_trades) read from a reporting database that is replicated from the matching system. Under normal load the lag is small, but it can stretch to tens of seconds during traffic spikes. Two consequences:
- A
404 data_not_foundafter a 200 fromsubmit_ordermeans "not visible yet," not "doesn't exist." Keep polling with backoff; cap the loop at 15–20 seconds rather than 5. - Don't use
get_orderalone for a time-critical fill decision. Use the WebSocket, andcancel_orderas the deterministic stop.
A 404 is never an auth problem (an expired token is a 401). The only other way to get a 404 here is calling with a different user's key than the one that placed the order, since every read is scoped to the caller.
3. Terminal states and what a partial fill looks like
An order is done filling when open_quantity is 0. There are exactly two ways that happens:
| Outcome | status | matching_status | quantity | filled_quantity |
|---|---|---|---|---|
| Filled in full | open | fully_matched | what you sent | = quantity |
| Filled partially, remainder killed/canceled | open | fully_matched | shrunk to the filled amount | = quantity |
| Nothing filled, killed/canceled | canceled | unmatched | — | 0 |
The partial case surprises people: the order is not marked canceled. When the unfilled remainder is removed, the order is rewritten as a smaller, fully matched order. quantity drops to what filled, matching_status becomes fully_matched, and status stays open because it's now a live position until settlement. To detect a partial, compare quantity to original_quantity (both are on get_order), or to what you submitted.
status moves from open to closed / void / manually_settled only at settlement — those aren't fill states.
fillOrKill timing
fillOrKill is "fill what's available at or better than your price, then cancel the rest." It is not all-or-nothing, and there's no all-or-none option today. The remainder is removed by a background sweep roughly 2 seconds after the order's last activity. Because that timer runs from the most recent fill rather than from placement, a remainder that keeps picking up small partial fills can stay open longer than 2 seconds (we've observed ~7s in testing — tracked internally). This is why "sleep 2s then read once" isn't safe: read until open_quantity is 0, or use cancel_order to force the question.
4. cancel_order outcomes by timing
cancel_order outcomes by timingcancel_order checks the live order and returns one of these. All of them are final from the matching system's point of view:
| When you cancel | Order state at that moment | Response | What it means |
|---|---|---|---|
| Before activation (within the first few hundred ms) | inactive | 409 order_is_placing | Retry in a moment |
| While a fill is being settled | — | 409 order_is_matching | Retry shortly |
| Remainder still open (partial or no fill yet) | open, open_quantity > 0 | 200 | Remainder canceled; order ends as in §3 (filled part stands) |
| After full fill, or after a partial's remainder was already killed | matching_status: fully_matched | 400 order_already_filled | Nothing to cancel — it filled (fully or partially) |
| After a zero-fill order was killed | status: canceled | 400 order_already_cancelled | Nothing filled |
| Cancelling someone else's order | — | 400 order_not_belongs_to_user | Wrong key |
Practical reading for a fillOrKill flow: a 200, order_already_filled, or order_already_cancelled all mean "this order can no longer fill." Treat the two 409s as transient and retry.
cancel_order doesn't return the order's quantities — for how much actually filled, read get_order (eventually consistent) or the WebSocket.
5. Blended fills are still one order
If your quantity is larger than what's resting at the best price, the engine walks down the book (best price first, never worse than your limit) and fills at each level. That's still one order with one order_id:
get_order/get_order_historyreturn a single object;priceon it is your limit, not an average fill price.- The per-fill breakdown is
GET /v4/mm/get_order_matched_detail?order_id=…(orget_trades): one row per fill with its ownprice,quantity, andmatched_at, all carrying the sameorder_id. That response also includeslast_synced_at, which tells you how fresh the reporting store is. - On the WebSocket you get one
ordersmessage per fill.filled_quantityon a message is that fill's size;total_filled_quantityis the running total for that order — not for the user's whole position on the strike.
6. REST get_order vs WebSocket orders: same vocabulary, different envelope
get_order vs WebSocket orders: same vocabulary, different envelopeField names and enum values are shared, so most parsing carries over — but you can't deserialize both into one struct blindly.
REST get_order | WebSocket orders event | |
|---|---|---|
| Envelope | { "data": { …order } } | { "info": { …order } } |
| Order id | order_id | id |
filled_quantity | cumulative | this fill only (omitted on non-fill messages) |
| Cumulative filled | filled_quantity | total_filled_quantity |
| Only here | original_quantity, created_at, aggregator_id | update_type (matching = a fill, status = lifecycle change), fill_price, fee, sequence_number, matching_id, aggressive, origin_market_strike |
Identical on both: user_id, market_id, sport_event_id, outcome_id, strike, strike_id, price, quantity, profit, open_quantity, matching_status, winning_status, status, external_id.
Tips: correlate on external_id (you set it; it's on both), order WebSocket messages by sequence_number, and use update_type to tell a fill from a status change (the remainder being killed arrives as update_type: status).
7. Reading a user's order history
For syncing a user's past orders into your app, use the user's own trading token against:
GET /v4/mm/get_order_history— paginated (next_cursor), filterable bystatus,matching_status,event_id,market_id, created/updated date ranges. Each row carriesorder_id,external_id,aggregator_id,strike_id, quantities,fee, and settlement fields.GET /v4/mm/get_order_matched_detail?order_ids=…— per-fill rows for one or more orders.GET /v4/mm/get_trades— the user's fills as a flat list.
Key your records off order_id (stable, unique per order). Don't scrape the ProphetX web app's own history endpoints — they group orders into positions and the grouping id can change between calls.
Updated 6 days ago
