Skip to content

Shopify Admin API 2025-10 is no longer supported: moving to 2026-10 without breaking anything

From the work/ 17 min read

Version 2025-10 stopped being accessible on 16 October 2026. Apps that still ask for it get answers from another version. How to see it, test 2026-10 and stay current.

  • Shopify
  • Admin API
  • GraphQL
  • Shopify apps
In this article (9)
  1. The app nobody opens again
  2. How Shopify's quarterly versions work
  3. What happens when an app asks for a version that is gone
  4. How to see which version actually answered
  5. Where the version is pinned, and how to keep it in one place
  6. What 2026-10 changes in orders, taxes and metafields
  7. Test on a development store before production
  8. A 200 does not mean it worked
  9. A quarterly checklist, with a reminder that runs itself

On 16 October 2026 at 15:00 UTC, version 2025-10 of the Shopify APIs stopped being accessible. That is the date in the release table on Shopify's API versioning page, where 2025-10 is now marked Unsupported. Nothing went down that afternoon. Apps that still request 2025-10 kept getting answers with HTTP 200. The answers just stopped coming from 2025-10.

This guide covers what that means for an app you already run: how to see which version is really answering, where the version hides in a codebase, what the 2026-10 release changes in orders, taxes and metafields, how to test the move on a development store, and a scheduled check so the next deadline does not arrive unnoticed.

The app nobody opens again

The apps most exposed to this are not the public ones in the App Store. Those have a team, a review process and usually someone reading the changelog. The exposed ones are the custom app built for one store in 2024, the integration that syncs orders to an ERP, the script that a contractor wrote to update prices from a spreadsheet. Someone pinned an API version on the day it was written, it worked, and nobody has had a reason to open the file since.

If you are not sure which kind of app you have, public, custom or private Shopify apps explains the differences. For versioning they all behave the same: every request names a version, and that version has an expiry date whether or not anyone is watching it. Credentials have the same problem, which is why public apps need expiring offline tokens by January 2027 is worth reading next to this one.

How Shopify's quarterly versions work

Shopify releases a new API version every three months, on the first day of each quarter, named after the release: 2026-01, 2026-04, 2026-07, 2026-10. The versioning page states two guarantees: each stable version is supported for a minimum of 12 months, and consecutive versions overlap by at least nine months. A release candidate for the next version is published on the same day as each stable release, and there is an unstable version for early testing. Neither belongs in production.

On the day of writing, the published table looks like this:

  • 2025-10: released 1 October 2025, accessible until 16 October 2026, 15:00 UTC. Unsupported.
  • 2026-01: accessible until 16 January 2027, 15:00 UTC. Stable.
  • 2026-04: accessible until 16 April 2027, 15:00 UTC. Stable.
  • 2026-07: accessible until 16 July 2027, 15:00 UTC. Stable.
  • 2026-10: released 1 October 2026, accessible until 16 October 2027, 15:00 UTC. Latest stable.
  • 2027-01: release candidate.

Scroll the figure sideways to see all of it

One detail to note: the 2026-10 release notes say the version is available for production use "until at least October 1, 2027 at 15:00 UTC", while the versioning table says 16 October 2027. Plan around the earlier date. The reminder later in this guide uses the documented 12-month minimum for exactly that reason.

What happens when an app asks for a version that is gone

The versioning page is explicit: "If your app targets an inaccessible version, Shopify falls forward and responds using the oldest accessible stable version." It does not reject the request or return an error status. It serves the request from a different version.

Today the oldest accessible stable version is 2026-01. So an app that requests /admin/api/2025-10/graphql.json is, right now, reading and writing through 2026-01. On 16 January 2027, when 2026-01 reaches the end of its table date, the same request will move again, to 2026-04 if the table holds. The app never changes. The version behind it changes three times a year.

Scroll the figure sideways to see all of it

This is a silent risk for three reasons. First, there is no error to alert on: the status code is 200 before and after. Second, the jump skips the review step: whatever changed between 2025-10 and 2026-01 now applies to your queries without anyone having read the 2026-01 release notes. A removed field, a new enum value or a stricter validation shows up as wrong data or a failed job, not as a deprecation warning. Third, it applies to webhooks too. Shopify's docs say webhook payloads are versioned the same way and fall forward when the selected version becomes inaccessible, so an order handler can start receiving a payload shape it was never tested with.

