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

# Buy over HTTP with the Purchase API

> Buy for your shoppers over plain HTTP, with no SDK and no CLI: one bearer token and one POST per turn.

The Purchase API is one endpoint, `POST /buy`. You send the shopper's request in plain language, Agentcard works out the store, the cart and the money, and answers with what it needs next. The [Commerce SDK](https://docs.agentcard.com/commerce-sdk/quickstart.md) wraps this endpoint for TypeScript agents. If your agent runs in another language or another framework, call it directly: everything the SDK does is on this page.

The base URL is `https://api.agentcard.sh`.

## Get a token

Your company's `client_id` and `client_secret` exchange for a bearer token. A sandbox client runs every call in the sandbox.

> [!NOTE]
>
> The CLI creates the Agentcard account these credentials belong to. The Get started form on agentcard.com asks the Agentcard team for an invite, so a signup that stops at the website has no credentials to copy. For HTTP alone, run [`npx agent-cards signin`](https://docs.agentcard.com/tools/cli/signin), where a new email creates the account, then [`npx agent-cards credentials --mode sandbox`](https://docs.agentcard.com/tools/cli/credentials), which asks your company's name the first time and copies the pair to your clipboard. Neither command installs a package or edits your project. [`npx agent-cards init`](https://docs.agentcard.com/tools/cli/init) creates the same account and goes further: it installs `@agent-cards/commerce-sdk` and has Agentcard's agent build the buy tool into a TypeScript project.

```bash theme={null}
curl https://api.agentcard.sh/api/v2/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "your_client_id",
    "client_secret": "your_client_secret"
  }'
```

```json theme={null}
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "api",
  "sandbox": true
}
```

Send it as `Authorization: Bearer <access_token>` on every call below. [Create an access token](https://docs.agentcard.com/api-reference/access-tokens/create.md) documents the endpoint in full.

## Send the shopper's id

A client-credentials token buys for a connected person, so each call carries your own id for that shopper as `external_user_id`. Agentcard's own `user_id`, which the connect flow returns for that shopper, works in its place.

> [!NOTE]
>
> `external_user_id` goes in every request, not only the first. A follow-up call that leaves it out is refused with `user_id_required`, even when it carries the right `conversation_id`.

```text theme={null}
{"error":"user_id_required","code":"user_id_required","message":"A client-credentials token buys for a connected person: send their user_id, the id the connect flow returned for them, or external_user_id, your own id for them (in the body of a POST, in the query of a GET)."}
```

## Expect one call per turn

A real order needs facts the first message rarely carries: which store, where it ships, and above all an explicit yes to the final price. Agentcard refuses to check out in the same turn that it showed the cart, so one call cannot always finish a purchase. That is deliberate, and it is why `needs_input` is the normal answer rather than an error.

```bash theme={null}
curl https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $AGENTCARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "ask": "a pack of AA batteries from amazon", "external_user_id": "user-42" }'
```

Every answer carries a `conversation_id`, including the first. Send it back on each later call to stay in the same purchase.

| `status` | What it means |
| - | - |
| `needs_input` | `reply` is a question. Answer it with the same `conversation_id`. The normal path. |
| `order_placed` | The order is in, and `reply` carries the confirmation. |
| `declined` | The loop refused: a budget, an unsupported store, or a failed gate. `decline_code` says which. |
| `approved_not_charged` | A sandbox checkout the shopper approved. The card never reached the store, so nothing was ordered or charged. |
| `partially_placed` | A multi-cart confirm placed some orders. Per-cart truth is in `placements`. |

The answer also carries `reply` and `messages` for the shopper, `message_id`, and the typed blocks below. Fields that do not apply to the turn are `null`.

## Read the products it found

A search turn answers with a `catalog` block. Each item carries the id a later call picks it by.

```json theme={null}
{
  "conversation_id": "conv_...",
  "status": "needs_input",
  "catalog": {
    "merchant": "retail",
    "merchant_name": "Amazon",
    "items": [
      {
        "id": "https://www.amazon.com/dp/B00NTCH52W",
        "name": "Amazon Basics 20-Pack AA Alkaline Batteries",
        "priceCents": 999,
        "currency": "usd",
        "image_url": "...",
        "url": "https://www.amazon.com/dp/B00NTCH52W",
        "store": "amazon.com"
      }
    ],
    "as_of": "..."
  }
}
```

Put one in the cart by sending the shopper's words as `ask` together with `pick`:

```bash theme={null}
curl https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $AGENTCARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "conv_...",
    "external_user_id": "user-42",
    "ask": "the first one",
    "pick": { "product_id": "https://www.amazon.com/dp/B00NTCH52W" }
  }'
```

## Verify the cart, then confirm

Whenever a cart is on the table, the answer carries a typed `cart` block. Read it rather than the prose: it is the same server-side snapshot the checkout gate trusts.

```json theme={null}
{
  "cart": {
    "merchant": "retail",
    "merchant_name": "Amazon",
    "currency": "usd",
    "items": [
      { "name": "Amazon Basics 20-Pack AA Alkaline Batteries", "qty": 1, "priceCents": 999, "lineTotalCents": 999, "line_id": "..." }
    ],
    "subtotalCents": 999,
    "serviceFeesCents": 25,
    "tipCents": 0,
    "totalCents": 1024,
    "totalIsEstimate": true,
    "hash": "ad9cdf1a1a5037ab"
  }
}
```

Confirm by sending the hash, never prose:

```bash theme={null}
curl https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $AGENTCARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "conv_...",
    "external_user_id": "user-42",
    "confirm": "ad9cdf1a1a5037ab"
  }'
```

The hash is checked before any model turn. `confirm` needs a `conversation_id`. One conversation can hold a cart at several stores at once: `carts[]` lists them, each with its own hash, `cart` is the most recent, and `confirm` also takes an array of hashes.

A cart that drifted after the shopper saw it, in an item, a quantity, a price, a tip or a fee rate, is refused, and nothing is charged:

```text theme={null}
409
{
  "error": "cart changed since that hash was issued. Verify the current carts and confirm their hashes",
  "code": "cart_changed",
  "conversation_id": "conv_0526419f5b8c059c2c38ef1b",
  "cart": { "hash": "de8d34f45ce82b9b", "subtotalCents": 3399, "serviceFeesCents": 85, "totalCents": 3484, "items": [ ... ] },
  "carts": [ ... ]
}
```

The refusal carries the carts as they stand now, in the same `cart` and `carts[]` blocks as a cart answer. For a conversation holding one cart, show the shopper `cart` and confirm its hash on their new yes:

```bash theme={null}
curl https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $AGENTCARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "conv_0526419f5b8c059c2c38ef1b",
    "external_user_id": "user-42",
    "confirm": "de8d34f45ce82b9b"
  }'
```

With a cart at more than one store, confirm every hash you still mean to buy. One stale hash refuses the whole array and charges nothing, and `cart` is only the most recent cart, not necessarily the one that drifted: match `carts[]` against the stores you asked for, show the shopper those carts, and send their hashes together in `confirm`.

## Show the shopper a step

Some turns answer with a URL instead of a question, because only the shopper can finish the step. Show it to them, and call `/buy` again once they are back.

| Field | The step |
| - | - |
| `add_card_url` | The shopper saves a card in the Vault. Paired with `add_card_expires_at`. |
| `approval_url` | The shopper approves this cart's payment. Paired with `approval_expires_at`. |
| `store_login_url` | The store checks out only on the shopper's own account, as DoorDash does. |

A retail order also needs a shipping address, a name for the label and a phone number. Agentcard asks for them one at a time through `reply`, and `suggested_address` and `address_issue` carry what it made of an address you sent.

## Check where Agentcard can pay

Ask before you send a shopper to a store. `q` takes a name, a domain, or a full URL: the scheme, `www.` and the path are stripped for you.

```bash theme={null}
curl "https://api.agentcard.sh/buy/merchants?kind=all&q=https://www.target.com/c/batteries" \
  -H "Authorization: Bearer $AGENTCARD_TOKEN"
```

```json theme={null}
{
  "merchants": [
    {
      "kind": "catalog",
      "name": "Target",
      "domain": "target.com",
      "buys_through": "retail",
      "supported": true,
      "supported_reason": null,
      "supported_detail": null,
      "countries": []
    }
  ],
  "next_cursor": null
}
```

`supported: false` with a `supported_reason` describes that store on that read, not a fault in your call. To list every store, omit `q` and keep `kind=all`. When `next_cursor` comes back as anything but `null`, send the same request again with `cursor` set to that value: the endpoint reads `cursor`, never `next_cursor`, so reusing the response's own field name returns the first page again. Repeat until `next_cursor` is `null`.

## Test in the sandbox

A sandbox token runs the whole flow against real stores up to the confirm. The Vault code that connects a test shopper is always `111111`, and you save any of [Stripe's published test cards](https://docs.stripe.com/testing).

The confirm then ends with `decline_code: sandbox_mode`, because a sandbox card cannot pay a real store. Which `status` carries it depends on the route: a catalog order ends `declined`, and a store's own checkout that the shopper approved ends `approved_not_charged`, where the card was approved and never sent to the store. Both are the pass condition, not a failure: everything before them, the search, the cart and the Vault steps, is identical to live.

## Read a refusal, take a next step

Every refusal carries a `code`, and the ones a first integration meets are below. A refusal is an HTTP status with a JSON body; a `declined` answer is a `200` whose `decline_code` says why.

| Code | What happened | Next step |
| - | - | - |
| `user_id_required` | `external_user_id` was missing. Every call needs it, not just the first in a conversation. | Send `external_user_id`, the same value for the same shopper, on every call. |
| `user_not_connected` | No shopper connected to your app in this mode carries that `external_user_id`. Sandbox and production shoppers are separate. | Run the connect flow or send the Vault link with your credentials for this mode, passing the same `external_user_id`, then send it again. |
| `invalid_request` | A field was the wrong shape, such as `pick` sent as a bare id. | Read `error`: it names the field and the shape it wants. |
| `nothing_to_confirm` | A `confirm` arrived before any cart was shown in the conversation. | Ask for the cart first, then confirm the hash that call returns. |
| `cart_changed` | One of the confirmed hashes is not a cart that stands now, so none of them was charged. | Show the shopper the carts the refusal carries and confirm the hashes of the ones they still want. |
| `sandbox_mode` | A sandbox card cannot pay a real store, so the confirm stopped at the card. | Nothing: this is the sandbox pass condition. Use a live token to place an order. |


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