Admin GraphQL API

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the Shopify GraphQL Admin API version 2026-10 (the latest stable version, released on 2026-10-01), Shopify CLI 5.0.0, @shopify/admin-api-client 2.0.1 and @shopify/shopify-api 15.0.1, and checked on 2026-10-27. The 2027-01 release candidate is already published and is mentioned where it differs. The Admin API is the main programmatic interface to a Shopify store for apps, integrations and scripts. Its throughput depends on the plan of the store. The page covers the request format, versioning, queries and mutations, the cost-based rate limits, pagination, error handling and idempotency, then the legacy REST Admin API and a migration path from REST to GraphQL.

The running example is Pocket Coffee, the small specialty-coffee shop used across this section. It sells the product "Single-origin beans" (three origins, three grinds and two sizes, 18 variants, SKUs such as PC-ETH-WHL-250) and a "Starter kit" at 24.00 EUR, has a "Coffee Club" customer tag, and wants an integration that reads products, keeps inventory in sync with a warehouse and tracks placed orders. Everything that needs a real store or a real token (calls to https://<shop>.myshopify.com, query costs, rate-limit behavior) is documented from the official pages and was not executed. What was executed offline is stated in the section on validation at the end of the page: every GraphQL document and its variables were validated against the downloaded public Admin schema of each supported version, and the pagination and throttling client was run against a local stand-in server.

What the Admin GraphQL API is

The GraphQL Admin API exposes the data and operations of the Shopify admin: products and variants, inventory, orders, fulfillments, customers, discounts, metafields and metaobjects, files, webhooks subscriptions, bulk operations and more. All operations go through one endpoint per shop and API version, as an HTTP POST:

https://{shop}.myshopify.com/admin/api/2026-10/graphql.json

The path has three parts that are easy to get wrong. /admin/ distinguishes it from the Storefront API, whose path is /api/2026-10/graphql.json. The version segment (2026-10) pins the schema. {shop}.myshopify.com is the permanent myshopify.com domain of the store, never a custom storefront domain. The official bulk-operation pages show Admin examples with the Storefront-style path /api/{api_version}/graphql.json; the working Admin path is the one above.

The request body is JSON with a query string, and optionally variables (an object) and operationName. The request must carry Content-Type: application/json, otherwise the API answers 400 Bad Request, and an access token in the X-Shopify-Access-Token header.

Part of the request Meaning

POST /admin/api/{version}/graphql.json

The only endpoint. Queries, mutations and introspection all use it. Queries are the equivalent of GET in REST, and mutations replace POST, PUT and DELETE.

X-Shopify-Access-Token

The access token of the app on that store. Which token and how it is obtained depends on the app type: Shopify-managed installation with token exchange for embedded apps, the authorization code grant for non-embedded apps, a client credentials grant for apps that act only on stores of the developer’s own organization.

Content-Type: application/json

Required. A missing or wrong content type is one of the causes of 400 Bad Request.

Shopify-GraphQL-Cost-Debug: 1

Optional. Adds a per-field cost breakdown under extensions.cost.fields in the response.

query, variables, operationName

The JSON body fields. Use variables for every dynamic value instead of building the query string by hand.

The response has the standard GraphQL shape plus a Shopify extension: a data object, an optional errors array, and an extensions.cost object that reports what the request cost (see the section on rate limits). Every response also carries the header X-Shopify-API-Version, which names the version that actually served the request.

Access scopes

An app can only read or write the objects covered by the access scopes it was granted at installation. For the Pocket Coffee integration the relevant scopes are read_products and write_products (products, variants, collections), read_inventory and write_inventory (inventory items and levels), read_orders (orders), read_customers and write_customers (customers) and read_metaobjects, write_metaobjects for metaobjects. Metafields follow the scope of the resource that owns them.

By default read_orders and write_orders cover only orders created in the last 60 days. Older orders require read_all_orders declared alongside read_orders or write_orders, and Shopify approval. Reading personal customer data (name, email, address) from orders and customers is additionally subject to protected customer data requirements for public apps. Scopes are declared in shopify.app.toml, as described in the page on authentication and access scopes (Authentication and Access Scopes).

A request that needs a scope the app does not have is answered with HTTP 200 and an error whose extensions.code is ACCESS_DENIED; do not branch on a 403 to detect a missing scope. On the GraphQL Admin API a 403 means the store has been marked as fraudulent. Starting on 2027-01-01 a request that presents the non-expiring offline access token of a public app also receives 403, with the message Non-expiring access tokens are no longer accepted for the Admin API.

Ways to call the API

Client Use it for

Admin client of the React Router app template (admin.graphql)

The default for embedded apps scaffolded with shopify app init. authenticate.admin(request) returns an admin object that already knows the shop, the access token and the API version configured in shopify.server.ts (ApiVersion.October26).

unauthenticated.admin(shop) of @shopify/shopify-app-react-router

Background jobs and webhook handlers that have no browser request, using the offline session stored for the shop.

@shopify/admin-api-client 2.0.1

A small, framework-free GraphQL client for any JavaScript runtime. createAdminApiClient takes storeDomain, apiVersion, accessToken, and optionally retries (0 to 3, applied only to HTTP 429 and 503), customFetchApi and logger. Its request() returns data, errors and extensions.

@shopify/shopify-api 15.0.1 (shopify.clients.Graphql)

Server-side Node.js code that manages sessions itself. The Ruby gem shopify_api is the equivalent for Ruby.

Direct API access

Code running inside the Shopify admin (app home pages, admin extensions) calls fetch('shopify:admin/api/2026-10/graphql.json') and Shopify authenticates the request. It needs [access.admin] embedded_app_direct_api_access = true in the app configuration.

shopify app execute and shopify store execute

Run one query or mutation from the command line against a dev store (--query, --query-file, --variables, --variable-file, --version, --json, --output-file). app execute allows mutations only on dev stores. store execute needs stored authentication (shopify store auth) and is read-only unless --allow-mutations is passed.

shopify app graphiql and shopify store graphiql

Open an authenticated local GraphiQL explorer for an installed app and a store.

cURL or any HTTP client

Debugging and non-JavaScript integrations.

The CLI help of the installed Shopify CLI 5.0.0 (shopify app execute --help, shopify store execute --help) was read and matches the flags listed above; the commands themselves need a login and were not executed.

The minimal request, with obvious placeholders for the shop and the token:

curl -sS -X POST "https://${SHOPIFY_SHOP}.myshopify.com/admin/api/2026-10/graphql.json" \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Access-Token: ${SHOPIFY_ACCESS_TOKEN}" \
  -H "Shopify-GraphQL-Cost-Debug: 1" \
  -d '{"query":"query ShopInfo { shop { name myshopifyDomain currencyCode } }"}'

The same operation from an embedded app built on the React Router template. The #graphql marker on the first line of the template string is what the code generation tooling uses to find operations in source files.

import type { LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const response = await admin.graphql(
    `#graphql
    query ShopInfo {
      shop { name myshopifyDomain currencyCode }
    }`,
  );
  const { data } = await response.json();
  return { shop: data.shop };
}

API versioning

The Admin GraphQL API is versioned. A new version is released every three months at 17:00 UTC on the first day of the quarter and is named YYYY-MM. Each stable version is supported for at least 12 months, and consecutive versions overlap for at least nine months. The same quarterly cadence applies to the Storefront API, the Customer Account API, the Function APIs, Webhooks and the Partner API; Liquid, OAuth endpoints, the Ajax API, App Bridge APIs and the Web Pixels API are unversioned and may change at any time.

Version Released Accessible until Status on 2026-10-27

2025-04

2025-04-01

2026-04-16 15:00 UTC

Unsupported

2025-07

2025-07-01

2026-07-16 15:00 UTC

Unsupported

2025-10

2025-10-01

2026-10-16 15:00 UTC

Unsupported

2026-01

2026-01-01

2027-01-16 15:00 UTC

Stable

2026-04

2026-04-01

2027-04-16 15:00 UTC

Stable

2026-07

2026-07-01

2027-07-16 15:00 UTC

Stable

2026-10

2026-10-01

2027-10-16 15:00 UTC

Latest stable

2027-01

2027-01-01

2028-01-16 15:00 UTC

Release candidate

There are three kinds of version:

  • Stable: recommended for production and guaranteed not to change for its supported lifetime. Since 2025-04-01 Shopify also adds new enum values to older stable versions retroactively, which makes enums non-exhaustive: refresh cached introspection results periodically and give every switch over an enum a catch-all branch.

  • Release candidate: published on the same day as the stable release before it (the 2027-01 candidate appeared on 2026-10-01). It may still contain backward-incompatible changes and is meant for testing your app before the release, not for production.

  • Unstable (unstable in the URL): updated continuously with in-progress changes that may be added or removed at any time. Use it only for early experiments.

Pin the version and watch for fall-forward

Always put an explicit version in the URL. When a request targets a version that is no longer accessible, Shopify does not fail: it falls forward and answers with the oldest accessible stable version. A request to the retired 2025-10 is therefore served by 2026-01 today, with a schema that may differ from the one the code was written for, and the only signal is the response header. Check it in tests and in monitoring:

const response = await fetch(url, init);
const served = response.headers.get('X-Shopify-API-Version');
if (served !== '2026-10') {
  console.warn(`Requested 2026-10 but Shopify served ${served}`);
}

The versioning page spells the header X-Shopify-API-Version for API responses and X-Shopify-Api-Version on webhook deliveries; HTTP header names are case-insensitive, so both are the same header for a client.

Official libraries pin the version for you. In the React Router template it is apiVersion: ApiVersion.October26 in shopify.server.ts, and the same constant feeds the code generation configuration in .graphqlrc.ts (shopifyApiProject({ apiType: ApiType.Admin, apiVersion: ApiVersion.October26, ... })). A version bump is therefore a one-line change plus a regeneration of the types, and the compiler then reports every operation the new schema no longer accepts. Other places that carry an API version in an app are the api_version of [webhooks] in shopify.app.toml, the api_version of each extension in its shopify.extension.toml, and the version in any hand-written URL.

Deprecation and removal

When part of the API becomes unnecessary, unsafe or outdated it is deprecated in all supported stable versions at once and announced in the developer changelog with migration guidance. It is removed in a subsequent release, for example something deprecated in 2026-10 can be removed in 2027-01. Because versions overlap by at least nine months, there is always time to migrate before removal. Deprecated fields carry the @deprecated directive in the schema, so introspection and the explorer show them with a reason. In the 2026-10 schema, for example, the query currentBulkOperation is deprecated in favor of bulkOperations, the mutation collectionAddProducts in favor of collectionUpdate, productChangeStatus in favor of productUpdate and fulfillmentCreateV2 in favor of fulfillmentCreate. The automaticDiscounts query is announced to be removed in 2027-01.

An entire API can be deprecated, not only fields; a deprecated API stays available but unsupported and may stop working at any time. Apps are warned through alerts in the Dev Dashboard, deprecation warnings in explorer tools, the changelog, the reference and, for imminent breaking changes, the emergency developer contact. Deprecated calls are attributed to the app whose access token made them, so do not reuse an app’s token in Postman collections or scripts: they raise alerts against the app. An app that keeps using unsupported resources after the upgrade deadline is delisted from the Shopify App Store, and users are blocked from installing it for at least seven days.

Upgrade routine

A quarterly upgrade is mostly mechanical when the operations live in source files that tooling can see:

  1. Read the release notes of the new version (Shopify: API release notes) for entries marked as requiring action.

  2. Download the schema of the new version and validate every operation against it (the section on validation shows a complete script). Anything that fails to validate is a field or argument that was removed or renamed.

  3. Change the version constant, regenerate types, fix compile errors, run the test suite.

  4. Check the changelog for runtime behavior changes that a schema cannot reveal, such as the 2026-10 change that recalculates taxes when an order shipping address is updated.

  5. Roll out, and compare X-Shopify-API-Version in the responses with the expected value.

Queries

A query starts at the fields of QueryRoot. The 2026-10 schema has 298 query root fields, 537 mutations and 3,708 types in total. Three habits keep queries cheap and stable: name every operation, pass dynamic values as variables, and ask only for the fields the code uses.

Reading the shop

query ShopInfo {
  shop {
    name
    myshopifyDomain
    currencyCode
    plan {
      displayName
    }
  }
}

Global IDs

Objects are identified by global IDs, not by the numeric IDs of the REST API. A global ID has the form gid://shopify/{ObjectName}/{id}, for example gid://shopify/Product/123. Some child objects need parameters, such as gid://shopify/InventoryLevel/123?inventory_item_id=456. Treat a global ID as an opaque string that is built by Shopify: store and pass it as returned and only construct one when migrating from a REST numeric ID (see the migration section).

Every object that implements the Node interface can be fetched by ID through the root fields node(id:) and nodes(ids:). Use inline fragments to select type-specific fields. nodes returns a list in the same order as the IDs, with null for IDs that do not resolve.

query ProductAndVariants($productId: ID!, $variantIds: [ID!]!) {
  node(id: $productId) {
    id
    ... on Product {
      title
      handle
      status
    }
  }
  nodes(ids: $variantIds) {
    id
    ... on ProductVariant {
      sku
      price
      product {
        title
      }
    }
  }
}
Variables
{
  "productId": "gid://shopify/Product/1000",
  "variantIds": [
    "gid://shopify/ProductVariant/2000",
    "gid://shopify/ProductVariant/2001"
  ]
}

The ID values above are placeholders. The array argument ids follows the general limit that input arrays accept at most 250 items on every Shopify API.

Single objects by handle or custom ID

For products, variants, collections, customers and a few other types, a ...ByIdentifier root field looks an object up by ID, handle or a unique metafield value, which saves a search round trip.

query ProductByHandle($handle: String!) {
  productByIdentifier(identifier: { handle: $handle }) {
    id
    title
    status
    totalInventory
    variantsCount {
      count
    }
    options {
      name
      values
    }
  }
}
Variables
{ "handle": "single-origin-beans" }

Search, sort and filter

Connection root fields (products, productVariants, orders, customers, collections, inventoryItems, and so on) accept a query string with Shopify’s search syntax, a sortKey enum specific to the connection, and reverse. The same grammar is used by the REST query parameter, webhook delivery filters and the admin search box. The grammar in short:

Feature Syntax Example

Field search

field:value, no space after the colon

sku:PC-ETH-WHL-250

Comparators

: :< :> :<= :>=

created_at:>=2026-10-01

Boolean

AND (implicit), OR, NOT or -; OR binds tighter than the implicit AND

status:active -tag:archived

Grouping and phrases

Parentheses and double quotes

(tag:'Coffee Club' OR tag:VIP)

Prefix and exists

* wildcard

title:Single*, published_at:*

Escaping

Backslash before : \ ( )

sku:PC\-ETH

Two behaviors cause silent bugs. A search on a field name that does not exist is ignored and the connection returns all results, so a typo in a filter does not raise an error; schema validation cannot catch it because the query argument is a plain string. And range searches on a large collection can time out or fail when the search field differs from the connection sort key, in which case set sortKey to the field being searched.

query FindBeansVariant {
  productVariants(first: 5, query: "sku:PC-ETH-WHL-250") {
    nodes {
      id
      sku
      price
      inventoryQuantity
      product {
        id
        title
      }
    }
  }
}

Orders show the date-range pattern, with the sort key matching the filtered field. This is the query Pocket Coffee uses to reconcile the day’s orders. It needs the read_orders scope and returns only orders from the last 60 days unless the app also has read_all_orders.

query RecentPaidOrders($since: String!) {
  orders(
    first: 50
    query: $since
    sortKey: CREATED_AT
    reverse: true
  ) {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      id
      name
      createdAt
      displayFinancialStatus
      displayFulfillmentStatus
      currentTotalPriceSet {
        shopMoney {
          amount
          currencyCode
        }
      }
    }
  }
}
Variables
{ "since": "created_at:>=2026-10-01 financial_status:paid" }

