Shopify

Shopify Admin API: GraphQL first, REST legacy. OAuth or custom app token, reads orders, catalogue, inventory and payouts, writes price and stock.

Shopify is the hosted storefront platform behind a large share of direct to consumer brands worldwide, including most Indian D2C brands that run their own website alongside marketplaces. For a seller database, a Shopify store is the cleanest source we will ever connect to: one API, one access token per store, full history of orders, products, variants, inventory by location, fulfilments, returns and, where Shopify Payments is enabled, settlement payouts. It is also the only channel in this catalogue where write back of price and stock is a first class supported operation rather than a feed with a processing report. Everything below refers to the Admin API, which is the merchant side API; the Storefront API and Customer Account API are separate surfaces and are not covered here.

At a glance

What it is

Shopify Inc. is a Canadian company that sells hosted commerce software on a monthly subscription, in tiers that run roughly Basic, Grow, Advanced, Plus and Commerce Components for enterprise. A store is identified by a permanent *.myshopify.com domain even when the merchant serves a custom domain, and that myshopify.com host is what every API call is addressed to. Merchants may run one store per country or one store with multiple markets; each store is a separate API tenant with its own access token, so a brand with an India store and a US store is two connections in our database.

In India, Shopify is the default D2C website platform for brands above roughly the earliest revenue stage, competing with WooCommerce at the low end and with Unicommerce or custom stacks at the high end. It is not a marketplace: there is no shared customer base and no seller onboarding queue. That means a Shopify connection gives us first party data, including unmasked customer email, phone and shipping address, which almost no marketplace in this catalogue will hand over.

API access

Anyone can get credentials. There are two routes and they differ in how the token is issued, not in what the API can do.

  1. Custom app installed by the merchant. The store owner goes to Settings, Apps and sales channels, Develop apps, creates an app, ticks the Admin API scopes and reveals an Admin API access token prefixed shpat_. That token is a static bearer credential for exactly that one store and never expires until the app is uninstalled or the token is revoked. This is the fastest path when a merchant is willing to paste a token into our onboarding form. Shopify's access tokens documentation on 2026-09-21 describes admin created custom apps as a legacy route that remains documented for existing apps, with new development pointed at the Dev Dashboard; treat the exact availability as medium confidence and verify at onboarding time.
  2. Public or custom app built in the Dev Dashboard. We register an app once, get a client id and client secret, and each merchant installs it through OAuth. This is the route for a connector that will serve many merchants, and it is the only route if we ever want App Store distribution.

API versions are dated, YYYY-MM, and ship quarterly on the first day of the quarter at 17:00 UTC. Each stable version is supported for at least twelve months, with at least nine months of overlap between consecutive versions, so a connector that upgrades once a year is safe. If an app calls a version that is no longer accessible, Shopify falls forward to the oldest accessible stable version rather than failing, and the version actually used is returned in the X-Shopify-API-Version response header. The latest version referenced on the reference pages on 2026-09-21 was 2026-07. There are also release-candidate and unstable channels, neither of which is safe for production. Note that the OAuth endpoints, Liquid and the Ajax API are explicitly unversioned.

The REST Admin API has been labelled legacy since 1 October 2024, and since 1 April 2025 all new public apps must be built with the GraphQL Admin API. REST still answers, but new platform capabilities land in GraphQL only, and several object graphs, notably fulfilment orders, returns and Shopify Payments balance detail, are far richer in GraphQL. Build the connector on GraphQL and treat REST as a fallback for anything the GraphQL schema does not expose.

Official libraries: @shopify/shopify-api (framework agnostic Node library covering OAuth, API calls and webhook verification), @shopify/admin-api-client, @shopify/graphql-client, @shopify/shopify-app-remix and @shopify/shopify-app-express, plus session storage adapters for Postgres, MySQL, MongoDB, Redis, SQLite, DynamoDB, Prisma and Drizzle. Shopify also publishes Ruby, Python, PHP and .NET libraries.

Authentication

Custom app token

Nothing to negotiate. The merchant supplies the shop domain and the shpat_ token, we store both and send the token on every call.

