Shopify's catalog for AI agents returns the page price, but hides sold-out sizes by default

Shopify’s docs say every store serves a product catalog for AI agents, and all 34 stores I checked did. It follows the Universal Commerce Protocol (opens in a new tab) (UCP), which Shopify built with Google. There’s no API key. The agent sends the URL of its profile, a small JSON file that lists the protocol version and the capabilities it uses. The store answers with prices, stock and checkout links.

Teams that monitor Shopify prices have read each store’s /products/<handle>.js or products.json for years. Neither was built for agents. UCP is built for them.

I tested UCP on 34 Shopify stores on October 24, 2026. Every price I could check matched the product page and the store’s own data. When sold-out sizes were requested, stock matched too, for all 50 sizes I checked. Each answer was 9 to 40 KB, compared with 0.4 to 6.4 MB for the page HTML.

Still, 3 defaults can give a data team missing or wrong data. Sold-out sizes are missing unless you ask for them. The cross-store Global Catalog returns each price in the seller’s currency. And anonymous calls, with no token or signature, stopped at about 100 per store per hour from my IP address.

How to call a store

Each store lists its UCP endpoint and capabilities at /.well-known/ucp. All 34 stores in the test had one. Walmart, Target, Amazon, Etsy, Wayfair and Home Depot didn’t, and returned 404 or 503.

The endpoint is an MCP (Model Context Protocol) server, but you don’t need an MCP client. A plain JSON-RPC POST works, as long as it carries your profile URL in meta.ucp-agent.profile.

The profile used in this test declares only catalog capabilities, for search and lookup on single stores and in the Global Catalog. Host your own copy on your domain. The store sees that URL on every call, so it names your agent, and you control what it declares. Without a token or signature, Shopify still counts the call as anonymous.

Terminal window
curl -s https://doccraft.dev/ucp/agent-profile.json | jq -c '.ucp | {version, capabilities: (.capabilities | keys)}'
{"version":"2026-08-25","capabilities":["dev.shopify.catalog","dev.shopify.catalog.global","dev.ucp.shopping.catalog.lookup","dev.ucp.shopping.catalog.search"]}

A catalog search with that profile looks like this.

Terminal window
curl -s https://misen.com/api/ucp/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "search_catalog", "arguments": {
"meta": {"ucp-agent": {"profile": "https://doccraft.dev/ucp/agent-profile.json"}},
"catalog": {"query": "carbon steel wok", "pagination": {"limit": 2}}}}}' \
| jq -c '.result.structuredContent.products[] | {title, price: .variants[0].price, available: .variants[0].availability.available}'
{"title":"12\" Pre-Seasoned Carbon Steel Wok","price":{"amount":14400,"currency":"USD"},"available":true}
{"title":"10\" Carbon Nonstick™ Wok","price":{"amount":11900,"currency":"USD"},"available":true}

Prices are in minor units, so 14400 is $144.00. When a store sets a compare-at price, the crossed-out one, each variant also carries it as list_price. On the True Classic chinos below, it was $129.98, the same as the page. The other catalog tools are lookup_catalog, which takes up to 10 IDs per call, and get_product. If the store can’t fetch your profile, it answers profile_unreachable.

Results

30 of the 34 products could be compared. 2 had been removed or renamed, and the 2 headless stores have no .js data to compare with.

CheckResult
Stores that publish /.well-known/ucp34 of 34
Price, compared with the page’s JSON-LD22 of 22 matched. The other 8 pages had no price there
Price, compared with the store’s .js data30 of 30 matched
Stock, sold-out sizes included, compared with the .js data50 of 50 sizes matched, on 5 stores
Median response time, 30 productsUCP lookup_catalog 365 ms, .js 207 ms, page HTML 774 ms
Response size, uncompressedUCP 9 to 40 KB, .js 3 to 31 KB, page HTML 0.4 to 6.4 MB

The .js data and UCP come from the same store, so the page’s JSON-LD is the more independent check. A UCP answer is larger because MCP sends the data twice, once as text and once as JSON. The .js endpoint is also faster, and it returns every variant in one call. UCP wins in 3 places.

Where UCP helps

Headless stores

SKIMS and Nomad run custom storefronts on Oxygen, Shopify’s hosting for headless stores. Their pages answer with powered-by: Shopify, Oxygen, and their .js and products.json endpoints return 400, 404 or a redirect. UCP still answered for both, and search_catalog found the exact products.

A terminal window. The SKIMS product page answers with powered-by Shopify, Oxygen. The .js endpoint returns 404. A UCP search returns 200 with the same polo at $88.00
The same SKIMS product through the `.js` endpoint and through UCP, October 24, 2026.

Headless stores can also be read through the Storefront GraphQL API, with the public token their pages load. UCP needs no token.

Prices for other countries

