Modern Shopify Admin API: GraphQL, Limits, Webhooks

·

Shopify keeps updating its APIs. If you're building custom features for your store, you need to know what's available and how to use it.

Shopify's Admin API changed shape in the last two years, and a lot of "custom development" advice on the web is quietly out of date. REST is now legacy; the calculated cost limiter punishes naive GraphQL; and a webhook handler that skips verification is a data-integrity hole. This is the current picture for anyone building real integrations on Shopify.

REST is legacy — GraphQL is the platform now

As of 2025, the Admin GraphQL API is the primary interface, and new public apps are required to use it. REST Admin endpoints still respond, but they no longer receive new objects and features (Shopify Functions, bundles, B2B, many metafield capabilities are GraphQL-only). If you are starting a custom app today, do not build it on REST — you will hit a wall the first time you need anything added after 2024.

The rate limiter is a cost budget, not a request count

The single biggest mistake in custom Shopify code is treating GraphQL like REST — "I'm under 2 requests/second, why am I throttled?" GraphQL uses a calculated query cost: every field has a point value, and you spend from a bucket (1,000 points on standard plans, higher on Plus) that refills at ~50 points/second. A query that pulls 250 orders with line items can cost hundreds of points in one call. The fix is not to slow down blindly — it is to read the cost Shopify hands back and pace against it:

query {
  orders(first: 50) {
    edges { node { id name totalPriceSet { shopMoney { amount } } } }
    pageInfo { hasNextPage endCursor }
  }
  # Shopify returns cost telemetry with every response:
  # extensions.cost.actualQueryCost
  # extensions.cost.throttleStatus.currentlyAvailable
  # extensions.cost.throttleStatus.restoreRate
}

Read throttleStatus.currentlyAvailable after each call; if it drops below your next query's actualQueryCost, sleep (needed - available) / restoreRate seconds before firing again. This keeps you at maximum sustainable throughput without ever eating a 429 — request-per-second heuristics can't do that because they don't know the cost.

Never paginate a big export by hand — use bulk operations

Fetching every product or order through cursor pagination is slow, expensive on the cost budget, and fragile. Shopify's bulk operations run the query asynchronously on their side and hand you a JSONL file to download — one mutation to start, then poll:

mutation {
  bulkOperationRunQuery(query: """
    { products { edges { node { id title variants { edges { node { sku price } } } } } } }
  """) {
    bulkOperation { id status }
    userErrors { field message }
  }
}
# then poll `currentBulkOperation { status url }` until COMPLETED,
# download the JSONL from `url`, stream-parse line by line.

One bulk op replaces thousands of paginated calls and does not draw down your interactive cost budget. Use it for any full-catalog or full-order sync.

Verify every webhook — an unverified handler is a forgery target

Webhooks are how your integration stays in sync (order created, product updated, app uninstalled). But the endpoint is public: anyone who finds the URL can POST fake payloads unless you verify the HMAC. Shopify signs the raw body with your app secret and sends it in X-Shopify-Hmac-Sha256. Verify against the raw body (not the parsed JSON) with a timing-safe comparison:

import crypto from "crypto";

function verifyWebhook(rawBody, hmacHeader, secret) {
  const digest = crypto
    .createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("base64");
  // timing-safe: never compare with === (leaks length/timing)
  const a = Buffer.from(digest);
  const b = Buffer.from(hmacHeader || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Two traps: your body parser must expose the raw bytes (in Express, mount express.raw() on the webhook route, or the HMAC will never match a re-serialized body), and you must reply 200 within a few seconds — do the real work on a queue, not inline, or Shopify retries and eventually disables the subscription.

Auth: session tokens, not long-lived cookies

Embedded apps authenticate with short-lived session tokens (JWTs from App Bridge), exchanged for an access token via token exchange. Offline tokens for background jobs, online tokens for user-scoped actions. Request only the scopes you use — over-scoping is the most common reason an app fails review and the most common thing a security audit flags.

The difference between a custom Shopify integration that survives a Black Friday traffic spike and one that gets throttled into failure is almost never the feature set — it's whether the code reads the cost budget and moves heavy reads to bulk operations.WS24 — how we scope Shopify API builds

Custom development on Shopify is not hard because the API is hard — it is hard because the defaults (REST, hand-pagination, unverified webhooks) look fine at demo scale and break in production. Build GraphQL-first, pace against the cost budget, bulk your big reads, and verify every inbound signature.