curl -X POST \
  "https://example-store.myshopify.com/admin/api/2026-07/graphql.json" \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Access-Token: shpat_0123456789abcdef0123456789abcdef" \
  -d '{"query":"{ shop { name myshopifyDomain currencyCode ianaTimezone plan { displayName } } }"}'

OAuth 2.0 authorization code grant

  1. Redirect the merchant to the store's authorize endpoint. scope is a comma separated list, state is a nonce we store, and adding grant_options[]=per-user asks for an online token scoped to the signed in staff member instead of the default offline token.
GET https://{shop}.myshopify.com/admin/oauth/authorize
  ?client_id={client_id}
  &scope=read_orders,read_products,write_products,read_inventory,write_inventory,read_fulfillments,read_returns,read_customers,read_locations,read_shopify_payments_payouts
  &redirect_uri=https://connectors.example.com/shopify/callback
  &state={nonce}
  1. Shopify redirects back with code, hmac, host, shop, state and timestamp. Before doing anything else: compare state to the stored nonce, check that shop matches ^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$, then remove hmac from the query, sort the remaining parameters, compute HMAC SHA-256 over the sorted query string using the client secret and compare in constant time.

  2. Exchange the code for a token.

POST https://{shop}.myshopify.com/admin/oauth/access_token
Content-Type: application/x-www-form-urlencoded

client_id={client_id}&client_secret={client_secret}&code={code}&expiring=1
  1. The response differs by token type. An offline token with expiring=1 (which new public apps are required to request) comes back with a refresh token:
{
  "access_token": "shpua_9f1d0c5b6e2a4d7f8c3b1a0e9d8c7b6a",
  "refresh_token": "shprt_3c2b1a0f9e8d7c6b5a4938271605f4e3",
  "scope": "read_orders,read_products,write_products,read_inventory,write_inventory",
  "expires_in": 3600,
  "refresh_token_expires_in": 7776000
}

An online token requested with grant_options[]=per-user carries the staff member instead:

{
  "access_token": "shpua_5e4d3c2b1a09f8e7d6c5b4a392817f6e",
  "scope": "read_orders,read_products",
  "expires_in": 86399,
  "associated_user_scope": "read_orders,read_products",
  "associated_user": {
    "id": 902541635,
    "first_name": "Priya",
    "last_name": "Menon",
    "email": "priya@example-store.in",
    "account_owner": true,
    "collaborator": false
  }
}
  1. Refresh before expiry by posting to the same /admin/oauth/access_token endpoint with grant_type=refresh_token and the stored refresh token. Refresh tokens last 90 days, so a store that is never touched for three months has to be reinstalled.

Token lifetimes, as published on 2026-09-21: expiring offline access token one hour, refresh token 90 days, online access token 24 hours or until the staff member's session ends, non expiring offline token valid until uninstall. Embedded apps can skip the redirect entirely and use token exchange, posting the App Bridge session id token to the token endpoint to get an access token back.

Multi account is simple: the unit of credential is (shop domain, token). Our credential store should key on myshopifyDomain rather than the custom domain, keep the granted scope string, and record expires_at plus refresh_token_expires_at so a refresh job can run daily.

Warning

Scopes are granted at install time. Adding a scope later, for example read_shopify_payments_payouts after the fact, requires sending the merchant through the authorize URL again. Ask for the full read set on first install.

Objects we can read

All examples below are GraphQL against POST /admin/api/2026-07/graphql.json. Every connection uses Relay cursor pagination: first/after forward, last/before backward, and pageInfo { hasNextPage endCursor }. Pagination caps at 25,000 objects for a single paged traversal, which is why history backfills should go through bulk operations rather than page loops.

Orders

orders(first, after, query, sortKey, reverse, savedSearchId). sortKey defaults to PROCESSED_AT. The query argument takes Shopify's search syntax, and the documented filter keys include created_at, updated_at, processed_at, financial_status, fulfillment_status, status, tag, tag_not, customer_id, email, sku, channel, sales_channel, gateway, payment_id, return_status, chargeback_status, risk_level, test, confirmation_number, current_total_price, total_weight and metafields.{namespace}.{key}. For incremental sync use query: "updated_at:>2026-09-20T00:00:00Z" with sortKey: UPDATED_AT.

