Shopify Admin API operator
Produces safe, reviewable reads and writes on one Shopify store through the GraphQL Admin API: a change plan with before/after values for the owner to approve, then an execution log that doubles as a rollback file. The naive approach (grab a token, loop REST calls, treat HTTP 200 as success) fails three ways: GraphQL answers 200 OK even when the operation failed, throttling arrives as a THROTTLED error in the body rather than HTTP 429, and a "convenient" mutation like productSet deletes every variant you left out of the input. The key insight: on this API, success is never the status code. It is an empty errors array, an empty userErrors list, and a re-read that shows the new value.
When to use
- The owner asks the agent to look something up in the store: a product by SKU, stock at a location, orders with a tag, a customer's order history, active discounts.
- The owner wants a batch change: prices, compare-at prices, tags, order notes, metafields, collection membership, inventory corrections.
- A large export (whole catalog, a year of orders) is needed as a file for another analysis.
When not to use
- Deciding what a price should be:
competitor-price-brief,promotion-profit-check,clearance-markdown-planner(Omnibus prior price),free-shipping-threshold-planner. This skill only executes a decided change. - Marketplace channel economics:
marketplace-profitability-check; feed quality:product-feed-optimizer,merchant-center-disapproval-fixer,gtin-identifier-auditor. - Replying to customers about orders:
order-status-reply-drafter,warranty-claim-handler. - Reconciling Shopify revenue with GA4 or Ads:
revenue-discrepancy-reconciler. - Rewriting product copy:
product-description-writer(then use this skill to write the approved text back).
Inputs
| input | definition | typical source |
|---|---|---|
| Shop domain | {shop}.myshopify.com, not the storefront domain |
Shopify admin URL |
| API version | a supported quarterly version to pin, e.g. 2026-10 |
owner or integration config |
| Access token | Admin API token, read from an environment variable or secret store at run time | legacy custom app token, or client credentials grant for a Dev Dashboard app |
| Granted scopes | the scopes the token actually holds | the scope field returned with the token, or the app's configuration |
| Plan | Standard, Advanced, Plus or Enterprise, which sets the restore rate | Shopify admin, Settings > Plan |
| The task | what to read or change, on which records, with the target values | the owner |
Optional: location IDs for inventory, a preferred batch size, an owner-approved change window, the store currency and time zone.
Missing input → stop and ask. Never guess a shop domain, an API version or a target value, and never fall back to "typical" store settings.
Best practices
Access and authentication
- GraphQL only. The REST Admin API is legacy as of 2024-10-01, and since 2025-04-01 new public apps must be built exclusively with the GraphQL Admin API. New fields land in GraphQL; REST answers drift out of date.
- One endpoint, one header.
POST https://{shop}.myshopify.com/admin/api/{version}/graphql.jsonwith the token inX-Shopify-Access-Token. Pin the version in the URL: versions ship quarterly (1 Jan, Apr, Jul, Oct, 17:00 UTC), each stable version is supported at least 12 months, and a request to an inaccessible version silently falls forward to the oldest accessible one, so field behaviour can change under you. - Know which token the store has. Custom apps can no longer be created in the Shopify admin after 2026-01-01; those created earlier ("legacy custom apps") keep working and are managed there. New custom apps are created in the Dev Dashboard; when app and store are in the same organization the app exchanges client ID and secret at
/admin/oauth/access_token(grant_type=client_credentials) for a token that expires after 24 hours (expires_in86399). Cache it and refresh before expiry. - Secrets never leave the secret store. Read the token and client secret from environment variables or a secret manager. Never write them into files, logs, change plans, commits or chat; log the variable name only. A token that appears in output is compromised and must be rotated by the owner.
- Least privilege, per task. Request only what the task needs:
read_products/write_products(products, variants, collections),read_inventory/write_inventory,read_orders/write_orders(orders of the last 60 days),read_customers,read_discounts/write_discounts. Do not ask forread_all_orders(needs Shopify approval) unless the task really needs orders older than 60 days. Each mutation's reference page lists its scope; check it before asking the owner to grant one. - Customer data is protected data. Name, email, phone and address are Level 2 protected customer data; Shopify requires data minimisation, stated purpose, retention limits and encryption. For admin-created custom apps Level 2 access depends on the store's plan. Read only the customer fields the task needs.
Reading efficiently
- Page with cursors. Use
first(max 250) andafter: endCursoruntilpageInfo.hasNextPageis false. Pagination stops at 25,000 objects, so bigger sets go to bulk. - Filter on the server. Use the
queryargument:sku:LS-01-M,updated_at:>'2026-09-01T00:00:00Z',-tag:archive,tag:wholesale OR tag:b2b, quoted phrases,field:*for "has a value". Fetching everything and filtering locally burns cost points. - Bulk for big exports.
bulkOperationRunQueryreturns JSONL with__parentIdfor nested rows; max five connections, two levels deep; must finish within 10 days; the result URL expires after a week. From2026-01an app can run up to five bulk queries per shop at once (earlier versions: one of each type) and pollsbulkOperation(id:);currentBulkOperationis deprecated. - Bulk imports only after a pilot.
bulkOperationRunMutationtakes a JSONL variables file (max 100 MB) uploaded viastagedUploadsCreatewithBULK_MUTATION_VARIABLES, must finish within 24 hours, and reports errors per line in the result file. Run the same mutation on 3 to 5 records first and verify them.
Rate limits
- Budget by cost, not by request count. Objects cost 1, connections are sized by
first/last, mutations cost 10, one query may not exceed 1,000 points. Restore rates: Standard 100, Advanced 200, Plus 1,000, Enterprise 2,000 points/second. Shopify does not publish bucket sizes on that page, so readextensions.cost.throttleStatus(maximumAvailable,currentlyAvailable,restoreRate) from each response and pace from that. - Back off on THROTTLED. Wait (requested cost − currently available) ÷ restore rate seconds, plus jitter, then retry the same request. Never retry a write without first checking it did not land.
Webhooks, briefly
- Polling is the default; webhooks only if a receiver exists. If the owner already runs one, verify each delivery: HMAC-SHA256 of the raw body with the app's client secret, base64, compared timing-safe with
X-Shopify-Hmac-SHA256; reply200within 5 seconds. Unverified payloads are discarded.
Writing
- Check three things after every mutation: top-level
errors, the mutation'suserErrors, and the returned object. HTTP 200 with a non-emptyuserErrorsis a failed write. - Use global IDs from reads. IDs look like
gid://shopify/ProductVariant/123; copy them from the read, never build them from storefront URLs or SKUs. - Pick the narrowest mutation. Prices and compare-at prices:
productVariantsBulkUpdate(one product per call,allowPartialUpdatesdefault false). Product fields:productUpdate.productSetreplaces list fields: variants, collections and metafields missing from the input are deleted, so use it only for full syncs the owner approved. - Inventory through quantities, with a reason.
inventorySetQuantities(absolute,availableoron_hand) orinventoryAdjustQuantities(delta), with areasonsuch ascorrectionorcycle_count_available, areferenceDocumentUri(GID format preferred) andchangeFromQuantityso a stale read fails withCHANGE_FROM_QUANTITY_STALEinstead of overwriting a sale. From2026-04these mutations require@idempotent(key: ...); reuse the key on retry so a timeout does not apply the change twice. - Tags and notes.
tagsAddappends tags to an order, product, customer or discount;orderUpdatewithtagsreplaces all existing tags. Notes viaorderUpdateoverwrite the old note, so log the old text. - Metafields atomically.
metafieldsSettakes up to 25 per call, is all-or-nothing, and acceptscompareDigestto refuse a write if someone changed the value since the read.
Process
- Confirm setup: shop domain, pinned version, token present in the environment (check presence, never print it), granted scopes cover the task.
- Read: query the target records with only the needed fields; save IDs and current values.
- Plan: build the change plan (Output format) with before, after and difference per record, scope used, batch size, and the mutation per batch.
- Approve: show the plan to the owner and wait for an explicit yes. A changed plan needs a new yes.
- Pilot: execute the first small batch (up to 5 records or one product).
- Verify: check
errors,userErrors, returned values, then re-read and compare with the plan. - Continue in batches, pacing by
throttleStatus; stop the run at the first unexpecteduserErrorsand report. - Log: write the execution log with before/after values, timestamps and any idempotency keys; the before column is the rollback plan.
- Run the quality checklist.
Pitfalls and edge cases
- Money is a string in shop currency. Send
"26.90", not 26.9; multi-currency markets may convert or override it, so check market price lists before promising a price abroad. - Compare-at price is a sale claim. Setting it creates a visible strike-through; in the EU the reference must follow the Omnibus prior-price rule, so route that decision to
clearance-markdown-planner. - One product per
productVariantsBulkUpdatecall. Variants of different products need separate calls. - Orders beyond 60 days are invisible without
read_all_orders; an empty result is not "no orders". - Search index lag. A record changed seconds ago may not match a
queryfilter yet; verify by ID. - Inventory by location. Quantities live per location and inventory item; a variant-level total hides which location changed.
- Fulfilment writes notify people.
fulfillmentCreatehas anotifyCustomeroption; marking orders fulfilled is a customer-facing action, not a data fix. - Timeouts on writes. A network timeout does not mean failure; re-read before retrying.
Rules
- Never write without an owner-approved change plan. Reads need no approval.
- Never, without a separate explicit approval naming the records: deletes (products, variants, metafields, discounts), refunds, order cancellations, fulfilments that notify customers,
productSetfull syncs, customer data exports, or any scope beyond the task. - Never print, log or store the access token or client secret; never send store data to any service other than the store's own Admin API.
- Never invent IDs, values or results; report what the API returned.
- Stop on the first unexpected
userErrorsand report instead of working around it. - Store prices, discounts and stock decisions belong to the owner; the agent executes them, it does not choose them.
Output format
CHANGE PLAN · <shop>.myshopify.com · API <version> · prepared <date time, tz>
Task: <one line> · scopes used: <list> · records: <n> · batches: <n> × <size>
| # | record (GID) | handle / SKU | field | before | after | difference |
Mutation per batch: <name> · checks: errors, userErrors, re-read
Not included / needs separate approval: <list or none>
Owner approval: <name, time> or PENDING
EXECUTION LOG · <shop> · API <version> · started <time> · finished <time>
| batch | record (GID) | field | before | after (read back) | status | userErrors | idempotency key |
Cost: requested <n> · actual <n> · lowest currentlyAvailable <n> · throttled retries <n>
Rollback: re-run <mutation> with the "before" column for rows with status OK
Open issues: <list or none>
Worked example
Illustrative numbers and IDs only. Slovak store in EUR on the Standard plan; the owner decided to raise the linen shirt prices and asked the agent to apply them.
Read (pinned 2026-10):
query VariantsBySku($q: String!) {
productVariants(first: 10, query: $q) {
nodes { id sku displayName price compareAtPrice product { id } }
pageInfo { hasNextPage endCursor }
}
}
Variables: {"q": "sku:LS-01-S OR sku:LS-01-M OR sku:LS-01-L"} returned three variants of gid://shopify/Product/8123456789, no compare-at prices, hasNextPage false.
Change plan shown to the owner and approved:
| 1 | gid://shopify/ProductVariant/45100000001 | LS-01-S | price | 24.90 | 26.90 | +2.00 (+8.0%) |
| 2 | gid://shopify/ProductVariant/45100000002 | LS-01-M | price | 24.90 | 26.90 | +2.00 (+8.0%) |
| 3 | gid://shopify/ProductVariant/45100000003 | LS-01-L | price | 29.90 | 32.90 | +3.00 (+10.0%) |
Mutation (one product, one batch; default mutation cost is 10 points):
mutation UpdateVariantPrices($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id sku price compareAtPrice }
userErrors { field message }
}
}
Variables: {"productId": "gid://shopify/Product/8123456789", "variants": [{"id": "gid://shopify/ProductVariant/45100000001", "price": "26.90"}, {"id": "gid://shopify/ProductVariant/45100000002", "price": "26.90"}, {"id": "gid://shopify/ProductVariant/45100000003", "price": "32.90"}]}
Check: response has no errors, userErrors is [], and productVariants returns 26.90, 26.90, 32.90. If userErrors were non-empty, nothing is retried: with allowPartialUpdates false the agent re-reads the three variants, logs what is actually stored and reports the message to the owner. Arithmetic: 2.00 ÷ 24.90 = 8.0%; 3.00 ÷ 29.90 = 10.0%. Rollback is the same mutation with 24.90, 24.90, 29.90.
Quality checklist
- Version pinned in the URL and stated in the plan and log.
- Token read from the environment; it appears nowhere in output.
- Scopes are the minimum for the task; no
read_all_orderswithout a stated need. - Every write was in an approved plan; destructive or customer-facing actions had their own approval.
- Every mutation result checked for
errorsanduserErrors, and re-read. - Before values logged for every changed field; rollback is executable from the log.
- Inventory writes carry reason, reference,
changeFromQuantityand an idempotency key. - Pagination ran to
hasNextPagefalse, or bulk was used above 25,000 objects.
Sources
- Shopify, GraphQL Admin API reference: https://shopify.dev/docs/api/admin-graphql
- Shopify, REST Admin API reference (legacy status, 2025-04-01 rule): https://shopify.dev/docs/api/admin-rest
- Shopify, API versioning: https://shopify.dev/docs/api/usage/versioning
- Shopify, GraphQL Admin API rate limits: https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits
- Shopify, API limits: https://shopify.dev/docs/api/usage/limits
- Shopify, Response status and error codes: https://shopify.dev/docs/api/usage/response-codes
- Shopify Help Center, Custom apps: https://help.shopify.com/en/manual/apps/app-types/custom-apps
- Shopify, Client credentials grant: https://shopify.dev/docs/apps/build/authentication-authorization/client-credentials-grant
- Shopify, Get API access tokens for Dev Dashboard apps: https://shopify.dev/docs/apps/build/dev-dashboard/get-api-access-tokens
- Shopify, Access scopes: https://shopify.dev/docs/api/usage/access-scopes
- Shopify, Protected customer data: https://shopify.dev/docs/apps/launch/protected-customer-data
- Shopify, Paginating results with GraphQL: https://shopify.dev/docs/api/usage/pagination-graphql
- Shopify, Search syntax: https://shopify.dev/docs/api/usage/search-syntax
- Shopify, Bulk operations, queries: https://shopify.dev/docs/api/usage/bulk-operations/queries
- Shopify, Bulk operations, imports: https://shopify.dev/docs/api/usage/bulk-operations/imports
- Shopify, Global IDs: https://shopify.dev/docs/api/usage/gids
- Shopify, productVariantsBulkUpdate: https://shopify.dev/docs/api/admin-graphql/latest/mutations/productVariantsBulkUpdate
- Shopify, productSet: https://shopify.dev/docs/api/admin-graphql/latest/mutations/productSet
- Shopify, productVariants query: https://shopify.dev/docs/api/admin-graphql/latest/queries/productVariants
- Shopify, inventorySetQuantities: https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities
- Shopify, inventoryAdjustQuantities: https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventoryAdjustQuantities
- Shopify, Manage inventory quantities and states: https://shopify.dev/docs/apps/build/orders-fulfillment/inventory-management-apps/manage-quantities-states
- Shopify, Changelog, Making idempotency mandatory for inventory adjustments and refund mutations: https://shopify.dev/changelog/making-idempotency-mandatory-for-inventory-adjustments-and-refund-mutations
- Shopify, metafieldsSet: https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet
- Shopify, tagsAdd: https://shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd
- Shopify, orderUpdate: https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderUpdate
- Shopify, fulfillmentCreate: https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentCreate
- Shopify, HTTPS webhook delivery (HMAC verification): https://shopify.dev/docs/apps/build/webhooks/subscribe/https
License
MIT