Skip to content

Endpoints

Luis Vervaet edited this page Aug 7, 2026 · 8 revisions

Endpoints

The gateway is self-describing: MCP's tools/list returns every tool it offers, with descriptions and input schemas. At the time of writing that is 62 tools, of which ddREST covers the 38 that make up ordinary browse-cart-order use.

bun run list-tools

That prints what the gateway currently advertises and flags anything ddREST calls that it does not — the check to run when adding a route or after DoorDash ships a change.

Method Path Tool
GET /v1/restaurants doordash_find_restaurants
GET /v1/nearby-stores internal_find_nearby_stores
GET /v1/offers internal_get_nearby_offers
GET /v1/stores/{store_id} internal_get_store_info
GET /v1/stores/{store_id}/menu doordash_get_restaurant_menu
GET /v1/stores/{store_id}/items internal_find_items_in_store
GET /v1/stores/{store_id}/items/{item_id} internal_get_item_details
GET /v1/stores/{store_id}/menus/{menu_id}/items/{item_id} doordash_get_food_item
GET /v1/stores/{store_id}/deals internal_get_store_deals
GET /v1/stores/{store_id}/promotions internal_list_eligible_cart_promotions
GET /v1/stores/{store_id}/promotions/{campaign_id}/items internal_get_promo_eligible_items
POST /v1/product-lists doordash_create_product_list
GET /v1/carts doordash_list_active_carts
POST /v1/carts/items doordash_add_to_cart
POST /v1/carts/{cart_uuid}/items doordash_add_to_cart
GET /v1/carts/{cart_uuid} doordash_get_cart
DELETE /v1/carts/{cart_uuid} doordash_clear_cart
PATCH /v1/carts/{cart_uuid}/items/{cart_item_id} internal_update_cart_item
DELETE /v1/carts/{cart_uuid}/items/{cart_item_id} doordash_remove_cart_item
PUT /v1/carts/{cart_uuid}/fulfillment doordash_update_delivery_option
GET /v1/carts/{cart_uuid}/promotions internal_get_applied_cart_promotions
POST /v1/carts/{cart_uuid}/promotions internal_apply_cart_promotion
DELETE /v1/carts/{cart_uuid}/promotions/{promo_code} internal_remove_cart_promotion
POST /v1/carts/{cart_uuid}/preview internal_preview_order
POST /v1/carts/{cart_uuid}/order internal_submit_order
GET /v1/carts/{cart_uuid}/checkout-url doordash_get_checkout_url
GET /v1/carts/{cart_uuid}/suggestions internal_get_mic_carousel
GET /v1/orders internal_get_order_history
GET /v1/orders/{order_uuid}/receipt internal_get_order_receipt
GET /v1/orders/{order_uuid}/status internal_get_order_status
GET /v1/orders/{order_uuid}/status/stream internal_get_order_status (polled)
POST /v1/orders/{order_uuid}/reorder internal_reorder
GET /v1/me doordash_get_user_info
GET /v1/addresses doordash_list_delivery_addresses
GET /v1/addresses/search doordash_address_autocomplete
POST /v1/addresses doordash_select_address
PUT /v1/addresses/current doordash_set_delivery_address
PUT /v1/addresses/{address_link_id}/instructions internal_set_delivery_instructions
PUT /v1/addresses/{address_link_id}/label internal_set_address_label
GET /v1/payment-methods doordash_get_payment_info

Two ids on a cart item are easy to confuse. The cart-line id identifies this line of this cart and is what PATCH and DELETE take; the menu item id identifies the product. Saved addresses have the same split: address_id is the place, address_link_id is this account's link to it and is what carries the label and the Dasher instructions.

PATCH .../items/{cart_item_id} with quantity: 0 and DELETE on the same path do the same thing.

POST /v1/carts/{cart_uuid}/order places a real order and charges the account. tip_amount_cents is required rather than defaulted, so a tip is always deliberate. See Ordering safely.

Shorthands

Ids are opaque and you rarely have the one you need to hand, so every id in a path also accepts a shorthand that says what you meant. Each one costs one extra upstream lookup; passing the real id costs nothing. Whatever a shorthand resolved to comes back in an X-Resolved-* response header, since the body does not reliably say.

Where Accepts Resolves to
{cart_uuid} latest The most recently updated active cart
{cart_uuid} store:<store_id> The active cart at that store
{order_uuid} latest Your most recent order in the last 90 days
{store_id} name:<store name> A store of that name near your default address
address_id default Whichever address DoorDash marks as the account default
address_id a label, e.g. home The saved address with that label, matched case-insensitively
menu_id on add-to-cart (omit it) The store's menu id, read off the store
curl -s "http://localhost:8787/v1/orders/latest/status" -H "authorization: Bearer dds2.…"
curl -s "http://localhost:8787/v1/carts/latest" -H "authorization: Bearer dds2.…"
curl -s "http://localhost:8787/v1/stores/name:Chipotle/menu" -H "authorization: Bearer dds2.…"