Money fields are MoneyBag objects with shopMoney and presentmentMoney; always read shopMoney for our ledger and keep presentmentMoney if the store sells in several currencies.

{
  "data": {
    "orders": {
      "edges": [
        {
          "node": {
            "id": "gid://shopify/Order/5678901234567",
            "name": "#1042",
            "createdAt": "2026-09-18T07:12:44Z",
            "processedAt": "2026-09-18T07:12:43Z",
            "updatedAt": "2026-09-19T04:02:10Z",
            "cancelledAt": null,
            "cancelReason": null,
            "currencyCode": "INR",
            "displayFinancialStatus": "PAID",
            "displayFulfillmentStatus": "FULFILLED",
            "returnStatus": "NO_RETURN",
            "email": "priya@example.com",
            "paymentGatewayNames": ["shopify_payments"],
            "currentSubtotalPriceSet": { "shopMoney": { "amount": "2499.00", "currencyCode": "INR" } },
            "totalShippingPriceSet": { "shopMoney": { "amount": "0.00", "currencyCode": "INR" } },
            "totalTaxSet": { "shopMoney": { "amount": "449.82", "currencyCode": "INR" } },
            "totalDiscountsSet": { "shopMoney": { "amount": "250.00", "currencyCode": "INR" } },
            "totalPriceSet": { "shopMoney": { "amount": "2698.82", "currencyCode": "INR" } },
            "shippingAddress": {
              "name": "Priya Menon", "address1": "14 Carter Road, Flat 302", "city": "Mumbai",
              "provinceCode": "MH", "countryCodeV2": "IN", "zip": "400050", "phone": "+919876543210"
            },
            "customer": { "id": "gid://shopify/Customer/7231884921", "displayName": "Priya Menon", "email": "priya@example.com", "numberOfOrders": "4" }
          }
        }
      ],
      "pageInfo": { "hasNextPage": true, "endCursor": "eyJsYXN0X2lkIjo1Njc4OTAxMjM0NTY3fQ==" }
    }
  }
}

The shippingAddress, email, phone and customer blocks are personally identifiable information and require the read_customers or protected customer data approval on public apps. Mark those columns as PII in our store.

Order items

order.lineItems(first, after). A line item carries both the historic values at purchase time and a live pointer to the variant, which may since have been renamed or deleted.

{
  "lineItems": {
    "nodes": [
      {
        "id": "gid://shopify/LineItem/13905553780871",
        "name": "Cold Pressed Coconut Oil - 500 ml",
        "variantTitle": "500 ml",
        "sku": "CPO-500",
        "quantity": 2,
        "currentQuantity": 2,
        "refundableQuantity": 2,
        "unfulfilledQuantity": 0,
        "originalUnitPriceSet": { "shopMoney": { "amount": "1249.50", "currencyCode": "INR" } },
        "discountedUnitPriceSet": { "shopMoney": { "amount": "1124.50", "currencyCode": "INR" } },
        "totalDiscountSet": { "shopMoney": { "amount": "250.00", "currencyCode": "INR" } },
        "taxLines": [{ "title": "IGST", "rate": 0.18, "priceSet": { "shopMoney": { "amount": "449.82", "currencyCode": "INR" } } }],
        "variant": { "id": "gid://shopify/ProductVariant/44102938475621", "sku": "CPO-500", "displayName": "Cold Pressed Coconut Oil - 500 ml" },
        "product": { "id": "gid://shopify/Product/8123456789012", "handle": "cold-pressed-coconut-oil" }
      }
    ],
    "pageInfo": { "hasNextPage": false, "endCursor": "eyJsYXN0X2lkIjoxMzkwNTU1Mzc4MDg3MX0=" }
  }
}

Products and listings

products(first, after, query, sortKey) with query keys such as updated_at, status, vendor, product_type, tag, sku, published_status. A Shopify product is the listing container; the sellable unit is the variant, which is what maps to our listings row, since price and stock live on the variant.