How to see which version actually answered

Every response tells you. In the versioning docs' words, "API responses include the X-Shopify-API-Version header reflecting the version used to fulfill the request." If it differs from the version you asked for, Shopify has fallen forward. Webhook deliveries over HTTPS carry the same header. Checking it costs one line, and it is the only signal you get.

With curl, print the response headers and keep the one that matters:

curl -sS -o /dev/null -D - \
  -X POST "https://$SHOP/admin/api/2025-10/graphql.json" \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" \
  -d '{"query":"{ shop { name } }"}' \
  | grep -i '^x-shopify-api-version'

Based on the current table, a request for 2025-10 should print 2026-01 there. That is the expected value from the documentation, not a captured response: run it against your own store and read what comes back.

With fetch, check the header on every call, in the one function all your Admin API calls go through. This version also handles errors, which the section on 200 responses explains below:

// admin-graphql.mjs: every Admin API call goes through this function.
const SHOP = process.env.SHOPIFY_SHOP; // your-store.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || '2026-10';

export async function adminGraphql(query, variables = {}) {
  const res = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
    body: JSON.stringify({ query, variables }),
  });

  // The version that actually answered. If it differs, Shopify fell forward.
  const served = res.headers.get('X-Shopify-API-Version');
  if (served && served !== API_VERSION) {
    console.warn(`Requested Admin API ${API_VERSION}, served ${served}`);
  }

  if (!res.ok) {
    // 401, 403, 404, 5xx: the request itself failed. Do not read it as data.
    throw new Error(`Admin API HTTP ${res.status}: ${await res.text()}`);
  }

  const body = await res.json();
  // A 200 can still carry errors: bad syntax, an unknown field, THROTTLED,
  // and in 2026-10 a metafield filter that the definition does not allow.
  if (body.errors?.length) {
    const err = new Error(body.errors.map((e) => e.message).join(' | '));
    err.codes = body.errors.map((e) => e.extensions?.code).filter(Boolean);
    err.partialData = body.data; // may be null, may be partial
    throw err;
  }
  return body.data;
}

With the official @shopify/shopify-api library, the version is the apiVersion option of shopifyApi(), and it is required. The ApiVersion enum in the library's current source includes October26 = '2026-10'; if your installed version does not have it, update the package first. The example below is for a custom app created in the store admin, which the library calls a custom store app. The GraphQL client returns the response headers, and it throws GraphqlQueryError when the response contains errors:

// shopify.server.ts: the one place the Admin API version is chosen.
import '@shopify/shopify-api/adapters/node';
import { shopifyApi, ApiVersion, GraphqlQueryError } from '@shopify/shopify-api';

export const SHOPIFY_API_VERSION = ApiVersion.October26; // '2026-10'

export const shopify = shopifyApi({
  apiSecretKey: process.env.SHOPIFY_API_SECRET ?? '',
  adminApiAccessToken: process.env.SHOPIFY_ADMIN_TOKEN ?? '',
  isCustomStoreApp: true, // a custom app created in the store admin
  isEmbeddedApp: false,
  hostName: 'your-store.myshopify.com',
  apiVersion: SHOPIFY_API_VERSION,
});

export async function servedVersion(shop: string): Promise<string | undefined> {
  const session = shopify.session.customAppSession(shop);
  // No apiVersion here, so the client uses SHOPIFY_API_VERSION.
  const client = new shopify.clients.Graphql({ session });
  try {
    const response = await client.request('{ shop { name } }');
    // Header names can arrive in any case, and a value can be a string or an array.
    const headers = response.headers ?? {};
    const key = Object.keys(headers).find((k) => k.toLowerCase() === 'x-shopify-api-version');
    const value = key ? headers[key] : undefined;
    return Array.isArray(value) ? value[0] : value;
  } catch (error) {
    if (error instanceof GraphqlQueryError) {
      // Errors returned inside a 200 response end up here.
      console.error(error.body?.errors?.graphQLErrors, error.headers);
    }
    throw error;
  }
}

