The API
Admin operations
Adding a .graphql file, and declaring its scopes.
Drop a .graphql file in src/api/operations/. The filename is the name the
client calls, and the build fails if it disagrees with the operation declared
inside.
1# src/api/operations/GetProductMetafields.graphql2#3# Scopes: read_products4query GetProductMetafields($id: ID!, $namespace: String!) {5 product(id: $id) {6 metafields(first: 20, namespace: $namespace) {7 nodes { key value type }8 }9 }10}const { product } = await admin('GetProductMetafields', { id, namespace: 'specs' })Use variables for anything that changes — interpolating into the document would
mean a redeploy per call. Mutations that report userErrors throw an
AdminError carrying them, so a "successful" 200 that actually failed doesn't
slip past.
Declare the scopes
Put a # Scopes: line in the header comment. npm run api:build unions them
across every operation and prints the exact block to paste into
shopify.app.toml:
Access scopes — paste into shopify-app/shopify.app.toml: [access_scopes] scopes = "read_inventory,read_locations,read_products,write_customers"Missing scopes fail at the Admin API with an access-denied error that names neither the scope nor the operation, which is the worst part of setting this up.
An operation with no # Scopes: line is reported as undeclared, so the list
can't quietly go stale. Write # Scopes: none when none are needed — that is
different from omitting the line.
What ships
GetShop, GetProductMetafields, GetVariantInventory, SubscribeCustomer.
Where's the Admin API token?
There isn't one. Shopify stopped issuing static shpat_… tokens for new
apps, and custom apps can no longer be created from the Shopify admin. A Dev
Dashboard app gives you a Client ID and a Client secret.
The replacement is the client credentials grant: those two values are traded for a token that lasts 24 hours. The gateway handles it — exchanging on demand, caching for the life of the container, refreshing a minute before expiry, and collapsing a concurrent burst into a single exchange.
Two consequences:
- The client secret does double duty. It verifies App Proxy signatures and
buys Admin tokens. To keep them separate, set
SHOPIFY_CLIENT_SECRETand it wins for the token exchange. - The app and the store must be in the same organization, or the exchange
fails with
shop_not_permitted, which no retry fixes.
On a custom app made before 2026, set SHOPIFY_ADMIN_TOKEN to its shpat_…
instead. It takes priority and nothing is exchanged.
Worked example: Store availability
Which locations have a variant in stock — the clearest reason this gateway exists, since the Storefront API can tell you whether something is in stock but not where.
Everything for it ships: the operation, the component, and a Store
availability block on the product section. Add the block in the theme editor
and it works — no code to write. It needs read_products, read_inventory and
read_locations.
Three behaviours are deliberate, and each would be a bug the other way:
- Untracked inventory renders nothing. A variant that doesn't track stock has no levels at all; reporting that as "out of stock everywhere" is wrong.
- Locations that don't fulfil online orders are hidden. Warehouse stock a shopper can't buy from is noise.
- A failed request renders nothing. A store that hasn't deployed the gateway just doesn't see the block, instead of an error where stock should be.