{
  "data": {
    "products": {
      "nodes": [
        {
          "id": "gid://shopify/Product/8123456789012",
          "title": "Cold Pressed Coconut Oil",
          "handle": "cold-pressed-coconut-oil",
          "status": "ACTIVE",
          "vendor": "Example Foods",
          "productType": "Edible Oil",
          "updatedAt": "2026-09-17T11:41:02Z",
          "publishedAt": "2025-03-05T04:30:00Z",
          "onlineStoreUrl": "https://example-store.in/products/cold-pressed-coconut-oil",
          "totalInventory": 148,
          "variants": {
            "nodes": [
              {
                "id": "gid://shopify/ProductVariant/44102938475621",
                "title": "500 ml",
                "sku": "CPO-500",
                "barcode": "8901234567890",
                "price": "1249.50",
                "compareAtPrice": "1499.00",
                "inventoryQuantity": 92,
                "inventoryPolicy": "DENY",
                "selectedOptions": [{ "name": "Size", "value": "500 ml" }],
                "inventoryItem": { "id": "gid://shopify/InventoryItem/45213098765432", "tracked": true, "unitCost": { "amount": "610.00", "currencyCode": "INR" } }
              }
            ]
          }
        }
      ],
      "pageInfo": { "hasNextPage": true, "endCursor": "eyJsYXN0X2lkIjo4MTIzNDU2Nzg5MDEyfQ==" }
    }
  }
}

Sales channel visibility is separate from status. A product with status: ACTIVE can still be unpublished on the online store. Read resourcePublicationsV2 or publishedOnPublication(publicationId:) per publication to know where the listing is live.

Inventory

Three objects matter. InventoryItem is the stock keeping record behind a variant, Location is a warehouse or store, and InventoryLevel is the pair of the two. Quantity names are available, on_hand, committed, incoming, reserved, damaged, safety_stock and quality_control.

{
  "data": {
    "inventoryItem": {
      "id": "gid://shopify/InventoryItem/45213098765432",
      "sku": "CPO-500",
      "tracked": true,
      "countryCodeOfOrigin": "IN",
      "harmonizedSystemCode": "15131100",
      "unitCost": { "amount": "610.00", "currencyCode": "INR" },
      "inventoryLevels": {
        "nodes": [
          {
            "id": "gid://shopify/InventoryLevel/112233445566?inventory_item_id=45213098765432", "updatedAt": "2026-09-19T03:55:12Z",
            "location": { "id": "gid://shopify/Location/74158963201", "name": "Bhiwandi FC" },
            "quantities": [
              { "name": "available", "quantity": 92 }, { "name": "on_hand", "quantity": 104 },
              { "name": "committed", "quantity": 12 }, { "name": "incoming", "quantity": 500 }
            ]
          }
        ]
      }
    }
  }
}

harmonizedSystemCode is the HSN code that Indian sellers need on invoices, and it is one of the few places a platform hands it to us directly.

Shipments and tracking

Shopify splits this in two. FulfillmentOrder is the work order that says which items at which location still need to ship, and Fulfillment is the shipment that actually went out. Read fulfilment orders to drive a 3PL, read fulfilments to fill our shipments table.

FulfillmentOrder fields include id, status, requestStatus, assignedLocation, destination, lineItems, deliveryMethod, supportedActions and fulfillAt, the last being the time a scheduled fulfilment order automatically becomes open. Statuses referenced in the reference include OPEN, SCHEDULED, ON_HOLD, IN_PROGRESS and CLOSED.

{
  "fulfillments": [
    {
      "id": "gid://shopify/Fulfillment/4671298374651",
      "name": "#1042.1",
      "status": "SUCCESS",
      "displayStatus": "DELIVERED",
      "createdAt": "2026-09-18T12:40:00Z",
      "deliveredAt": "2026-09-21T09:15:00Z",
      "estimatedDeliveryAt": "2026-09-22T00:00:00Z",
      "location": { "id": "gid://shopify/Location/74158963201", "name": "Bhiwandi FC" },
      "trackingInfo": [{ "company": "Delhivery", "number": "1234567890123", "url": "https://www.delhivery.com/track/package/1234567890123" }],
      "events": { "nodes": [{ "status": "DELIVERED", "happenedAt": "2026-09-21T09:15:00Z", "city": "Mumbai", "province": "Maharashtra" }] },
      "fulfillmentLineItems": { "nodes": [{ "id": "gid://shopify/FulfillmentLineItem/9988776655", "quantity": 2, "lineItem": { "sku": "CPO-500" } }] }
    }
  ]
}