search_catalog takes the buyer’s country and currency in context, for example "context": {"address_country": "GB", "currency": "GBP"}. The same search for 4 countries, sent from Mumbai, returned each store’s local price.

StoreUSUKIndiaGermany
SKIMS$88£94₹9,100€112
Kith$75£72₹7,400€91
Allbirds$25$25$25$25

I checked 2 of these prices with the stores. Kith’s .js endpoint returned the same £72 with a UK localization cookie, and the SKIMS page for India shows ₹9,100. Allbirds stays in dollars because it runs a separate Shopify store for each country.

The SKIMS product page for India in a browser, at skims.com/en-in, with the price ₹9,100 outlined under the title Mens Relaxed Polo
The SKIMS page for India, October 24, 2026. UCP returned the same ₹9,100 for a buyer in India.

One request across stores

Shopify’s Global Catalog (opens in a new tab) at catalog.shopify.com/api/ucp/mcp searches across stores, and its lookup_catalog takes up to 50 product URLs per call. One call with all 30 URLs returned 24 products from 24 stores in under a second, each with its seller and one variant’s price and stock. All 24 matched the stores’ .js data. The other 6 came back not_found. Shopify’s docs limit the Global Catalog to eligible merchants and don’t list the rules.

A terminal window. One request with 30 URLs took 911 ms and returned 24 products and 6 not_found, followed by 8 sellers with their prices in USD and available=True
30 product URLs from 30 stores in one Global Catalog request, October 24, 2026.

Anonymous calls to this endpoint share one rate limit with all other anonymous traffic. Shopify’s rate-limit docs (opens in a new tab) say it plainly, “Don’t use unauthenticated requests for production traffic”.

3 defaults that can give you missing or wrong data

All 3 make sense for a shopping agent. For a data team, the first 2 change the data without an error, and the third stops the crawl.

Sold-out sizes are missing

A lookup of 10 True Classic chino variants returned 3. The other 7 were exactly the sizes that the .js endpoint lists as sold out, and the response didn’t mention them.

The True Classic product page for Slim Dressy Chino Pants, with waist 30 and length 28L selected. The 28L button, the $109.99 price and the Notify Me When Available button are outlined
Waist 30, length 28L on the True Classic page, October 24, 2026. The page shows it as sold out. $109.99 is the 2-pack price, the one UCP returns. UCP's default answer didn't include this size.

The cause is the available filter, which Shopify describes as “Default true (only sale-ready items)”. For a shopping agent, that’s the right default. For stock monitoring, a sold-out size looks the same as a size that doesn’t exist.

import httpx
STORE = "https://www.trueclassictees.com"
PROFILE = "https://doccraft.dev/ucp/agent-profile.json"
def lookup(ids, **filters):
catalog = {"ids": ids}
if filters:
catalog["filters"] = filters
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "lookup_catalog", "arguments": {
"meta": {"ucp-agent": {"profile": PROFILE}}, "catalog": catalog}}}
r = httpx.post(f"{STORE}/api/ucp/mcp", json=body, timeout=30)
products = r.json()["result"]["structuredContent"]["products"]
return [v for p in products for v in p["variants"]]
# The first 10 variants of one product, from the store's own product data
product = httpx.get(f"{STORE}/products/black-khaki-slim-chino-pants-2-pack.js").json()
ids = [f"gid://shopify/ProductVariant/{v['id']}" for v in product["variants"][:10]]
default = lookup(ids)
everything = lookup(ids, available=False)
print("Default: ", len(default), "of", len(ids), "variants")
print("available=false: ", len(everything), "of", len(ids), "variants")
for v in everything[:4]:
print(f" {v['title']:<12} ${v['price']['amount'] / 100:.2f} available={v['availability']['available']}")
Default: 3 of 10 variants
available=false: 10 of 10 variants
30 / 28L $109.99 available=False
30 / 30L $109.99 available=True
30 / 32L $109.99 available=True
30 / 34L $109.99 available=False

With available set to false, all 50 variants checked across 5 stores matched the .js data, sold-out sizes included. The Global Catalog has the same default. A lookup of 10 Outdoor Voices variants there returned 6, and all 10 with available set to false. To read every size, look up variant IDs. A lookup by product ID returned just 1 variant, even with available set to false.

On a headless store, there’s no .js endpoint to get the IDs from. There, get the product ID from search_catalog, which also lists the product’s options and their values. Then call get_product with one option value selected and available set to false. It returns every variant with that value. The catalog part of the call looks like this.

{
"id": "gid://shopify/Product/10246876987745",
"selected": [{"name": "Color", "label": "LIGHT HEATHER GREY"}],
"filters": {"available": false}
}

On SKIMS, this call returned all 9 sizes of the polo, because SKIMS lists each color as its own product. In UCP, the True Classic chinos have 2 options, 9 waists and 5 lengths, so they would take 9 calls, one per waist.

Global Catalog prices come in the seller’s currency