Customers are searched with tags in the same way. Reading email and other personal fields is protected customer data for public apps. The Coffee Club members of Pocket Coffee (for example ada@example.test) are the customers with the tag Coffee Club:

query CoffeeClubMembers($cursor: String) {
  customers(first: 100, after: $cursor, query: "tag:'Coffee Club'", sortKey: NAME) {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      id
      displayName
      email
      tags
    }
  }
}

Counts

Counts are separate root fields (productsCount, ordersCount, customersCount, productVariantsCount) that return a Count object with a number and a precision. They accept the same query string and an optional limit. Counts are accurate up to 25,000: for larger sets they return 25,001, which means "more than 25,000".

query ActiveProductCount {
  productsCount(query: "status:active") {
    count
    precision
  }
}

Variables, fragments, aliases and directives

Standard GraphQL features work as in any server. Fragments remove repetition, aliases let one request fetch the same field twice with different arguments, and the standard @include(if:) and @skip(if:) directives turn parts of the selection on and off from variables. Besides the built-ins the schema defines @idempotent(key:) (see the section on idempotency), @accessRestricted(reason:), which marks schema elements with restricted access, @deprecated(reason:) and @oneOf, which marks input objects of which exactly one field must be supplied.

fragment VariantFields on ProductVariant {
  id
  sku
  price
  inventoryQuantity
}

