# Catalog API

Call Shopify's Global Catalog from your own backend for product search, with an agent profile you publish and an optional API key for higher rate limits.

Shop MCP's catalog tools search Shopify's Global Catalog. You can also call the Catalog yourself, at `https://catalog.shopify.com/api/ucp/mcp`, when an app only needs product search and has no use for a Shop account, carts or checkout. It takes the same three tools and the same `catalog` arguments as Shop MCP, so [Find products](search.md) applies to both.

| | Shop MCP | Catalog |
| --- | --- | --- |
| Address | `https://mcp.shop.com/` | `https://catalog.shopify.com/api/ucp/mcp` |
| Tools | All 17 | `search_catalog`, `lookup_catalog` and `get_product` |
| Agent profile | Shop MCP sends its own | You send yours with every call |
| Headers | The MCP headers and `_meta` from the [quickstart](quickstart.md) | `Content-Type` and `Accept` |
| Credentials | A Shop account token, optional for search | An API key, optional, for higher rate limits |
| Results | `structuredContent`, `content` and `isError` | `structuredContent` only |
| A `limit` over 50 | Refused with a `-32602` error | Lowered to 50, with a `pagination_limit_clamped` message |

Shop MCP adds its own profile to every catalog call, and its catalog tools take only `catalog`, so don't send `meta` to Shop MCP.

## Search

This finds rain jackets for a shopper in Canada, priced up to 80 CAD:

```sh
curl -sS https://catalog.shopify.com/api/ucp/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "search_catalog",
      "arguments": {
        "meta": {
          "ucp-agent": {
            "profile": "https://shopify.dev/ucp/agent-profiles/examples/2026-08-25/valid-with-capabilities.json"
          }
        },
        "catalog": {
          "query": "rain jacket",
          "context": { "address_country": "CA", "currency": "CAD" },
          "filters": { "price": { "max": 8000 } },
          "pagination": { "limit": 5 }
        }
      }
    }
  }'
```

`meta.ucp-agent.profile` is the URL of your agent profile, which tells the Catalog which capabilities your app supports. The one here is Shopify's sample, which is fine for trying the API. Before you go live, [publish your own](#publish-a-profile).

A call without a profile gets HTTP 422:

```json
{
  "error": {
    "code": -32001,
    "message": "UCP discovery failed",
    "data": { "code": "invalid_profile_url", "content": "Unable to fetch agent profile: Missing profile uri" }
  }
}
```

## Read the result

The result has only `structuredContent`. Its `ucp` object lists the capabilities the Catalog agreed to use with your profile, and `status` says whether the call worked:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "structuredContent": {
      "ucp": {
        "version": "2026-08-25",
        "capabilities": {
          "dev.ucp.shopping.catalog.search": [{ "version": "2026-08-25" }],
          "dev.ucp.shopping.catalog.lookup": [{ "version": "2026-08-25" }],
          "dev.shopify.catalog.global": [{ "version": "2026-08-25" }]
        },
        "status": "success"
      },
      "products": [
        { "id": "gid://shopify/p/33aRQ8AfwUkvYQ0wdHUCr1", "title": "Craven Mens Waterproof Packaway Jacket - Black" }
      ],
      "messages": [
        {
          "type": "info",
          "code": "price_filter_applied",
          "path": "$.filters.price",
          "content": "Price filtering was applied on a USD basis; returned prices are contextualized to the buyer's market and may differ."
        }
      ],
      "pagination": { "has_next_page": true, "total_count": 368, "cursor": "eyJvZmZzZXQiOjUsInRvdGFsX2NvdW50IjozNjgsInJldHVybmVkIjo1fQ" }
    }
  }
}
```

The capabilities and products above are cut down. [Read the result](quickstart.md#read-the-result) in the quickstart goes through a product's main fields.

An error from the Catalog also comes back with HTTP 200. `ucp.status` is `"error"`, and `messages` says what went wrong, such as `not_found` for a product ID that doesn't exist. Check the HTTP status, the JSON-RPC `error` and `ucp.status` before you use the data:

```js
async function readCatalogResult(response) {
  if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
  const body = await response.json();
  if (body.error) throw new Error(body.error.message);
  const data = body.result?.structuredContent;
  if (!data) throw new Error("Catalog response has no structuredContent");
  if (data.ucp?.status === "error") {
    throw new Error(data.messages?.map((m) => m.content).join("; ") || "Catalog error");
  }
  return data;
}
```

`search_catalog` and `lookup_catalog` return `products`, and `get_product` returns `product`.

To get the next page, send the same arguments with `pagination.cursor` set to the returned cursor. Pages stop at 1,000 results, so `has_next_page` turns `false` there however many products match.

## Look up products and get details

Use the same request with another tool name and `catalog` object:

| Tool | Example `catalog` |
| --- | --- |
| `lookup_catalog` | `{ "ids": ["gid://shopify/p/33aRQ8AfwUkvYQ0wdHUCr1"], "context": { "address_country": "CA", "currency": "CAD" } }` |
| `get_product` | `{ "id": "gid://shopify/p/33aRQ8AfwUkvYQ0wdHUCr1", "selected": [{ "name": "Size", "label": "M" }], "context": { "address_country": "CA", "currency": "CAD" } }` |

Use the IDs and option labels the Catalog returned. [Look up known items](search.md#look-up-known-items) and [Choose a size or color](search.md#choose-a-size-or-color) explain the arguments.

## Publish a profile

An agent profile is a JSON document at a public HTTPS URL you control, such as `https://your-app.example/profiles/agent.json`. It declares the UCP version and the capabilities your app supports. It holds no secret, and it doesn't sign anyone in.

