validate_order, quote_order, and submit_order share one canonical order-intent shape; the by-id tools enforce ownership (an agent only touches orders it created).
validate_order
Run Phantom rule validation before quote or submit. Input —{ "restaurant_id": "…", "order": <canonical order intent> }:
restaurant_idat the tool level and insideordermust match, or the call throws.order.agent_idis optional — omit it. Phantom stamps the authenticated agent from your API key, which is why no example here sends one (you have no way to know your own Phantom agent id before your first call). Supplying a different one throws.requested_fulfillment_timeis ISO-8601 and accepts a UTCZinstant or an explicit offset (2026-08-11T12:00:00-07:00); it is normalized to UTC on the way in, so what you read back is alwaysZ. Every tool taking a time uses this same contract.fulfillment_addressis required for delivery; sendlatitude/longitudewhen the client’s address autocomplete has geometry (courier dispatch needs them).address2(unit/apartment/suite) is optional and part of the address — it reaches both the courier and the restaurant. Dropoff instructions belong innotes, which only the courier sees when one is engaged.- Per-player naming: a line with
player_first_namemust carryquantity: 1— two players means two item lines.
fulfillmentTime echoes the requested instant as a wall clock in the restaurant’s timezone. Check restaurantLocal against what the user actually asked for: a time resolved against the end user’s zone instead of the restaurant’s is a perfectly valid instant for the wrong hour, and this is where you catch it.
Issue codes currently emitted:
ordering_disabled, channel_deactivated, restaurant_closed_at_time, location_paused, lead_time_too_short, lead_time_too_long (scheduled more than 7 days out — the platform scheduling horizon), headcount_too_large, fulfillment_type_not_allowed, self_delivery_not_enabled, menu_version_stale, player_name_incomplete, player_name_requires_separate_item_line, item_not_found, item_unavailable, item_unavailable_at_time, mapping_needs_review, modifier_group_invalid, modifier_invalid, modifier_unavailable, nested_modifier_quantity_invalid, modifier_not_nested, required_modifier_missing, too_many_modifiers, modifier_quantity_exceeded, too_many_items, order_value_too_large.
Some codes are error codes, not issue codes: they arrive as a thrown tool error, never as an entry in issues[].
quote_order
Return pricing for a valid order. Input: same{ restaurant_id, order } shape as validate_order.
Output:
deliveryFeeCents is the courier fee, already folded into totalCents; it is 0/absent on pickup or when no dispatch quote was available. serviceFeeCents is the agent’s consumer fee (a configured share of subtotal).
A clean quote does not guarantee a submit. Three issue codes are deliberately non-blocking at quote time — order_value_too_large, too_many_items, and headcount_too_large — so a customer still sees priced totals for an over-cap cart. start_payment and submit_order both reject them. Read validate_order’s issues[] rather than treating a successful quote as a green light.
Idempotency replays are re-shaped to this current schema before being returned.
Scheduled times on marketplace supply
Marketplace providers publish no store hours. What they do publish, with every quote, is the list of slots they will actually schedule for the cart’s fulfillment type — with gaps (a store open all day may take scheduled delivery only in the evening) and only a few days ahead.quote_order checks the requested time against that list and refuses with the error scheduled_time_unavailable when:
- the time falls outside every slot that day (before opening, after closing, or in a gap),
- the day is past how far ahead the provider schedules,
- the provider takes no scheduled orders from the store at all, or
- the order is ASAP and the provider says ASAP is not possible (a closed store that takes orders for later).
[scheduled_time_unavailable] so a client can recognize it. Relay the sentence (without the tag), ask for a time inside the named hours, and never retry the same time or guess hours. submit_order repeats the check at the last moment, so a quote that went stale is refused the same way before anything is placed.
submit_order
Create the order in Phantom and enter the provider-submission lifecycle. Orders auto-approve — there is no manager review step. Input: same{ restaurant_id, order } shape as validate_order.
Output:
submit_order never bypasses the restaurant’s ordering rules — opening hours, busy-mode pause, lead times, and caps are all enforced server-side, on exactly the same code path validate_order runs.
The payment must cover the order. The prepaid gate checks the amount, not just that a payment exists: an authorization smaller than the order total is rejected with payment_amount_insufficient, naming both figures. This matters because the payment is matched by external_order_reference, and most clients keep that reference stable across cart edits — so a hold placed on an earlier, smaller cart would otherwise carry a larger order. If the cart changed after you started payment, call start_payment again with the current order: it releases the previous hold and authorizes the new total under the same reference. An authorization larger than the order is accepted, and capture takes only the order total.
get_order_status
Phantom’s last known order state, latest provider order identifier, and the payment snapshot. Input:{ "order_id": "order_123" }
Output:
payment is null when no payment record exists. get_order_status exposes Phantom’s order lifecycle, not raw provider-only state.
Marketplace (DoorDash-supplied) orders additionally return a provider block with the raw provider stage and delivery facts — providerStatus, statusMessage, etaTrend, lateReason, cancellationReason, quoted/actual delivery and pickup times. For these orders totalEstimateCents is null: the provider’s status feed carries no amounts and Phantom holds no order record for marketplace orders.
totalEstimateCents is Phantom’s own quote total for the order — the same figure the payment gate and the capture use — never a total supplied by the caller.
Once a marketplace order reaches completed, both get_order_status and get_order_tracking also return a receipt: the provider’s finalized money with the MealOps service fee layered on. serviceFeeCents and totalCents are the customer-facing figures; the provider-only parts are broken out. receipt is null until the provider has a finalized receipt, and absent on platform orders.
otherFees carries provider charges that are neither delivery, service nor tax — regulatory response fees, bag fees, small-order fees — labeled exactly as the provider labels them. They are never folded into serviceFeeCents. The same otherFeesCents / otherFees fields appear on quote_order results for marketplace quotes.
get_order_tracking
Full tracking snapshot: kitchen status + timestamped status-event timeline, and for delivery orders the courier leg. Input:{ "order_id": "order_123" }
Output:
delivery: null.
Marketplace (DoorDash-supplied) orders return a snapshot built from the provider’s live lifecycle feed: statusEvents carries only the current stage (no history exists provider-side), driver is always null, and the delivery block adds etaTrend, lateReason, and actualDropoffTime. Pickup marketplace orders get a pickup block with estimated/actual pickup times, and a top-level cancellationReason is set when the provider cancelled the order.
cancel_order
Cancel a pending order the calling agent placed. Only possible before the restaurant accepts it. Cancels the POS ticket and any courier, and best-effort reverses the payment (refunds captured charges with payout clawback, voids held authorizations). Input:{ "order_id": "order_123", "reason": "Customer changed plans" } (reason optional, ≤200 chars)
Output: { "ok": true, "status": "cancelled", "message": "…", "payment": "refunded" }
There is deliberately no restaurant-access check here: an agent must be able to cancel its own pending order even if the restaurant has since disabled ordering or its channel was deactivated.
update_order
Update a live order’s fulfillment details and re-point any courier (partial PATCH). Input:status echoes the order’s current lifecycle status, not an update result.
A new requested_fulfillment_time clears the same time gate submit_order enforces — opening hours, paused kitchen, lead-time floor, scheduling horizon — and is rejected with the usual restaurant_closed_at_time / location_paused / lead_time_too_short / lead_time_too_long codes otherwise. The check runs before the courier is contacted, so a rejected update moves nothing.
The fulfillment time is only editable until the restaurant accepts. Once the order reaches accepted (or preparing / ready), a time change is rejected with order_already_accepted. Deliverect has no order-update API, so the kitchen’s ticket keeps the original time no matter what — changing it here would only re-point the courier, and the food would come up at the original hour and sit. Cancel and reorder if the time really has to move. The dropoff address and notes stay editable after acceptance: those are courier-only details the kitchen never sees.
A delivery edit re-prices the order. The courier fee depends on where and when it delivers, and it is part of the total the eater paid — so changing a delivery order’s dropoff address or time re-quotes it before contacting the courier:
- The new quote becomes the order’s total, and capture follows it. A cheaper move is simply captured cheaper.
- If the new total exceeds the payment already held on the order’s
external_order_reference, the update is rejected withpayment_amount_insufficientand nothing moves — not the address, not the courier. Cancel and reorder at the new price. - Re-quoting also re-checks courier coverage, so a dropoff the courier will not serve fails here with
delivery_unavailablerather than at dispatch.