Where the version is pinned, and how to keep it in one place

An app rarely pins its version once. These are the places to look:

  • The library config: apiVersion in shopifyApi(), or the equivalent in the Ruby, PHP or Python clients.
  • Per-client overrides: new shopify.clients.Graphql({ session, apiVersion }) accepts its own version and silently overrides the default.
  • Raw URLs: any string with /admin/api/2025-10/ in it, often in old scripts, cron jobs and Postman collections.
  • REST resource imports: @shopify/shopify-api/rest/admin/2026-01 carries a version in the import path.
  • shopify.app.toml: [webhooks] api_version is "the API version used to serialize webhooks", and [events] api_version is the version used to validate and run event subscription queries. Both are required by the app configuration docs.
  • Webhook subscriptions created through the API: for shop-specific subscriptions created with the GraphQL Admin API, the webhooks docs say the version is determined by the request URL, not by the TOML file.
  • shopify.extension.toml: extensions declare a top-level api_version. For UI extensions, the versioning page notes that Shopify CLI blocks deploys targeting versions older than 12 months.
  • Environment variables: SHOPIFY_API_VERSION or similar, set per environment, sometimes different in staging and production.

One search finds most of them. Run it from the repository root:

grep -rnE "admin/api/(20[0-9]{2}-[0-9]{2}|unstable)|api_version|apiVersion|ApiVersion\.|rest/admin/20|SHOPIFY_API_VERSION" \
  --exclude-dir=node_modules --exclude-dir=.git .

Then reduce the list to two sources of truth: one constant in code that every client imports, and the TOML fields, which cannot import anything. Remove every per-client override unless it has a written reason next to it. The check at the end of this guide reads both, so a TOML file that was forgotten during an upgrade fails the build instead of falling forward three months later.

What 2026-10 changes in orders, taxes and metafields

The 2026-10 release notes open by saying that several changes "require code updates, especially around order taxes, draft order discounts, metafield filters, and customer account checkouts." Here is what the notes and the changelog entries they link actually list for these three areas, and nothing more.

Taxes: changing a shipping address now recalculates them

From 2026-10, updating the shipping address of an unfulfilled order recalculates taxes for the new destination. It applies to the orderUpdate mutation and to the REST order update endpoint. Before, the address was saved but the original tax lines stayed, so totals no longer matched the destination. Partially or fully fulfilled orders keep their taxes, and so do requests on versions before 2026-10. The entry is tagged as a breaking API change. If your app edits addresses, read taxLines, totalTaxSet and the order totals again after the update, and expect an orders/edited webhook. When an already paid order's balance goes up, the entry suggests orderMarkAsPaid within your tolerance or orderInvoiceSend for larger amounts; when it goes down, refundCreate.

Orders: one field removed, three changed shapes

  • The deprecated priceRule field is removed from DraftOrderDiscountNotAppliedWarning. A query that selects it fails validation on 2026-10.
  • Order.displayFulfillmentStatus can now return FULFILLMENT_NOT_REQUIRED instead of UNFULFILLED for orders with nothing left to fulfil. Any switch or lookup table on that enum needs a new branch.
  • orderCancel now returns a structured jobResult, so you can confirm that the asynchronous cancellation succeeded.
  • Return shipping fees have new tax fields.

Metafields: invalid filters now fail

Until 2026-07, a products query filtered with metafields.custom.material:cotton on a metafield that cannot be filtered had the filter silently ignored, and the query returned results anyway. From 2026-10, the API validates metafield filters and returns an error naming the filter. The usual causes are a metafield with no definition, a definition that does not allow filtering, or a comparison its type does not support. This change can make a job that "worked" start failing, so it is the strongest reason to handle the errors array properly. The Metafield object also gains a non-null translatable field, and checkout and customer account UI extensions now require owner_type in their metafield declarations.

The 2026-10 notes do not list any change to metaobjects. The other action-required items are outside these three areas: ProductVariant.barcode is deprecated in favour of a barcodes connection (not removed yet), segment query functions use MATCHES and NOT MATCHES, SMS marketing consent is now available as smsMarketingConsent on CustomerPhoneNumber, the ITEM_NOT_STOCKED_AT_LOCATION error is removed, and the Customer Account API drops lastIncompleteCheckout. POS UI extensions have two breaking changes of their own.

