OptionalProductivityVersion 1.0.0

Query Shopify Admin and Storefront GraphQL APIs via curl with Hermes Agent

Query Shopify Admin/Storefront GraphQL APIs via curl.

Written by Neura Market from the official Hermes Agent documentation for Shopify. Commands, paths, and version numbers are reproduced from the source unchanged.

Read the official documentation

Shopify GraphQL API Reference (curl)

This reference describes how to interact with Shopify stores programmatically using GraphQL APIs and curl, without SDKs or app frameworks. It covers authentication, common procedures, parameters, constraints, and failure modes.

Prerequisites

Before you begin, ensure you have:

  • A Shopify store with a custom app created in the admin (or via the Dev Dashboard after 2026-01-01).
  • An Admin API access token (starts with shpat_) saved to your environment.
  • Your store domain (e.g., my-store.myshopify.com) and API version (e.g., 2026-01) saved to environment variables.
  • curl and jq installed.
  • Appropriate Admin API scopes configured for your tasks (see Common Scopes).
  • For Storefront API: a Storefront access token (starts with shpua_) and endpoint knowledge.
  • For webhooks: the app client secret for HMAC verification.

Setup and Authentication

Create a Custom App and Obtain an Access Token

  1. In Shopify admin, go to Settings → Apps and sales channels → Develop apps → Create an app.
  2. Click Configure Admin API scopes, select the required scopes (see Common Scopes), and save.
  3. Install the app. The Admin API access token appears once. Copy it immediately (it starts with shpat_).
  4. Save the token, store domain, and API version to an environment file (e.g., ${HERMES_HOME:-~/.hermes}/.env).
  5. If after 2026-01-01 and you have no existing custom app, use the Dev Dashboard (shopify.dev/docs/apps/build/dev-dashboard) instead of the admin flow.

Environment Variables

Set the following environment variables:

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

Common Admin API Scopes

The following scopes are commonly required:

read_products write_products read_inventory write_inventory read_locations read_orders write_orders read_all_orders read_customers write_customers read_draft_orders write_draft_orders read_fulfillments write_fulfillments

Executing GraphQL Queries and Mutations

Base curl Wrapper

Use this shell function to simplify requests:

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

Request Structure

  • Send a POST request to https://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.json.
  • Headers: Content-Type: application/json and X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN.
  • Body is JSON: {"query": "...", "variables": {...}}.
  • Parse the response with jq. Check the top-level errors array and per-field userErrors.
  • Inspect extensions.cost for rate limit information.

The full endpoint URL pattern is https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.json for the Storefront API, but for the Admin API the path includes /admin/ as shown in the base wrapper.

Query Shop Info

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

List Supported API Versions

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

Products

Search Products

Search with rich query syntax using parameters like title:, sku:, vendor:, product_type:, status:active, tag:, and created_at:>2025-01-01.

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 Through Products

Use cursor-based pagination with first and after.

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 Single Product

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 Variants

First, create the product:

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

Then add variants:

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

Orders

List Recent Paid Orders

Use filters like financial_status:paid|pending|refunded, fulfillment_status:unfulfilled|fulfilled, tag:gift, email:foo@example.com, and created_at:>2025-01-01.

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

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

Customers

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

Inventory Management

Get Inventory for a Variant Across All Locations

# 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 Inventory by Delta

Use inventoryAdjustQuantities with a reason, name ("available"), and changes array containing delta, inventoryItemId, and locationId.

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 Inventory Quantity

Use inventorySetQuantities with reason, name ("available"), ignoreCompareQuantity: true, and a quantities array.

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

Read and Write Metafields

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

Storefront API

The Storefront API uses a different endpoint and token header. Use X-Shopify-Storefront-Access-Token: for public tokens or Shopify-Storefront-Private-Token: for private tokens.

The Storefront API endpoint is https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.json, without the /admin/ path segment.

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

Bulk Operations

Start, Poll, and Download Bulk Operations

# 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

Bulk operation results are JSONL. Nested connections have a __parentId field for reassembly.

Webhooks

Create a Webhook Subscription

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

Compute HMAC-SHA256 of the raw request body using the app's client secret, base64-encode it, and compare to the X-Shopify-Hmac-Sha256 header.

echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64
# Compare to X-Shopify-Hmac-Sha256 header

