Skills

shopify-admin-api-operator

Agent tools v1
@cartlift 0 installs updated today MIT license

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

  1. 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.
  2. One endpoint, one header. POST https://{shop}.myshopify.com/admin/api/{version}/graphql.json with the token in X-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.
  3. 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_in 86399). Cache it and refresh before expiry.
  4. 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.
  5. 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 for read_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.
  6. 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

  1. Page with cursors. Use first (max 250) and after: endCursor until pageInfo.hasNextPage is false. Pagination stops at 25,000 objects, so bigger sets go to bulk.
  2. Filter on the server. Use the query argument: 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.
  3. Bulk for big exports. bulkOperationRunQuery returns JSONL with __parentId for nested rows; max five connections, two levels deep; must finish within 10 days; the result URL expires after a week. From 2026-01 an app can run up to five bulk queries per shop at once (earlier versions: one of each type) and polls bulkOperation(id:); currentBulkOperation is deprecated.
  4. Bulk imports only after a pilot. bulkOperationRunMutation takes a JSONL variables file (max 100 MB) uploaded via stagedUploadsCreate with BULK_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

  1. 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 read extensions.cost.throttleStatus (maximumAvailable, currentlyAvailable, restoreRate) from each response and pace from that.
  2. 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

  1. 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; reply 200 within 5 seconds. Unverified payloads are discarded.

Writing

  1. Check three things after every mutation: top-level errors, the mutation's userErrors, and the returned object. HTTP 200 with a non-empty userErrors is a failed write.
  2. 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.
  3. Pick the narrowest mutation. Prices and compare-at prices: productVariantsBulkUpdate (one product per call, allowPartialUpdates default false). Product fields: productUpdate. productSet replaces list fields: variants, collections and metafields missing from the input are deleted, so use it only for full syncs the owner approved.
  4. Inventory through quantities, with a reason. inventorySetQuantities (absolute, available or on_hand) or inventoryAdjustQuantities (delta), with a reason such as correction or cycle_count_available, a referenceDocumentUri (GID format preferred) and changeFromQuantity so a stale read fails with CHANGE_FROM_QUANTITY_STALE instead of overwriting a sale. From 2026-04 these mutations require @idempotent(key: ...); reuse the key on retry so a timeout does not apply the change twice.
  5. Tags and notes. tagsAdd appends tags to an order, product, customer or discount; orderUpdate with tags replaces all existing tags. Notes via orderUpdate overwrite the old note, so log the old text.
  6. Metafields atomically. metafieldsSet takes up to 25 per call, is all-or-nothing, and accepts compareDigest to refuse a write if someone changed the value since the read.

Process

  1. Confirm setup: shop domain, pinned version, token present in the environment (check presence, never print it), granted scopes cover the task.
  2. Read: query the target records with only the needed fields; save IDs and current values.
  3. Plan: build the change plan (Output format) with before, after and difference per record, scope used, batch size, and the mutation per batch.
  4. Approve: show the plan to the owner and wait for an explicit yes. A changed plan needs a new yes.
  5. Pilot: execute the first small batch (up to 5 records or one product).
  6. Verify: check errors, userErrors, returned values, then re-read and compare with the plan.
  7. Continue in batches, pacing by throttleStatus; stop the run at the first unexpected userErrors and report.
  8. Log: write the execution log with before/after values, timestamps and any idempotency keys; the before column is the rollback plan.
  9. 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 productVariantsBulkUpdate call. 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 query filter 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. fulfillmentCreate has a notifyCustomer option; 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, productSet full 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 userErrors and 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_orders without a stated need.
  • Every write was in an approved plan; destructive or customer-facing actions had their own approval.
  • Every mutation result checked for errors and userErrors, and re-read.
  • Before values logged for every changed field; rollback is executable from the log.
  • Inventory writes carry reason, reference, changeFromQuantity and an idempotency key.
  • Pagination ran to hasNextPage false, or bulk was used above 25,000 objects.

Sources

License

MIT