One important caveat: an app moving from 2025-10 to 2026-10 skips three releases. The 2026-01, 2026-04 and 2026-07 release notes apply too, and this article does not cover them. Read them before you change the constant.

For the three areas above, these searches find the code that needs attention:

grep -rnE "orderUpdate|shippingAddress|taxLines|totalTaxSet" src/
grep -rnE "metafields\.[a-z0-9_]+\.[a-z0-9_]+:" src/
grep -rnE "priceRule|UNFULFILLED|displayFulfillmentStatus|orderCancel" src/
grep -rnE "barcode" src/

Test on a development store before production

Changing the version is one line. Proving nothing else changed takes an afternoon on a development store, in this order:

  1. Read the release notes for every version you skip, and list the items that touch your queries.
  2. On a branch, set the constant and the TOML fields to 2026-10.
  3. Run shopify app dev --store your-dev-store.myshopify.com. It creates a dev preview on that store with your local configuration, including [webhooks] api_version, without affecting other stores.
  4. Open shopify app graphiql --store your-dev-store.myshopify.com --version 2026-10 and paste each query and mutation your code sends. A removed field or an invalid metafield filter fails here, in seconds.
  5. Exercise the changed behaviour with real data: create an unfulfilled test order, change its shipping address to another tax region with orderUpdate, and compare totalTaxSet before and after. Run every metafield filter. Process an order that needs no fulfilment and check that your code handles FULFILLMENT_NOT_REQUIRED.
  6. Send a 2026-10 webhook payload to your handler with shopify app webhook trigger --topic orders/updated --api-version 2026-10 --delivery-method http --address https://your-tunnel.example/webhooks. The payload is fixed and the command does not test your subscriptions, but it proves the handler parses the new shape.
  7. Assert the header in your integration tests: the served version must equal the constant.
  8. Deploy the code, then the configuration. shopify app deploy --no-release creates an app version without releasing it, so the TOML change can go live at a moment you choose.

A 200 does not mean it worked

The GraphQL Admin API reference warns that it "can return a 200 OK response code in cases that would typically produce 4xx or 5xx errors in REST." Errors arrive in a top-level errors array, each with a message and usually an extensions.code such as THROTTLED. The adminGraphql() function above turns that array into an exception, so a caller cannot read data without noticing.

Mutations add a second layer. When the operation is valid but the input is not, the response has no top-level errors, and the problem is in userErrors, which you only get if you select it:

const data = await adminGraphql(
  `mutation UpdateAddress($input: OrderInput!) {
    orderUpdate(input: $input) {
      order { id totalTaxSet { shopMoney { amount currencyCode } } }
      userErrors { field message }
    }
  }`,
  { input: { id: orderId, shippingAddress: newAddress } },
);

const { order, userErrors } = data.orderUpdate;
if (userErrors.length) {
  // Nothing was saved. Show or log the reason; do not retry the same input.
  throw new Error(userErrors.map((e) => `${(e.field || []).join('.')}: ${e.message}`).join('; '));
}
// On 2026-10, taxes may have changed: compare before reporting success.
console.log(order.totalTaxSet.shopMoney);

Treat the three cases differently. An HTTP error means the request did not reach the API correctly. A top-level errors entry means the operation is wrong for this version, or you were throttled. A userErrors entry means the data was rejected. Only the throttled case is worth retrying automatically.

A quarterly checklist, with a reminder that runs itself

Shopify releases on 1 January, 1 April, 1 July and 1 October. Put a recurring one-hour task in the first week after each release:

  • Read the new release notes and the changelog entries marked breaking or action required.
  • Run the version search above and confirm that every pin still points to the constant.
  • Check the X-Shopify-API-Version header in your logs for a mismatch.
  • If your pinned version is the oldest supported one, start the upgrade now, on a dev store.
  • Update @shopify/shopify-api or your client library, so the new ApiVersion exists.

A calendar reminder depends on a person. A CI check does not. This script reads the pinned versions and fails when one is within 30 days of the end of its guaranteed support, with a warning at 90. It uses the documented minimum of 12 months from the release date, which is earlier than the dates the table has shown so far, so it never warns late:

