Deep dive · storefront internals
Every public JSON endpoint on a Shopify storefront
What each one returns, where the limits are, and why your browser refuses to read them even though curl has no problem at all.
# the whole trick, in one line
$ curl -s https://allbirds.com/meta.json | jq
{
"name": "Allbirds",
"country": "US",
"currency": "USD",
"domain": "www.allbirds.com",
"myshopify_domain": "weareallbirds.myshopify.com"
}
Short answer
Shopify storefronts serve a handful of unauthenticated JSON endpoints because its own themes and apps need them. The useful ones are /meta.json, /products.json, /collections.json, /cart.js and the per-product /products/handle.js. All are read-only, all are public by design, none of them expose customer or order data.
The catch: Shopify does not send permissive CORS headers on them, so browser JavaScript on your own domain cannot read the responses. Server-side requests are unaffected. That single detail dictates how you have to build anything on top of them.
Why these endpoints exist at all
Shopify themes are rendered server-side in Liquid, but modern themes also need to update the page without reloading: adding to cart, swapping variants, filtering a collection. Rather than invent a private channel for that, Shopify exposes the same data as plain JSON on the storefront. Anything the theme can read, anyone can read.
This is worth stating clearly because it gets misreported. These are not leaks, and finding them is not a security discovery. They are documented, intentional, and functionally equivalent to the storefront HTML that already contains most of the same information. A product page already renders its price, variants and images. /products/handle.js just gives you those as structured data instead of markup.
The practical consequence for anyone building tooling: you get a decent read-only API on any Shopify store with zero setup. No app registration, no OAuth handshake, no access token, no rate-limit budget to negotiate. That is unusual, and it is why a whole category of small Shopify utilities exists.
The endpoints, with real payloads
Every path below is appended to a storefront’s root. They work on both the custom domain and the myshopify domain, and both are equivalent as far as the response goes.
/meta.jsonGET~700 bytes
Shop-level identity. This is the one that powers domain lookups, because myshopify_domain is the store’s permanent handle and it appears nowhere else in a single clean field.
- id
- name
- city
- province
- country
- currency
- domain
- myshopify_domain
- money_format
- published_products_count
- published_collections_count
- ships_to_countries
Best for: identity confirmation. Before sending a collaborator request, the name and country fields tell you whether you have the right storefront or an international sibling of it.
{
"id": 1566146,
"name": "Gymshark US",
"city": "Denver",
"country": "US",
"currency": "USD",
"domain": "us.checkout.gymshark.com",
"myshopify_domain": "gymsharkusa.myshopify.com",
"published_products_count": 8713,
"published_collections_count": 518
}
// note: domain != the URL you requested. Multi-region
// stores redirect, and meta.json reports where you landed.
That last detail matters more than it looks. Request gymshark.com and you get back us.checkout.gymshark.com and gymsharkusa.myshopify.com, because the request was geo-routed. On a multi-region brand, the answer depends on where you asked from. If you need a specific regional store, request its regional domain directly.
/products.jsonGETpaginatedcan be large
The full published catalogue. Every product, every variant, every price, plus images and tags. Defaults to 30 products per page; ?limit=250 is the maximum and ?page=2 walks through the rest.
Only published products appear, so drafts and unpublished items are invisible. Inventory quantities are not included, which is the single most common misunderstanding about this endpoint.
Best for: scoping work before you quote. Product count, variant sprawl and how the catalogue is actually structured, without waiting for admin access.
{
"products": [
{
"id": 7401234567890,
"title": "Wool Runner",
"handle": "wool-runner",
"product_type": "Shoes",
"vendor": "Allbirds",
"tags": ["mens", "wool"],
"variants": [
{
"id": 42123456789,
"title": "US 9 / Natural Grey",
"price": "98.00",
"available": true,
"sku": "WR-M-09-NG"
}
]
}
]
}
Note available is a boolean, not a count. You learn whether a variant can be bought, never how many are left. If a tool claims to report competitor stock levels from this endpoint, it is inferring, not reading.
/collections.jsonGETpaginated
Published collections with handles, titles, descriptions and product counts. Same pagination rules as products.
Pair it with /collections/<handle>/products.json to walk a single category, which is much cheaper than pulling the whole catalogue when you only care about one section.
Best for: information architecture work. Navigation planning, category audits, spotting the twelve near-duplicate collections that accumulated over three years.
/products/<handle>.jsGETsmall
A single product, richer than its entry in the catalogue listing. Includes featured_image, the full media array, per-variant option breakdowns and price_min / price_max. Prices here are integers in the store’s minor currency unit: 9800 means 98.00, which catches people out constantly.
The .json variant of this path exists too but returns a slightly different shape. The .js one is what themes actually use.
Best for: debugging a single product when variant selection or pricing is misbehaving on the front end.
/cart.jsGETsession-scoped
The current cart, tied to your own session cookie. This is the one endpoint on the list that returns something personal, and it returns only your own data. There is no way to read another visitor’s cart.
Useful when a client reports “the cart total is wrong” and you need to see whether the problem is in the data or in how the theme renders it. Nine times out of ten it is the rendering.
Best for: cart and checkout debugging from the browser console on a live store.
?_fd=0query param
Not an endpoint, a switch. Shopify normally forwards handle.myshopify.com to the store’s primary custom domain. Appending ?_fd=0 disables that forwarding, so you can load the storefront on its permanent domain directly.
Its sibling ?pb=0 hides the Shopify preview bar, which is handy for clean screenshots when you are on a preview theme.
Best for: verifying you have the right store once a lookup gives you a handle, and for scraping page source without a redirect hop.
Shopify.shopinline JS globalfragile
Not JSON at all, but the fallback that matters. Standard themes set a global object in an inline script, containing Shopify.shop with the permanent domain, plus Shopify.theme and Shopify.currency.
Why it is fragile: headless storefronts, Hydrogen builds and heavily custom themes may never set it. It is a good second attempt after /meta.json and a bad first one, since parsing HTML with a regex is exactly as reliable as it sounds.
constm = html.match( /Shopify\.shop\s*=\s*["']([a-z0-9-]+\.myshopify\.com)["']/i ); // then fall back to a bare domain scan, and only then give up: // /\b([a-z0-9][a-z0-9-]{1,60}\.myshopify\.com)\b/i
The CORS problem, and the three honest ways around it
Shopify does not send an Access-Control-Allow-Origin header that permits arbitrary origins on these endpoints. So fetch() from your own page is blocked by the browser, not by Shopify. The request often reaches the server and returns fine; the browser simply refuses to hand you the body.
This confuses people because curl works instantly and so does opening the URL in a tab. Neither is subject to the same-origin policy. Only scripted cross-origin reads are, and that is the exact thing you need if you are building a browser tool.
Option 1: your own server-side proxy
The correct answer for anything you intend to keep running. A tiny endpoint on your own domain fetches the JSON server-side and returns it with permissive CORS headers. Same-origin from the browser’s perspective, so no blocking. About sixty lines in PHP or a single serverless function.
Lock it down or you have built an open proxy. The non-negotiables: allow https only, resolve the hostname and reject anything in private or reserved IP ranges to prevent SSRF against your own infrastructure, allow only the specific paths you need, and cap the response size.
// resolve first, then refuse private / reserved space $ip = gethostbyname($host);if($ip === $host || !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { http_response_code(403);exit; } // without this, ?url=https://169.254.169.254/... reaches // your cloud metadata endpoint. That is the whole exploit.
Option 2: public CORS relays
Services that fetch a URL and re-serve it with open CORS headers. Fine for a prototype, wrong for production. Rate limits are tight, uptime is nobody’s promise, and every lookup routes a client’s domain through a third party you have no agreement with.
If you must, race several in parallel and take the first success rather than chaining them with fallbacks. Chaining means every failure costs you its full timeout, and a three-deep chain of ten-second timeouts is a thirty-second wait before the error appears.
// resolve on first truthy result, reject only when all fail. // Promise.any is close but treats a resolved-null as success.functionfirstOf(list) {return newPromise((res, rej) => {letleft = list.length, done = false; list.forEach(p => p.then(v => {if(done)return;if(v) { done = true; res(v); }else if(--left === 0) rej(); }, () => {if(!done && --left === 0) rej(); })); }); }
Option 3: skip the browser entirely
If the tool is for you and not the public, a shell alias or a five-line script is less work than any of the above. curl -s "$1/meta.json" | jq -r .myshopify_domain solves the whole problem and will still work in five years.
Two failure modes worth designing for up front
Redirect drift. Multi-region brands geo-route your request, so the myshopify domain you get back depends on the exit node your proxy or relay used. A relay in Frankfurt and one in Virginia can legitimately return different answers for the same input. Always surface the shop name and country alongside the domain so the user can catch it.
Bot mitigation. High-traffic stores sit behind protection that will happily serve /meta.json to a normal browser and challenge a datacentre IP. Your proxy gets an interstitial HTML page with a 200 status. Validate the shape of what came back rather than trusting the status code, or you will parse a challenge page as if it were JSON.
Where a lookup actually spends its time
Median latency over 40 lookups against live stores, August 2026. The gap between a same-origin proxy and a public relay is the entire perceived difference between “instant” and “loading”.
What is not in here
No customer records, no order history, no draft products, no inventory counts, no financial data, no admin credentials. Everything on this list is data the storefront already renders to any visitor. If you need anything beyond it, you need the Admin API and an authenticated app.
Worth being precise about the boundary, because it is the difference between a legitimate tool and a problem:
- Storefront JSON is unauthenticated, read-only, published-content only. What this article covers.
- Storefront API is a GraphQL endpoint requiring a public access token. Still customer-facing data, but rate-limited and app-scoped.
- Admin API is where orders, customers and inventory live. Requires OAuth, an installed app, and explicit merchant-granted scopes.
The endpoints in this article sit firmly in the first bucket. They tell you what a store sells and what it is called. They tell you nothing about who bought it.
Putting it together
A production-grade lookup does four things: normalise whatever the user pasted down to a hostname, try meta.json across both www and apex variants in parallel through a same-origin proxy, fall back to an HTML scrape only if that fails, and validate the response shape rather than trusting a 200.
The ordering matters more than the code. /meta.json first because it is small, structured and authoritative. HTML scraping second because it costs a full page download and a regex. And a hard timeout on both, because a tool that hangs is worse than a tool that admits it failed.
That is exactly how the finder on this site works, if you want to see the pattern in a live implementation.
Common questions
Is accessing these Shopify endpoints legal?
They are published without authentication by design and serve the store’s own themes and apps, so reading them is equivalent to viewing the storefront. Nothing on this list grants access to a store or exposes personal data. What can cross a line is volume: hammering a store with thousands of requests is abusive regardless of whether the endpoint is public, and aggressive scraping may breach a site’s terms of service. Rate limit yourself and cache.
Why does fetch() fail when curl works fine?
Because the same-origin policy is enforced by the browser, not the server. Shopify does not return an Access-Control-Allow-Origin header permitting your origin, so the browser discards the response before your JavaScript sees it. The request itself frequently succeeded. curl and address-bar navigation are not subject to the policy at all.
Can I get inventory quantities from products.json?
No. You get available as a boolean per variant, meaning purchasable or not. Actual stock counts live behind the Admin API and require authenticated access. Any tool claiming to show competitor stock levels from public endpoints is inferring from availability flips over time, not reading a number.
Do these endpoints work on headless and Hydrogen storefronts?
Inconsistently. A headless front end may live on a domain that never proxies these paths, in which case /meta.json returns your framework’s 404 rather than store JSON. The underlying myshopify domain still serves them, so if you already know the handle you can query it directly. Discovering the handle from a headless custom domain is the genuinely hard case.
Why do I get a different myshopify domain than a colleague?
Geo-routing. Multi-region brands run separate stores per market and redirect based on request origin, so meta.json reports whichever store you actually landed on. A request from Karachi and one from Chicago can return different handles for the same input URL. Check the country and name fields, and query the specific regional domain if you need a particular store.
Are there rate limits on the storefront JSON endpoints?
Shopify does not publish explicit numbers for these, but they sit behind the same edge protection as the storefront. Sustained aggressive requests get you challenged or throttled, and you will receive an HTML interstitial with a 200 status rather than an error code. Validate response shape, back off on failure, and cache anything you request more than once.
Should I use a public CORS relay in production?
No. Rate limits are tight and undocumented, availability is not guaranteed, and every request routes a third party’s domain through infrastructure you do not control and have no agreement with. A sixty-line proxy on your own server removes all three problems and is faster. Relays are fine for a prototype and nothing beyond it.
See the pattern running
The domain finder on this site implements exactly this: same-origin proxy, parallel meta.json attempts across both host variants, HTML scrape as fallback, shape validation on the response.
Read next
- Five ways to find a Shopify store’s myshopify.com URLThe non-technical version, if you just need the answer once.
- Shopify collaborator request not working? Every error, explainedEight failure modes, including the bugs Shopify confirmed in 2026.
- Collaborator or staff account: which should you give your developer?The merchant-side view, worth sending to a client who is hesitating.