Shopify GraphQL Admin API Best Practices for 2026
If you are commissioning or building a Shopify app right now, there is no decision to make about which API to use. Shopify now requires new public App Store submissions to use the GraphQL Admin API. The REST Admin API still exists — but it is officially legacy, receives no new features, and will progressively narrow in scope. Building against it today means carrying migration debt from day one.
This post covers how to build against the GraphQL Admin API the right way: rate-limit cost accounting, bulk operations for large datasets, the quarterly versioning cadence, webhook-driven sync, idempotent retry handling, and choosing between online and offline access tokens.
TL;DR
- The REST Admin API is legacy; new public apps must use GraphQL.
- GraphQL rate limits are calculated by query cost, not request count — request only the fields you need.
- For any dataset larger than a few hundred records, use bulk operations (
bulkOperationRunQuery/bulkOperationRunMutation) instead of paginated loops. - Shopify releases a new API version every quarter; each version lives ~12 months — pin a version and upgrade on a schedule.
- Use webhooks for event-driven sync; never poll the API in a tight loop.
- Background jobs need offline access tokens; user-facing actions need online tokens.
REST is legacy — what that actually means
Shopify marked the REST Admin API as legacy. New features, new resource types, and performance improvements all go to GraphQL only. The REST API is frozen where it stands.
The immediate consequence: several things simply do not exist in REST. The expanded product variant model (supporting far more variants per product than the old REST resource) is GraphQL-only. Bulk operations, cursor-based pagination on large result sets, and fine-grained field selection all require GraphQL.
For existing apps that already use REST, there is no forced migration deadline. But every quarter that passes, the GraphQL-only surface area grows. Starting a new integration in REST today means writing migration code before the app is even stable.
The GraphQL Admin API endpoint is https://{shop}.myshopify.com/admin/api/{version}/graphql.json — a single POST endpoint for all operations, with the query or mutation in the request body.
The rate-limit model: query cost, not request count
This is the most misunderstood part of the GraphQL Admin API, and getting it wrong produces throttle errors that are hard to diagnose under load.
The GraphQL Admin API uses a calculated query cost model backed by a leaky-bucket algorithm. Every query has a cost in points, calculated from the number of fields and connections requested. The bucket refills at a fixed restore rate that scales with the merchant's plan tier, and there is a maximum single-query cost ceiling that applies regardless of tier.
Shopify returns cost data on every response under the extensions key:
{
"extensions": {
"cost": {
"requestedQueryCost": 72,
"actualQueryCost": 38,
"throttleStatus": {
"maximumAvailable": 1000.0,
"currentlyAvailable": 962.0,
"restoreRate": 100.0
}
}
}
}
Two things to notice. First, Shopify pre-charges the requestedQueryCost before execution starts — your bucket must have that capacity available or the query is rejected with a throttle error. Second, after execution, Shopify refunds the difference between requested and actual cost. A query that could return 50 connections but only finds 3 records ends up costing far less than its worst case.
Practical rules:
- Request only the fields your code actually uses. Every field on a connection costs points. Pulling
images,variants,metafields, andoptionsin one query when you only needtitleandstatusis the fastest way to hit throttle limits. - Paginate with cursors (
first: N, after: $cursor), not largefirst:values. Requestingfirst: 250on a products connection costs more thanfirst: 50even if the result set is sparse. - Read the
throttleStatuson every response and back off whencurrentlyAvailableis low. A simple sleep-and-retry with exponential backoff handles most cases. - Never run parallel requests that saturate the bucket. A queue with controlled concurrency is more reliable than a flood of concurrent calls.
The REST Admin API used a simpler leaky bucket of 40 requests over 2 seconds. The cost-based model is more flexible — a single cheap query does not count the same as a deeply nested one — but it requires you to think about query design upfront.
Comparison: REST Admin API vs. GraphQL Admin API
| REST Admin API | GraphQL Admin API | |
|---|---|---|
| Status | Legacy (frozen) | Primary / default |
| New App Store submissions | Not allowed | Required |
| Endpoint count | Many resource-specific URLs | Single POST endpoint |
| Rate limiting | 40 requests / 2 sec (leaky bucket) | Cost-based (points/sec, scales by plan) |
| Field selection | Full object always returned | Request only what you need |
| Pagination | Page-based (page=, limit=) |
Cursor-based (first, after) |
| Bulk data operations | Not available | bulkOperationRunQuery / bulkOperationRunMutation |
| New features | None planned | Active development |
| Real-time cost visibility | No | extensions.cost on every response |
Bulk operations: the right tool for large datasets
The single biggest mistake in Shopify integrations is using a paginated loop to read or write tens of thousands of records. A loop that paginates through 50,000 orders in batches of 250 runs 200 API calls, burns through rate-limit budget, and takes minutes. Bulk operations do the same job asynchronously, server-side, without competing with your interactive queries for budget.
Reading large datasets with bulkOperationRunQuery
You submit your query once, Shopify processes it in the background, and delivers the result as a JSONL file when complete. Each line in the file is one record.
mutation {
bulkOperationRunQuery(
query: """
{
orders(query: "created_at:>2026-01-01") {
edges {
node {
id
name
totalPriceSet { shopMoney { amount currencyCode } }
lineItems {
edges { node { title quantity } }
}
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}
Poll status with currentBulkOperation, or — better — subscribe to the bulk_operations/finish webhook and let Shopify push the notification rather than polling. When the status is COMPLETED, the url field on the bulk operation object points to the JSONL file. Nested connections in the result are flattened with a __parentId field linking child records to their parent.
Writing large datasets with bulkOperationRunMutation
You upload a JSONL file to Shopify (via stagedUploadsCreate), then submit bulkOperationRunMutation with the mutation template and the staged upload URL. Shopify executes the mutation once per line in the file.
Use bulk operations for any import/export job, any sync that touches more than a few hundred records, and any scheduled background task. Paginated loops are for small, real-time fetches only.
Quarterly versioning: pin it, upgrade on a schedule
Shopify releases a new API version on the first of every quarter: January, April, July, October. Each version is date-named (2026-01, 2026-04, and so on) and supported for a minimum of 12 months.
When a version is retired, Shopify does not break your app silently. Instead, it falls forward: requests targeting a retired version are served by the oldest currently accessible stable version, and the X-Shopify-API-Version response header tells you which version actually processed the request. Useful as a safety net — not a reason to skip upgrades.
What this means in practice: pin your app to a specific version string in config. Never use unstable in production — that track receives breaking changes without notice. Read the release notes when a new version drops. Plan a version bump every six months at minimum. The overlap between consecutive versions is generous, but the teams that wait for the last month always regret it.
Webhooks instead of polling
Polling the GraphQL API for changes — querying orders or products every few minutes to detect updates — consumes rate-limit budget continuously and introduces latency. Webhooks invert this: Shopify pushes a payload to your endpoint the moment the relevant event fires.
Register webhook subscriptions via GraphQL using webhookSubscriptionCreate. Topics follow the pattern {resource}/{event} — orders/create, products/update, inventory_levels/update, and so on. The delivery endpoint must be HTTPS.
Two things that trip up every implementation:
First, respond with HTTP 200 immediately before doing any processing. If your handler calls a database, an external API, or a slow queue, and that takes more than a few seconds, Shopify marks the delivery as failed. Shopify retries failed deliveries several times over a few hours with exponential backoff. After the final retry, the subscription can be automatically removed. Accept the payload, enqueue it, respond 200, process asynchronously.
Second, verify the HMAC signature on every incoming payload. Shopify signs each delivery with your app's client secret. Skip this check and your endpoint is open to spoofed payloads. (Webhook security is covered in depth in Shopify app security best practices.)
Idempotency and retry handling
Network failures happen. Your mutation fires, the response is lost in transit, and you do not know whether the operation succeeded. Retrying naively creates duplicate records.
Where Shopify exposes an idempotencyKey argument on a mutation, pass a stable, unique key (a UUID tied to the business event, not a random value generated per attempt) and Shopify will return the original result if the same key is submitted twice within the deduplication window.
Where idempotencyKey is not available, design your mutations to be naturally idempotent: check for existence before creating, or store the Shopify-assigned id after the first successful response before retrying.
For retries, use exponential backoff with jitter. A fixed retry interval under load turns one throttled client into a synchronized thundering herd.
Online vs. offline access tokens
The distinction matters, and choosing wrong means either security gaps or broken functionality.
Offline tokens are long-lived and not tied to any individual user session. They are issued once during app installation and persist until revoked. Use these for everything that runs in the background: webhook handlers, scheduled sync jobs, bulk operations, cron tasks. This is the default access mode.
Online tokens are scoped to a specific store staff member and expire when that user's session ends. Use these when your embedded app UI needs to respect per-user permissions — for example, when only certain staff roles should be able to trigger a product import.
A common pattern: the app stores one offline token per shop for background operations, and issues online tokens for the embedded UI session. Most apps need both. For deeper architecture guidance on how access modes fit into the overall app scaffold, the Shopify app development process post covers this as part of Phase 2 architecture decisions. And remember — every app needs a backend you host yourself; our hosting guide for developers covers the options.
FAQ
Do custom apps also need to use GraphQL? The mandate specifically covers new public apps submitted to the Shopify App Store. Custom apps built in the Partner Dashboard are not subject to the same submission requirement, but the same practical argument applies: the REST Admin API receives no new features, so custom app integrations built today should also target GraphQL to avoid migration work later.
Is there a maximum query cost per request? Yes — a single GraphQL query cannot exceed a fixed cost ceiling regardless of your store's plan tier. Plan tier affects the restore rate (how fast the bucket refills), not the per-query ceiling.
How do I know what a query will cost before I run it?
The requestedQueryCost in the extensions.cost block is calculated before execution. You can also use Shopify's GraphiQL explorer to see cost estimates while building queries. Design queries to leave headroom for the variability between requested and actual cost.
Should I use the unstable API version in staging?
You can use unstable to preview upcoming changes, but never in production and only deliberately in staging. Breaking changes land in unstable without notice and without migration guides. Pin to a specific stable version in all environments and upgrade on the quarterly schedule.
What happens if I miss a version upgrade and my pinned version is retired?
Shopify falls forward: requests to a retired version are automatically served by the oldest currently accessible stable version. The X-Shopify-API-Version header in the response tells you which version ran the request. You will not get a hard error — but you will be running on an older version than intended, and any fields removed in the intervening versions will be missing from responses.
When should I use bulk operations vs. paginated queries? If the dataset is under a few hundred records and the query is user-facing (the result needs to appear in a UI within a second or two), use a paginated query. For anything larger — product exports, order syncs, inventory reconciliation, customer imports — use bulk operations. The crossover point where bulk becomes faster and cheaper is lower than most developers expect.
Scoping a Shopify app build or an API integration audit? The hard part is usually the API layer — complex ERP sync, high-volume bulk operations, custom storefront data pipelines. The Shopify App Cost Estimator gives a structured breakdown of the components involved, and contacting us is the fastest way to talk through the architecture before you commit to a design.