query BeansWithOptionalVariants($withVariants: Boolean!) {
  beans: productByIdentifier(identifier: { handle: "single-origin-beans" }) {
    id
    title
    variants(first: 20) @include(if: $withVariants) {
      nodes {
        ...VariantFields
      }
    }
  }
  kit: productByIdentifier(identifier: { handle: "starter-kit" }) {
    id
    title
  }
}
Variables
{ "withVariants": true }

For the language itself (fragments, variables, directives, introspection, validation rules) see the GraphQL reference of this documentation, in particular Queries and Fields, Variables, Directives and Fragments, Pagination and Response and Error Handling.

Mutations and user errors

Mutations change data. Each mutation takes an input (an input object or arguments), returns a payload object and, almost always, a userErrors list. The convention matters for error handling:

  • The response errors array reports problems with the request as a whole: syntax or validation errors, missing scopes (ACCESS_DENIED), throttling (THROTTLED), query cost, internal errors. When it is present the operation usually did not run.

  • userErrors in the payload report business-rule failures of one mutation: a missing title, an unknown ID, a duplicate SKU. The request itself was valid and the HTTP status is 200. Most payloads use the UserError type with field (a path to the invalid input) and message; many mutations use a specialized type that adds a code enum (ProductVariantsBulkCreateUserError, MetafieldsSetUserError, ProductSetUserError). Always select userErrors, with code where the type has it, because a mutation that is not asked for its userErrors fails silently.

  • Mutations have a default cost of 10 points, and several (product mutations, metafieldsSet) have dynamic costs.

Shopify also changes which problems surface as userErrors over time, for example productCreate started to return input errors as userErrors instead of top-level errors in June 2025. Clients should check both places on every call.

Creating products

The product model has three tiers: the product, its options (such as origin or size, each with values) and its variants, which are the purchasable combinations of option values and carry the SKU, price, barcode and inventory. The REST endpoints that list, create, update and delete products have been deprecated since REST version 2024-04 in favor of this model, and the old variant mutations productVariantCreate and productVariantUpdate no longer exist in the 2026-10 schema. The incremental mutations are productCreate, productUpdate, productDelete, productOptionsCreate (and productOptionUpdate, productOptionsReorder, productOptionsDelete), productVariantsBulkCreate, productVariantsBulkUpdate, productVariantsBulkDelete and productVariantsBulkReorder. For an external system that is the source of truth the declarative productSet replaces the whole state of a product in one call.

productCreate takes a product argument of type ProductCreateInput. It creates the product with one standalone default variant unless productOptions are supplied. The Pocket Coffee "Starter kit" is a simple product: create it, then set the price of its default variant with productVariantsBulkUpdate, exactly as the React Router app template does.