A single store’s endpoint returned local prices, as shown above. The Global Catalog doesn’t convert prices, even when the buyer’s currency is set to USD. A search for “CeraVe moisturizing cream” for a buyer in the US returned 6 prices in USD. The other 4 came from stores in Qatar, Kenya, India and Oman, in their own currencies.

A terminal window with 10 CeraVe listings. Each row shows the seller, the amount, the currency, the amount divided by 100 and the real price. The GLOWSECRET row in KES and the AuraSkin row in OMR are highlighted, where 7500 OMR divided by 100 gives 75.00 but the real price is OMR 7.500
The 10 Global Catalog results for a buyer in the US, October 24, 2026.

Each amount is an integer in the currency’s minor units. A pipeline that ignores currency would compare KES 2,799.99 with $7.79 as if both were dollars.

Dividing by 100 fails too. The Omani rial has 3 decimal places, so 7500 OMR is OMR 7.500, not 75. Shopify’s descriptions of its MCP tools, which each endpoint returns in its tool list, cover 2-decimal and 0-decimal currencies, but not 3-decimal ones.

The AuraSkin product page for CeraVe Moisturizing Cream 236ml, with the price 7.500 OMR outlined next to the crossed-out 8.000 OMR
The AuraSkin page, October 24, 2026. The price has 3 decimal places, 7.500 OMR.

Anonymous calls stopped at about 100 per store per hour

Shopify sets UCP rate limits by identity. Calls with a Shopify token get the highest limits, signed calls get lower ones, and anonymous calls get the lowest.

Shopify's developer docs page Auth and rate limiting. The Anonymous row of the traffic tiers table and the line Rate limits scale with identification are outlined
Shopify's Auth and rate limiting page, October 24, 2026. It ranks the levels but gives no numbers for them.

From one IP address, anonymous calls stopped at about 100 per store per hour, and spacing calls 2 seconds apart didn’t raise the limit. At 05:01 UTC, Allbirds answered exactly 100 calls, and call 101 returned 429. Rumpl answered 99 calls sent 2 seconds apart, after 1 earlier call that hour. Every Retry-After ended at the top of an hour. While one store was blocked, the others kept answering, and a second profile from the same IP address got the same 429. So the count is per store, not per profile. I didn’t test other IP addresses.

Going over 100 cost more than the rest of the hour. In the next hour, Allbirds answered only 1 call and Rumpl 6 before another 429. That second 429 didn’t extend the penalty. The hour after that, Allbirds answered 100 calls and Rumpl 96. I sent no calls to Rumpl during its block, so this wasn’t caused by retrying too early.

A terminal window with 6 rate-limit runs on 2 stores. Allbirds answered 91, then 1, then 100 calls before a 429. Rumpl answered 99 calls at 1 call every 2 seconds, then 6, then 96. Each row shows the Retry-After wait in seconds
Every rate-limit run on October 24, 2026, printed from the saved logs. Start times are UTC, and each wait ended at the top of the hour. The first Allbirds run came after about 9 earlier calls that hour, and the first Rumpl run after 1.

At 10 variant IDs per call, about 100 calls cover about 1,000 variants per store per hour. For more, Shopify offers a token (opens in a new tab), from an API key in its Dev Dashboard, or signed calls (opens in a new tab), with a key published in your profile.

What to run

On stores that use a Shopify theme, I’d keep the .js endpoint as the main read. It’s faster and returns every variant in one call. I’d use UCP for headless stores, and for agents that need one interface across stores. Outside Shopify, product data still comes from the pages.

When you read through UCP:

  • Host your own profile. The store offers tools based on the capabilities your profile declares, so declare only the ones you use.
  • Set available to false when you look up variants for stock.
  • Look up variant IDs, 10 per call, instead of product IDs.
  • Store currency with every amount, and use each currency’s decimal places from ISO 4217.
  • For production volume, get a token. Without one, stay well under 100 calls per store per hour, because going over cost most of the next hour too. On a 429, stop calling that store until Retry-After ends.

Test setup and limits

  • Stores. One product page from each of 34 Shopify stores that brands run themselves, like Allbirds, SKIMS, Kith and Stanley. They’re the same stores as my Clairic test.
  • Setup. Mumbai, a home connection, Python 3.14 and httpx 0.28, one request at a time. Every call sent this profile, which declares only catalog search and lookup. The calls had no token or signature, so they were anonymous.
  • Checks. UCP and .js both come from the store’s own data. So each price was also checked against the page’s JSON-LD, where the page had one.
  • Limits. 1 day and 1 location. The rate limit was measured on 2 stores from 1 IP address. It wasn’t measured on the Global Catalog, which has a separate limit in Shopify’s docs. I didn’t measure limits on the .js endpoint.

If you want the store list, the results or the scripts, email me and I’ll share them.

Doccraft writes research and tutorials like this for web data and AI infrastructure companies.