Tracking events are only as good as what the app or carrier writes back. Many Indian stores ship through Shiprocket or a similar aggregator, which may or may not push scan events into Shopify; if the events array is thin, the authoritative source is the carrier connector, not Shopify.

Returns and cancellations

Cancellation is a field on the order: cancelledAt plus cancelReason (CUSTOMER, DECLINED, FRAUD, INVENTORY, OTHER, STAFF). Returns are a separate object requiring the read_returns scope. The Return object carries id, name, status, order, totalQuantity, requestApprovedAt, decline, returnLineItems, refunds and reverseFulfillmentOrders. Statuses seen in the reference include REQUESTED and OPEN; the full enum was not printed on the page we fetched, so enumerate it from the schema at the version you pin.

{
  "returns": {
    "nodes": [
      {
        "id": "gid://shopify/Return/1122334455",
        "name": "#1042-R1",
        "status": "OPEN",
        "totalQuantity": 1,
        "order": { "id": "gid://shopify/Order/5678901234567", "name": "#1042" },
        "returnLineItems": {
          "nodes": [
            { "id": "gid://shopify/ReturnLineItem/778899", "quantity": 1, "returnReason": "SIZE_TOO_SMALL", "returnReasonNote": "Wanted the 250 ml" }
          ]
        }
      }
    ]
  }
}

Refunds hang off the order and are the money side of a return: order.refunds gives id, createdAt, note, totalRefundedSet, refundLineItems and transactions.

Payments and settlements

Two layers. Order level money movement is order.transactions, with kind (SALE, AUTHORIZATION, CAPTURE, REFUND, VOID), status, gateway, amountSet, processedAt and paymentId. Store level settlement, only for stores on Shopify Payments, is shopifyPaymentsAccount.payouts, which requires payout permission.

{
  "data": {
    "shopifyPaymentsAccount": {
      "payouts": {
        "nodes": [
          {
            "id": "gid://shopify/ShopifyPaymentsPayout/992384756",
            "legacyResourceId": "992384756",
            "issuedAt": "2026-09-19T00:00:00Z",
            "status": "PAID",
            "transactionType": "DEPOSIT",
            "net": { "amount": "184320.55", "currencyCode": "INR" },
            "summary": {
              "chargesGross": { "amount": "196400.00", "currencyCode": "INR" },
              "chargesFee": { "amount": "-4126.40", "currencyCode": "INR" },
              "refundsFeeGross": { "amount": "-7800.00", "currencyCode": "INR" },
              "refundsFee": { "amount": "0.00", "currencyCode": "INR" },
              "adjustmentsGross": { "amount": "0.00", "currencyCode": "INR" },
              "adjustmentsFee": { "amount": "0.00", "currencyCode": "INR" },
              "retriedPayoutsFee": { "amount": "-153.05", "currencyCode": "INR" }
            }
          }
        ]
      }
    }
  }
}

The payout summary is an aggregate. To attribute fees to individual orders, read the balance transaction list on the same account, which links a charge, its fee and its net back to the source order. Field names on that connection were not confirmed on 2026-09-21, so verify them against the schema before writing the mapping. Stores that use Razorpay, PayU, Cashfree or PayPal instead of Shopify Payments expose no settlement data through Shopify at all; their payouts have to come from the gateway's own API.

Customers

customers(first, after, query) with filters on updated_at, email, phone, tag, orders_count, total_spent and country. Fields: id, firstName, lastName, displayName, email, phone, verifiedEmail, numberOfOrders, amountSpent, createdAt, updatedAt, tags, note, taxExempt, defaultAddress and addresses. Everything except the counters is PII. Public apps need Shopify's protected customer data approval before these fields return values; without it, customer fields come back null even though the scope was granted.

Locations

