Endpoints
Phantom speaks MCP (Streamable HTTP) — the natural surface for AI agents: your client gets the tool list with schemas, relevance-ranked search envelopes, cross-restaurant dish search, the availability calendar, and server-published flow instructions on initialize.
Getting credentials
Access is by partnership. Email support@mealops.ai to set up a partner account. You receive:- A partner identity (your company) and one or more agent identities (a specific integration or app surface).
- An API key per agent. The raw key is shown exactly once at creation — Phantom stores only a prefix and hash and can never show it again. Keys can be rotated (new raw key, old one stops working) and revoked instantly; every lifecycle action is audit-logged.
- A set of scopes on the key (see below). Ask for the narrowest set you need; scopes can be upgraded later and apply on your next request with no reconnect.
- Use one key per integration surface; do not share a key across systems.
- Rotate keys when integration owners change; revoke immediately on any suspected exposure.
Authentication
Send the key as a bearer token on every request:x-agent-api-key: <key> is also accepted and is treated identically.
Scopes
The MCP tool list is scope-filtered per credential. Tools your key cannot call are not registered at all — a discovery-only key (
restaurants:read + menus:read) sees 7 tools instead of 17. Consequence: calling a tool your key can’t see returns the MCP “tool not found” error, not a scope message. If a tool seems missing from your list, check your key’s scopes first.
Connecting over MCP
Phantom speaks MCP over Streamable HTTP atPOST /mcp. A typical client configuration:
instructions — a short script of the canonical flow (discover → inspect → price → pay → submit → track) so agents sequence tools and pick the right payment branch without trial and error.
The canonical order flow
A complete order is about six calls:- Discover —
search_restaurants(orsearch_menu_itemsfor “who has pad thai near me”). A location is required for restaurant results; a name query without a location returns brand-level summaries so you can ask the user “which area?”. - Inspect —
get_menufor the chosen restaurant. Item search results never carry modifier data; always read the full menu before building an order. - Pick a time —
get_availability_calendarreturns the concrete windows an order may be scheduled inside (hours ∩ lead-time floor ∩ scheduling horizon). Use it instead of guess-and-check validate loops. - Validate —
validate_orderruns the restaurant’s full rule set and returns machine-readable issue codes. - Price —
quote_orderreturns the authoritative total (subtotal, tax, fees, courier fee, tip). - Pay —
start_payment(see payment branches below). Every order is prepaid: payment is charged or authorized before submit, always. - Submit —
submit_ordercreates the order and sends it to the restaurant’s POS (orders auto-approve; there is no manager review step). - Track —
get_order_status/get_order_tracking.
The ordering-time model
Several tools return timing fields. They are components of one formula — do not invent your own:transit is the restaurant’s own deliveryTransitEstimateMinutes (seeded from the POS provider’s average delivery time when available); unknown transit contributes nothing to the floor — show a wider ETA range instead of inventing a number.
The formula sets the earliest schedulable time; the latest is the platform scheduling horizon: orders cannot be scheduled more than 7 days ahead — validation rejects later times with lead_time_too_long, and get_availability_calendar never returns windows beyond it. update_order applies the identical gate to a rescheduled order, so a time you could not have submitted is not reachable by editing afterwards either — and only up until the restaurant accepts, after which the time is fixed entirely (see Visibility and ownership rules).
Timezones — read this before scheduling anything. All times on the wire are ISO-8601 instants. Every tool that takes a time accepts a UTC Z instant or an explicit offset (2026-08-11T12:00:00-07:00) and normalizes it to UTC, so everything you read back is Z. Never send a bare local wall clock with no zone.
A scheduled time is restaurant-local in meaning: opening hours, per-item availability, and get_availability_calendar are all judged in the restaurant’s own IANA timezone (get_restaurant_detail.restaurant.timezone). So when a user says “7pm”, resolve that against the restaurant’s zone, not the end user’s device zone — the two produce different instants, and only one of them is 7pm to the kitchen.
To make that checkable, validate_order and submit_order echo the resolved time back:
restaurantLocal is the wall clock the restaurant will read. Confirm it matches what the user asked for before you submit — an off-by-a-timezone is otherwise invisible until the order lands in the kitchen at the wrong hour. Scheduled fulfillment times are restaurant-local in meaning — use the restaurant’s timezone to render them to users. For self-delivery orders the pickup and delivery times are the same instant; the 15-minute courier collection lead applies only when a real courier collects.
The order intent
validate_order, quote_order, start_payment, and submit_order all take the same { restaurant_id, order } shape:
restaurant_idat the tool level and insideordermust match, or the call throws.order.agent_idis optional — leave it out, as every example here does. Phantom stamps the authenticated agent from your API key; you have no way to know your own Phantom agent id before your first call, and you never need it. Supplying a different one throws.fulfillment_addressis required for delivery. Sendlatitude/longitudewhen your address autocomplete has geometry — courier dispatch needs them.- Per-person naming: an item line with
player_first_namemust havequantity: 1— two named people means two item lines. fulfillment_typeis one ofpickup | delivery | eat_in | curbside.
Validation issue codes
validate_order returns { valid, issues: [{ code, message, field, severity }] }. Codes currently emitted:
ordering_disabled, channel_deactivated, restaurant_closed_at_time, location_paused, lead_time_too_short, lead_time_too_long, 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[]: delivery_unavailable (quote), order_validation_failed (submit / start_payment), payment_required and payment_amount_insufficient (submit, and the latter also on a re-pricing update_order), payment_reference_conflict (start_payment), and order_already_accepted (update_order). The full list is in the ordering reference.
Payments
All orders are prepaid — there is no invoicing or pay-on-delivery path. This is enforced server-side:submit_order rejects with a payment_required error unless the order’s payment (matched by external_order_reference) is already charged or authorized. Phantom is charged-first merchant of record: the eater’s card is charged (or authorized) before submit, and the restaurant is paid out on acceptance. start_payment picks its branch from what you supply:
The payment must cover the order, not merely exist.
submit_order compares the authorized amount to the order total and rejects a short one with payment_amount_insufficient. Since payments are keyed by external_order_reference and most clients keep that reference stable across cart edits, this is the case you will actually hit: 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 over-authorization is accepted and capture takes only the order total.
Supporting tools: void_payment (release a held authorization when a submit fails) and get_payment_status (reconciliation by your external_order_reference). Capture and the restaurant payout run automatically when the restaurant accepts the order — no agent action settles money.
Rate limits
All quotas are per agent on a fixed 60-second window — every credential your agent holds, and both auth headers, share one bucket. Responses carryx-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (epoch ms); a 429 adds retry-after (seconds). Treat 429 as retryable after a short backoff. The MCP endpoint returns rate-limit errors as JSON-RPC errors (error.code: -32000), matching every other error it returns; the REST routes return { "error": "…" }.
MCP (POST /mcp):
The two per-tool caps have independent budgets: spending all 60 dish searches leaves your 60 delivery probes intact, and neither draws down the other. Both are per agent on their own 60-second window, and both reject before doing any work, so a throttled call costs nothing but the error.
The limits above are the ones you’ll encounter in normal integration work. Additional undisclosed abuse-prevention limits also apply platform-wide; a well-behaved agent operating within the published limits will never hit them.
Visibility and ownership rules
- A restaurant is omitted from your results entirely when agent ordering is off, the restaurant is not payout-ready, or its POS channel is deactivated (re-activation restores it automatically).
- Order tools enforce ownership: your agent can only read, track, cancel, or update orders it created. Foreign and missing order ids both return
Order <id> not found.— ids are not enumerable across agents. cancel_orderworks only before the restaurant accepts. It cancels the POS ticket and any courier, and best-effort reverses payment (refund with payout clawback, or void of a held authorization).update_ordercan change fulfillment time, dropoff address, and dropoff notes on a live order. Menu items are not editable post-submit — the restaurant’s POS ticket cannot be amended; cancel and reorder instead. Two boundaries follow from that same fact:- Time changes stop at acceptance (
order_already_accepted). The kitchen’s ticket keeps the original time, so moving it afterwards would only re-point the courier and leave the food waiting. Dropoff address and notes stay editable — the kitchen never sees them. - Delivery edits re-price the order. Changing a delivery order’s dropoff or time re-quotes the courier fee before anything moves; the new total becomes the order total and capture follows it. If it no longer fits inside the payment you already placed, the update is rejected with
payment_amount_insufficientand nothing changes.
- Time changes stop at acceptance (
Best practices
- Prefer server-resolved values over recomputing:
resolvedMinLeadTimeMinutes,earliest_orderable_time, the quote total, andcheck_delivery_availabilityare authoritative. - Use
get_availability_calendarto pick fulfillment times instead of loopingvalidate_order. - Use the
fieldsprojection and pagination (offset,total_matches,next_offset) onsearch_restaurantsfor cheap disambiguation before pulling full entries. - When a search returns empty, read
no_results_reasonand recover (location_required→ ask for a location;all_closed_at_requested_time→ offer a later time) instead of guessing. - Respect
menu_version_stale: re-read the menu and rebuild the order if validation says your menu snapshot is out of date. submit_ordernever bypasses the restaurant’s ordering rules — opening hours, busy-mode pause, lead times, and caps are enforced server-side. Orders auto-approve; track status for POS acceptance.