The Authorization: Bearer header is not used for Shopify GraphQL APIs; instead, use X-Shopify-Access-Token for Admin API or X-Shopify-Storefront-Access-Token for Storefront API.

Parameters Reference

ParameterMeaningRequired
SHOPIFY_ACCESS_TOKENAdmin API access token, starts with shpat_Yes
SHOPIFY_STORE_DOMAINShop's myshopify.com domainYes
SHOPIFY_API_VERSIONGraphQL API version string (e.g., 2026-01)Yes
SHOPIFY_STOREFRONT_TOKENStorefront API access token, starts with shpua_For Storefront API
APP_SECRETClient secret for webhook HMAC verificationFor webhook verification
queryGraphQL query or mutation stringYes
variablesJSON object of variable valuesOptional
firstNumber of records per page (max 250 for products)For paginated queries
afterCursor from previous page's endCursorFor subsequent pages
query (search)Search string using Shopify's syntaxFor filtered searches
idGlobal ID (GID) of the resourceFor single-resource queries
inputInput object for mutationsFor mutations
topicWebhook topic string (e.g., ORDERS_CREATE)For webhook creation
callbackUrlHTTPS URL for webhook payloadsFor webhook creation
reasonReason string for inventory adjustmentsFor inventory mutations
deltaInteger change in quantityFor delta adjustment
ignoreCompareQuantityBoolean to skip compare-and-setFor absolute set
namespaceNamespace for metafield (e.g., custom)For metafield operations
keyKey name for metafieldFor metafield operations
typeMetafield type stringFor metafield operations
valueValue string for metafieldFor metafield operations

Constraints and Caveats

  • The REST Admin API is legacy since 2024-04; use GraphQL Admin for all admin work.
  • The Admin access token is shown only once during app installation; save it immediately.
  • As of 2026-01-01, new legacy custom apps cannot be created in the admin; use the Dev Dashboard instead.
  • HTTP 200 does not guarantee success; always check the errors array and userErrors.
  • IDs must be full GID strings (e.g., gid://shopify/Variant/...), not numeric. The pattern gid://shopify/Variant/... is the correct format for variant IDs.
  • Rate limiting uses a query cost leaky bucket: standard shops have 100 points bucket, 50/s restore; Plus shops have 1000/100.
  • Without read_all_orders scope, the orders(...) query silently caps at a 60-day window and 30 most recent orders.
  • Pagination with reverse: true sorts by id DESC, not created_at; use sortKey: CREATED_AT, reverse: true for newest first.
  • Amounts are strings (e.g., "49.00"), not numbers like 49.0. Use jq tonumber to convert.
  • Multi-currency fields have shopMoney and presentmentMoney; pick one consistently.
  • The Storefront API uses a different endpoint and token header than the Admin API.
  • Bulk operation results are JSONL; nested connections have __parentId for reassembly.
  • Webhook HMAC verification uses the app client secret, not the access token.
  • Mutations are irreversible; no staging clone exists unless you have a separate dev store.

Failure Modes

  • 401 Unauthorized: Wrong token format or header. Admin tokens start with shpat_, Storefront public with shpua_.
  • 403 Access denied: Valid token but missing required scope. Re-configure scopes and reinstall the app. Response: {"errors":[{"message":"Access denied for ..."}]}.
  • Rate limit exceeded: Query cost exceeds currentlyAvailable. Back off and retry. Check throttleStatus.{currentlyAvailable, maximumAvailable, restoreRate}.
  • Empty userErrors but null data: Some failures populate neither; inspect the full response.
  • Silent data truncation: Missing read_all_orders scope causes the orders(...) query to return fewer results without error.
  • Token lost: If not saved during installation, must regenerate by reinstalling the app.
  • Incorrect endpoint: Using the admin endpoint for storefront queries or vice versa.

Rate Limiting

Inspect the extensions.cost object in responses for requestedQueryCost, actualQueryCost, and throttleStatus.{currentlyAvailable, maximumAvailable, restoreRate}. The currentlyAvailable value indicates remaining capacity.

Important Notes

  • Use products(first: 250) for maximum page size.
  • The endpoint /admin/api/.../products.json is the legacy REST endpoint; use GraphQL instead.
  • Mutations like productDelete, orderCancel, and refundCreate are available but irreversible.
  • When paginating with products(first: N, reverse: true), sorting is by id DESC, not created_at.

Skills the docs pair this with

More Productivity skills