mutation CreateStarterKit($product: ProductCreateInput!) {
  productCreate(product: $product) {
    product {
      id
      handle
      status
      variants(first: 1) {
        nodes {
          id
          price
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}
Variables
{
  "product": {
    "title": "Starter kit",
    "handle": "starter-kit",
    "vendor": "Pocket Coffee",
    "productType": "Equipment",
    "status": "DRAFT",
    "tags": ["equipment", "kit"]
  }
}
mutation SetStarterKitPrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants {
      id
      price
      sku
    }
    userErrors {
      field
      message
      code
    }
  }
}
Variables
{
  "productId": "gid://shopify/Product/1001",
  "variants": [
    {
      "id": "gid://shopify/ProductVariant/2018",
      "price": "24.00",
      "inventoryItem": { "sku": "PC-KIT-001", "tracked": true }
    }
  ]
}

The IDs in the variables are placeholders: they would come from the productCreate response. productVariantsBulkCreate and productVariantsBulkUpdate accept up to 2,048 variants per call, productVariantsBulkDelete up to 250. With productVariantsBulkCreate the strategy argument controls the standalone default variant: REMOVE_STANDALONE_VARIANT removes it so that all 2,048 slots are usable, while DEFAULT and PRESERVE_STANDALONE_VARIANT keep it.

Syncing a whole product with productSet

"Single-origin beans" has three options and 18 variants. When the catalogue is maintained in a warehouse or PIM system, productSet is the right mutation: it creates or updates the product and replaces the lists of options and variants with exactly what is sent, and it changes only the scalar fields that are present. It takes an input of type ProductSetInput, a synchronous flag and an optional identifier (ID, handle or custom ID) to update an existing product. With synchronous: false it returns a productSetOperation to poll, which is how large products are handled.

mutation SyncBeans($input: ProductSetInput!, $synchronous: Boolean!) {
  productSet(input: $input, synchronous: $synchronous) {
    product {
      id
      handle
      variantsCount {
        count
      }
    }
    productSetOperation {
      id
      status
    }
    userErrors {
      field
      message
      code
    }
  }
}

The variables are generated from the catalogue definition rather than typed by hand. The script below builds the 18 variants, the SKUs (PC-<ORIGIN>-<GRIND>-<SIZE>) and the prices of the running example: 8.90 for 250 g and 29.90 for 1 kg, plus 1.00 per 250 g for Ethiopia (9.90 for 250 g and 33.90 for 1 kg).

const origins = [['ETH', 'Ethiopia Yirgacheffe'], ['COL', 'Colombia Huila'], ['BRA', 'Brazil Cerrado']];
const grinds = [['WHL', 'Whole bean'], ['ESP', 'Espresso'], ['FIL', 'Filter']];
const sizes = [['250', '250 g', 8.9, 250], ['1KG', '1 kg', 29.9, 1000]];
const warehouse = 'gid://shopify/Location/9000'; // placeholder location ID

const variants = [];
for (const [o3, origin] of origins) {
  for (const [g3, grind] of grinds) {
    for (const [s, size, base, grams] of sizes) {
      const surcharge = o3 === 'ETH' ? (grams / 250) * 1.0 : 0;
      variants.push({
        optionValues: [
          { optionName: 'Origin', name: origin },
          { optionName: 'Grind', name: grind },
          { optionName: 'Size', name: size },
        ],
        price: (base + surcharge).toFixed(2),
        inventoryItem: {
          sku: `PC-${o3}-${g3}-${s}`,
          tracked: true,
          measurement: { weight: { value: grams, unit: 'GRAMS' } },
        },
        inventoryQuantities: [{ locationId: warehouse, name: 'available', quantity: 40 }],
      });
    }
  }
}

export const variables = {
  synchronous: true,
  input: {
    title: 'Single-origin beans',
    handle: 'single-origin-beans',
    vendor: 'Pocket Coffee',
    productType: 'Coffee',
    status: 'ACTIVE',
    productOptions: [
      { name: 'Origin', position: 1, values: origins.map(([, n]) => ({ name: n })) },
      { name: 'Grind', position: 2, values: grinds.map(([, n]) => ({ name: n })) },
      { name: 'Size', position: 3, values: sizes.map(([, n]) => ({ name: n })) },
    ],
    variants,
  },
};

This script was run, and its output validated against the 2026-10 schema as the variables of the SyncBeans mutation: 18 variants, SKUs from PC-ETH-WHL-250 to PC-BRA-FIL-1KG, and ProductSetInput accepted every field. The first two variant entries of the generated document are:

Variables (first two of 18 variant entries)
{
  "synchronous": true,
  "input": {
    "title": "Single-origin beans",
    "handle": "single-origin-beans",
    "vendor": "Pocket Coffee",
    "productType": "Coffee",
    "status": "ACTIVE",
    "productOptions": [
      { "name": "Origin", "position": 1, "values": [{ "name": "Ethiopia Yirgacheffe" }, { "name": "Colombia Huila" }, { "name": "Brazil Cerrado" }] },
      { "name": "Grind", "position": 2, "values": [{ "name": "Whole bean" }, { "name": "Espresso" }, { "name": "Filter" }] },
      { "name": "Size", "position": 3, "values": [{ "name": "250 g" }, { "name": "1 kg" }] }
    ],
    "variants": [
      {
        "optionValues": [
          { "optionName": "Origin", "name": "Ethiopia Yirgacheffe" },
          { "optionName": "Grind", "name": "Whole bean" },
          { "optionName": "Size", "name": "250 g" }
        ],
        "price": "9.90",
        "inventoryItem": { "sku": "PC-ETH-WHL-250", "tracked": true, "measurement": { "weight": { "value": 250, "unit": "GRAMS" } } },
        "inventoryQuantities": [{ "locationId": "gid://shopify/Location/9000", "name": "available", "quantity": 40 }]
      },
      {
        "optionValues": [
          { "optionName": "Origin", "name": "Ethiopia Yirgacheffe" },
          { "optionName": "Grind", "name": "Whole bean" },
          { "optionName": "Size", "name": "1 kg" }
        ],
        "price": "33.90",
        "inventoryItem": { "sku": "PC-ETH-WHL-1KG", "tracked": true, "measurement": { "weight": { "value": 1000, "unit": "GRAMS" } } },
        "inventoryQuantities": [{ "locationId": "gid://shopify/Location/9000", "name": "available", "quantity": 40 }]
      }
    ]
  }
}

Because productSet replaces list fields, omitting a variant that exists in the store deletes it. That is the intent when the external system is the source of truth, and the main pitfall otherwise: for targeted edits use the incremental mutations. The cost of productSet is dynamic (10 points, plus 0.2 per variant, 0.6 per variant file, 0.4 per metafield and 1.9 per product file), so by the published formula the Pocket Coffee product costs 10 + 18 x 0.2 = 13.6 points; this is a calculation from the formula, not a measured value. The mutation supports up to 2,048 variants on every plan.

Metafields

The roast date shown by the "Roast date badge" of Pocket Coffee is a product metafield, custom.roast_date of type date. metafieldsSet writes up to 25 metafields in one call (with a total payload of at most 10 MB), creating or updating each by owner, namespace and key. The operation is atomic: if one metafield is rejected, none is persisted. Since 2024-07 it supports compare-and-set: pass the compareDigest of a previously read metafield and the value is only written if the stored one still matches, or pass null to write only when the metafield does not exist yet. Since 2026-09-15 the cost of the mutation is 10 points plus a weight for every distinct owner resource, so batch writes by owner rather than sending one call per metafield.

mutation SetRoastDate($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields {
      id
      namespace
      key
      value
      type
    }
    userErrors {
      field
      message
      code
    }
  }
}
Variables
{
  "metafields": [
    {
      "ownerId": "gid://shopify/Product/1000",
      "namespace": "custom",
      "key": "roast_date",
      "type": "date",
      "value": "2026-10-20"
    }
  ]
}

Metafield definitions, metaobjects and how a theme reads these values are covered in the page on metafields and metaobjects (Metafields and Metaobjects).

Tags

Tags are added and removed with tagsAdd and tagsRemove, which take the global ID of a resource that supports tags (for example a product, an order or a customer; a resource that does not support tagging returns a resource-not-found error). Adding the Coffee Club tag is how Pocket Coffee enrolls a customer in the club:

mutation EnrollCoffeeClub($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) {
    node {
      id
    }
    userErrors {
      field
      message
    }
  }
}
Variables
{ "id": "gid://shopify/Customer/3000", "tags": ["Coffee Club"] }

Inventory

Inventory is a separate object graph: a variant has an InventoryItem, which has an InventoryLevel at each location with quantities by name (available, on_hand, committed, and so on). To set an absolute quantity, use inventorySetQuantities; for a relative change use inventoryAdjustQuantities. Both require the write_inventory scope and, since API version 2026-04, the @idempotent directive (see the next section). inventorySetQuantities takes a reason, the quantity name and a list of quantities; each entry names an inventoryItemId, a locationId and the new quantity.

mutation SetBeansStock($input: InventorySetQuantitiesInput!, $idempotencyKey: String!) {
  inventorySetQuantities(input: $input) @idempotent(key: $idempotencyKey) {
    inventoryAdjustmentGroup {
      id
      reason
      changes {
        name
        delta
      }
    }
    userErrors {
      field
      message
      code
    }
  }
}
Variables
{
  "idempotencyKey": "3f5b6ebf-143c-4da5-8d0f-fb8553bfd85d",
  "input": {
    "name": "available",
    "reason": "correction",
    "referenceDocumentUri": "logistics://pocket-coffee/stocktake/2026-10-27",
    "quantities": [
      {
        "inventoryItemId": "gid://shopify/InventoryItem/4000",
        "locationId": "gid://shopify/Location/9000",
        "quantity": 36,
        "changeFromQuantity": 40
      }
    ]
  }
}

As of 2026-04, changeFromQuantity is mandatory on every entry of quantities: the mutation only updates a quantity when the stored value equals changeFromQuantity (a compare-and-swap) and otherwise returns the user error CHANGE_FROM_QUANTITY_STALE, instead of overwriting a concurrent change. Passing null explicitly skips the check, which is acceptable only when the integration is the single source of truth for that stock; omitting the field is an error. The field is already accepted by the 2026-01 schema, so the same document works on all supported versions.

Updating a product

productUpdate takes a product argument (type ProductUpdateInput) that carries the id, and an optional identifier argument to address the product by handle or custom ID instead. Older examples, including one on the official Global IDs page, still show the pre-2024-04 form with an input argument; that form is rejected by the 2026-10 schema, as the validation section shows.

mutation PublishBeans($product: ProductUpdateInput!) {
  productUpdate(product: $product) {
    product {
      id
      status
      updatedAt
    }
    userErrors {
      field
      message
    }
  }
}
Variables
{
  "product": {
    "id": "gid://shopify/Product/1000",
    "status": "ACTIVE",
    "tags": ["beans", "single-origin"]
  }
}

Cleanup and scale

Webhook subscriptions can be created with webhookSubscriptionCreate (and listed with webhookSubscriptions), but apps should declare them in shopify.app.toml, which is covered in the page on webhooks and events (Webhooks and Events). For reading or writing more than a few thousand objects, use the bulk operations bulkOperationRunQuery and bulkOperationRunMutation, which are not subject to the single-query cost limit (Bulk Operations).

Cost-based rate limits