locations(first, includeInactive, includeLegacy) returning id, name, isActive, shipsInventory, fulfillsOnlineOrders, hasActiveInventory, address { address1 address2 city provinceCode countryCode zip phone }. These become our locations rows and are the location_id foreign key for both inventory and fulfilments.

Writing back: listings, price and stock

Shopify is a genuine write target. All of these are mutations on the same GraphQL endpoint and return a userErrors array that must be checked even on HTTP 200.

Setting stock, absolute form, with optimistic concurrency via compareQuantity:

{
  "input": {
    "name": "available",
    "reason": "correction",
    "referenceDocumentUri": "https://connectors.example.com/sync/2026-09-21T06:00Z",
    "ignoreCompareQuantity": false,
    "quantities": [
      {
        "inventoryItemId": "gid://shopify/InventoryItem/45213098765432",
        "locationId": "gid://shopify/Location/74158963201",
        "quantity": 92,
        "compareQuantity": 104
      }
    ]
  }
}

The response confirms the movement rather than echoing the new level:

{
  "data": {
    "inventorySetQuantities": {
      "inventoryAdjustmentGroup": {
        "reason": "Inventory correction",
        "changes": [{ "name": "available", "delta": -12 }, { "name": "on_hand", "delta": -12 }]
      },
      "userErrors": []
    }
  }
}

If compareQuantity does not match the current value the mutation fails rather than overwriting, which is exactly what we want when several systems write to the same store. Set ignoreCompareQuantity: true only for a deliberate full reconciliation.

Note

There is a resource limit on catalogue growth: once a store holds 500,000 product variants, no more than 10,000 new variants can be created per day on any API unless the store is on Shopify Plus. Bulk catalogue loads for very large stores need to be spread out.

Bulk read, for the initial backfill, is bulkOperationRunQuery. Submit a query with no pagination arguments, poll the operation, then download a JSONL file. The BulkOperation object exposes id, status (CREATED, RUNNING, COMPLETED, FAILED, CANCELED), errorCode (ACCESS_DENIED, INTERNAL_SERVER_ERROR, TIMEOUT), createdAt, completedAt, objectCount, fileSize, url and partialDataUrl. Nested connections are flattened into the same file, with each child line carrying a __parentId that points at the parent object; __parentId is not part of the schema and only appears in bulk output. The download URL is valid for one week, an operation must finish within ten days, and for API versions before 2026-01 an app could run only one bulk operation of each type per store at a time, polled through currentBulkOperation.

Webhooks and notifications

Subscribe with the webhookSubscriptionCreate mutation, or declare subscriptions in the app configuration file so they are applied at install. Delivery targets are an HTTPS endpoint, Amazon EventBridge or Google Cloud Pub/Sub; the last two are the right choice for our VM if we already have a queue, because they remove the 5 second response budget.