For the Catalog, declare the two base capabilities and Shopify's extension. This is the catalog part of Shopify's [sample profile](https://shopify.dev/docs/agents/profiles#valid-profiles):

```json
{
  "dev.ucp.shopping.catalog.search": [
    {
      "version": "2026-08-25",
      "spec": "https://ucp.dev/2026-08-25/specification/catalog/search",
      "schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_search.json"
    }
  ],
  "dev.ucp.shopping.catalog.lookup": [
    {
      "version": "2026-08-25",
      "spec": "https://ucp.dev/2026-08-25/specification/catalog/lookup",
      "schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_lookup.json"
    }
  ],
  "dev.shopify.catalog.global": [
    {
      "version": "2026-08-25",
      "spec": "https://shopify.dev/docs/agents/catalog/global-catalog",
      "schema": "https://shopify.dev/ucp/schemas/2026-08-25/shopify_catalog_global.json",
      "extends": ["dev.ucp.shopping.catalog.lookup", "dev.ucp.shopping.catalog.search"]
    }
  ]
}
```

The base capabilities give you the tools, and the extension adds Shopify's fields, such as each variant's `seller`. Without the extension, results come back without those fields. An extension whose base capability is missing is dropped. Start from the full sample, take out what your app doesn't use, and send your profile's URL in place of the sample's. [Profiles](https://shopify.dev/docs/agents/profiles) on shopify.dev covers the format.

## Get an API key

Calls without credentials get the Catalog's lowest rate limits. A token from an API key gets the highest. The key identifies your app to Shopify. It doesn't sign a shopper in, and it isn't a Shop account token.

1. In the [Dev Dashboard](https://dev.shopify.com/dashboard/), go to **Catalogs** and click **Get an API key**.
2. Keep the client ID and client secret on your server.
3. Exchange them for a token:

```js
async function getCatalogToken() {
  const response = await fetch("https://api.shopify.com/auth/access_token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      grant_type: "client_credentials",
    }),
    signal: AbortSignal.timeout(15000),
  });
  if (!response.ok) throw new Error(`Token HTTP ${response.status}`);
  const { access_token } = await response.json();
  if (!access_token) throw new Error("Token response has no access_token");
  return access_token;
}
```

Send the token in an `Authorization: Bearer` header on each Catalog call, and keep sending the profile in the body. The token lasts 60 minutes, so get a new one before then. Keep it in server memory, and keep the key and token out of browser code and logs.

Shopify also has a middle tier for apps that sign their requests instead. [Auth and rate limiting](https://shopify.dev/docs/agents/profiles/auth-and-rate-limiting) on shopify.dev covers all three.

## Build on it

- Call the Catalog from your backend, with a timeout on each request.
- On HTTP 429, wait before you try again, for as long as `Retry-After` says when the response has one.
- Don't cache results. They reflect each merchant's current pricing, availability and presentation.
- Show product images from the URLs the Catalog returns, next to the product they belong to. Don't download them to your servers.
- Some fields are inferred by Shopify's AI, so they can be missing or less accurate. The Catalog reference marks them as inferred.

Shopify's [Catalog usage guidelines](https://shopify.dev/docs/agents/catalog#usage-guidelines) has the full rules.