The GraphQL Admin API does not count requests: it charges every query a number of points, the calculated query cost, against a leaky bucket that belongs to the combination of one app and one store. Calls from one app do not affect another app on the same store, and calls to one store do not affect the same app on another store.

A request reserves its requested cost from the leaky bucket, the difference to the actual cost is refunded, and the bucket refills at the restore rate of the plan

The restore rate depends on the plan of the store:

Plan Restore rate

Standard

100 points per second

Advanced Shopify

200 points per second

Shopify Plus

1000 points per second

Shopify for enterprise (Commerce Components)

2000 points per second

The official page publishes the restore rates but not the bucket sizes. The sample responses in the reference show a maximumAvailable of 1000 and a restoreRate of 50, which do not match the Standard row, so treat sample numbers as illustrations and read throttleStatus from your own responses. The pricing page of Shopify summarizes the same idea as standard API limits, up to two times on select APIs for Advanced and up to ten times for Plus.

How the cost is computed

Every field in the schema has an integer cost. By default the cost depends on what the field returns:

Field returns Default cost

Scalar

0

Enum

0

Object

1

Interface or union

The maximum cost of the possible selections

Connection

Sized by the first and last arguments

Mutation

10

Shopify reserves the right to set manual costs on fields. The cost of a query is the maximum cost of the fields it selects, so the cheapest queries ask for few fields and small pages. Because a connection is sized by its page size, a nested connection multiplies the size of the connections above it: the error sample in the reference shows a query whose cost is 2003, over the limit of 1000.

Each query is priced twice:

  • The requested cost is computed from the shape of the query before it runs. The bucket must hold at least this many points, otherwise the request is rejected.

  • The actual cost is computed from the data returned, and is lower when connections return fewer nodes than requested. When execution finishes, the bucket is refunded the difference.

A single query may not exceed 1,000 points, whatever the plan. This is checked on the requested cost before execution. The only way to know the true cost of a query is to run it and read the response; it cannot be computed offline.

Reading the cost

Every response includes the cost under extensions.cost:

{
  "data": { "shop": { "name": "Pocket Coffee" } },
  "extensions": {
    "cost": {
      "requestedQueryCost": 101,
      "actualQueryCost": 46,
      "throttleStatus": {
        "maximumAvailable": 1000,
        "currentlyAvailable": 954,
        "restoreRate": 50
      }
    }
  }
}

maximumAvailable is the bucket size, currentlyAvailable the points left, restoreRate the points returned per second. With the header Shopify-GraphQL-Cost-Debug: 1 the same object has a fields array with one entry per field (path, definedCost, requestedTotalCost, requestedChildrenCost), which shows which part of the query is expensive. Use it while developing a query, not in production.

What a rejection looks like

Rejections are HTTP 200 responses with an errors array; the Admin API does not use 429 for rate limiting. Branch on errors[n].extensions.code:

Code Meaning and reaction

THROTTLED

The bucket does not hold the requested cost. Wait until enough points have been restored and retry. The documented recommended backoff is one second, and throttleStatus tells how long the wait should be.

MAX_COST_EXCEEDED

The requested cost of one query is above 1,000 points. Retrying cannot help: reduce page sizes or nested connections, split the query, or use a bulk operation. The error carries cost and maxCost.

ACCESS_DENIED

The app lacks a scope or the credentials are wrong; similar to 401 or 403 in REST.

SHOP_INACTIVE

The shop is not active, which can happen when a store repeatedly exceeds API limits or for fraud risk.

INTERNAL_SERVER_ERROR

Shopify failed internally. The message includes a request ID to quote in support requests. Retry with backoff.

Dynamic costs of mutations

Some mutations do not use the flat cost of 10:

Mutation Cost formula Since

productCreate, productUpdate

10 + 0.4 per product metafield + 1.9 per product media

2025-10-01 (changelog)

productSet

10 + 0.2 per variant + 0.6 per variant file + 0.4 per variant metafield + 0.4 per product metafield + 1.9 per product file

2025-10-01 (changelog)

metafieldsSet

10 + a weight for each distinct owner resource, by owner type

2026-09-15 (changelog)

Other limits that are not about cost

  • Input arrays: any argument that accepts an array takes at most 250 items on every Shopify API; larger arrays return an error. This is why variant mutations take 2,048 variants (a dedicated limit) but ids, tags and most lists stop at 250.

  • Page size: connections return at most 250 nodes per page.

  • Pagination depth: paginating an array of objects is capped at 25,000 objects, and counts are capped the same way.

  • Variant creation throttle: once a store has 500,000 product variants, no more than 10,000 new variants can be created per day on any API. The mutations productCreate, productUpdate and productVariantCreate are subject to the throttle, and an app that reaches it receives HTTP 429 with a message that a throttle was applied. Stores on Shopify Plus are exempt. (The reference still lists productVariantCreate, which is gone from the 2026-10 schema; the variant mutations that create variants are productVariantsBulkCreate and productSet.)

  • Request timeout: Shopify waits up to 10 seconds for a response before answering 504 Gateway Timeout; the advice is to split the request into smaller ones.

  • Shop locks: stores that repeatedly exceed API limits can be locked (423 Locked, or SHOP_INACTIVE in GraphQL).

  • Shopify may temporarily reduce rate limits to protect platform stability, so code has to handle throttling at all times.

Handling throttling in code

The practices the documentation recommends are generic: ask only for the data the app needs, cache, spread requests evenly, catch errors instead of hammering the API, use the cost metadata in responses to adapt, and stop making requests until enough time has passed, with a recommended backoff of one second. The Pocket Coffee client below applies them. It is built on @shopify/admin-api-client. That client’s retries option only repeats requests answered with HTTP 429 or 503, which the GraphQL Admin API does not use for throttling, so a THROTTLED response has to be handled by the application: the run function waits until the bucket would hold the requested cost again, never less than one second, and gives up after five attempts. allProducts pages through the active products with the cursors described in the next section.

export const PRODUCTS_QUERY = `#graphql
  query PocketCoffeeProducts($first: Int!, $after: String) {
    products(first: $first, after: $after, query: "status:active", sortKey: ID) {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        title
        handle
        variants(first: 10) { nodes { id sku price inventoryQuantity } }
      }
    }
  }`;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Sends one operation and retries it while the API answers with a THROTTLED error.
export async function run(client, query, variables, { maxAttempts = 5 } = {}) {
  for (let attempt = 1; ; attempt++) {
    const { data, errors, extensions } = await client.request(query, { variables });
    const throttled = errors?.graphQLErrors?.some((e) => e.extensions?.code === 'THROTTLED');
    if (!throttled) {
      if (errors) throw new Error(errors.message ?? 'GraphQL request failed');
      return { data, cost: extensions?.cost };
    }
    if (attempt === maxAttempts) throw new Error(`still throttled after ${attempt} attempts`);
    // Wait until the bucket holds the requested cost again, but never less than one second.
    const status = extensions?.cost?.throttleStatus;
    const missing = Math.max(0, (extensions?.cost?.requestedQueryCost ?? 0) - (status?.currentlyAvailable ?? 0));
    const restoreMs = status ? Math.ceil((missing / status.restoreRate) * 1000) : 0;
    await sleep(Math.max(1000, restoreMs));
  }
}

// Follows pageInfo.endCursor until hasNextPage is false.
export async function* allProducts(client, pageSize = 50) {
  let after = null;
  for (;;) {
    const { data, cost } = await run(client, PRODUCTS_QUERY, { first: pageSize, after });
    yield { products: data.products.nodes, cost };
    const { hasNextPage, endCursor } = data.products.pageInfo;
    if (!hasNextPage) return;
    after = endCursor;
  }
}

Production code usually adds jitter to the wait, a queue that serializes calls per shop, and a circuit breaker for repeated failures. In a real app the client is created once per shop with the access token of that shop:

import { createAdminApiClient } from '@shopify/admin-api-client';
import { allProducts } from './pocket-coffee-products.mjs';

