Skip to main content
Stateful Shopify emulation of the GraphQL Admin API (/admin/api/<version>/graphql.json), validated against Shopify’s published schema for API version 2026-10. It covers products with options, variants, and media, inventory items and levels, customers, orders with transactions, refunds, and cancellation, fulfillment orders, fulfillments, and holds, the shop, apps, staff, and locations, with Shopify’s search syntax in query: arguments, Relay cursor pagination, userErrors, and extensions.cost with a leaky bucket that answers THROTTLED when it runs dry. It also installs apps over OAuth (authorization code grant, offline, online, and expiring tokens, token exchange of session tokens, client credentials) and sends HMAC-signed webhooks. The tests drive it with @shopify/shopify-api (15.0.1) and @shopify/admin-api-client (2.0.1).

At a glance

Start

Shopify starts only when named in config or with --service. When it is the only enabled service, it listens on http://localhost:4000. The emulator is one shop, baseline-store.myshopify.com unless shop.domain says otherwise. Without config, the shop has one location, an owner staff member, and a default app (baseline-shopify-app). Shopify’s SDKs always call https://{shop}. Point them at the emulator with a fetch that swaps the origin. With @shopify/admin-api-client:
With @shopify/shopify-api, wrap the runtime’s fetch once after importing the adapter:

URL mapping

Auth

Admin API requests carry X-Shopify-Access-Token.
  • Tokens seeded as an app’s access_token act as that app (installed, with its scopes).
  • Any other token the CLI accepts acts as the first app with every scope, so a quick start needs no setup.
  • An unknown, revoked, or expired token answers HTTP 401 {"errors":"[API] Invalid API key or access token (unrecognized login or wrong password)"}.
  • A field outside the token’s scopes answers ACCESS_DENIED (Access denied for productCreate field. Required access: \write_products` access scope.`). A write scope includes its read scope.

API versions

Every supported version (the four stable ones, the release candidate, and unstable) is served from the pinned 2026-10 schema. An older version falls forward to the oldest supported version, as Shopify does; a version that is not a quarterly release (2026-05, latest) or is not out yet answers 404 {"errors":"Not Found"}. X-Shopify-API-Version on every response names the version that answered. /admin/api/graphql.json without a version is served as the oldest supported version. The schema is vendored from Shopify’s @shopify/dev-mcp package (ISC). A weekly drift check moves the pin when Shopify releases a new stable version.

OAuth

Apps install the way they do on a real shop. shopify.auth.begin, auth.callback, auth.tokenExchange, auth.refreshToken, auth.clientCredentials, and auth.migrateToExpiringToken all work against the emulator.
  • GET /admin/oauth/authorize — Install screen: the app, the scopes it asks for, and a button per staff member. client_id must be a seeded app and redirect_uri one of its redirect_urls (or under its application_url when it has none). grant_options[]=per-user asks for an online token.
  • POST /admin/oauth/authorize — Approve install: installs the app with the scopes and redirects to redirect_uri with code, host, shop, state, timestamp, and hmac.
  • POST /admin/oauth/access_token — Get access token: JSON or form body with client_id and client_secret. Grants below.
  • GET /admin/oauth/access_scopes.json — List access scopes: {"access_scopes":[{"handle":"write_products"}]} for the token in X-Shopify-Access-Token.
  • GET /admin/apps/<api key or handle> — Open app: as the Shopify admin does, a redirect to its application_url with the signed query, plus embedded=1, id_token (a session token), session, and locale for embedded apps. An app that is not installed yet is installed with its configured scopes first (managed installation). ?staff=<email> picks the user.
The access token call grants:
  • Authorization code: code from the redirect, once, within ten minutes. An offline token (shpat_) never expires; with expiring=1 it lasts an hour and comes with a refresh_token that lasts 90 days. An online token (shpua_) lasts a day and carries associated_user and associated_user_scope.
  • Token exchange (grant_type=urn:ietf:params:oauth:grant-type:token-exchange): a session token as subject_token with subject_token_type=urn:ietf:params:oauth:token-type:id_token, for an online or offline token (requested_token_type). The session token must be signed with the app’s client secret (HS256), be for the app’s client ID (aud) and this shop (dest), and not be expired. A non-expiring offline token as subject_token with the offline token type migrates it to an expiring one and revokes the old token.
  • Refresh token (grant_type=refresh_token): a new expiring offline token; the old token and refresh token stop working.
  • Client credentials (grant_type=client_credentials): a 24 hour offline token for an installed app.
Errors use OAuth’s shape, as {"error":"invalid_subject_token","error_description":"Session token signature is invalid"}, with 401 for a wrong client secret and 400 otherwise. Shopify signs the queries it sends to an app with hmac: the hex HMAC-SHA256 of the other parameters, sorted by name and URL encoded, keyed with the client secret. shopify.utils.validateHmac accepts the emulator’s.

