Shopify GraphQL: Order Ingestion & Updates
A Markdown reference for Shopify GraphQL order work in CFML. Covers GIDs, cost-based rate limits, and why HTTP 200 means nothing without checking userErrors. Ingestion: webhooks, paginated queries, bulk operations. Updates: orderUpdate, order edits, fulfillmentCreate, orderCreate for marketplace imports. Includes a CFC harness using script-based cfhttp, with one retry on THROTTLED. Notes the 2026-10 address-triggers-tax-recalc change.
A working reference for pulling orders into an external system and pushing changes back. Assumes a custom/private app with an Admin API access token (not a public-app OAuth flow).
1. Why GraphQL, not REST
Shopify marked the REST Admin API as legacy on October 1, 2024. Since April 1, 2025, all new public apps must use the GraphQL Admin API, and REST is on a slow deprecation path for everything else. New fields and mutations increasingly land GraphQL-only. If you're starting or rebuilding order integration work now, there's no reason to touch REST.
2. Core mechanics
Endpoint & versioning
POST https://{shop}.myshopify.com/admin/api/{version}/graphql.json
X-Shopify-Access-Token: {token}
Content-Type: application/json
Versions are quarterly, date-stamped (2026-07 is current stable as of this writing, 2026-10 is the release candidate). Pin a version explicitly — don't rely on a default — and budget time each quarter to check the changelog for deprecations that affect you.
IDs are GIDs
Every object is addressed as a global ID string, not a bare integer:
gid://shopify/Order/5989087838301
If you're storing order IDs in your own DB from webhook payloads (which still use plain numeric id), you'll need to convert: "gid://shopify/Order/" & orderId. The reverse — pulling the numeric ID back out of a GID for lookups against legacy REST-derived data — is legacyResourceId on the Order object, queryable directly if you need it.
Cost-based rate limiting
There's no simple requests-per-second cap. Each query/mutation costs points based on the fields and connections you ask for; you have a bucket (1000 points for most plans, refilling at 50/sec) and a request is rejected with a THROTTLED error if it doesn't fit. The response includes the cost breakdown:
"extensions": {
"cost": {
"requestedQueryCost": 42,
"actualQueryCost": 42,
"throttleStatus": { "maximumAvailable": 1000, "currentlyAvailable": 958, "restoreRate": 50.0 }
}
}
For anything doing bulk ingestion, read throttleStatus.currentlyAvailable and back off before you hit zero rather than reacting to the throttle error after the fact.
Errors don't fail the HTTP request
A GraphQL mutation can return HTTP 200 with a userErrors array populated instead of applying the change. Always check userErrors on every mutation response — a green HTTP status tells you nothing about whether the order was actually updated.
3. Ingesting orders
Two ways in: webhooks for real-time, GraphQL queries (plain or bulk) for backfill and reconciliation.
Webhooks
Standard topics: orders/create, orders/updated, orders/paid, orders/cancelled, orders/fulfilled. Webhook payloads are still the older flat JSON shape (numeric id, not a GID), not a GraphQL response — so treat the webhook as a "something changed" signal and, if you need fields the payload doesn't carry, follow up with a GraphQL query for the full order.
Two things worth building in from day one:
- Idempotency. Shopify will redeliver. Key your upsert on
order.id+updated_at(or a hash of the payload), not on "did I see this webhook before." - Ordering isn't guaranteed.
orders/updatedcan arrive out of sequence relative toorders/paid. Don't assume the payload you're processing is the latest state — if you need current truth, re-fetch via GraphQL rather than trusting webhook order.
GraphQL query (targeted fetch / small-scale polling)
query GetOrders($cursor: String) {
orders(first: 50, after: $cursor, query: "updated_at:>2026-09-01") {
edges {
cursor
node {
id
legacyResourceId
name
createdAt
updatedAt
displayFinancialStatus
displayFulfillmentStatus
tags
note
customer { id email displayName }
shippingAddress { address1 address2 city provinceCode countryCodeV2 zip }
lineItems(first: 50) {
edges {
node {
id
sku
quantity
variant { id }
originalUnitPriceSet { shopMoney { amount currencyCode } }
}
}
}
}
}
pageInfo { hasNextPage }
}
}
Use cursor-based pagination (after + pageInfo.hasNextPage) — offset pagination isn't a thing here. The query: argument takes Shopify's search syntax, so updated_at:>..., financial_status:paid, fulfillment_status:unfulfilled etc. let you filter server-side instead of pulling everything and filtering locally.
Bulk operations (full backfill / large exports)
For anything beyond a few thousand orders, don't paginate manually — use bulkOperationRunQuery. It runs asynchronously server-side and hands you back a JSONL file URL when done, with no cost-throttling concerns during the run itself.
mutation {
bulkOperationRunQuery(
query: """
{
orders {
edges {
node {
id
name
createdAt
displayFinancialStatus
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}
Poll currentBulkOperation { status url } until COMPLETED, then download and parse the JSONL. This is the right tool for an initial historical load; webhooks + targeted queries handle steady-state after that.
4. Updating orders
Shopify splits "update" across several mutations depending on what's changing — there's no single do-everything orderUpdate.
| Change | Mutation |
|---|---|
| Email, tags, note, shipping address, metafields | orderUpdate |
| Line items — add/remove/change qty, add discounts | orderEditBegin → edit calls → orderEditCommit |
| Mark items fulfilled, attach tracking | fulfillmentCreate |
| Update tracking on an existing fulfillment | fulfillmentTrackingInfoUpdate |
| Mark as paid (no real transaction) | orderMarkAsPaid |
| Cancel | orderCancel |
| Close / reopen | orderClose / orderOpen |
| Create a new order from an external source (marketplace import) | orderCreate |
Simple attribute update:
mutation OrderUpdate($input: OrderInput!) {
orderUpdate(input: $input) {
order { id tags }
userErrors { field message }
}
}
{ "input": { "id": "gid://shopify/Order/5989087838301", "tags": ["synced", "amazon"] } }
One sharp edge: as of API version 2026-10, updating an order's shipping address through orderUpdate triggers a tax recalculation. If you're syncing addresses from a marketplace order and don't want Shopify silently touching tax figures, that's now a real side effect to account for, not a hypothetical.
Line item changes go through an edit session rather than a direct mutation, because Shopify wants a commit step before anything's final:
mutation { orderEditBegin(id: "gid://shopify/Order/5989087838301") {
calculatedOrder { id }
userErrors { field message }
} }
Then orderEditAddVariant, orderEditSetQuantity, orderEditAddLineItemDiscount, etc. against the calculatedOrder.id, and finally:
mutation { orderEditCommit(id: "gid://shopify/CalculatedOrder/...", notifyCustomer: false) {
order { id }
userErrors { field message }
} }
Fulfillment + tracking:
mutation FulfillOrder($fulfillment: FulfillmentInput!) {
fulfillmentCreate(fulfillment: $fulfillment) {
fulfillment { id status trackingInfo { number url company } }
userErrors { field message }
}
}
fulfillment.lineItemsByFulfillmentOrder needs the FulfillmentOrder id, not the Order id — query order.fulfillmentOrders first to get it. This two-step (fetch fulfillment order → create fulfillment) trips people up coming from REST, where you could fulfill directly against the order.
Order creation for marketplace imports (relevant if you're pushing Amazon/eBay/Walmart orders into Shopify rather than the other direction): orderCreate takes line items, customer, addresses, and a financialStatus, but does not replicate automatic discounts — if the source order had a discount applied, you reconstruct it explicitly via discountCodes or line-item pricing, it won't be inferred.
5. Practical notes / gotchas
- GID vs legacy ID everywhere. Every input variable that takes an order/customer/variant reference wants the full
gid://shopify/...string. Store GIDs, not just the trailing integer, if you can — fewer string-building bugs. userErrors, always. A mutation returning HTTP 200 with a non-emptyuserErrorsarray did nothing. Check it before assuming success.- Webhook payload ≠ GraphQL shape. Field names and nesting differ between the two (flat REST-ish JSON vs GraphQL's
edges/node). Don't reuse the same parsing code for both. - Version drift. New API version every ~3 months; fields get deprecated on a schedule, not removed without warning, but "not removed" doesn't mean "unaffected" — read the changelog for the version you're pinned to before bumping.
- Rate-limit budget for bulk work. For steady polling, watch
throttleStatus.currentlyAvailablein the response and pace yourself; for large one-off pulls, usebulkOperationRunQueryinstead of fighting the throttle.
6. Minimal CFML call shape
Script-based tag syntax — standard since ColdFusion 11, and supported on Lucee too. (The new http() component alternative is deprecated as of ColdFusion 2018 and was removed outright in ColdFusion 2025 — don't reach for that one.)
cfhttp(url = "https://#shop#.myshopify.com/admin/api/2026-07/graphql.json", method = "POST", result = "result", timeout = 30) {
cfhttpparam(type = "header", name = "X-Shopify-Access-Token", value = accessToken);
cfhttpparam(type = "header", name = "Content-Type", value = "application/json");
cfhttpparam(type = "body", value = serializeJSON({
"query" : graphqlQueryString,
"variables" : variablesStruct
}));
}
response = deserializeJSON(result.fileContent);
if (arrayLen(response.data.orderUpdate.userErrors ?: [])) {
// handle business-logic failure — HTTP succeeded, the mutation didn't
}
Same shape for queries and mutations — only the query string and variables struct change. The snippet above is fine for a one-off script; once you've got more than two or three call sites, wrap it — which is what the harness below does.
7. A basic GraphQL harness (CFC)
A small standalone component: one place for the endpoint, auth header, errors/userErrors checking, and a single retry on THROTTLED. No framework dependency — construct it directly, or map it in WireBox if you're wiring it into a ColdBox module.
// ShopifyGraphQL.cfc
component {
/**
* @shop store handle, without .myshopify.com
* @accessToken Admin API access token
* @apiVersion defaults to current stable; pin explicitly per integration
*/
function init(required string shop, required string accessToken, string apiVersion = "2026-07") {
variables.accessToken = arguments.accessToken;
variables.endpoint = "https://#arguments.shop#.myshopify.com/admin/api/#arguments.apiVersion#/graphql.json";
return this;
}
/**
* Runs a single query or mutation. Retries once on a THROTTLED error;
* anything else — HTTP failure, a bad query, a second throttle — is thrown.
*
* @query GraphQL query/mutation string
* @variables struct of GraphQL variables
* @retry internal use only — leave at default
*/
function execute(required string query, struct variables = {}, boolean retry = true) {
cfhttp(url = variables.endpoint, method = "POST", result = "local.httpResult", timeout = 30) {
cfhttpparam(type = "header", name = "X-Shopify-Access-Token", value = variables.accessToken);
cfhttpparam(type = "header", name = "Content-Type", value = "application/json");
cfhttpparam(type = "body", value = serializeJSON({
"query" : arguments.query,
"variables" : arguments.variables
}));
}
if (local.httpResult.status_code != 200) {
throw(type = "ShopifyGraphQL.HttpError",
message = "Shopify GraphQL HTTP #local.httpResult.status_code#",
detail = local.httpResult.fileContent);
}
var parsed = deserializeJSON(local.httpResult.fileContent);
if (structKeyExists(parsed, "errors") && arrayLen(parsed.errors)) {
var throttled = arrayLen(arrayFilter(parsed.errors, function(e) {
return structKeyExists(e, "extensions") && e.extensions.code == "THROTTLED";
}));
if (throttled && arguments.retry) {
sleep(1000); // crude fixed backoff — read extensions.cost.throttleStatus for something better
return execute(arguments.query, arguments.variables, false);
}
throw(type = "ShopifyGraphQL.QueryError",
message = "Shopify GraphQL returned errors",
detail = serializeJSON(parsed.errors));
}
return parsed;
}
/**
* Runs a mutation and throws if its userErrors array is non-empty,
* so call sites don't each have to remember to check it.
*
* @mutationName top-level key in the response data, e.g. "orderUpdate"
*/
function executeMutation(required string mutationName, required string query, struct variables = {}) {
var result = execute(arguments.query, arguments.variables);
var payload = result.data[arguments.mutationName];
if (structKeyExists(payload, "userErrors") && arrayLen(payload.userErrors)) {
throw(type = "ShopifyGraphQL.UserError",
message = "#arguments.mutationName# rejected the change",
detail = serializeJSON(payload.userErrors));
}
return payload;
}
}
Usage — with the actual GraphQL query text and variables spelled out, rather than pointing at undefined names:
shopify = new ShopifyGraphQL(shop = "my-store", accessToken = accessToken);
// --- query, with a GraphQL variable ($cursor) ---
getOrdersQuery = "
query GetOrders($cursor: String) {
orders(first: 50, after: $cursor, query: ""updated_at:>2026-09-01"") {
edges {
cursor
node { id name displayFinancialStatus }
}
pageInfo { hasNextPage }
}
}
";
result = shopify.execute(
query = getOrdersQuery,
variables = { "cursor": "" } // empty string / omit for the first page
);
orderEdges = result.data.orders.edges;
// --- mutation, with a GraphQL variable ($input) ---
orderUpdateMutation = "
mutation OrderUpdate($input: OrderInput!) {
orderUpdate(input: $input) {
order { id tags }
userErrors { field message }
}
}
";
updated = shopify.executeMutation(
mutationName = "orderUpdate",
query = orderUpdateMutation,
variables = {
"input": {
"id" : "gid://shopify/Order/5989087838301",
"tags": ["synced", "amazon"]
}
}
);
// updated == { order: { id: ..., tags: [...] } } — userErrors already checked inside executeMutation
Note the doubled quotes (""updated_at:>2026-09-01"") inside the query string — that's CFML's own escaping for a literal " inside a double-quoted string, unrelated to GraphQL syntax. If that reads awkwardly, switch the outer CFML string to single quotes and leave the GraphQL double quotes alone.
What this deliberately doesn't do: no query-cost tracking, no connection pooling, no retry beyond the one THROTTLED case. Bolt those on when you actually hit the wall, not before — for a handful of ingestion/update calls this is enough; a high-volume bulk job should be reading extensions.cost.throttleStatus itself rather than relying on a single blind sleep(1000).
Reference
- Admin GraphQL API docs: https://shopify.dev/docs/api/admin-graphql
- API versioning & release notes: https://shopify.dev/docs/api/usage/versioning
- Order editing guide: https://shopify.dev/docs/apps/build/orders-fulfillment/order-management-apps/edit-orders
- Bulk operations: https://shopify.dev/docs/api/usage/bulk-operations/queries
No comments yet — be the first.