Pay with UPI end-to-end
The shared UPI Payment stage - from picker to paid order to tracking - across Food, Instamart, and Dineout.
In-chat UPI payment. This recipe picks up once the cart (or paid booking) is ready and walks the shared Payment stage: get_payment_options → place-order → check_payment_status → confirm_order. The three shared tools (get_payment_options, check_payment_status, confirm_order) — plus each server's own place-order tool — work across all three servers. COD is the universal fallback — when UPI isn't available for the cart, the picker offers Cash automatically.
The flow
cart ready (get_food_cart / get_cart / create_cart)
│
▼
get_payment_options ─────► renders the payment picker (UPI apps + scan-QR, or Cash)
│
▼
place_food_order / checkout / book_table (paymentMethod:"UPI" + intentApp OR generateUPIQR)
│ → order created in PENDING_PAYMENT (returns paasId + orderId)
▼
check_payment_status ◄── the widget auto-polls; auto-finalizes on any terminal outcome
│
▼
confirm_order ─────────► PENDING_PAYMENT → PLACED (or failed)
│
▼
track_food_order / track_order / get_booking_status
Step 1 - Fetch payment options
const opts = await client.callTool({
name: "get_payment_options",
// Food only: pass the same addressId you used for get_food_cart (it's echoed to the picker)
arguments: { addressId: home.id },
});
One call returns both device surfaces merged into a picker widget:
data.platforms.mobile.methods[]- UPI apps (intent)data.platforms.desktop.methods[]- scan-QRdata.cod- Cash, when available
The widget auto-detects the device and promotes the matching surface - you don't ask the user what device they're on. If UPI isn't available for the cart, platforms is omitted and only Cash is offered via data.allMethods. See get_payment_options.
Step 2 - Place the order with the picked method
The user taps a method in the picker; pass their choice into the place-order tool byte-for-byte:
// Mobile UPI app:
const order = await client.callTool({
name: "place_food_order",
arguments: {
addressId: home.id,
paymentMethod: "UPI",
intentApp: picked.id, // the method id from get_payment_options, echoed exactly
},
});
// Desktop scan-QR: paymentMethod:"UPI" + generateUPIQR:true (no intentApp)
This creates the order in PENDING_PAYMENT and returns paasId + orderId. Echo them into check_payment_status. The confirm_order args differ per server (see the contract below): Food uses orderId + addressId + lat + lng (not paasId); Instamart / Dineout use orderId + paasId.
Step 3 - Wait for payment (don't babysit the poll)
check_payment_status is a long-poll (the server holds ~19s). The confirmation widget polls it automatically and auto-finalizes on any terminal outcome, so on a widget host you do nothing here. Only if you drive your own loop (no widget), cap it and read data.status:
// self-hosted clients only - the widget does this for you.
// Food: ALSO echo addressId + lat + lng, else the server can't reconcile the order and it stays pending.
const deadline = Date.now() + order.data.maxTimeToPollForInMs; // honor the window place-order gave you
let s;
do {
s = (await client.callTool({
name: "check_payment_status",
arguments: { paasId: order.data.paasId, orderId: order.data.orderId, addressId: home.id, lat: home.lat, lng: home.lng },
})).data;
if (s.terminal) break;
await sleep(order.data.pollingIntervalInMs); // gentle — it's a ~19s long-poll
} while (Date.now() < deadline); // capped — never tight-loop
Branch on data.status:
success/paid→ placed (usually already confirmed for you;data.confirmed)failed→ don't confirm; re-showget_payment_optionsfor a fresh attemptcancelled→ order was cancelled (refund if already paid); tell the user, don't retrycart_changed→ cart price/stock changed, so the order was NOT placed (not a payment failure); ask the user to review the cart, then place againrefund-initiated→ a refund is already underway; tell the user, don't retrypending→ keep waiting, capped - never tight-loop (it stresses the payment cache)
See check_payment_status.
Step 4 - Finalize
On the widget path this is automatic. If you hit your own cap while still pending, call confirm_order once to finalize (the backend marks it failed if payment is still non-terminal; a late success reconciles server-side):
await client.callTool({
name: "confirm_order",
// Food: orderId + addressId + lat + lng (no paasId). IM/Dineout: orderId + paasId.
arguments: { orderId: order.data.orderId, addressId: home.id, lat: home.lat, lng: home.lng },
});
Step 5 - Track
Same as the COD journeys: track_food_order / track_order / get_booking_status.
Non-UI (headless) clients
Everything above assumes a widget host — the picker and confirmation widgets render the QR (or launch the UPI app), auto-poll check_payment_status, and auto-confirm. Headless clients — terminal MCP clients, server-to-server agents, anything that can't render a widget — run the same four tools. They just present options as text, share a payment link, and drive the poll loop themselves.
Both payment methods are fully supported without any UI:
| Method | How it works headless |
|---|---|
| Cash (COD) | No payment leg at all — place with paymentMethod:"Cash" and you're done. The truly zero-UI path. |
| UPI (app intent and scan-QR) | The place-order response returns a ready-to-use bridgeUrl — an opaque HTTPS payment link — in data.bridgeUrl (present for both the app-intent and scan-QR choices, which resolve to the same link). Read it from data.bridgeUrl and share it with the user. Opening it renders a scan-or-tap payment page — a scannable QR plus an "Open in your UPI app" button — so the user scans it on desktop, or taps to open their UPI app on mobile; you don't need to render your own QR. The app-intent flow also relays the link in the response message; the scan-QR flow does not — so always read data.bridgeUrl rather than parsing the message. |
The headless flow
get_payment_options ──► read data.allMethods (flat text list) ──► present as text; user picks
│
▼
place-order (paymentMethod:"UPI" + intentApp | generateUPIQR OR paymentMethod:"Cash")
│ UPI → response carries: bridgeUrl, paasId, orderId, isQrFlow,
│ pollingIntervalInMs, maxTimeToPollForInMs
▼
share bridgeUrl with the user (opens a scan-or-tap QR page: scan on desktop, tap on mobile)
│
▼
check_payment_status ──► YOUR capped loop (honor maxTimeToPollForInMs; re-poll gently on pending)
│
▼
confirm_order ──► call it yourself on SUCCESS, or once at your cap if still pending
│
▼
track-order
Three behavioural differences from the widget path: (1) you present data.allMethods as text instead of a rendered picker, (2) you hand the user data.bridgeUrl (opening it shows a scan-or-tap QR page) instead of an in-widget QR / tap-button, and (3) nothing auto-polls or auto-confirms — you own the check_payment_status cadence and the final confirm_order.
// 1. Options as text (no picker widget)
const opts = (await client.callTool({
name: "get_payment_options",
arguments: { addressId: home.id }, // Food only
})).data;
// opts.allMethods → flat list you can print; opts.cod → Cash fallback
// 2. Place the order with the user's pick (UPI shown; for Cash use paymentMethod:"Cash")
const order = (await client.callTool({
name: "place_food_order",
arguments: { addressId: home.id, paymentMethod: "UPI", intentApp: picked.id },
})).data;
// Cash has no payment leg — the order is already placed. Skip straight to tracking.
if (picked.paymentMethod === "Cash") {
// → track-order; nothing more to do (no bridgeUrl, no polling).
} else {
// 3. UPI → share the payment link (opens a scan-or-tap QR page).
console.log(`Pay here — scan on desktop, tap on mobile: ${order.bridgeUrl}`);
// 4. Drive your OWN capped poll — the widget isn't here to do it.
// Food: ALSO echo addressId + lat + lng, or the server can't reconcile the order → stuck pending.
const deadline = Date.now() + order.maxTimeToPollForInMs;
let s;
do {
s = (await client.callTool({
name: "check_payment_status",
arguments: { paasId: order.paasId, orderId: order.orderId, addressId: home.id, lat: home.lat, lng: home.lng },
})).data;
if (s.terminal) break;
await sleep(order.pollingIntervalInMs); // gentle — it's a ~19s long-poll
} while (Date.now() < deadline);
// 5. Finalize yourself
if (s.status === "success" || s.status === "paid") {
if (!s.confirmed) { // usually already confirmed server-side; confirm if not
await client.callTool({
name: "confirm_order",
arguments: { orderId: order.orderId, addressId: home.id, lat: home.lat, lng: home.lng },
});
}
} else if (!s.terminal) {
// hit the cap still pending → confirm once to finalize (a late success reconciles server-side)
await client.callTool({
name: "confirm_order",
arguments: { orderId: order.orderId, addressId: home.id, lat: home.lat, lng: home.lng },
});
}
}
Per-server contract
The Payment stage is shared, but each server echoes the identifiers its place-order and confirm need. Echo them from the responses - don't reconstruct them.
| Server | Place-order tool | UPI args | confirm_order args |
|---|---|---|---|
| Food | place_food_order | addressId + paymentMethod:"UPI" + intentApp / generateUPIQR | orderId + addressId + lat + lng |
| Instamart | checkout | paymentMethod:"UPI" + intentApp / generateUPIQR | orderId + paasId |
| Dineout | book_table (paid deal) | cartKey + paymentMethod:"UPI" + intentApp / generateUPIQR | orderId + paasId |
Dineout's paid path also needs create_cart (DEAL_TICKET_PURCHASE) first, which returns the cartKey.
Full agent prompt
What can go wrong
- UPI not available → the picker shows only Cash; fall back to COD (
paymentMethod:"Cash"). - Payment
failed→ do NOT callconfirm_order; re-showget_payment_optionsand place again (a fresh transaction on the same cart). refund-initiated→ the money was debited and a refund has already started; tell the user and do not retry.- Timed out (still
pendingat your cap) → callconfirm_orderonce to finalize; a late payment success is reconciled server-side. - Tight-looping
check_payment_status→ it's a long-poll; hammering it stresses the payment cache. Let the widget own the cadence, or cap your own loop. - NPCI compliance → never ask the user for their UPI ID (VPA); the picker and QR handle it. UPI Collect and saved VPAs are not surfaced.