To convert a Shopify GID to a REST ID, ask the Admin API for legacyResourceId, or read the admin_graphql_api_id field that REST payloads already carry. If you only have the string, parse it as gid://shopify/{Type}/{id} with an optional query string, and keep the ID as a string. A plain JavaScript number can silently round it.
Shopify’s GraphQL APIs (Admin, Storefront and Customer Account) identify objects by global ID. The REST Admin API, which Shopify has called a legacy API since 1 October 2024, uses plain numbers. An integration halfway through a REST-to-GraphQL move holds both kinds, so somewhere it converts between them. When that conversion is a one-line string split, it works for products and breaks on the IDs below.
We ran every TypeScript sample here on Node 24.12.0, which runs .ts files directly by stripping types, and type-checked them with TypeScript 5.9.3 in strict mode. Storefront responses quoted below came from mock.shop, Shopify’s public demo store, on 10 November 2026. The Admin query further down was checked against the 2026-10 schema with Shopify’s own validator, but we did not run it against a real store.
What is inside a Shopify global ID?
Shopify’s global ID guide gives the default shape as gid://shopify/{object_name}/{id}, so a product with ID 123 is gid://shopify/Product/123. The object name is the GraphQL type name, exactly as the schema spells it: ProductVariant, not Variant.
Some IDs carry parameters. The same guide documents gid://shopify/InventoryLevel/123?inventory_item_id=456, where the child object needs its parent’s ID to be unique.
And some IDs are not numeric at all. When we created a cart on mock.shop, the ID came back as:
gid://shopify/Cart/c1-3dfbe96a16eb42eeb1fe75685e8f66e2?key=a5f53dc6180dba2cc4e41825b10d912a
That cart has no REST equivalent, and the key parameter is part of the identifier. Strip it and you no longer have a working cart ID.
Why does split(’/‘).pop() give the wrong answer?
Because it assumes the last path segment is the whole ID. Here is what the usual one-liners returned on Node 24 for three real shapes:
| Input | split('/').pop() | parseInt(..., 10) |
|---|---|---|
gid://shopify/Product/7982853619734 | 7982853619734 | 7982853619734 |
.../InventoryLevel/123?inventory_item_id=456 | 123?inventory_item_id=456 | 123 |
.../Cart/c1-3dfb...?key=a5f5... | c1-3dfb...?key=a5f5... | NaN |
The product row is the case the shortcut was written for, and it works. The inventory row is the dangerous one: parseInt quietly drops the parameter and hands back a number that looks right.
new URL() does better than you might expect, since gid: parses as a non-special scheme. It returned shopify as the host, /InventoryLevel/123 as the path and the parameter in searchParams. You still have to split the path yourself, though, and it throws ERR_INVALID_URL on anything malformed, so a small dedicated parser is clearer.
How do you convert a Shopify GID to a REST ID safely?
Parse once, return strings, and refuse anything that does not match. This is the module we tested:
// gid.ts: parse and build Shopify global IDs without losing anything.
export interface ShopifyGid {
type: string;
id: string;
params: Record<string, string>;
}
const GID = /^gid:\/\/shopify\/([A-Za-z]+)\/([^/?#]+)(?:\?(.*))?$/;
export function parseGid(value: string): ShopifyGid {
const match = GID.exec(value);
if (!match) {
throw new TypeError(`Not a Shopify global ID: ${value}`);
}
const [, type, id, query] = match;
return { type, id, params: Object.fromEntries(new URLSearchParams(query ?? '')) };
}
export function toGid(type: string, id: string | number | bigint): string {
if (typeof id === 'number' && !Number.isSafeInteger(id)) {
throw new RangeError(`${id} has already lost precision; use the string ID`);
}
const digits = String(id);
if (!/^\d+$/.test(digits)) {
throw new TypeError(`Expected a numeric REST ID, got: ${digits}`);
}
return `gid://shopify/${type}/${digits}`;
}
export function expectType(value: string, type: string): string {
const gid = parseGid(value);
if (gid.type !== type) {
throw new TypeError(`Expected a ${type} ID, got a ${gid.type} ID`);
}
return gid.id;
}
parseGid gave { type: 'InventoryLevel', id: '123', params: { inventory_item_id: '456' } } for the documented example and kept the cart’s key in params. expectType exists because a type mix-up does not always fail loudly. On mock.shop, product(id:) given a real variant’s ID (gid://shopify/ProductVariant/43695710371862), a bare number or a Base64 string returned "product": null with HTTP 200 and no errors array. We have not checked whether a real store’s Admin API errors instead, so check the type before the request rather than relying on the response.
Treat parsing as the fallback. When the API will tell you the REST ID, ask it. The global ID guide documents legacyResourceId on most Admin objects, and admin_graphql_api_id on most REST resources, going the other way. Neither needs string handling at all.
Where do Shopify IDs lose digits?
In JSON.parse. legacyResourceId is typed UnsignedInt64, which the reference defines as values between 0 and 2^64 - 1 “encoded as a string of base-10 digits”. GraphQL hands it to you as a string. REST payloads, including webhooks, send id as a bare JSON number, and JavaScript numbers are exact only up to 9007199254740991.
The products on mock.shop have 13-digit IDs, well inside that limit. The sample payloads in Shopify’s webhook reference are not. This is the customers/delete sample, run through JSON.parse:
// The customers/delete sample payload from Shopify's webhook reference.
const body = '{"id": 706405506930370084, "tax_exemptions": [], "admin_graphql_api_id": "gid://shopify/Customer/706405506930370084"}';
const payload = JSON.parse(body);
console.log(payload.id); // 706405506930370000
console.log(`gid://shopify/Customer/${payload.id}`); // wrong customer
console.log(payload.admin_graphql_api_id); // exact
console.log(Number.isSafeInteger(payload.id)); // false
Those are documentation fixtures, and Shopify does not say whether live IDs will reach that length. The schema allows them to, though, and the failure is silent: you get a valid-looking ID for a different record. So build the GID from admin_graphql_api_id, never from id, and if you need the REST number itself, keep it as a string. Our post on parsing large integers with JSON.parse covers the reviver that reads the original digits. That is also why toGid above throws a RangeError for an unsafe number instead of formatting it.
The same rule applies to the webhook handler itself. Verify the HMAC against the raw body before you parse anything, as in our Shopify webhook HMAC walkthrough, then parse.
Converting a batch of REST IDs
If you have a column of REST customer IDs, build the GIDs with toGid from the stored strings and fetch them in one call with the Admin nodes query:
query CustomersByRestId($ids: [ID!]!) {
nodes(ids: $ids) {
... on Customer {
id
legacyResourceId
displayName
}
}
}
Shopify’s validator accepted this against the 2026-10 Admin schema and listed read_customers as the required scope. We could not run it end to end: mock.shop’s Storefront endpoint returned an empty nodes list even for IDs it had just served us, so it is no help as a test bed for nodes. Run it against a development store before you trust it with a migration.
What about old Base64 Storefront IDs?
Before 2022-04, the Storefront API returned IDs Base64-encoded, so a product was Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzE= rather than gid://shopify/Product/1. Shopify’s migration guide for non-encoded IDs says encoded IDs as arguments were deprecated in 2022-04, with removal planned for 2023-07, and that you need to migrate if you cache, parse or persist IDs.
Mock.shop returned null when we passed it an encoded product ID. It also returns null for a wrong-type ID, so that shows only that the encoded form no longer resolved there, not the reason. If an old headless build saved encoded IDs to a database, decode them once:
// IDs saved from Storefront API versions before 2022-04 were Base64-encoded.
export function normaliseStoredId(value: string): string {
if (value.startsWith('gid://')) return value;
const decoded = Buffer.from(value, 'base64').toString('utf8');
if (!decoded.startsWith('gid://shopify/')) {
throw new TypeError(`Not a global ID, encoded or otherwise: ${value}`);
}
return decoded;
}
What we would do
Store the full GID string, query string included, as the canonical key in your own database, and treat the REST number as a derived string you only produce at the edge where legacy code needs it. Get it from legacyResourceId or admin_graphql_api_id when the API offers it, and reach for parseGid only when it does not. Never put a Shopify ID through Number().
The exception is a short-lived script against IDs you can see are 13 or 14 digits: there, the one-liner on a product ID works, and a parser is ceremony. Anything that persists IDs, handles carts or inventory levels, or reads webhooks deserves the parser.
We move integrations from REST to GraphQL, ID layer included, as part of our Shopify development work. If your app still builds GIDs from webhook id fields, that is a small fix we can make before it turns into a data repair.