Shopify Admin API
Overview
The Shopify Admin API gives apps full access to a merchant's store data — products, variants, orders, customers, inventory, metafields, and more. It is available in both GraphQL (recommended) and REST flavors, with GraphQL offering precise field selection, bulk operations, and better rate limiting via the calculated cost system. Use the @shopify/shopify-api Node.js library or direct HTTP calls with an Admin API access token.
When to Use This Skill
- When reading or writing product catalog data (titles, variants, pricing, images, inventory)
- When fulfilling or updating orders programmatically from an external system
- When syncing customer records between Shopify and a CRM or ERP
- When running bulk data exports or imports using Bulk Operations
- When building an internal tool that needs merchant store access via a Custom App token
- When automating inventory adjustments from a warehouse management system
Core Instructions
Obtain an Admin API access token
For a custom app (single store), create it in Admin → Settings → Apps and Sales Channels → Develop apps. For a public/partner app, the token is obtained after OAuth (see @shopify-app-development).
# .env
SHOPIFY_ADMIN_API_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SHOPIFY_SHOP=mystore.myshopify.com
SHOPIFY_API_VERSION=2025-01
Initialize the Admin API client
// lib/shopify-admin.ts
import { shopifyApi, ApiVersion, Session } from "@shopify/shopify-api";
import "@shopify/shopify-api/adapters/node";
const shopify = shopifyApi({
apiKey: process.env.SHOPIFY_API_KEY!,
apiSecretKey: process.env.SHOPIFY_API_SECRET!,
scopes: ["read_products", "write_products", "read_orders", "write_orders"],
hostName: process.env.SHOPIFY_APP_URL!,
apiVersion: ApiVersion.January25,
isEmbeddedApp: false, // true for merchant-facing embedded apps
});
// For custom apps with a static token
const session = new Session({
id: `offline_${process.env.SHOPIFY_SHOP}`,
shop: process.env.SHOPIFY_SHOP!,
state: "",
isOnline: false,
accessToken: process.env.SHOPIFY_ADMIN_API_TOKEN,
});
export const adminClient = new shopify.clients.Graphql({ session });
export const restClient = new shopify.clients.Rest({ session });
Query products with GraphQL
// Fetch products with variants and inventory
export async function getProducts(cursor?: string) {
const response = await adminClient.request(`
query GetProducts($cursor: String) {
products(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
title
status
variants(first: 100) {
edges {
node {
id
sku
price
inventoryQuantity
inventoryItem { id }
}
}
}
}
}
}
}
`, { variables: { cursor } });
return response.data.products;
}
// Update a product's price via mutation
export async function updateVariantPrice(variantId: string, price: string) {
const response = await adminClient.request(`
mutation UpdateVariantPrice($id: ID!, $price: Money!) {
productVariantUpdate(input: { id: $id, price: $price }) {
productVariant { id price }
userErrors { field message }
}
}
`, { variables: { id: variantId, price } });
const { userErrors } = response.data.productVariantUpdate;
if (userErrors.length > 0) throw new Error(userErrors[0].message);
return response.data.productVariantUpdate.productVariant;
}
Fetch and update orders
// Query unfulfilled orders
export async function getUnfulfilledOrders() {
const response = await adminClient.request(`
query {
orders(first: 50, query: "fulfillment_status:unfulfilled financial_status:paid") {
edges {
node {
id
name
email
createdAt
lineItems(first: 50) {
edges {
node {
title
quantity
variant { id sku }
}
}
}
shippingAddress {
firstName lastName address1 city province zip country
}
}
}
}
}
`);
return response.data.orders.edges.map(({ node }: any) => node);
}
// Mark an order as fulfilled
export async function fulfillOrder(orderId: string, trackingNumber: string, trackingCompany: string) {
// First get fulfillment order ID
const orderResponse = await adminClient.request(`
query GetFulfillmentOrders($id: ID!) {
order(id: $id) {
fulfillmentOrders(first: 5) {
edges {
node { id status lineItems(first: 20) { edges { node { id remainingQuantity } } } }
}
}
}
}
`, { variables: { id: orderId } });
const fulfillmentOrder = orderResponse.data.order.fulfillmentOrders.edges[0]?.node;
if (!fulfillmentOrder) throw new Error("No fulfillment order found");
const response = await adminClient.request(`
mutation FulfillOrder($fulfillment: FulfillmentInput!) {
fulfillmentCreate(fulfillment: $fulfillment) {
fulfillment { id status }
userErrors { field message }
}
}
`, {
variables: {
fulfillment: {
lineItemsByFulfillmentOrder: [{ fulfillmentOrderId: fulfillmentOrder.id }],
trackingInfo: { number: trackingNumber, company: trackingCompany },
notifyCustomer: true,
},
},
});
return response.data.fulfillmentCreate;
}
Run Bulk Operations for large datasets
For exporting thousands of products or orders, use Bulk Operations (GraphQL only) — they run asynchronously and return a JSONL file URL:
// Start a bulk operation
export async function startBulkProductExport() {
const response = await adminClient.request(`
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id title status
variants {
edges {
node { id sku price inventoryQuantity }
}
}
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}
`);
return response.data.bulkOperationRunQuery.bulkOperation;
}
// Poll for completion and download URL
export async function getBulkOperationStatus() {
const response = await adminClient.request(`
query {
currentBulkOperation {
id status errorCode
objectCount
url # JSONL download URL — available when status is COMPLETED
}
}
`);
return response.data.currentBulkOperation;
}
Examples
Customer search and update via REST API
// REST is still valid for simple lookups where GraphQL overhead isn't worth it
export async function searchCustomers(email: string) {
const response = await restClient.get({
path: "customers/search",
query: { query: `email:${email}` },
});
return response.body.customers;
}
export async function tagCustomer(customerId: number, tags: string[]) {
const response = await restClient.put({
path: `customers/${customerId}`,
data: { customer: { id: customerId, tags: tags.join(",") } },
});
return response.body.customer;
}
Inventory adjustment
export async function adjustInventory(inventoryItemId: string, locationId: string, delta: number) {
const response = await adminClient.request(`
mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!) {
inventoryAdjustQuantities(input: $input) {
inventoryAdjustmentGroup {
changes {
name
delta
item { id }
location { name }
}
}
userErrors { field message }
}
}
`, {
variables: {
input: {
name: "available",
reason: "correction",
changes: [
{
inventoryItemId,
locationId,
delta,
},
],
},
},
});
return response.data.inventoryAdjustQuantities;
}
Best Practices
- Prefer GraphQL over REST — GraphQL has a cost-based rate limit (1000 cost units/second) that's more forgiving than REST's 40 requests/second; it also avoids over-fetching
- Use Bulk Operations for exports above 250 records — never page through thousands of records manually; Bulk Operations handle up to millions of records in a single async job
- Always handle
userErrors on mutations — a 200 HTTP response does not mean success; check userErrors array before treating a mutation as successful
- Use
gid://shopify/Product/123 format for IDs — Admin API GraphQL uses global IDs; never send numeric IDs without the GID prefix
- Implement exponential backoff — respect
Retry-After headers and implement backoff on 429 and 503 responses
- Cache immutable product data — product titles and handles rarely change; cache them with a reasonable TTL to reduce API calls
- Scope to minimum required permissions — requesting fewer scopes reduces merchant trust friction at install time
Common Pitfalls
| Problem |
Solution |
Cost exceeds bucket size GraphQL error |
Reduce the first: argument on connections (use 50 instead of 250) or restructure the query to avoid deeply nested connections |
| Numeric vs GID ID format mismatch |
Always convert REST numeric IDs to GID format: gid://shopify/Product/${numericId} |
| Bulk operation URL returns 403 |
The JSONL URL is a time-limited signed S3 URL — download it immediately after polling COMPLETED status |
Order fulfillment fails with FULFILLMENT_ORDER_NOT_FOUND |
Orders must be fulfilled via Fulfillment Orders API (not legacy Fulfillments API) since API version 2022-07 |
| Webhook events trigger duplicate processing |
Use the admin_graphql_api_id in webhook payloads and implement idempotency keying |
| Customer update clears existing tags |
When updating tags, always fetch current tags first and append — the API replaces, not merges |
Related Skills
- @shopify-app-development
- @shopify-webhooks
- @shopify-metafields
- @shopify-storefront-api
- @bulk-data-operations
1---2name: shopify-admin-api3description: Automate Shopify store operations — products, orders, inventory, and customers — using the GraphQL Admin API with bulk operation support4---56# Shopify Admin API78## Overview910The Shopify Admin API gives apps full access to a merchant's store data — products, variants, orders, customers, inventory, metafields, and more. It is available in both GraphQL (recommended) and REST flavors, with GraphQL offering precise field selection, bulk operations, and better rate limiting via the calculated cost system. Use the `@shopify/shopify-api` Node.js library or direct HTTP calls with an Admin API access token.1112## When to Use This Skill1314- When reading or writing product catalog data (titles, variants, pricing, images, inventory)15- When fulfilling or updating orders programmatically from an external system16- When syncing customer records between Shopify and a CRM or ERP17- When running bulk data exports or imports using Bulk Operations18- When building an internal tool that needs merchant store access via a Custom App token19- When automating inventory adjustments from a warehouse management system2021## Core Instructions22231. **Obtain an Admin API access token**2425 For a custom app (single store), create it in Admin → Settings → Apps and Sales Channels → Develop apps. For a public/partner app, the token is obtained after OAuth (see `@shopify-app-development`).2627 ```bash28 # .env29 SHOPIFY_ADMIN_API_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx30 SHOPIFY_SHOP=mystore.myshopify.com31 SHOPIFY_API_VERSION=2025-0132 ```33342. **Initialize the Admin API client**3536 ```typescript37 // lib/shopify-admin.ts38 import { shopifyApi, ApiVersion, Session } from "@shopify/shopify-api";39 import "@shopify/shopify-api/adapters/node";4041 const shopify = shopifyApi({42 apiKey: process.env.SHOPIFY_API_KEY!,43 apiSecretKey: process.env.SHOPIFY_API_SECRET!,44 scopes: ["read_products", "write_products", "read_orders", "write_orders"],45 hostName: process.env.SHOPIFY_APP_URL!,46 apiVersion: ApiVersion.January25,47 isEmbeddedApp: false, // true for merchant-facing embedded apps48 });4950 // For custom apps with a static token51 const session = new Session({52 id: `offline_${process.env.SHOPIFY_SHOP}`,53 shop: process.env.SHOPIFY_SHOP!,54 state: "",55 isOnline: false,56 accessToken: process.env.SHOPIFY_ADMIN_API_TOKEN,57 });5859 export const adminClient = new shopify.clients.Graphql({ session });60 export const restClient = new shopify.clients.Rest({ session });61 ```62633. **Query products with GraphQL**6465 ```typescript66 // Fetch products with variants and inventory67 export async function getProducts(cursor?: string) {68 const response = await adminClient.request(`69 query GetProducts($cursor: String) {70 products(first: 50, after: $cursor) {71 pageInfo { hasNextPage endCursor }72 edges {73 node {74 id75 title76 status77 variants(first: 100) {78 edges {79 node {80 id81 sku82 price83 inventoryQuantity84 inventoryItem { id }85 }86 }87 }88 }89 }90 }91 }92 `, { variables: { cursor } });93 return response.data.products;94 }9596 // Update a product's price via mutation97 export async function updateVariantPrice(variantId: string, price: string) {98 const response = await adminClient.request(`99 mutation UpdateVariantPrice($id: ID!, $price: Money!) {100 productVariantUpdate(input: { id: $id, price: $price }) {101 productVariant { id price }102 userErrors { field message }103 }104 }105 `, { variables: { id: variantId, price } });106107 const { userErrors } = response.data.productVariantUpdate;108 if (userErrors.length > 0) throw new Error(userErrors[0].message);109 return response.data.productVariantUpdate.productVariant;110 }111 ```1121134. **Fetch and update orders**114115 ```typescript116 // Query unfulfilled orders117 export async function getUnfulfilledOrders() {118 const response = await adminClient.request(`119 query {120 orders(first: 50, query: "fulfillment_status:unfulfilled financial_status:paid") {121 edges {122 node {123 id124 name125 email126 createdAt127 lineItems(first: 50) {128 edges {129 node {130 title131 quantity132 variant { id sku }133 }134 }135 }136 shippingAddress {137 firstName lastName address1 city province zip country138 }139 }140 }141 }142 }143 `);144 return response.data.orders.edges.map(({ node }: any) => node);145 }146147 // Mark an order as fulfilled148 export async function fulfillOrder(orderId: string, trackingNumber: string, trackingCompany: string) {149 // First get fulfillment order ID150 const orderResponse = await adminClient.request(`151 query GetFulfillmentOrders($id: ID!) {152 order(id: $id) {153 fulfillmentOrders(first: 5) {154 edges {155 node { id status lineItems(first: 20) { edges { node { id remainingQuantity } } } }156 }157 }158 }159 }160 `, { variables: { id: orderId } });161162 const fulfillmentOrder = orderResponse.data.order.fulfillmentOrders.edges[0]?.node;163 if (!fulfillmentOrder) throw new Error("No fulfillment order found");164165 const response = await adminClient.request(`166 mutation FulfillOrder($fulfillment: FulfillmentInput!) {167 fulfillmentCreate(fulfillment: $fulfillment) {168 fulfillment { id status }169 userErrors { field message }170 }171 }172 `, {173 variables: {174 fulfillment: {175 lineItemsByFulfillmentOrder: [{ fulfillmentOrderId: fulfillmentOrder.id }],176 trackingInfo: { number: trackingNumber, company: trackingCompany },177 notifyCustomer: true,178 },179 },180 });181 return response.data.fulfillmentCreate;182 }183 ```1841855. **Run Bulk Operations for large datasets**186187 For exporting thousands of products or orders, use Bulk Operations (GraphQL only) — they run asynchronously and return a JSONL file URL:188189 ```typescript190 // Start a bulk operation191 export async function startBulkProductExport() {192 const response = await adminClient.request(`193 mutation {194 bulkOperationRunQuery(195 query: """196 {197 products {198 edges {199 node {200 id title status201 variants {202 edges {203 node { id sku price inventoryQuantity }204 }205 }206 }207 }208 }209 }210 """211 ) {212 bulkOperation { id status }213 userErrors { field message }214 }215 }216 `);217 return response.data.bulkOperationRunQuery.bulkOperation;218 }219220 // Poll for completion and download URL221 export async function getBulkOperationStatus() {222 const response = await adminClient.request(`223 query {224 currentBulkOperation {225 id status errorCode226 objectCount227 url # JSONL download URL — available when status is COMPLETED228 }229 }230 `);231 return response.data.currentBulkOperation;232 }233 ```234235## Examples236237### Customer search and update via REST API238239```typescript240// REST is still valid for simple lookups where GraphQL overhead isn't worth it241export async function searchCustomers(email: string) {242 const response = await restClient.get({243 path: "customers/search",244 query: { query: `email:${email}` },245 });246 return response.body.customers;247}248249export async function tagCustomer(customerId: number, tags: string[]) {250 const response = await restClient.put({251 path: `customers/${customerId}`,252 data: { customer: { id: customerId, tags: tags.join(",") } },253 });254 return response.body.customer;255}256```257258### Inventory adjustment259260```typescript261export async function adjustInventory(inventoryItemId: string, locationId: string, delta: number) {262 const response = await adminClient.request(`263 mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!) {264 inventoryAdjustQuantities(input: $input) {265 inventoryAdjustmentGroup {266 changes {267 name268 delta269 item { id }270 location { name }271 }272 }273 userErrors { field message }274 }275 }276 `, {277 variables: {278 input: {279 name: "available",280 reason: "correction",281 changes: [282 {283 inventoryItemId,284 locationId,285 delta,286 },287 ],288 },289 },290 });291 return response.data.inventoryAdjustQuantities;292}293```294295## Best Practices296297- **Prefer GraphQL over REST** — GraphQL has a cost-based rate limit (1000 cost units/second) that's more forgiving than REST's 40 requests/second; it also avoids over-fetching298- **Use Bulk Operations for exports above 250 records** — never page through thousands of records manually; Bulk Operations handle up to millions of records in a single async job299- **Always handle `userErrors` on mutations** — a 200 HTTP response does not mean success; check `userErrors` array before treating a mutation as successful300- **Use `gid://shopify/Product/123` format for IDs** — Admin API GraphQL uses global IDs; never send numeric IDs without the GID prefix301- **Implement exponential backoff** — respect `Retry-After` headers and implement backoff on 429 and 503 responses302- **Cache immutable product data** — product titles and handles rarely change; cache them with a reasonable TTL to reduce API calls303- **Scope to minimum required permissions** — requesting fewer scopes reduces merchant trust friction at install time304305## Common Pitfalls306307| Problem | Solution |308|---------|----------|309| `Cost exceeds bucket size` GraphQL error | Reduce the `first:` argument on connections (use 50 instead of 250) or restructure the query to avoid deeply nested connections |310| Numeric vs GID ID format mismatch | Always convert REST numeric IDs to GID format: `gid://shopify/Product/${numericId}` |311| Bulk operation URL returns 403 | The JSONL URL is a time-limited signed S3 URL — download it immediately after polling `COMPLETED` status |312| Order fulfillment fails with `FULFILLMENT_ORDER_NOT_FOUND` | Orders must be fulfilled via Fulfillment Orders API (not legacy Fulfillments API) since API version 2022-07 |313| Webhook events trigger duplicate processing | Use the `admin_graphql_api_id` in webhook payloads and implement idempotency keying |314| Customer update clears existing tags | When updating tags, always fetch current tags first and append — the API replaces, not merges |315316## Related Skills317318- @shopify-app-development319- @shopify-webhooks320- @shopify-metafields321- @shopify-storefront-api322- @bulk-data-operations