---
title: Checkout tools
description: The seven MCP tools that find a merchant, price an order, and place it — with the rules that decide when each one is safe to call.
---

# Checkout tools

Seven tools do the whole job: find a merchant, look at a product, price an order, place it, and follow it to the end. They are exposed by the Agnic MCP server at `https://mcp.agnic.ai/sse`.

**This page describes the tools.** There are HTTP routes underneath them, listed in the [API reference](/docs/api-reference/checkout), and they are not simply the same thing with a different envelope. One difference decides how you write your code, so it is worth reading before anything else.

<Callout type="warn">
  **Confirmation tokens are a tool-layer idea. The HTTP routes do not have them.**

  `preview_order` mints a token bound to a hash of the order — items, amount, currency, destination, spending caps — and `place_order` refuses if any of it changed. That is what stops a delivery address or a spending limit moving between the moment a person said yes and the moment money moves.

  `POST /api/autofill/dispatch` never sees a token. It rebuilds the cart itself and refuses on its own terms: `409` if a spending cap is breached, `price_changed` if the total came out above the amount you authorised. Both are pre-charge and both are safe — but the guarantee is different, and a REST integration that assumes token binding is assuming something that is not there.

  Everything else on this page is true of both.
</Callout>

Two ideas run through all of them, and if you only remember two things, remember these.

**Preview is a promise; place is the only thing that spends money.** `preview_order` never launches a browser and never charges. It returns a `confirmation_token` bound to the exact order it priced. `place_order` will not run without that token, and the token stops matching the moment anything about the order changes.

**Processing is not failure.** A real checkout takes about a minute — longer than a tool call politely waits. `place_order` returns `processing` and an `order_id`. Poll `get_order_status`. Never call `place_order` twice for the same order; that is how a customer gets charged twice.

---

## Finding something to buy

`find_merchants` takes an optional `query` and returns merchants already in the network, each with a `merchant_id`, a `rail`, and an `is_test` flag. The rail matters more than it looks: `shopify` merchants quote live prices and real shipping options, while `worker` merchants price from a cached catalogue. Ship-to only works on the Shopify rail.

`discover_merchant` takes a `merchant_url` and a `goal` in plain language, and onboards a store Agnic has never seen. A model drives the live checkout read-only, stops at the payment step, charges nothing, and records what an order there takes. Use it when the merchant you want is not in `find_merchants` yet. It is the slow one — allow a couple of minutes.

`get_product_details` takes a `merchant_id` and a `sku` and returns the live product. Prices here are browse-time. The preview is the money truth.

### When the user names a thing, not a shop

`search_products` takes a `query` and a `country` and searches the whole vetted network at once. It is the tool for "a beeswax candle" where `find_merchants` is the tool for "that candle shop in Ealing".

Every result carries a `sku` you can hand straight to `preview_order`. Some carry an `onboard.merchant_url` instead of a `merchant_id`, which means Agnic has never bought from that shop. That is normal and it is most of the network: call `discover_merchant` with that URL first, then preview. A result without `onboard` is ready immediately.

Markets are US, GB, CA and AU — the four with a hand-vetted shop pool. Anything else is an answer, not a retryable error.

An empty result does not mean the product does not exist. This searches vetted shops, not the web.

`lookup_product` takes a `url` and is what you reach for the moment someone pastes a link. **Send the URL exactly as they copied it.** The `?variant=` parameter is which size or colour they chose; strip it and you are guessing on their behalf. Without one, the first available option is used and the response says so, which is your cue to ask rather than assume.

It is Shopify-only and it never onboards anything by itself. A GET that quietly created a merchant record would be a side effect nobody asked for.

---

## Pricing an order

`preview_order` takes the merchant, the items, and what you believe the amount is. You do not need to be right about the amount, and you do not need to know the exact SKU — pass your best guess and read what comes back.

It returns one of three things.

**Blockers.** A list, each with a `code` and a `next_action` written for a model to follow. `unknown_sku` carries the nearest real products. `setup_required` lists every missing prerequisite at once, each with a deep link, so the user completes setup in one pass rather than three round trips. A blocker is a workflow step, not an error.

Blockers are a tool-layer idea too. `setup_required` in particular is `preview_order`'s own poka-yoke: it refuses to hand you a token until a card, a profile and a signed spending mandate all exist. **The HTTP route does not enforce that.** `POST /dispatch` will place an order for a caller with no mandate on file, recording that fact on the order rather than refusing. If you are integrating over HTTP and you want the mandate to be mandatory, check for it yourself.

**A question.** When a merchant offers more than one fulfilment option and you have not chosen, `requires_fulfillment_choice` is true and **no token is issued**. Show the options, ask, and call preview again with `fulfillment_option_id`.

**A token.** `ready_to_place` is true, and `preview_summary_for_user` is a sentence written to be shown verbatim. Show it, get an affirmative reply, and pass that reply into `place_order`.

