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

The 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

SignalLatencyAuthoritative?Use it for
WebSocket orders event on the user's private channelSub-second typicalDriven 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 loadEventually consistentBackstop / reconciliation
POST /v4/mm/cancel_orderSynchronousYes — a direct write to the matching systemThe 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:

  1. Submit. Record order_id + external_id.
  2. Watch the WebSocket for orders events with that id (poll get_order as a backstop).
  3. Decide "done" when open_quantity is 0 (see §3).
  4. 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 (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_found after a 200 from submit_order means "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_order alone for a time-critical fill decision. Use the WebSocket, and cancel_order as 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:

Outcomestatusmatching_statusquantityfilled_quantity
Filled in fullopenfully_matchedwhat you sent= quantity
Filled partially, remainder killed/canceledopenfully_matchedshrunk to the filled amount= quantity
Nothing filled, killed/canceledcanceledunmatched—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 checks the live order and returns one of these. All of them are final from the matching system's point of view:

When you cancelOrder state at that momentResponseWhat it means
Before activation (within the first few hundred ms)inactive409 order_is_placingRetry in a moment
While a fill is being settled—409 order_is_matchingRetry shortly
Remainder still open (partial or no fill yet)open, open_quantity > 0200Remainder canceled; order ends as in §3 (filled part stands)
After full fill, or after a partial's remainder was already killedmatching_status: fully_matched400 order_already_filledNothing to cancel — it filled (fully or partially)
After a zero-fill order was killedstatus: canceled400 order_already_cancelledNothing filled
Cancelling someone else's order—400 order_not_belongs_to_userWrong 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_history return a single object; price on it is your limit, not an average fill price.
  • The per-fill breakdown is GET /v4/mm/get_order_matched_detail?order_id=… (or get_trades): one row per fill with its own price, quantity, and matched_at, all carrying the same order_id. That response also includes last_synced_at, which tells you how fresh the reporting store is.
  • On the WebSocket you get one orders message per fill. filled_quantity on a message is that fill's size; total_filled_quantity is 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

Field names and enum values are shared, so most parsing carries over — but you can't deserialize both into one struct blindly.

REST get_orderWebSocket orders event
Envelope{ "data": { …order } }{ "info": { …order } }
Order idorder_idid
filled_quantitycumulativethis fill only (omitted on non-fill messages)
Cumulative filledfilled_quantitytotal_filled_quantity
Only hereoriginal_quantity, created_at, aggregator_idupdate_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 by status, matching_status, event_id, market_id, created/updated date ranges. Each row carries order_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.


Did this page help you?