A literal id is always tried first, so a real cart whose uuid happened to be latest still wins over the keyword. Ambiguity is refused rather than resolved arbitrarily: name: matching two nearby branches, or a label on two addresses, is a 400 listing the candidates — picking one silently is how you order from the wrong side of town.

Ordering safely

POST /v1/carts/{cart_uuid}/order supports two things worth using every time.

Idempotency-Key (header). A lost response otherwise leaves you unable to tell whether you were charged, and both options are bad. Send a unique value and a repeat of the same request returns the first response — marked Idempotency-Replayed: true — instead of placing a second order. Keys are scoped to your session and honoured for 24 hours. Reusing one with a different body is a 409 idempotency_conflict rather than a silent replay.

confirm_total_cents (body). State the pre-tip total you expect, from quote.net_total_before_tip.unit_amount on the preview response. The cart is re-priced first and the order refused with 412 total_mismatch if it has moved past confirm_total_tolerance_cents (default 0). Nothing is ordered on a mismatch.

curl -sX POST "http://localhost:8787/v1/carts/latest/order" \
  -H "authorization: Bearer dds2.…" \
  -H "content-type: application/json" \
  -H "idempotency-key: $(uuidgen)" \
  -d '{"tip_amount_cents": 500, "confirm_total_cents": 3410}'

Setting READ_ONLY=true refuses every state-changing request outright, for an instance handed to an agent or exposed beyond the account holder. Browsing keeps working; logging in and pairing stay allowed, since blocking those would not be safer, only useless.

Watching an order

GET /v1/orders/{order_uuid}/status/stream is the status endpoint as Server-Sent Events, so a client waits instead of polling.

curl -N "http://localhost:8787/v1/orders/latest/status/stream" -H "authorization: Bearer dds2.…"

Events are status (the full payload, on the first poll and on every change afterwards), error (one poll failed; the stream continues) and end (why it stopped: terminal, timeout or client). It is deliberately quiet — a stream open across a 40-minute delivery emits a handful of events, not one every tick. It closes itself once the order reaches a terminal status.

ORDER_STREAM_INTERVAL_SECONDS (default 15, floor 5) and ORDER_STREAM_MAX_SECONDS (default 1800) tune it. Reconnect if you still care after a stream ends on timeout.

Locations

Anywhere coordinates are accepted — GET /v1/restaurants, GET /v1/nearby-stores, GET /v1/offers and GET /v1/stores/{id}/deals — you can pass an address_id from GET /v1/addresses instead, and ddREST reads the coordinates off the saved address for you:

curl -s "http://localhost:8787/v1/restaurants?query=pizza&address_id=addr-home" \
  -H "authorization: Bearer dds2.…"

That costs one extra upstream lookup, so passing latitude/longitude directly stays the cheaper path. Nothing is cached — an address you just added would otherwise be invisible until a TTL elapsed.

Pass address_id=default for whichever address DoorDash marks as the account default, so the common case needs no lookup at all:

curl -s "http://localhost:8787/v1/restaurants?query=pizza&address_id=default" \
  -H "authorization: Bearer dds2.…"

The id is the address_id field, not address_link_id.

Sending address_id and coordinates is refused rather than picking a winner, since the two can disagree and you would have no way to tell which was used. An unknown id returns address_not_found listing the addresses that do exist — ids are opaque numbers, so each comes with its printable address:

{
  "error": "address_not_found",
  "message": "No saved address has the id \"9999\".",
  "addresses": "/v1/addresses",
  "known_addresses": [
    { "id": "1611178960", "address": "Thompson Hotels, 21 E Bellevue Pl, Chicago, IL 60611, USA" }
  ]
}

A saved address carrying no coordinates returns address_missing_coordinates rather than quietly falling back to the configured default.

Optional parameters

Every optional argument the gateway advertises is exposed, except two groups that do nothing useful here:

  • Guest-session arguments (session_id, session_nonce, is_guest) — these switch identity away from your authenticated account, or ask for an AES-encrypted response ddREST has no key for.
  • Widget display arguments (delivery_address_label, store_name) — these only change labels inside DoorDash's own interactive widget. They have no effect on what the API returns, so exposing them would just be noise.

/docs has the full list; the ones worth knowing about:

Route Parameter Why it matters
/v1/restaurants radius Miles. The schema documents a default of 3, but a live search with it unset came back reporting search_radius: 8 — so the default is not fixed and may vary by location. Set it explicitly when the reach matters.
/v1/restaurants desired_restaurant_name, item_name Narrow to a named restaurant or a specific dish.
/v1/stores/{id}/menu include_extras Every item's options inline. Much larger response.
/v1/stores/{id}/items disable_ads Suppress sponsored placements. Worth setting if you take only the first result, since a sponsored placement is not ranked.
/v1/stores/{id}/items limit, snap_eligible_only Results per item; SNAP/EBT filter.
/v1/carts/{uuid}, add-to-cart include_pricing Subtotal, taxes and fees inline. An estimate — the preview endpoint is what an order is priced against.
/v1/carts/{uuid}/preview and /order address_id Deliver this order to a saved address without changing the account default. Sent upstream as delivery_address_id.
/v1/nearby-stores use_store_ranker Experimental upstream ranking instead of soonest-ETA.

Boolean query parameters accept true/false, 1/0, yes/no, and reject anything else rather than guessing — ?disable_ads=maybe is a 400, not a silent true.

Every result-count parameter is spelled limit, whatever the upstream tool calls it (max_stores, max_carts, max_results, max_orders). The translation happens server-side.

The auth and pairing routes are not tool-backed:

Method Path Purpose
POST /v1/auth/login/start Begin the paste-back login
POST /v1/auth/login/complete Finish it and get a session
GET /v1/auth/session Inspect the current session
POST /v1/auth/logout Revoke it
POST /v1/auth/pair/request Device: ask for a pairing code
POST /v1/auth/pair/token Device: poll for the session
GET/POST /v1/auth/pair Human: the approval pages (HTML)
POST /v1/auth/pair/verify Human: look up a code (JSON)
POST /v1/auth/pair/complete Human: approve it (JSON)
POST /v1/auth/pair/deny Human: refuse it (JSON)

About intent

Most MCP tools accept an intent string, and per dd-cli's own help text DoorDash "may review this data for research and product-improvement purposes". This API generates it server-side, per operation, and forwards no end-user text. Callers cannot set or influence it. Every string sent is in one auditable place: src/mcp/tools.ts.

Where a tool does accept it, it is optional, which the gateway's own schemas confirm — they describe it as "logged for product analytics and monitoring only — has no effect on the results returned". ddREST sends it anyway, because a generated one-line description of the operation is a smaller disclosure than an empty field is a benefit, and it keeps behaviour close to dd-cli's. Nothing would break if it were dropped.

Twelve of the 38 declare no intent at all, and ddREST sends none for them rather than an argument the gateway never asked for:

doordash_update_delivery_option        internal_get_nearby_offers
internal_update_cart_item              internal_get_store_deals
internal_get_applied_cart_promotions   internal_set_delivery_instructions
internal_get_promo_eligible_items      internal_set_address_label
internal_get_mic_carousel              doordash_select_address
doordash_address_autocomplete          doordash_get_user_info

Errors

{ "error": "session_expired", "message": "", "login_start": "/v1/auth/login/start" }

error is a stable machine-readable code. Notable ones: session_missing, session_invalid, session_expired, csrf_origin_rejected, login_ticket_expired, state_mismatch, token_exchange_failed, doordash_unauthorized, doordash_forbidden, upstream_error, cart_not_found, order_not_found, store_not_found, menu_not_found, read_only, total_mismatch, idempotency_conflict.

A 403 doordash_forbidden with private_beta_gating: true means the account authenticated fine but is not an approved consumer-MCP tester.

Errors say what would work. A wrong or unresolvable id comes back with the things that do exist, so it can be corrected without a second call or a trip to these docs — known_addresses on an address, known_carts on a cart, matches on an ambiguous store name, and a pointer at the endpoint the id comes from.

success: false is an error, not a 200. DoorDash reports a semantic failure — cart gone, store closed, item unavailable — inside the body of an otherwise successful response. ddREST turns that into a 502 doordash_tool_error carrying the upstream message, with the whole original payload under upstream_result so nothing is lost. Set STRICT_TOOL_ERRORS=false to go back to passing it through, if DoorDash ever starts reporting success: false in a benign case.

429 responses carry Retry-After. So does the RFC 8628 slow_down during device pairing, where the header matches the new interval in the body.

Response bodies

Tool responses are passed through unvalidated, but they are documented. The gateway advertises an outputSchema per tool alongside its inputs, so /docs shows the real response shape for most tools. The other six declare a bare {additionalProperties: true} and fall back to the generic pass-through result:

doordash_get_restaurant_menu     internal_get_item_details
doordash_list_active_carts       doordash_get_checkout_url
doordash_list_delivery_addresses doordash_get_payment_info

Documented is not enforced. Nothing validates a response against its schema — DoorDash can add a field at any time and it must still pass through untouched.

The schemas are committed rather than fetched at boot, so the API document is identical for everyone, needs no account, and renders /docs without a network call. Refresh them after a DoorDash change:

bun run list-tools --dump && bun run gen-schemas

That rewrites src/schemas/results.generated.ts, hoisting each tool's $defs into shared components so their $refs resolve inside the OpenAPI document.

Clone this wiki locally