### The amount is either a total or a ceiling

`amount_is_final` decides which, and telling a user a ceiling is the total is the mistake this field exists to prevent. When it is true, `amount_minor` is what will be charged. When it is false, the merchant adds tax at checkout: the real charge is read off the checkout page and is never above the figure you were given. State it as "subtotal plus tax, never more than X".

### Delivering to someone else

Pass `ship_to` when the buyer is not the person receiving the parcel — a gift, a registry, a customer of yours. It is order data: it does not touch the cardholder's saved address, and the next order goes back to the cardholder unless you say otherwise.

```json
{
  "name": "Dana Okonkwo",
  "street_address": "Unit 18, Dickens Yard",
  "address_locality": "Ealing",
  "postal_code": "W5 2TG",
  "address_country": "GB"
}
```

`address_country` is an ISO-3166-1 alpha-2 code. A province or state is required for Canada, the United States and Australia, because those checkouts will not complete without one. Everything else in the object is required; a half-address is not an address, and the refusal names the field that is missing rather than making you guess.

The preview prices shipping in the **destination's** market, so the options and totals you see are the ones that apply to where the parcel is actually going. The merchant's confirmation email still goes to the cardholder. That is deliberate, and it is what someone buying on behalf of a customer wants.

Ship-to works on Shopify-rail merchants, and it cannot be combined with in-store pickup. Both refusals come back as blockers with a next action.

### Refusing before the card

Pass `constraints` when the user has stated a limit.

```json
{ "max_total_minor": 6000, "max_shipping_minor": 1200 }
```

Both are in minor units of the order currency, the same unit as `amount_minor`. They are checked at preview and checked again against a freshly built cart before the card is used.

Through the tools they are also bound into the confirmation token, so they cannot change between approving and placing. Over HTTP that binding does not exist — the second check still happens, and a breach is still a `409` before any charge, but nothing stops the caller sending different caps the second time. They are the caller's own caps either way; the binding matters when a model is choosing them on a user's behalf.

When a limit is breached you get a blocker carrying both figures — what it costs and what the limit was — so you can state the gap rather than describe it. Tell the user and ask. Do not raise a limit yourself; it is an instruction, not a suggestion.

On a merchant whose rail cannot price shipping before checkout, a shipping limit returns `constraint_unverifiable`. That is deliberate. It says we cannot check, which is not the same as saying it passed.

---

## Placing it

`place_order` needs the `confirmation_token`, the user's literal reply, and a timestamp. If you passed `ship_to` or `constraints` to preview, pass exactly the same values here.

That last rule is not bureaucracy. The token is bound to a hash of the order, destination and limits included, so a corrected typo or a rounded cap makes the token stop matching and nothing is charged. It is what stops an address or a spending limit changing between the moment a person said yes and the moment the money moves. If the user genuinely wants something different, preview again and get a fresh approval.

`place_order` can come back as `approval_required`. The user is being asked to approve this specific purchase with their passkey, because it falls outside the standing policy they signed. Show the approval URL, poll `check_approval_status`, then call `place_order` again with the `approval_token`. It is a step in the flow, not a failure.

Over HTTP the same thing arrives as a **`202`** from `POST /dispatch`, carrying `approval_required`, `approval_token`, `approval_url`, `expires_in` and `reason`. Handle it: open the URL so the user can approve with their passkey, then dispatch again with the `approval_token`. An integration that treats a 202 as success will report an order placed that never was.

---

## Knowing what happened

`get_order_status` takes an `order_id`. Poll it every few seconds while an order is `processing`.

Every finished order carries two fields that exist so you never have to pattern-match an error string.

`retryable` is `true` when placing the order again is safe, and it only says that when the evidence proves the card was never submitted. `false` means a retry fails the same way, or it already succeeded. **`null` means nobody knows and money may have moved** — stop, and tell the user to check their statement.

`retry_action` is the single next call: `re_preview` when the quote is dead, `poll` when it is not finished, `handoff` when a person has to act at the live URL, `contact_support` when nothing should be automated past this point, and `none` when it is settled.

Both are computed server-side from the status, the error code and the charge evidence together, which is why they are worth more than the error code alone. The `help` string beside them is the sentence to say to a person; these two are what your code branches on.

A `handoff` means a human step appeared mid-order — a captcha, a bank's 3-D Secure check, or a merchant sign-in. Show the live URL and keep polling. Do not re-place.

---

## Timings, and one honest caveat

A confirmation token lives five minutes. A preview is free and idempotent, so re-preview rather than nursing a token.

A `discover_merchant` call can take two minutes. A `place_order` typically settles in sixty to seventy seconds. Both of those are longer than a comfortable tool call, which is why both hand you an id and expect you to poll.

There are no webhooks yet. Polling is the documented path, not a workaround.