const client = createAdminApiClient({
  storeDomain: `${process.env.SHOPIFY_SHOP}.myshopify.com`,
  apiVersion: '2026-10',
  accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
});

for await (const { products } of allProducts(client, 50)) {
  console.log(products.map((p) => p.handle));
}

The pagination and throttling logic was run for real, with the real @shopify/admin-api-client 2.0.1, against a local HTTP server that stands in for the shop. The stand-in checks the path and the token header, validates every incoming query against the real 2026-10 schema, serves five products, and answers every third request with a THROTTLED error. It was started with customFetchApi redirecting the client to it. The numbers in extensions.cost come from the stand-in and are not Shopify costs. Run with a page size of two:

page 1 at 25 ms: single-origin-beans, starter-kit | requested 8 actual 8 available 994
page 2 at 27 ms: hand-grinder, filter-papers | requested 8 actual 8 available 994
page 3 at 1033 ms: gift-card | requested 8 actual 5 available 997

The third request was throttled, the client waited one second and the retry returned the last page (1033 ms on the clock). Calls to the real API with a token were not executed.

Design guidelines that follow from the cost model:

  • Prefer one query for several related objects over several round trips, as long as the requested cost stays well below 1,000; a query that selects first: 250 products with first: 250 variants each is priced by its shape and is rejected before it runs.

  • Reduce first to what the page really needs. Requested cost, not actual cost, decides whether the request is accepted.

  • Never poll in a loop. Subscribe to webhooks, and use bulk operations for large reads.

  • Keep a per-shop queue so that parallel workers do not spend one bucket concurrently.

  • Log requestedQueryCost, actualQueryCost and currentlyAvailable to see which operations are expensive.

Pagination

Lists are connections. A connection field takes paging arguments and returns the nodes of the page plus information to continue. Pagination is cursor-based, on the Admin API, the Storefront API and the Customer Account API alike.

Argument Direction Meaning

first: Int

Forward

The number of nodes of the page, at most 250.

after: String

Forward

The cursor to start after. Normally the endCursor of the previous page. If the variable is null, after is ignored.

last: Int

Backward

The number of nodes counted from the end. Not every connection supports backward paging.

before: String

Backward

The cursor to end before. Normally the startCursor of the later page.

reverse: Boolean

Either

Reverses the order of the connection.

sortKey

Either

An enum specific to the connection, such as CREATED_AT, TITLE, UPDATED_AT or ID.

query: String

Either

The search filter described above.

A connection exposes nodes (the objects) and pageInfo, and edges (a cursor plus the node) for the rare case where the cursor of each item is needed. In almost all cases select nodes and pageInfo.

PageInfo field Type Meaning

hasNextPage

Boolean!

Whether there are nodes after the current page.

hasPreviousPage

Boolean!

Whether there are nodes before the current page.

startCursor

String

The cursor of the first node of the page.

endCursor

String

The cursor of the last node of the page.

The loop is always the same: request the first page with after: null, then, while hasNextPage is true, repeat with after set to the endCursor.

query PocketCoffeeProducts($first: Int!, $after: String) {
  products(first: $first, after: $after, query: "status:active") {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      id
      title
      handle
      status
      variants(first: 10) {
        nodes {
          id
          sku
          price
          inventoryQuantity
        }
      }
    }
  }
}
Variables for the first page, then for the next one
{ "first": 50, "after": null }

Some rules and pitfalls:

  • A cursor is opaque. Do not decode, build or store cursors across long periods: they identify a position in a particular sort order of a particular query. Reuse a cursor only with the same query, arguments and sort key.

  • Data can change between pages: a product created or updated while paging can appear twice or be missed, depending on the sort key. Sorting by ID (or by CREATED_AT for append-only data) gives the most stable traversal, which is why the Pocket Coffee client above uses sortKey: ID. For incremental synchronization store the updatedAt of the last processed object, filter with updated_at:>... and rely on webhooks for freshness.

  • Paging deeper than 25,000 objects in one array is not possible. Add filters to narrow the result, or use a bulk query. This limit also caps counts at 25,001.

  • Each page costs points sized by first. A larger page costs more per request but fewer requests are made; 50 to 100 is a good starting point, measured against requestedQueryCost.

  • Nested connections are paged independently. Fetching more than the first page of variants of every product needs a second query per product (or a bulk operation); do not raise the nested first to 250 on a list of 250 products.

  • The Storefront API and Liquid have the same 25,000 limit and a maximum page size of 250.

The official statement is that you can retrieve up to 250 resources per connection page and should use a bulk query to work with larger volumes. The developer changelog entry of 2025-06-17 introduced the 25,000 limit for Liquid and the Storefront API and says that the Admin API keeps higher limits for merchant-facing workflows; the current limits page states the 25,000 cap for pagination in general, so design Admin integrations for it.

Idempotency

A network failure after sending a mutation leaves the caller unsure whether it ran. Idempotency keys make a retry safe: the first request with a given key is executed, and later requests with the same key and the same parameters return the original result without running again.

In GraphQL a key is supplied with the @idempotent(key: String!) directive on the mutation field, for the mutations that document it (the schema lists the directive on its definition, which has locations on the mutation fields that accept it). A few mutations take the key as a normal input field instead, such as subscriptionBillingAttemptCreate. Mutations that update or delete an object are idempotent by nature and need no key.

The directive became mandatory on API version 2026-04 for these mutations, as listed by the changelog: refundCreate, inventoryAdjustQuantities, inventoryMoveQuantities, inventorySetQuantities, inventorySetOnHandQuantities, inventoryActivate, inventorySetScheduledChanges, inventoryShipmentCreate, inventoryShipmentCreateInTransit, inventoryShipmentReceive, inventoryShipmentAddItems, inventoryTransferCreate, inventoryTransferCreateAsReadyToShip, inventoryTransferDuplicate, inventoryTransferSetItems, locationActivate and locationDeactivate. In the public 2026-10 schema inventorySetScheduledChanges is not present and inventorySetOnHandQuantities is deprecated in favor of inventorySetQuantities; the other mutations of the list exist, and their descriptions mention the @idempotent directive. The schema does not show the directive as required; calling one of these mutations without it fails at runtime.

The rules for using keys correctly:

  • A key identifies one intended operation. Use a random UUID (v4 or v7), or a deterministic UUID v5 built from a stable job ID and the variables for background jobs.

  • Reuse the same key when retrying after a timeout, network failure or an IDEMPOTENCY_CONCURRENT_REQUEST error; generate a new key for a genuinely new operation or when any parameter changes. Generating a fresh UUID on every call defeats the protection.

  • Shopify remembers keys for 24 hours. After that, a retry is treated as a new operation.

  • A duplicate of a successful request returns the cached response, built from current database records, so it can differ from the original in rare cases.

  • The key is fingerprinted with all input parameters, including their order: the same key with different parameters returns the user error code IDEMPOTENCY_KEY_PARAMETER_MISMATCH.

  • Persist the key before sending the request (or derive it deterministically), so that a crash does not lose the ability to retry safely.

  • In bulk mutation operations, idempotency is applied per JSONL row, and each row needs its own key.

HTTP status codes and error handling

On a GraphQL API the HTTP status describes the HTTP exchange, not the operation. A 200 OK means Shopify answered, not that the operation worked. The checklist for every response is therefore: check the HTTP status, then the errors array, then the userErrors of each mutation payload, then the data.

Status Meaning for the Admin GraphQL API

200

The default. The body can still contain errors (throttling, cost, access, internal errors, validation) or userErrors.

400

The request was not understood: bad syntax or a Content-Type that is not application/json. Also returned by a token exchange with an invalid ID token.

401

Credentials missing or wrong, or an expired access token. Re-authenticate; for an expiring offline token use the refresh token. A refresh token that can no longer be used returns 401 with invalid_request: treat it as final.

402

The shop is frozen; the owner has to pay the outstanding balance.

