shopify
Shopify Admin & Storefront GraphQL APIs via curl. Products, orders, customers, inventory, metafields.
Shopify Admin & Storefront GraphQL APIs via curl. Products, orders, customers, inventory, metafields.
Real data. Real impact.
Emerging
Developers
Per week
Excellent
Skills give you superpowers. Install in 30 seconds.
Work with Shopify stores directly through
curl: list products, manage inventory, pull orders, update customers, read metafields. No SDK, no app framework — just the GraphQL endpoint and a custom-app access token.
The REST Admin API is legacy since 2024-04 and only receives security fixes. Use GraphQL Admin for all admin work. Use Storefront GraphQL for read-only customer-facing queries (products, collections, cart).
shpat_.~/.hermes/.env:
SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx SHOPIFY_STORE_DOMAIN=my-store.myshopify.com SHOPIFY_API_VERSION=2026-01
Heads up: As of January 1, 2026, new "legacy custom apps" created in the Shopify admin are gone. New setups should use the Dev Dashboard (
). Existing admin-created apps keep working. If the user's shop has no existing custom app and it's after 2026-01-01, direct them to Dev Dashboard instead of the admin flow.shopify.dev/docs/apps/build/dev-dashboard
Common scopes by task:
read_products, write_productsread_inventory, write_inventory, read_locationsread_orders, write_orders (30 most recent without read_all_orders)read_customers, write_customersread_draft_orders, write_draft_ordersread_fulfillments, write_fulfillmentshttps://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.jsonX-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN (NOT Authorization: Bearer)POST, always Content-Type: application/json, body is {"query": "...", "variables": {...}}errors array and per-field userErrors. Always check both.gid://shopify/Product/10079467700516, gid://shopify/Variant/..., gid://shopify/Order/.... Pass these verbatim — don't strip the prefix.extensions.cost with requestedQueryCost, actualQueryCost, throttleStatus.{currentlyAvailable, maximumAvailable, restoreRate}. Back off when currentlyAvailable drops below your next query's cost. Standard shops = 100 points bucket, 50/s restore; Plus = 1000/100.Base curl pattern (reusable):
shop_gql() { local query="$1" local variables="${2:-{}}" curl -sS -X POST \ "https://${SHOPIFY_STORE_DOMAIN}/admin/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \ -H "Content-Type: application/json" \ -H "X-Shopify-Access-Token: ${SHOPIFY_ACCESS_TOKEN}" \ --data "$(jq -nc --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')" }
Pipe through
jq for readable output. -sS keeps errors visible but hides the progress bar.
shop_gql '{ shop { name myshopifyDomain primaryDomain { url } currencyCode plan { displayName } } }' | jq
shop_gql '{ publicApiVersions { handle supported } }' | jq '.data.publicApiVersions[] | select(.supported)'
shop_gql ' query($q: String!) { products(first: 20, query: $q) { edges { node { id title handle status totalInventory variants(first: 5) { edges { node { id sku price inventoryQuantity } } } } } pageInfo { hasNextPage endCursor } } }' '{"q":"hoodie status:active"}' | jq
Query syntax supports
title:, sku:, vendor:, product_type:, status:active, tag:, created_at:>2025-01-01. Full grammar: https://shopify.dev/docs/api/usage/search-syntax
shop_gql ' query($cursor: String) { products(first: 100, after: $cursor) { edges { cursor node { id handle } } pageInfo { hasNextPage endCursor } } }' '{"cursor":null}' # subsequent calls: pass the previous endCursor
shop_gql ' query($id: ID!) { product(id: $id) { id title handle descriptionHtml tags status variants(first: 20) { edges { node { id sku price compareAtPrice inventoryQuantity selectedOptions { name value } } } } metafields(first: 20) { edges { node { namespace key type value } } } } }' '{"id":"gid://shopify/Product/10079467700516"}' | jq
shop_gql ' mutation($input: ProductCreateInput!) { productCreate(product: $input) { product { id handle } userErrors { field message } } }' '{"input":{"title":"Test Hoodie","status":"DRAFT","vendor":"Hermes","productType":"Apparel","tags":["test"]}}'
Variants now have their own mutations in recent versions:
# Add variants after creating the product shop_gql ' mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkCreate(productId: $productId, variants: $variants) { productVariants { id sku price } userErrors { field message } } }' '{"productId":"gid://shopify/Product/...","variants":[{"optionValues":[{"optionName":"Size","name":"M"}],"price":"49.00","inventoryItem":{"sku":"HD-M","tracked":true}}]}'
shop_gql ' mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkUpdate(productId: $productId, variants: $variants) { productVariants { id sku price } userErrors { field message } } }' '{"productId":"gid://shopify/Product/...","variants":[{"id":"gid://shopify/ProductVariant/...","price":"55.00"}]}'
read_all_orders)shop_gql ' { orders(first: 20, reverse: true, query: "financial_status:paid") { edges { node { id name createdAt displayFinancialStatus displayFulfillmentStatus totalPriceSet { shopMoney { amount currencyCode } } customer { id displayName email } lineItems(first: 10) { edges { node { title quantity sku } } } } } } }' | jq
Useful order query filters:
financial_status:paid|pending|refunded, fulfillment_status:unfulfilled|fulfilled, created_at:>2025-01-01, tag:gift, email:foo@example.com.
shop_gql ' query($id: ID!) { order(id: $id) { id name email shippingAddress { name address1 address2 city province country zip phone } lineItems(first: 50) { edges { node { title quantity variant { sku } originalUnitPriceSet { shopMoney { amount currencyCode } } } } } transactions { id kind status amountSet { shopMoney { amount currencyCode } } } } }' '{"id":"gid://shopify/Order/...."}' | jq
# Search shop_gql ' { customers(first: 10, query: "email:*@example.com") { edges { node { id email displayName numberOfOrders amountSpent { amount currencyCode } } } } }' # Create shop_gql ' mutation($input: CustomerInput!) { customerCreate(input: $input) { customer { id email } userErrors { field message } } }' '{"input":{"email":"test@example.com","firstName":"Test","lastName":"User","tags":["api-created"]}}'
Inventory lives on inventory items tied to variants, quantities tracked per location.
# Get inventory for a variant across all locations shop_gql ' query($id: ID!) { productVariant(id: $id) { id sku inventoryItem { id tracked inventoryLevels(first: 10) { edges { node { location { id name } quantities(names: ["available","on_hand","committed"]) { name quantity } } } } } } }' '{"id":"gid://shopify/ProductVariant/..."}'
Adjust stock (delta) — uses
inventoryAdjustQuantities:
shop_gql ' mutation($input: InventoryAdjustQuantitiesInput!) { inventoryAdjustQuantities(input: $input) { inventoryAdjustmentGroup { reason changes { name delta } } userErrors { field message } } }' '{ "input": { "reason": "correction", "name": "available", "changes": [{"delta": 5, "inventoryItemId": "gid://shopify/InventoryItem/...", "locationId": "gid://shopify/Location/..."}] } }'
Set absolute stock (not delta) —
inventorySetQuantities:
shop_gql ' mutation($input: InventorySetQuantitiesInput!) { inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { id } userErrors { field message } } }' '{"input":{"reason":"correction","name":"available","ignoreCompareQuantity":true,"quantities":[{"inventoryItemId":"gid://shopify/InventoryItem/...","locationId":"gid://shopify/Location/...","quantity":100}]}}'
Metafields attach custom data to resources (products, customers, orders, shop).
# Read shop_gql ' query($id: ID!) { product(id: $id) { metafields(first: 10, namespace: "custom") { edges { node { key type value } } } } }' '{"id":"gid://shopify/Product/..."}' # Write (works for any owner type) shop_gql ' mutation($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id key namespace } userErrors { field message code } } }' '{"metafields":[{"ownerId":"gid://shopify/Product/...","namespace":"custom","key":"care_instructions","type":"multi_line_text_field","value":"Wash cold. Tumble dry low."}]}'
Different endpoint, different token, used for customer-facing apps/hydrogen-style headless setups. Headers differ:
https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.jsonX-Shopify-Storefront-Access-Token: <public token> — embeddable in browserShopify-Storefront-Private-Token: <private token> — server-onlycurl -sS -X POST \ "https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \ -H "Content-Type: application/json" \ -H "X-Shopify-Storefront-Access-Token: ${SHOPIFY_STOREFRONT_TOKEN}" \ -d '{"query":"{ shop { name } products(first: 5) { edges { node { id title handle } } } }"}' | jq
For dumps larger than rate limits allow (full product catalog, all orders for a year):
# 1. Start bulk query shop_gql ' mutation { bulkOperationRunQuery(query: """ { products { edges { node { id title handle variants { edges { node { sku price } } } } } } } """) { bulkOperation { id status } userErrors { field message } } }' # 2. Poll status shop_gql '{ currentBulkOperation { id status errorCode objectCount fileSize url partialDataUrl } }' # 3. When status=COMPLETED, download the JSONL file curl -sS "$URL" > products.jsonl
Each JSONL line is a node, and nested connections are emitted as separate lines with
__parentId. Reassemble client-side if needed.
Subscribe to events so you don't have to poll:
shop_gql ' mutation($topic: WebhookSubscriptionTopic!, $sub: WebhookSubscriptionInput!) { webhookSubscriptionCreate(topic: $topic, webhookSubscription: $sub) { webhookSubscription { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } } userErrors { field message } } }' '{"topic":"ORDERS_CREATE","sub":{"callbackUrl":"https://example.com/webhook","format":"JSON"}}'
Verify incoming webhook HMAC using the app's client secret (not the access token):
echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64 # Compare to X-Shopify-Hmac-Sha256 header
/admin/api/.../products.json. Use GraphQL.shpat_. Storefront public tokens with shpua_. If you have one and the wrong header, every request returns 401 without a useful error body.{"errors":[{"message":"Access denied for ..."}]}. Re-configure Admin API scopes on the app, then reinstall to regenerate the token.userErrors is empty != success. Also check data.<mutation>.<resource> is non-null. Some failures populate neither — inspect the whole response.gid://shopify/Product/<numeric>.products(first: 250) with deep nesting can cost 1000+ points and throttle immediately on a standard-plan shop. Start narrow, read extensions.cost, adjust.products(first: N, reverse: true) sorts by id DESC, not created_at. Use sortKey: CREATED_AT, reverse: true for "newest first."read_all_orders for historical data. Without it, orders(...) silently caps at the 60-day window. You won't get an error, just fewer results than expected. For Shopify Plus merchants with many orders, request this scope via the app's protected-data settings."49.00" not 49.0. Don't jq tonumber blindly if you care about zero-padding.shopMoney (store's currency) AND presentmentMoney (customer's). Pick one consistently.Mutations in Shopify are real — they create products, charge refunds, cancel orders, ship fulfillments. Before running
productDelete, orderCancel, refundCreate, or any bulk mutation: state clearly what the change is, on which shop, and confirm with the user. There is no staging clone of production data unless the user has a separate dev store.MIT
mkdir -p ~/.hermes/skills/productivity/shopify && curl -o ~/.hermes/skills/productivity/shopify/SKILL.md https://raw.githubusercontent.com/NousResearch/hermes-agent/main/optional-skills/productivity/shopify/SKILL.md1,500+ AI skills, agents & workflows. Install in 30 seconds. Part of the Torly.ai family.
© 2026 Torly.ai. All rights reserved.