> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentvault.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Run a checkout end to end

> Take an agent from a product page to a placed order, with the account holder's limits applied and every human step in the right place.

Take an agent from a product page to a placed order, charging the card once. Wiring it up takes about 20 minutes. A run takes a few minutes, plus any time the account holder spends on a prompt.

## Before you start

* An [API key](/dashboard/api-keys) for the agent, with **Fill a test card instead of my card** cleared.
* A card in the vault, governed by an [agent card](/dashboard/agent-cards) with the limits you want.
* The [Playwright SDK](/sdk/install) or the [MCP server](/mcp/setup), connected to a proxy session.
* The card's tokens: `listTokens()` in the SDK, `get_token_catalog` over MCP.

<Warning>
  This guide places a real order with the account holder's real card. A key with **Fill a test card instead of my card** ticked fills a test card, but a real store can still record the order. To practice, use the practice checkout from the [Quickstart](/get-started/quickstart).
</Warning>

## When the account holder is involved

Four different prompts can pause a checkout. Know which one is waiting before you retry anything.

| Prompt | Raised when | The account holder | Agent waits | If nobody answers |
| - | - | - | - | - |
| **Purchase approval** | The total is at or above the agent card's `confirmAbove` threshold, or the agent card has a threshold and the total cannot be read | Taps **Review purchase**, then **Approve** or **Deny** | 5 minutes by default | `approval_timeout` |
| **New site** | A card, security code, identity or address fill on a site that is not yet an authorized [destination](/dashboard/destinations) | Taps **Review site authorization**, then **Approve** or **Deny** | 15 minutes | `destination_unauthorized` with `decision: "timed_out"` |
| **Live CVV** | The agent fills the card's `cvv` token on a deployment with live CVV entry | Taps **Enter security code**, then types the code | 3 minutes | `cvv_entry_required` |
| **Challenge handoff** | The agent calls `requestChallengeHandoff()` on a 3DS, OTP or CAPTCHA step | Taps **Open live checkout**, then completes the step on the live page | 10 minutes | `challenge_required` |

Each prompt shows as a dashboard banner. It also goes out as an email, desktop notification or **Background alerts** notification on each channel the account holder turned on, but only while the prompt's switch under **Needs you** is on: **A purchase needs my approval** (purchase approvals and new sites), **A checkout needs my card security code** or **A checkout needs me to clear a bank or verification step**. With the switch off, only the banner appears. Alerts are best effort. See [Approvals and notifications](/dashboard/approvals-and-notifications).

<Note>
  A purchase approval happens automatically during the card fill. A handoff happens only when the agent asks for it. If an approval is waiting, `requestChallengeHandoff()` finds no challenge and does not help.
</Note>

## Run the checkout