403

The shop is marked as fraudulent. From 2027-01-01 also the answer to a non-expiring offline token of a public app.

404

The resource is not available, often because it was deleted.

423

The shop is locked, for example after repeatedly exceeding limits, or for fraud or compromise.

5xx

A Shopify-side failure. 500 is usually reported as the INTERNAL_SERVER_ERROR code in a 200 response. 502 and 503 are transient. 504 means the request did not finish within 10 seconds, so split it.

A helper that applies the checklist, used by the Pocket Coffee sync after each mutation:

export function assertMutationOk(result, rootField) {
  if (result.errors) {
    const codes = (result.errors.graphQLErrors ?? []).map((e) => e.extensions?.code).filter(Boolean);
    throw new Error(`GraphQL errors (${codes.join(', ') || result.errors.message})`);
  }
  const userErrors = result.data?.[rootField]?.userErrors ?? [];
  if (userErrors.length > 0) {
    const detail = userErrors.map((e) => `${(e.field ?? []).join('.')}: ${e.message}`).join('; ');
    throw new Error(`${rootField} rejected: ${detail}`);
  }
  return result.data[rootField];
}

The REST Admin API is legacy

The REST Admin API is a legacy API as of 2024-10-01. Shopify’s statement is that all apps and integrations should be built with the GraphQL Admin API, and that starting 2025-04-01 all new public apps must be built exclusively with it (an App Store requirement). The reference adds that some newer platform features may be available only in GraphQL. As of 2026-10-27 no shutdown date for REST has been published on the REST reference, on the rate limit page or in the changelog; legacy means maintained but no longer developed, and deprecations arrive resource by resource. Plan the migration from REST to GraphQL for any integration you maintain; for a new app there is no choice.

Concrete REST deprecations that have been announced:

Date Change

2019-07

Page-based pagination (page parameter) deprecated in favor of cursor-based pagination with Link headers.

2024-04

Listing, creating, updating and deleting products through REST is deprecated, in favor of the new product model in GraphQL.

2024-10-01

REST Admin API declared legacy.

2025-01

The REST product image resource is deprecated; its admin_graphql_api_id returns a media image ID to ease the move to GraphQL media.

2025-04-01

New public apps must use GraphQL exclusively.

2026-03

pre_tax_price removed from the REST Order resource (changelog of 2026-03-23).

2026-10-01

scriptTagCreate and scriptTagUpdate (and the REST ScriptTag create and update) return an error in all API versions; script tags in online store storefronts stop running on 2027-03-01.

REST limits compared

REST limits are request based, still a leaky bucket, per app and store:

Aspect REST Admin API GraphQL Admin API

Unit

One request, whatever it returns

Calculated query cost in points

Rate (Standard, Advanced, Plus, enterprise)

2, 4, 20, 40 requests per second

100, 200, 1000, 2000 points per second

Bucket

40 requests (400 on Plus), leak rate 2 per second (20 on Plus)

Not published; read throttleStatus

Limit signal

HTTP 429 with Retry-After; header X-Shopify-Shop-Api-Call-Limit: 32/40

HTTP 200 with THROTTLED error; extensions.cost

Page size

Default 50, up to 250 with limit; Link header with a page_info token that cannot be combined with other parameters

Up to 250 with first or last; pageInfo cursors, used with the same query arguments on every page

Offset limit

page offsets above 100,000 return 429

Pagination depth of 25,000 objects

Migrating from REST to GraphQL

The migration is easiest as a sequence of small, testable steps, one resource at a time, with both APIs working side by side until the last REST call disappears.

  1. Inventory the REST calls. Search the code for /admin/api/, .json, X-Shopify-Shop-Api-Call-Limit and the REST methods of the client libraries. Check the access logs and the Dev Dashboard alerts of the app to find calls that code search misses (for example from a worker).

  2. Map every endpoint to a GraphQL root field. The reference page of every REST resource lists, next to each endpoint, its GraphQL equivalent. The table below collects the Pocket Coffee set; all names were checked to exist in the 2026-10 schema.

  3. Convert IDs. REST uses numeric IDs, GraphQL uses global IDs. REST responses of most resources include admin_graphql_api_id; GraphQL objects include legacyResourceId, the numeric ID. A conversion is gid://shopify/Product/ plus the number, and it is reliable for objects whose type name you know. Convert at the boundary of the code, keep global IDs internally.

  4. Reshape the data. REST returns one JSON document per resource, with embedded arrays; in GraphQL select exactly the fields you use and page nested lists separately. Order amounts in GraphQL come as MoneyBag objects with a shopMoney and a presentmentMoney (for example currentTotalPriceSet), so read the one that matches the currency you need.

  5. Replace pagination. Swap Link header handling for pageInfo cursors. Do not port page parameter code: it was deprecated in 2019.

  6. Replace rate-limit handling. Swap the 40-request bucket logic and Retry-After for cost-aware handling of THROTTLED (see the client above). Review concurrency: a REST design that fires parallel requests may need a queue.

  7. Convert writes carefully. Many REST writes map to several GraphQL mutations (a product with variants is productCreate plus productVariantsBulkCreate, or productSet), always check userErrors, and add the @idempotent directive where required.

  8. Cut over by resource. Ship with a feature flag, compare results of both implementations for a period, then remove the REST code.

  9. Replace features that no longer have a REST or GraphQL equivalent. Script tags, for example, are replaced by theme app extensions and web pixels rather than by scriptTagCreate.

REST endpoint (2026-10) GraphQL operation Notes for Pocket Coffee

GET /products.json

products(first:, after:, query:)

Select variants { nodes { ... } } explicitly. Replaces limit, since_id, fields and the Link header.

GET /products/{id}.json

product(id:) or productByIdentifier

Convert the numeric ID to a global ID.

GET /products/count.json

productsCount(query:)

Capped at 25,001.

POST /products.json

productCreate, then productVariantsBulkCreate, or productSet

productSet for the 18-variant beans.

PUT /products/{id}.json

productUpdate(product:)

Variants are updated by productVariantsBulkUpdate.

GET /products/{id}/variants.json

productVariants(query:) or Product.variants

productVariantsCount replaces /variants/count.json.

POST /products/{id}/variants.json

productVariantsBulkCreate

The REST page maps it to productCreate and productVariantsBulkCreate.

PUT /variants/{id}.json

productVariantsBulkUpdate

Takes the product ID and a list of variants.

GET /customers.json, GET /customers/search.json

customers(query:)

query:"tag:'Coffee Club'" replaces the search endpoint.

POST /customers.json, PUT /customers/{id}.json

customerCreate, customerUpdate

Tags through tagsAdd.

GET /orders.json?status=any

orders(query:)

Limited to 60 days without read_all_orders.

POST /orders.json

orderCreate

Also used for imports.

POST /orders/{id}/cancel.json

orderCancel

Requires restock and reason.

POST /fulfillments.json

fulfillmentCreate

The REST reference still points to fulfillmentCreateV2, which is deprecated.

POST /collects.json

collectionUpdate (with inclusion.selectionsToAdd)

The REST reference points to collectionAddProducts, which is deprecated.

POST /webhooks.json

webhookSubscriptionCreate, or [webhooks] in shopify.app.toml

Prefer the declarative form.

POST /{resource}/{id}/metafields.json

metafieldsSet

custom.roast_date example above.

POST /inventory_levels/set.json, adjust.json

inventorySetQuantities, inventoryAdjustQuantities

Both need the @idempotent directive. connect.json maps to inventoryActivate.

Side by side, listing the active products of Pocket Coffee in REST and in GraphQL:

# REST (legacy): the response carries a Link header with rel="next" and a page_info token
curl -sS -D - "https://${SHOPIFY_SHOP}.myshopify.com/admin/api/2026-10/products.json?status=active&limit=50&fields=id,title,handle,variants" \
  -H "X-Shopify-Access-Token: ${SHOPIFY_ACCESS_TOKEN}"