// scripts/check-shopify-api-version.mjs
// Fails CI when a pinned Shopify API version is close to the end of its support.
// Node 20+, no dependencies. Run from the repository root.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const WARN_DAYS = Number(process.env.API_VERSION_WARN_DAYS || 90);
const FAIL_DAYS = Number(process.env.API_VERSION_FAIL_DAYS || 30);

// Every file that pins a version. Add yours if it lives somewhere else.
const files = ['shopify.app.toml', 'app/shopify-api-version.ts'].filter(existsSync);
if (existsSync('extensions')) {
  for (const f of readdirSync('extensions', { recursive: true })) {
    if (String(f).endsWith('shopify.extension.toml')) files.push(join('extensions', String(f)));
  }
}

const MONTH = { January: 1, April: 4, July: 7, October: 10 };
const found = [];
for (const file of files) {
  const text = readFileSync(file, 'utf8');
  // TOML: api_version = "2026-10"
  for (const m of text.matchAll(/api_version\s*=\s*"(\d{4})-(\d{2})"/g)) {
    found.push({ file, year: Number(m[1]), month: Number(m[2]) });
  }
  // @shopify/shopify-api: ApiVersion.October26
  for (const m of text.matchAll(/ApiVersion\.(January|April|July|October)(\d{2})\b/g)) {
    found.push({ file, year: 2000 + Number(m[2]), month: MONTH[m[1]] });
  }
  // A plain string constant: SHOPIFY_API_VERSION = '2026-10'
  for (const m of text.matchAll(/SHOPIFY_API_VERSION\s*=\s*['"](\d{4})-(\d{2})['"]/g)) {
    found.push({ file, year: Number(m[1]), month: Number(m[2]) });
  }
}

if (found.length === 0) {
  console.log('::error::No pinned Shopify API version found. Check the file list in this script.');
  process.exit(1);
}

let failed = false;
for (const v of found) {
  const name = `${v.year}-${String(v.month).padStart(2, '0')}`;
  // Shopify guarantees at least 12 months from the release on the 1st of the month.
  // The published table has so far shown the 16th at 15:00 UTC; the 1st is the safe bound.
  const end = Date.UTC(v.year + 1, v.month - 1, 1);
  const days = Math.floor((end - Date.now()) / 86_400_000);
  const where = `${v.file}: ${name}`;
  const when = days < 0 ? `ended ${-days} days ago` : `ends in ${days} days`;
  if (days <= FAIL_DAYS) {
    console.log(`::error::${where} - guaranteed support ${when}. Upgrade now.`);
    failed = true;
  } else if (days <= WARN_DAYS) {
    console.log(`::warning::${where} - guaranteed support ${when}. Plan the upgrade.`);
  } else {
    console.log(`${where} - ${days} days of guaranteed support left.`);
  }
}

process.exit(failed ? 1 : 0);

The ::error:: and ::warning:: prefixes are GitHub Actions annotations, so the message appears on the run summary. Run it on every pull request and once a week, because a version expires on a date, not on a commit:

# .github/workflows/shopify-api-version.yml
name: Shopify API version
on:
  pull_request:
  schedule:
    - cron: '0 7 * * 1'   # Mondays at 07:00 UTC
  workflow_dispatch:
jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
      - name: Check pinned versions
        run: node scripts/check-shopify-api-version.mjs

Two details from GitHub's schedule documentation matter here. Notifications for a failed scheduled run go to the user who last modified the cron line, so that person should be the one who owns the app. And in a public repository, scheduled workflows are disabled after 60 days without activity, which is exactly the kind of repository this check is for. Private repositories are the usual case for custom apps; if yours is public, add the pull request trigger and check that the schedule is still enabled at each quarterly review.

The work for 2025-10 is already late, but it is not difficult: find the pins, read three sets of release notes, test the changed queries on a dev store and add the check. If you would rather hand this over, it is part of our private app maintenance work.

Written by

Luis Morales

Luis Morales

Frontend and Shopify developer, EcomLabs

@luismoralesdev on Instagram

Published October 22, 2026 · updated October 23, 2026

← Blog