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.graphql
2#
3# Scopes: read_products
4query 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 scopespaste 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_SECRET and 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.