<Steps>
  <Step title="Check the registry for the store">
    A store whose guest checkout is PayPal-only or wallet-only never takes a card.

    <CodeGroup>
      ```ts SDK theme={null}
      import { recipesForUrl } from "@agentvault/playwright";
      const { merchant, platform } = await recipesForUrl(productUrl);
      if (merchant?.deadEnd) {
        return { status: "DEAD_END", reason: merchant.deadEnd.type };
      }
      ```

      ```text MCP theme={null}
      Call get_checkout_recipes with the product URL.
      ```
    </CodeGroup>

    If the account holder named a product but no store, `buyableMerchants()` lists stores that work, verified first, with dead ends removed.

    <Warning>
      Over MCP, send the host and path, never the raw page URL. The lookup is unauthenticated, and a hosted-checkout path (a Stripe `cs_...` path, a Shopify `/checkouts/...` path) carries a session token. The SDK's `recipesForUrl()` strips it for you.
    </Warning>
  </Step>

  <Step title="Snapshot the page before the first fill">
    After any token fill, snapshots and frame listings are refused and screenshots turn black. Look at the product page, cart and empty form now with `page.snapshot()`. Afterwards you have `observe()`, `count()` and `detectChallenge()`. See [Redaction](/security/redaction).
  </Step>

  <Step title="Dismiss the cookie or consent banner">
    It intercepts clicks. Click "Accept all", "Accept" or "Got it" first.
  </Step>

  <Step title="Add to cart and confirm the cart filled">
    Add-to-cart often does nothing when clicked too early. Load `/cart/`, check for line items, and click again if it is empty.
  </Step>

  <Step title="Clear any delivery or region gate">
    Large retailers hide the buy button until a delivery location is set, and the browser starts with an empty profile every run. Fill the location box from the address tokens (`av_addr_<label>_zip_<userId>` and its `_city` and `_country` siblings). The registry reports a known gate as `preconditions: ["delivery_location"]`.

    The first address fill on an unauthorized site can pause here for up to 15 minutes for a **New site** approval.
  </Step>

  <Step title="Set the country first">
    Country selects often default to the UK, which then rejects a US ZIP. Select the country (`#billing_country` on WooCommerce), wait for the page to refresh, then the state (`#billing_state`).
  </Step>

  <Step title="Fill billing from the card's billing address">
    Address verification compares billing with the card, and a mismatched postcode declines the charge. The token catalog does not say which address is a card's billing address: `TokenCatalogCard` carries no address label or ID. Use the label the account holder gives you for that card, or ask them. An address typed while adding a card is labelled `card1`, `card2` and so on, so do not assume a `bill` label exists.
  </Step>

  <Step title="Accept the terms by clicking the label">
    The real `#terms` checkbox is usually hidden. Click `label[for="terms"]` or `label:has-text("I have read and agree")`.
  </Step>

  <Step title="Point at the total if the page names it oddly">
    If the proxy cannot read the total, a capped card is refused with `checkout_total_unknown`. Declare a hint on arrival: `page.hint("order.total", { selector: ".summary__grand-total" })`. See [Page hints](/guides/page-hints).
  </Step>

  <Step title="Fill the card in order">
    <Warning>
      This step types the real card number into the store. Check the cart and the total first.
    </Warning>

    Expiry and billing first, then the card number, then the security code with nothing in between. See [the card fill order](#the-card-fill-order). This is where a **New site**, **purchase approval** or **live CVV** prompt can reach the account holder.
  </Step>

  <Step title="Submit once">
    <Warning>
      Never click pay or place-order a second time. A duplicate can charge twice. If the result is unclear, go to **Confirm the order**.
    </Warning>

    If a 3DS, OTP or CAPTCHA step appears, hand it over with [Handle challenges](/guides/challenges).
  </Step>

  <Step title="Confirm the order">
    Over MCP, race a success selector against a failure selector with `get_session_outcome`, passing `confirmsCheckout: true` and the `submitSelector` you clicked. Then read `get_run_report`.

    In the SDK there is no race. Check the page yourself first: the success marker is present and the failure marker is absent. Only then call `page.recordOutcome()`, which records a confirmation as soon as you call it, even on an unconfirmed page. It accepts only an optional `orderTotal`. Then read `browser.outcome()`.

    Either way, the merchant's emailed receipt is the source of truth. See [Confirming what happened](/guides/confirming-outcomes).
  </Step>
</Steps>

Your agent can now pay at this store. [Contribute a recipe](/registry/contribute) so the next agent starts ahead. Per-platform steps for the [supported platforms](/registry/overview) ship in the SDK as `checkout-playbooks.md`.

<Frame caption="An agent checkout with the dashboard Activity page updating live.">
  <video controls muted playsInline preload="metadata" className="w-full" poster="/images/guides/checkout-flow-poster.webp" src="https://mintcdn.com/self-labs/VeDWRFlNWGO5jQN6/videos/guides/checkout-flow.mp4?fit=max&auto=format&n=VeDWRFlNWGO5jQN6&q=85&s=1376645363d7c6dd389b67d86d9ed584" aria-label="An agent checkout with the dashboard Activity page updating live." data-path="videos/guides/checkout-flow.mp4" />
</Frame>

## The card fill order

Filling the card number checks the total against the account holder's limits and, once approved, arms a one-shot authorization for the security code. The next **action** on the page spends it.

| Spends it | Does not spend it |
| - | - |
| Another field in the same batch | A screenshot |
| A click or a key press | `observe()` or `detectChallenge()` |
| A navigation | `locator.count()` |
| A `waitForSelector` that **returns** | A wait that **timed out** |

Look at the page between the number and the code if you need to. Do anything that acts on the page before the number.

One exception: a screenshot that returns `securityCodeAuthorizationReleased` has released it. Fill the number again, then the code.

If expiry appears only after a number is typed, mark those controls `cardField: true` in [`fill_checkout`](/mcp/tool-reference#fill_checkout) and they move after the code. Any unmarked field between the number and the code gets the batch refused.

### Stripe and other hosted card frames

After a fill, `listFrames()` and matching a frame by name or URL are refused with `sensitive_data_blocked`. Target the frame by CSS selector, and type with `pressSequentially`: Stripe Elements ignores value-only fills.

```ts theme={null}
// expiryMonth and expiryYear are numbers on the catalog card, such as 11 and 2029.
const mmyy =
  String(card.expiryMonth).padStart(2, "0") + String(card.expiryYear).slice(-2);
const el = page.frameLocator('iframe[title="Secure payment input frame"]');
await el.locator('input[name="expiry"]').pressSequentially(mmyy);
await el.locator('input[name="number"]').pressSequentially(card.number);
await el.locator('input[name="cvc"]').pressSequentially(card.cvv);
```

The legacy Card Element splits this into three frames (`Secure card number input frame` and its expiry and CVC siblings) with inputs `cardnumber`, `exp-date` and `cvc`.

Probe each input with `count()` before you fill the number. If no selector reaches visible card fields, report the variant instead of submitting blind.

## What the account holder sees

The account holder gets a dashboard banner. They also get a **Review purchase** email and a desktop or **Background alerts** notification on each channel they turned on, as long as their **A purchase needs my approval** switch is on. See [Approvals and notifications](/dashboard/approvals-and-notifications). The approval page names the agent card, the merchant site and the amount, with a redacted screenshot.

<Frame caption="A purchase approval on the account holder's phone.">
  <img className="block dark:hidden" src="https://mintcdn.com/self-labs/VeDWRFlNWGO5jQN6/images/guides/approval-phone-light.webp?fit=max&auto=format&n=VeDWRFlNWGO5jQN6&q=85&s=df6c4430d727a3dad262c92028ceb4b2" alt="Phone screen titled Approval needed, naming the agent card, the merchant site and the amount, with a redacted page screenshot and Approve and Deny buttons." width="2880" height="1800" data-path="images/guides/approval-phone-light.webp" />

  <img className="hidden dark:block" src="https://mintcdn.com/self-labs/VeDWRFlNWGO5jQN6/images/guides/approval-phone-dark.webp?fit=max&auto=format&n=VeDWRFlNWGO5jQN6&q=85&s=21d25990e6f38175e526a020cacaf309" alt="Phone screen titled Approval needed, naming the agent card, the merchant site and the amount, with a redacted page screenshot and Approve and Deny buttons." width="2880" height="1800" data-path="images/guides/approval-phone-dark.webp" />
</Frame>

* **Approve**: the fill continues. If the total changed since, the agent gets `approval_denied` and a retry asks again.
* **Deny**: the agent gets `approval_denied`, and no real value is typed.
* **No answer**: the agent gets `approval_timeout`. A retry asks again.

A live CVV prompt is a separate page. See [Live CVV entry](/guides/live-cvv).

## If something goes wrong

| Symptom | Cause | Fix |
| - | - | - |
| `merchant.deadEnd` from the registry | The store cannot take a card for this flow | Pick another store with `buyableMerchants()` |
| The cart is empty after add-to-cart | The click fired too early | Wait for the page to settle, then click again |
| The postcode is rejected | The country defaulted wrong, or the wrong address went into billing | Set the country first and use the address label the account holder gave you for the card's billing address |
| `checkout_total_unknown` | The proxy could not read the total | Declare a [page hint](/guides/page-hints). Do not ask for a higher limit |
| A second approval for the same purchase | Something acted on the page between the number and the code | Fill the number, then the code, with nothing in between |
| `approval_denied` or `approval_timeout` | The account holder declined, the total changed, or nobody answered | Tell the account holder. Retry only if the message says the total changed, or after a timeout |
| `paired_cvv_refused` | The number is already in the form and only the code was withheld | Do not refill the whole card. See [paired\_cvv\_refused](/sdk/error-codes#card-and-merchant-refusals) |
| `cvv_entry_required` or `challenge_required` | The code or a challenge needs the account holder | Follow [Live CVV entry](/guides/live-cvv) or [Handle challenges](/guides/challenges) |

Everything else, including `limit_reached` and `sensitive_data_blocked`, is in [Error codes](/sdk/error-codes) and [Troubleshooting](/reference/troubleshooting).

**Next:** [Confirming what happened](/guides/confirming-outcomes) to prove the order landed before you report it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.