The GraphQL equivalent is the PocketCoffeeProducts query of the pagination section: a single products field with query: "status:active", first: 50 and a nested variants selection, paged with after. In both cases the account-bound HTTP calls were not executed on this page.

IDs

const toGid = (type, numericId) => `gid://shopify/${type}/${numericId}`;
const toNumericId = (gid) => Number(gid.split('/').pop().split('?')[0]);

toGid('Product', 632910392); // "gid://shopify/Product/632910392"
toNumericId('gid://shopify/InventoryLevel/123?inventory_item_id=456'); // 123

For objects whose ID is parameterized (such as InventoryLevel) the numeric part alone is not enough to rebuild the global ID, so persist the global ID when you first see it, or read legacyResourceId together with the required parameters.

Tooling around the API

  • GraphiQL: shopify app graphiql and shopify store graphiql open an authenticated explorer on a store; the GraphiQL app can be installed on a store for ad hoc work. Deprecation warnings appear in the editor.

  • Code generation: @shopify/api-codegen-preset generates TypeScript types for every #graphql operation of the project from the schema of the chosen version, as in the .graphqlrc.ts of the app template. This makes API upgrades compile-time events.

  • Schema download: the public schema of every version can be fetched without credentials by sending an introspection query to https://shopify.dev/admin-graphql-direct-proxy/{version}, the URL that the codegen preset uses. The version is 2026-10, an older stable version, 2027-01 for the release candidate or unstable.

  • AI tooling: the Shopify AI Toolkit (formerly the Dev MCP server) gives coding assistants access to the docs and the schemas and validates GraphQL code against them.

  • Bulk operations: shopify app bulk execute and shopify store bulk execute run a bulk query or mutation from the command line (--watch waits for the result and writes the JSONL).

Validating operations against the schema

Query cost and store behavior need a store, but the shape of every operation can be checked offline with the standard GraphQL validation rules against the downloaded schema. The script below downloads the schema of a version, validates documents with graphql 16 (the 17.x line rejects Shopify’s introspection result because of its deprecation rules, so use 16.x for Shopify schemas) and, for operations that come with a variables document, checks that the variables coerce to the declared variable types.

import fs from 'node:fs';
import { buildClientSchema, coerceInputValue, getIntrospectionQuery, parse, typeFromAST, validate } from 'graphql16';

export async function downloadSchema(version, file) {
  const response = await fetch(`https://shopify.dev/admin-graphql-direct-proxy/${version}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: getIntrospectionQuery() }),
  });
  fs.writeFileSync(file, await response.text());
}

export function check(schemaFile, document, variables) {
  const schema = buildClientSchema(JSON.parse(fs.readFileSync(schemaFile, 'utf8')).data);
  const ast = parse(document);
  const problems = validate(schema, ast).map((e) => e.message);
  for (const definition of ast.definitions) {
    if (definition.kind !== 'OperationDefinition' || !variables) continue;
    for (const variable of definition.variableDefinitions ?? []) {
      const name = variable.variable.name.value;
      const type = typeFromAST(schema, variable.type);
      coerceInputValue(variables[name], type, (path, _input, error) =>
        problems.push(`variable $${name}${path.length ? '.' + path.join('.') : ''}: ${error.message}`));
    }
  }
  return problems;
}

All operations of this page were extracted from the page text and validated with this logic against the Admin schemas of 2026-01, 2026-04, 2026-07, 2026-10 and 2027-01. The 16 operations of the page validate without a single problem in all five versions, and every variables document coerces to the declared variable types, including the 18-variant productSet input generated by the script above and the shortened excerpt, which is identical to the first two generated variants. That is useful to know in practice: nothing used on this page changes between the currently accessible stable versions and the release candidate, so an app that uses only these operations can move from 2026-01 to 2026-10 and on to 2027-01 without schema-level edits. It does not say anything about behavior, which can change without a schema change.

The checker also rejects bad variables. With a missing required field, a wrong scalar and a null key, the stock mutation reports variable $input.quantities.0: Field "locationId" of required type "ID!" was not provided., variable $input.reason: String cannot represent a non string value: 5 and variable $idempotencyKey: Expected non-nullable type "String!" not to be null.; an enum value that does not exist is reported as Value "LIVE" does not exist in "ProductStatus" enum.

Two negative checks show what the validator catches. A misspelled field, and the pre-2024-04 form of productUpdate, each of which fails in all five schemas:

query Bad {
  products(first: 5) {
    nodes {
      id
      titel
    }
  }
}
mutation OldProductUpdate {
  productUpdate(input: { id: "gid://shopify/Product/1000", status: ACTIVE }) {
    product {
      id
    }
  }
}

The validator reports Cannot query field "titel" on type "Product". Did you mean "title"? for the first one. For the second it reports that the argument input is unknown on productUpdate. What a schema cannot catch is a wrong filter field inside a query string, a missing scope, a missing @idempotent directive where the server enforces it at runtime, cost and throttling.

Pitfalls

Pitfall Remedy

Treating 200 OK as success

Check errors and every userErrors list.

Not pinning the version, or targeting a retired one

Always use /admin/api/2026-10/; assert X-Shopify-API-Version in tests. A retired version is silently served by the oldest accessible stable version.

Using the /api/... path for Admin calls

The Admin path contains /admin/. /api/{version}/graphql.json is the Storefront API.

Retrying MAX_COST_EXCEEDED

It never succeeds. Lower the page sizes, split the query or use a bulk operation.

Relying on a client’s retries for throttling

retries covers HTTP 429 and 503. A GraphQL THROTTLED error is a 200 and needs your own retry logic.

Large first on nested connections

The requested cost multiplies; the request is rejected even if the data is small.

A typo in the query filter string

The unknown field is ignored and all results are returned. Test filters against a development store.

Paging past 25,000 objects, or storing cursors for long

Narrow with filters or use bulk operations; cursors are tied to the query and the sort order.

Omitting userErrors from the selection

The mutation fails without any signal. Always select userErrors { field message }, and code where it exists.

Using productSet for a small edit

It replaces options and variants. Use productUpdate or the variant mutations.

Calling productUpdate(input:), productVariantUpdate, productVariantCreate

Removed from the schema. Use productUpdate(product:), productVariantsBulkUpdate, productVariantsBulkCreate.

Calling inventory or refund mutations without @idempotent

Required since 2026-04; the request fails at runtime although the schema allows it.

Following deprecated mappings of the REST reference

fulfillmentCreateV2 and collectionAddProducts are deprecated; use fulfillmentCreate and collectionUpdate.

Reusing an app token in Postman or scripts

Deprecation alerts are attributed to the app. Use a separate development app or the CLI for experiments.

Assuming non-expiring offline tokens keep working

From 2027-01-01 public apps must use expiring offline tokens, otherwise the Admin API answers 403.

What was executed and what was not

Executed offline during the preparation of this page, with Node.js 24, graphql 16.14.2, @shopify/admin-api-client 2.0.1 and Shopify CLI 5.0.0:

  • Every GraphQL document and variables document on this page was validated against the downloaded public Admin schema of 2026-01, 2026-04, 2026-07, 2026-10 and 2027-01, including the 18-variant productSet input generated by the script above; the two negative examples fail as described.

  • The existence and the deprecation status of every root field named in the migration table were checked in the 2026-10 schema.

  • The run and allProducts client code was executed with @shopify/admin-api-client against a local stand-in server, including one THROTTLED response and the retry.

  • The text of the official pages, the changelog and the Shopify CLI help for app execute, store execute and app graphiql was read and compared with the statements above.

Not executed, because they need a store, an account or a token (none was created or used): any call to https://<shop>.myshopify.com, the cURL and React Router examples, Direct API access, shopify app execute and shopify store execute, the real query costs and throttleStatus values, throttling by the real API, idempotency behavior, and the REST requests. Cost figures marked as computed come from the published formulas.