GraphQL

Queries are validated against the schema and answered with graphql-ruby’s wording (Field 'nope' doesn't exist on type 'Shop', Field 'product' is missing required arguments: id), on HTTP 200.
  • Products: products, productsCount, product, productByHandle, productByIdentifier, productVariants, productVariantsCount, productVariant, inventoryItem.
  • Product mutations: productCreate (with productOptions and media), productUpdate, productDelete, productVariantsBulkCreate (with the REMOVE_STANDALONE_VARIANT and PRESERVE_STANDALONE_VARIANT strategies), productVariantsBulkUpdate, productVariantsBulkDelete, productOptionsCreate. Problems come back in userErrors with Shopify’s messages and codes (Title can't be blank, VARIANT_ALREADY_EXISTS, CANNOT_DELETE_LAST_VARIANT).
  • Customers: customers, customersCount, customer, customerByIdentifier, customerCreate, customerUpdate, customerDelete, customerAddressCreate, customerAddressUpdate, customerAddressDelete, customerUpdateDefaultAddress.
  • Orders: orders, ordersCount, order, orderByIdentifier, orderCreate, orderUpdate, orderCancel, orderClose, orderOpen, orderMarkAsPaid, orderCapture, refundCreate, tagsAdd, tagsRemove.
  • Fulfillment: fulfillmentOrders, fulfillmentOrder, fulfillment, fulfillmentCreate, fulfillmentCancel, fulfillmentTrackingInfoUpdate, fulfillmentOrderHold, fulfillmentOrderReleaseHold.
  • Shop: shop, app, appInstallation, currentAppInstallation, currentStaffMember, staffMember, locations, locationsCount, location, node, nodes, job, appUninstall.
  • Webhooks: webhookSubscriptions, webhookSubscriptionsCount, webhookSubscription, webhookSubscriptionCreate, webhookSubscriptionUpdate, webhookSubscriptionDelete. See Webhooks.
A product starts with the Title option and its Default Title variant, or with the first value of each option given. Handles come from the title and get -1, -2 suffixes when taken. Media accepts IMAGE and EXTERNAL_VIDEO sources and is READY at once.

Customers

A customer needs a name, an email address, or a phone number (A name, phone number, or email address must be present). Emails are stored lowercase and, like phone numbers (kept in E.164), are unique (Email has already been taken). A customer with orders cannot be deleted. numberOfOrders, amountSpent (payments received less refunds), lastOrder, and orders come from the customer’s orders.

Orders

orderCreate takes line items for variants (title, SKU, vendor, and price come from the variant unless given) or custom line items with a title and priceSet, shipping lines, tax lines (with a priceSet, or a rate applied to the line total), transactions, financialStatus, fulfillmentStatus or fulfillment, addresses, tags, and a customer to associate (toAssociate by ID or email) or upsert (toUpsert). Orders are numbered from #1001. With options.inventoryBehaviour set to DECREMENT_IGNORING_POLICY or DECREMENT_OBEYING_POLICY, the variants’ inventory goes down, and the second refuses (INVENTORY_CLAIM_FAILED) when a DENY variant lacks stock. The financial status is derived from the transactions, as Shopify does: PAID once sales and captures cover the total, PARTIALLY_PAID before that, AUTHORIZED with uncaptured authorizations, REFUNDED or PARTIALLY_REFUNDED once refunds exist, VOIDED when the authorizations were voided, else PENDING. A financialStatus of PAID or AUTHORIZED without transactions records a manual sale or authorization for the total; other statuses are kept as given until a transaction is recorded.
  • orderCapture captures part or all of an authorization (finalCapture voids the rest) and refuses more than is left.
  • orderMarkAsPaid records a manual sale for the outstanding amount.
  • refundCreate refunds line items (RETURN, CANCEL, and LEGACY_RESTOCK put stock back), shipping, and money through REFUND transactions, up to the net payment received.
  • orderCancel returns a Job (done when polled with job), and with refundMethod: { originalPaymentMethodsRefund: true } refunds the net payment, voids open authorizations, restocks when asked, closes the order and its fulfillment orders, and sets cancelledAt, cancelReason, and cancellation.staffNote. Orders with fulfillments cannot be cancelled.
  • orderClose and orderOpen archive and unarchive; a cancelled order cannot be reopened.

Fulfillment orders

Each order gets one fulfillment order at the shop’s first location. fulfillmentCreate fulfills whole fulfillment orders or chosen quantities (Invalid fulfillment order line item quantity requested.), with tracking; a fulfillment order with nothing left is CLOSED, a partly fulfilled one IN_PROGRESS. fulfillmentCancel gives the quantities back. fulfillmentOrderHold puts a fulfillment order ON_HOLD with a reason and handle (unique per app, at most 10 holds); holding only some quantities splits the rest into a new fulfillment order, returned as remainingFulfillmentOrder. fulfillmentOrderReleaseHold releases some or all holds. The order’s displayFulfillmentStatus follows: UNFULFILLED, ON_HOLD, IN_PROGRESS, PARTIALLY_FULFILLED, FULFILLED. Orders search on name, status (open, closed, cancelled, not_closed, any), financial_status, fulfillment_status (shipped, partial, unshipped, unfulfilled, on_hold), email, tag, tag_not, customer_id, sku, test, source_name, po_number, confirmation_number, created_at, updated_at, and processed_at. Customers search on email, phone, first_name, last_name, state, tag, tag_not, country, accepts_marketing, created_at, and updated_at. Order reads need read_orders, customer reads read_customers, and fulfillment orders one of the read_*_fulfillment_orders scopes (write_merchant_managed_fulfillment_orders to fulfill or hold).

Unmodeled fields

Fields Baseline does not model answer as an empty shop would: metafield is null and lists and connections are empty. Any other root field answers NOT_EMULATED with its name.

Pagination

Connections take first or last (one is required, at most 250) with after and before cursors, sortKey, and reverse, and return nodes, edges with cursors, and pageInfo.

Search syntax

query: arguments take Shopify’s search syntax: free text, field:value, field:<10, field:>=2024-01-01, field:* (has a value), trailing * prefixes, comma lists (status:active,draft), AND, OR, NOT, a leading -, parentheses, and quoted phrases. A field the resource does not support filters nothing and is reported in extensions.search:
Products search on title, handle, vendor, product_type, status, tag, tag_not, sku, barcode, gift_card, inventory_total, price, variant_id, created_at, updated_at, published_at, published_status, and out_of_stock_somewhere.

Query cost

Every response reports the cost:
Scalars cost 0, objects 1, connections 2 plus first (or last) times their nodes’ cost, and mutations 10. The requested cost is taken from the bucket before the query runs, and the difference from the actual cost is returned after. When the bucket holds less than the requested cost, the response is {"errors":[{"message":"Throttled","extensions":{"code":"THROTTLED",...}}]}. A query over 1000 points answers MAX_COST_EXCEEDED. Set the bucket with shop.throttle.

Webhooks

Subscribe with webhookSubscriptionCreate (uri, or the older callbackUrl), shopify.webhooks.register, or the webhooks list on a seeded app. When a request changes something, each matching subscription of an installed app gets one POST of the topic’s REST-shaped JSON payload with these headers: shopify.webhooks.validate accepts these deliveries. Topics sent: products/create, products/update, products/delete, customers/create, customers/update, customers/delete, orders/create, orders/updated, orders/paid, orders/cancelled, orders/fulfilled, orders/partially_fulfilled, order_transactions/create, refunds/create, fulfillments/create, fulfillments/update, fulfillment_orders/placed_on_hold, fulfillment_orders/hold_released, and app/uninstalled. Other topics can be subscribed to and are never sent.
  • includeFields cuts the payload to the named fields; nested fields use dots (line_items.title).
  • filter takes the search syntax over the payload’s fields, with dots for nested fields (total_price:>100, variants.price:>=10.00). A payload that does not match is not sent.
  • A subscription needs the topic’s read scope (read_orders for orders, transactions, refunds, and fulfillments), else userErrors explains which scope is missing. The same address twice for a topic is Address for this topic has already been taken. Pub/Sub and EventBridge addresses and the XML format are refused.
  • A delivery fails when the address does not answer 2xx within five seconds. Failed deliveries are retried eight times, after 1, 2, 4, 8, 16, 32, 60, and 60 seconds (Shopify spreads them over four hours).
  • An app sees and changes only its own subscriptions.
  • Uninstalling an app (appUninstall, or the inspector’s Uninstall button) sends its app/uninstalled webhooks with the shop as payload, then revokes its tokens and deletes its subscriptions.

Seed config

An app with access_token is installed with that offline token, as a custom app is; without one it is installed through OAuth. webhooks topics are written as orders/create or ORDERS_CREATE. Seeded order line items name a variant by sku or give a title and price. Orders are seeded only into an empty store, so a restored state is not seeded twice.

Inspector

GET /_inspector shows products with their variants and inventory, customers, orders with their payment and fulfillment status and holds, apps with their granted scopes and tokens (with Open app and Uninstall buttons), webhook subscriptions and deliveries with their status and retries, and the shop settings, locations, and staff.

Limits

@shopify/shopify-api talks to the Admin API through GraphQL; its REST resources are deprecated and need restResources to be passed in. Baseline serves no REST Admin resources; the only REST calls the SDK makes are the OAuth ones above, which are served.

Conformance

See CONFORMANCE.md for every provider.