Query Shopify Admin and Storefront GraphQL APIs with curl

Work with a Shopify store directly through the Shopify GraphQL APIs: list products, manage inventory, pull orders, update customers, and read metafields using curl with a custom-app access token.

Hermes OpenClaw Intermediate Copied: 0
1 resource

Requirements

  • Requires curl and jq, plus environment variables SHOPIFY_ACCESS_TOKEN (an Admin API token starting with shpat_), SHOPIFY_STORE_DOMAIN (the permanent myshopify.com domain, not a custom domain), and SHOPIFY_API_VERSION (defaults to 2026-01; override for an older version). Works on Linux, macOS, and Windows.
  • No SDK or app framework is needed — just the GraphQL endpoint and a custom-app access token.
  • Requires Shopify admin access that permits creating or managing a custom app.
  • Requires permission to configure the app's Admin API scopes needed for the chosen operations.
  • Products and collections work need product and collection scopes.
  • Inventory work needs the inventory scopes.
  • Orders work needs the order scopes; the 30 most recent orders are available without the read_all_orders scope.
  • Customer work needs the customer scopes.
  • Draft order work needs the draft order scopes.
  • Fulfillment work needs the fulfillment scopes.
  • Metafields and metaobjects are covered by the matching resource scopes.

How to use

The REST Admin API is legacy since 2024-04 and only receives security fixes; use GraphQL Admin for all admin work, and Storefront GraphQL for read-only customer-facing queries such as products, collections, and cart.

Heads up: as of January 1, 2026, new legacy custom apps can no longer be created in the Shopify admin; new setups should use the Dev Dashboard instead, while existing admin-created apps keep working. If the shop has no existing custom app and it is after 2026-01-01, use the Dev Dashboard flow rather than the admin flow.

Rate limits use a leaky bucket calculated from query cost; each response includes a throttling extensions object with maximumAvailable, currentlyAvailable, and restoreRate. Back off when currentlyAvailable drops below the next query's cost. Standard shops have a 100-point bucket restoring 50 per second; Plus shops 1000/100.

Pipe the output through jq for readable output; adding -sS to curl keeps errors visible but hides the progress bar.

Product query syntax supports field matching, quoted phrases, prefix matching, logical operators AND and OR, negation NOT, and parentheses for grouping.

Useful order query filters include financial_status, fulfillment_status, status, created_at, and test.

Each JSONL line is a node, and nested connections are emitted as separate lines with a parent id; reassemble them client-side if needed.

GID vs numeric ID. Legacy REST gave numeric IDs; GraphQL wants full GID strings. To convert: gid://shopify/Product/<numeric>.

Watch the rate limit: a single products query with deep nesting can cost 1000+ points and throttle immediately on a standard-plan shop. Start narrow, read the throttling extensions in each response, and adjust.

Request the read_all_orders scope for historical data; without it, the orders query silently caps at the 60-day window with no error, just fewer results. For Shopify Plus merchants with many orders, request this scope via the app's protected-data settings.

Mutations are real: they create products, charge refunds, cancel orders, and ship fulfillments. Before running productCreate, orderCancel, refundCreate, or any bulk mutation, state clearly what the change is and on which shop, and confirm with the user. There is no staging clone of production data unless the user has a separate dev store.

Set up the token first: install the app in Shopify admin and the Admin API access token appears ONCE — copy it immediately, since Shopify will never show it again. Tokens start with shpat_.

In Shopify admin, go to Settings → Apps and sales channels → Develop apps → Create an app.

Click Configure Admin API scopes, select what you need (examples below), and save.

Install the app, then the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with shpat_.

Save these values to a .env file:

   SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx
   SHOPIFY_STORE_DOMAIN=my-store.myshopify.com
   SHOPIFY_API_VERSION=2026-01

API basics for the reusable curl pattern:

The Admin endpoint is https://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.json.

Use the auth header X-Shopify-Access-Token, NOT Authorization.

The method is always POST, with Content-Type: application/json, and the body is a JSON object with query and variables.

HTTP 200 does not mean success: GraphQL returns errors in a top-level errors array and per-field userErrors — always check both.

IDs are GID strings such as gid://shopify/Product/123; pass them verbatim and don't strip the prefix.

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}')"
}

Fetch shop info and the current API version.

shop_gql '{ shop { name myshopifyDomain primaryDomain { url } currencyCode plan { displayName } } }' | jq

List all supported API versions.

shop_gql '{ publicApiVersions { handle supported } }' | jq '.data.publicApiVersions[] | select(.supported)'

Search products, returning the first 20 matching the query.

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

Paginate products with a cursor.

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

Get a product with its variants and metafields.

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

Create a product with one variant.

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}}]}'

Update a variant's price or SKU.

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"}]}'

List recent orders, the last 30 by default without 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

Fetch a single order with its shipping address.

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 and create customers.

# 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"]}}'

Work with inventory.

Inventory lives on inventory items tied to variants, with 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 as a 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 a delta) — uses 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}]}}'

Read and write metafields and metaobjects.

Metafields attach custom data to resources such as products, customers, orders, and the 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."}]}'

Use the Storefront API for public read-only access.

It has a different endpoint and token, used for customer-facing apps and hydrogen-style headless setups; the headers differ.

The endpoint is https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.json.

For public access, use X-Shopify-Storefront-Access-Token — it is embeddable in the browser.

For private access, use the private token header — server-only.

curl -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

Run bulk operations.

Use these for dumps larger than rate limits allow, such as a full product catalog or a year of all orders.

# 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

Subscribe to webhooks.

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

REST endpoints still exist but are frozen: don't write new integrations against the REST path; use GraphQL.

Check token formats: Admin tokens start with shpat_, storefront public tokens with a different prefix. If you have one and use the wrong header, every request returns 401 without a useful error body.

A 403 with a valid token means a missing scope: Shopify returns an access-denied error. Re-configure the app's Admin API scopes, then reinstall to regenerate the token.

An empty userErrors array is not the same as success: also check that the data field is non-null. Some failures populate neither — inspect the whole response.

GID vs numeric ID. Legacy REST gave numeric IDs; GraphQL wants full GID strings. To convert: gid://shopify/Product/<numeric>.

Pagination order: default product sorting is by id, not created_at. Use reverse: true for newest first.

Currencies are strings: amounts come back as a string, not a number, so don't convert blindly if you care about zero-padding.

Multi-currency Money fields carry both presentmentMoney (the customer's currency) and shopMoney (the store's); pick one consistently.

Source: nousresearch/hermes-agent and its contributors. License: MIT.

Copyright (c) 2025 Nous Research

Translation and editorial adaptation by ClawCases.

>Resources

>Related Use Cases

>More at this level: Intermediate