Topics we care about: orders/create, orders/updated, orders/paid, orders/cancelled, orders/fulfilled, orders/partially_fulfilled, fulfillments/create, fulfillments/update, fulfillment_orders/*, refunds/create, returns/*, products/create, products/update, products/delete, inventory_levels/update, inventory_items/update, customers/create, customers/update, locations/*, app/uninstalled and app/scopes_update. Apps distributed through the App Store must also subscribe to the mandatory compliance topics covering customer data requests, customer redaction and shop redaction.

A delivered request carries these headers:

POST /webhooks/shopify HTTP/1.1
Content-Type: application/json
X-Shopify-Topic: orders/updated
X-Shopify-Hmac-Sha256: 0mFQqk1vWiqHMvUYRkJ3QhU3B6Xy5DGZq4bWQkq0Xw8=
X-Shopify-Shop-Domain: example-store.myshopify.com
X-Shopify-Webhook-Id: b54557e4-bdd9-4b37-8a5f-bf7d70bcd043
X-Shopify-API-Version: 2026-07
X-Shopify-Triggered-At: 2026-09-19T04:02:10.123Z

Verification: compute HMAC SHA-256 over the raw request body using the app's client secret as the key, base64 encode it, and compare in constant time against X-Shopify-Hmac-Sha256. The body must be captured before any JSON parsing middleware touches it, or the bytes will not match.

echo -n "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SHOPIFY_CLIENT_SECRET" -binary \
  | base64

Respond 200 OK within 5 seconds, with a 1 second connection timeout; 3xx responses count as failures. Shopify retries 8 times over the following 4 hours, and after 8 consecutive failures it deletes the subscription and emails the app's emergency developer contact. Deliveries are at least once, so deduplicate on X-Shopify-Webhook-Id.

Webhooks are a change signal, not a complete record. The right design is: webhook marks the order id dirty, a worker reads the full object over GraphQL, plus a nightly reconciliation pass over updated_at to catch anything the retry window swallowed.

Rate limits and pagination

GraphQL Admin API. Cost based, not request based. Each query is assigned a cost in points, charged against a leaky bucket whose size and refill rate vary by plan. Every response carries the accounting:

{
  "extensions": {
    "cost": {
      "requestedQueryCost": 3,
      "actualQueryCost": 3,
      "throttleStatus": { "maximumAvailable": 1000.0, "currentlyAvailable": 997, "restoreRate": 50.0 }
    }
  }
}

Those figures come from the reference page example. The per plan table was not reachable on the pages we could fetch on 2026-09-21, so the connector should read throttleStatus.maximumAvailable and restoreRate from the first response of each store and size its own token bucket from that, rather than hardcoding a number per plan. requestedQueryCost is the pessimistic estimate charged up front, actualQueryCost is what was actually spent and refunded against. Throttling returns a THROTTLED error in errors[] with the same cost block, at HTTP 200; retry with exponential backoff of at least one second.

REST Admin API. Classic leaky bucket: 40 requests per app per store, refilling at 2 per second, with a 10x multiplier on Shopify Plus. Usage is exposed as X-Shopify-Shop-Api-Call-Limit: 32/40, and exceeding it returns HTTP 429 with a Retry-After header in seconds.

Shared limits. Input arrays cap at 250 elements on any API. A paged traversal caps at 25,000 objects, so anything larger must go through a bulk operation. Bulk operations themselves are not charged against the query cost bucket, which makes them the correct tool for backfills.

Practical cadence for our sync: one bulk query per store for the initial history load, webhooks for live changes, and a delta poll on updated_at every 15 minutes for orders and every hour for products and inventory. Even a Standard plan store absorbs that comfortably. Concurrency should be limited to one or two in flight requests per store, because the bucket is per app per store, not per app.

Mapping to the unified model

orders

order_items

listings

inventory

shipments

returns and settlements

Return.id to return_id, returnLineItems to items[], returnReason to reason, status to status, the linked Refund.totalRefundedSet to refund_amount, Return.createdAt to initiated_at, and the reverse fulfilment order's received timestamp to received_at. For settlements, ShopifyPaymentsPayout.id to settlement_id, issuedAt to paid_at, net to net_amount, the summary fields to fees[], with period_start and period_end inferred from the payout interval since Shopify does not publish them as fields. Order level attribution needs the balance transaction list.

Gaps and open questions

  • The per plan GraphQL cost bucket table (Basic, Grow, Advanced, Plus, Commerce Components) was not on any page our fetcher could reach on 2026-09-21. The connector should read throttleStatus at runtime instead. Medium confidence on any quoted per plan number.
  • The access tokens page describes merchant created custom apps as a legacy route, with new app creation pointed at the Dev Dashboard. Whether a merchant can still create a fresh shpat_ token from the admin today needs confirming against a live store before we build onboarding around it.
  • The ReturnStatus and FulfillmentOrderStatus enums were referenced but not printed in full; enumerate them from the schema at the pinned version.
  • Field names on the Shopify Payments balance transaction connection, which is what we need for per order fee attribution, were not verified.
  • Shipping cost per shipment is only available when the merchant buys the label through Shopify Shipping, which is not available in India. For Indian stores, shipping cost has to come from Shiprocket, Delhivery or whichever aggregator the store uses.
  • Protected customer data approval is required for public apps before customer PII fields return values. The approval criteria and turnaround were not checked.
  • Multi currency stores return both shop and presentment money. We store shop money; if a brand reports in presentment currency, the two will not agree.

Sources