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 documentationShopify 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. curlandjqinstalled.- 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
- In Shopify admin, go to Settings → Apps and sales channels → Develop apps → Create an app.
- Click Configure Admin API scopes, select the required scopes (see Common Scopes), and save.
- Install the app. The Admin API access token appears once. Copy it immediately (it starts with
shpat_). - Save the token, store domain, and API version to an environment file (e.g.,
${HERMES_HOME:-~/.hermes}/.env). - 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
POSTrequest tohttps://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.json. - Headers:
Content-Type: application/jsonandX-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN. - Body is JSON:
{"query": "...", "variables": {...}}. - Parse the response with
jq. Check the top-levelerrorsarray and per-fielduserErrors. - Inspect
extensions.costfor 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
| Parameter | Meaning | Required |
|---|---|---|
SHOPIFY_ACCESS_TOKEN | Admin API access token, starts with shpat_ | Yes |
SHOPIFY_STORE_DOMAIN | Shop's myshopify.com domain | Yes |
SHOPIFY_API_VERSION | GraphQL API version string (e.g., 2026-01) | Yes |
SHOPIFY_STOREFRONT_TOKEN | Storefront API access token, starts with shpua_ | For Storefront API |
APP_SECRET | Client secret for webhook HMAC verification | For webhook verification |
query | GraphQL query or mutation string | Yes |
variables | JSON object of variable values | Optional |
first | Number of records per page (max 250 for products) | For paginated queries |
after | Cursor from previous page's endCursor | For subsequent pages |
query (search) | Search string using Shopify's syntax | For filtered searches |
id | Global ID (GID) of the resource | For single-resource queries |
input | Input object for mutations | For mutations |
topic | Webhook topic string (e.g., ORDERS_CREATE) | For webhook creation |
callbackUrl | HTTPS URL for webhook payloads | For webhook creation |
reason | Reason string for inventory adjustments | For inventory mutations |
delta | Integer change in quantity | For delta adjustment |
ignoreCompareQuantity | Boolean to skip compare-and-set | For absolute set |
namespace | Namespace for metafield (e.g., custom) | For metafield operations |
key | Key name for metafield | For metafield operations |
type | Metafield type string | For metafield operations |
value | Value string for metafield | For 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
errorsarray anduserErrors. - IDs must be full GID strings (e.g.,
gid://shopify/Variant/...), not numeric. The patterngid://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_ordersscope, theorders(...)query silently caps at a 60-day window and 30 most recent orders. - Pagination with
reverse: truesorts byid DESC, notcreated_at; usesortKey: CREATED_AT, reverse: truefor newest first. - Amounts are strings (e.g.,
"49.00"), not numbers like49.0. Usejq tonumberto convert. - Multi-currency fields have
shopMoneyandpresentmentMoney; 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
__parentIdfor 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 withshpua_. - 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. CheckthrottleStatus.{currentlyAvailable, maximumAvailable, restoreRate}. - Empty userErrors but null data: Some failures populate neither; inspect the full response.
- Silent data truncation: Missing
read_all_ordersscope causes theorders(...)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.jsonis the legacy REST endpoint; use GraphQL instead. - Mutations like
productDelete,orderCancel, andrefundCreateare available but irreversible. - When paginating with
products(first: N, reverse: true), sorting is byid DESC, notcreated_at.