Skip to Content
SimulatorsShopifyOverview

Simulators

Shopify simulator

shopify-twin is a local, stateful simulator of Shopify’s two GraphQL APIs, for testing code that reads and writes a store. The Admin API at /admin/api/2026-10/graphql.json serves the catalog, customers, orders, fulfillment, inventory, custom data and Online Store content. The Storefront API at /api/2026-10/graphql.json serves what a shopper sees and their carts.

The store behaves as Shopify’s does, except where a line says Differs from Shopify or Limitations lists a known issue. “Unconfirmed” marks store behavior not yet checked against a live Shopify store. The simulator never calls Shopify, takes no payment, sends no email and accepts any placeholder access token.

Quickstart

Install the CLI, sign in and boot the simulator with RYSTIC_URL pointing at it.

On Pro you sign in with an API key from your account .

curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…   # your API key
export RYSTIC_URL=$(rystic run shopify-twin -d --port 0 --scenario shopify_apparel_storefront --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"shopify-twin"...}

On Enterprise, the licensed edition signs in with a license file in place of the key: rystic login ./rystic-license.json (licensing ).

First calls

The default scenario is a lived-in apparel store. Read its first product on the Admin API, create a page there and read the page back on the Storefront API:

ADMIN="$RYSTIC_URL/admin/api/2026-10/graphql.json"
STOREFRONT="$RYSTIC_URL/api/2026-10/graphql.json"
curl -s "$ADMIN" -H "X-Shopify-Access-Token: shpat_placeholder" -H "Content-Type: application/json" -d '{"query": "{ products(first: 1) { nodes { id title } } }"}' | jq -c '.data.products.nodes[0]'
# -> {"id":"gid://shopify/Product/1030","title":"Cedar Rain Shell"}
curl -s "$ADMIN" -H "X-Shopify-Access-Token: shpat_placeholder" -H "Content-Type: application/json" -d '{"query": "mutation($page: PageCreateInput!) { pageCreate(page: $page) { page { id handle } userErrors { message } } }", "variables": {"page": {"title": "Shipping", "body": "<p>Free over 50</p>", "isPublished": true}}}' | jq -c '.data.pageCreate.page'
# -> {"handle":"shipping","id":"gid://shopify/Page/1"}
curl -s "$STOREFRONT" -H "X-Shopify-Storefront-Access-Token: placeholder" -H "Content-Type: application/json" -d '{"query": "{ pageByHandle(handle: \"shipping\") { id title } }"}' | jq -c '.data.pageByHandle'
# -> {"id":"gid://shopify/Page/1","title":"Shipping"}

Connect your client

A client that takes a base URL points it at $RYSTIC_URL. The official libraries pin the store’s own https://<shop>.myshopify.com host and offer no base URL. For them, boot the simulator with rystic run shopify-twin -d --port 0 --intercept and start your program as rystic exec -- <command>, which routes every *.myshopify.com host to it. Any shop name works, and the access token is a placeholder. JavaScript:

import { createAdminApiClient } from "@shopify/admin-api-client";

const client = createAdminApiClient({
  storeDomain: "example.myshopify.com",
  apiVersion: "2026-10",
  accessToken: "shpat_placeholder",
});
const { data } = await client.request("{ shop { name currencyCode } }");

Python:

import json

import shopify

session = shopify.Session("example.myshopify.com", "2026-10", "shpat_placeholder")
shopify.ShopifyResource.activate_session(session)
result = json.loads(shopify.GraphQL().execute("{ shop { name currencyCode } }"))

Go:

import (
	"context"

	goshopify "github.com/bold-commerce/go-shopify/v4"
)

func main() {
	client, _ := goshopify.NewClient(goshopify.App{}, "example.myshopify.com", "shpat_placeholder", goshopify.WithVersion("2026-10"))
	var resp struct {
		Shop struct {
			Name string `json:"name"`
		} `json:"shop"`
	}
	client.GraphQL.Query(context.Background(), "{ shop { name } }", nil, &resp)
}

Pin 2026-10 in every client: the simulator serves no other version. A Storefront client sends its token as X-Shopify-Storefront-Access-Token and reaches the same host.

Each client’s quickstart was run against this simulator in each way it connects, with the vendor’s own hosts blocked.

ClientVersionKindWorks withFails withCheckedKnown gap
@shopify/admin-api-client (npm)v2.0.1sdkproxy—2026-11-06—
shopify_api (RubyGems)v16.3.1sdkproxy—2026-11-06—
@shopify/cli (npm)v5.0.0cli—proxy2026-11-06The simulator serves the Admin API at version 2026-10 only, and serves neither POST /admin/api/unstable/graphql.json nor the Admin API’s publicApiVersions and themes queries. So the Shopify CLI’s shopify theme commands fail at their first call, which reads the version list from the unstable endpoint. Workaround: Call the simulator through @shopify/admin-api-client, @shopify/shopify-api or another Admin API client pinned to version 2026-10. Such a client sends each query to the endpoint the simulator serves. Theme reads and theme file uploads have no workaround.
shopify-mcp (npm)v1.0.8mcp-serverproxy—2026-11-06—

Auth

The Admin API takes its token in X-Shopify-Access-Token and the Storefront API in X-Shopify-Storefront-Access-Token or Shopify-Storefront-Private-Token; the simulator checks that a token is present and never its value, so any placeholder works.

An Admin request with no token, an empty one or only an Authorization: Bearer header is refused before the query is read with 401 and {"errors": "[API] Invalid API key or access token (unrecognized login or wrong password)"}, a string as in Shopify, beside WWW-Authenticate: Basic Realm="Shopify API Authentication". A Storefront token on the Admin route counts as none.

A Storefront request with no token, or with the Admin header only, returns 400 with one error, as a store whose storefront is not open does. The error’s code is BAD_REQUEST and its message Online Store channel is locked.

Which orders an Admin token can read depends on the grants bound to it (Order history window).

API version

The simulator serves Shopify API version 2026-10, on both surfaces: POST /admin/api/2026-10/graphql.json and POST /api/2026-10/graphql.json.

A caller pins the version in the path, which every Shopify library does from its apiVersion setting. Any other version, unstable included, returns 404 {"message": "No route in twin for POST /admin/api/2025-01/graphql.json"}, and so does every other path and method: the REST Admin API is not served.

Errors and limits

Error format

Both routes take a JSON body, {"query": …, "variables": …}. A refusal returns an errors array, and what each entry carries depends on the error:

ErrorStatusEach errors entry carriesdata
a body that is not JSON, a form body included400message (Bad Request) onlyabsent
a syntax error200message, locationsabsent
a field the schema doesn’t have200message, locations, path, extensions.code undefinedFieldabsent
a wrongly typed variable200message, locations and extensions with value and problems; on the Admin API also extensions.code INVALID_VARIABLEabsent
a cursor the simulator didn’t issue (unconfirmed)200message, locations, pathnull
first or last above a connection’s cap (250 on most), or a connection given neither200message, locations, path; on the Admin API also extensions.code BAD_REQUESTnull up to the nearest nullable parent
a global id of another type, such as an order id passed to product(id:)200message, locations, path; on the Admin API Invalid id: <id> with extensions.code RESOURCE_NOT_FOUND, on the Storefront API invalid id, except blog(id:), which returns null with no errorthat field null, the others kept
a global id that does not parse, written into the query200message (Invalid global id '<id>'), locations, path, extensions.code argumentLiteralsIncompatibleabsent
a null in a field the schema declares non-null200message, locations, pathnull up to the nearest nullable parent; when that is the whole data, every other field is dropped
a refused mutation argument, such as inventoryAdjustQuantities without changeFromQuantity200message, locations, path, extensions.code INVALID_FIELD_ARGUMENTSthat field null, the others kept

A mutation refuses bad input as data: its payload’s userErrors lists each problem beside a null record. Not every payload’s error type has a code, and selecting code where it has none fails the whole query with undefinedField:

userErrorsMutations
field, message and codeevery Storefront cart mutation; on the Admin API, articleCreate, blogCreate, bulkOperationRunQuery, collectionReorderProducts, inventoryAdjustQuantities, inventoryMoveQuantities, inventorySetQuantities, menuCreate, pageCreate, productOptionUpdate, productOptionsCreate, productSet, productVariantsBulkCreate, productVariantsBulkUpdate and urlRedirectCreate
field and message onlybulkOperationCancel, collectionAddProducts, collectionCreate, collectionRemoveProducts, collectionUpdate, inventoryActivate, inventoryItemUpdate, productCreate, productCreateMedia, productReorderMedia, productUpdate, productUpdateMedia, publishablePublish and publishableUnpublish

Rate limits

An Admin API response carries its cost in extensions.cost, with requestedQueryCost, actualQueryCost and a throttleStatus. The cost figures and the bucket differ from Shopify’s, and no Admin or Storefront request is ever throttled (Limitations).

Network profiles refuse a share of requests on a route with the status and body you give them, so a test can send the THROTTLED error Shopify returns.

Pagination

An Admin connection such as orders pages forward with first and after, or backward with last and before. It returns nodes, and edges with a cursor on each. Its pageInfo holds hasNextPage, hasPreviousPage, startCursor and endCursor. Treat a cursor as opaque; passing an Admin cursor this simulator never handed out is refused with a GraphQL error (unconfirmed).

first or last above a connection’s cap, or a connection given neither, returns an error (Error format). The cap is 250 on most connections. The Admin API’s productTags takes up to 5000, and productTypes, productVendors and customerSegmentMembers up to 1000. A bulk query needs neither.

Idempotency

The Admin API’s inventory writes take Shopify’s @idempotent(key:) directive: inventorySetQuantities, inventoryAdjustQuantities, inventoryMoveQuantities and inventoryActivate. The simulator accepts the directive on any field and ignores its key, so a retry with the same key applies the write again (Limitations). A pageCreate sent twice with one key makes two pages.

Coverage

The simulator covers the Admin API’s reads of a store’s catalog, customers, orders, fulfillment, inventory, custom data and content. Its Admin writes cover products (variants, options and media included), collections, inventory, publishing, pages, blogs, articles, menus, URL redirects and bulk operations. On the Storefront API it covers the catalog, content and cart operations. Order, customer and discount writes, payments, checkout, webhooks and introspection are out of scope, and so is the Customer Account API. GET /_rystic/routes lists the two routes a running simulator serves, and the table below names each GraphQL field.

On 2026-11-06, the simulator matched Shopify on 255 of the 275 tests that compare the two (92.7%), and 4 of them gave no verdict.

2 routes, counted from the simulator’s route table.

Show all operations
ResourceOperations
Abandoned checkout (2)POST /admin/api/2026-10/graphql.json QueryRoot.abandonedCheckouts; POST /admin/api/2026-10/graphql.json QueryRoot.abandonedCheckoutsCount
Article (3)POST /admin/api/2026-10/graphql.json Mutation.articleCreate; POST /api/2026-10/graphql.json QueryRoot.article; POST /api/2026-10/graphql.json QueryRoot.articles
Blog (4)POST /admin/api/2026-10/graphql.json Mutation.blogCreate; POST /api/2026-10/graphql.json QueryRoot.blog; POST /api/2026-10/graphql.json QueryRoot.blogByHandle; POST /api/2026-10/graphql.json QueryRoot.blogs
Bulk operation (7)POST /admin/api/2026-10/graphql.json Mutation.bulkOperationCancel; POST /admin/api/2026-10/graphql.json Mutation.bulkOperationRunQuery; POST /admin/api/2026-10/graphql.json QueryRoot.bulkOperation; POST /admin/api/2026-10/graphql.json QueryRoot.bulkOperations; POST /admin/api/2026-10/graphql.json QueryRoot.currentBulkOperation; POST /admin/api/2026-10/graphql.json QueryRoot.node; POST /admin/api/2026-10/graphql.json QueryRoot.nodes
Cart (7)POST /api/2026-10/graphql.json Mutation.cartAttributesUpdate; POST /api/2026-10/graphql.json Mutation.cartBuyerIdentityUpdate; POST /api/2026-10/graphql.json Mutation.cartCreate; POST /api/2026-10/graphql.json Mutation.cartDiscountCodesUpdate; POST /api/2026-10/graphql.json Mutation.cartNoteUpdate; POST /api/2026-10/graphql.json Mutation.cartSelectedDeliveryOptionsUpdate; POST /api/2026-10/graphql.json QueryRoot.cart
Cart delivery address (4)POST /api/2026-10/graphql.json Mutation.cartDeliveryAddressesAdd; POST /api/2026-10/graphql.json Mutation.cartDeliveryAddressesRemove; POST /api/2026-10/graphql.json Mutation.cartDeliveryAddressesReplace; POST /api/2026-10/graphql.json Mutation.cartDeliveryAddressesUpdate
Cart line (3)POST /api/2026-10/graphql.json Mutation.cartLinesAdd; POST /api/2026-10/graphql.json Mutation.cartLinesRemove; POST /api/2026-10/graphql.json Mutation.cartLinesUpdate
Cart metafield (2)POST /api/2026-10/graphql.json Mutation.cartMetafieldDelete; POST /api/2026-10/graphql.json Mutation.cartMetafieldsSet
Collection (9)POST /admin/api/2026-10/graphql.json Mutation.collectionCreate; POST /admin/api/2026-10/graphql.json Mutation.collectionUpdate; POST /admin/api/2026-10/graphql.json QueryRoot.collection; POST /admin/api/2026-10/graphql.json QueryRoot.collectionByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.collections; POST /admin/api/2026-10/graphql.json QueryRoot.collectionsCount; POST /api/2026-10/graphql.json QueryRoot.collection; POST /api/2026-10/graphql.json QueryRoot.collectionByHandle; POST /api/2026-10/graphql.json QueryRoot.collections
Collection product membership (3)POST /admin/api/2026-10/graphql.json Mutation.collectionAddProducts; POST /admin/api/2026-10/graphql.json Mutation.collectionRemoveProducts; POST /admin/api/2026-10/graphql.json Mutation.collectionReorderProducts
Customer (4)POST /admin/api/2026-10/graphql.json QueryRoot.customer; POST /admin/api/2026-10/graphql.json QueryRoot.customerByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.customers; POST /admin/api/2026-10/graphql.json QueryRoot.customersCount
Fulfillment (1)POST /admin/api/2026-10/graphql.json QueryRoot.fulfillment
Fulfillment order (2)POST /admin/api/2026-10/graphql.json QueryRoot.fulfillmentOrder; POST /admin/api/2026-10/graphql.json QueryRoot.fulfillmentOrders
Inventory item (3)POST /admin/api/2026-10/graphql.json Mutation.inventoryItemUpdate; POST /admin/api/2026-10/graphql.json QueryRoot.inventoryItem; POST /admin/api/2026-10/graphql.json QueryRoot.inventoryItems
Inventory level (5)POST /admin/api/2026-10/graphql.json Mutation.inventoryActivate; POST /admin/api/2026-10/graphql.json Mutation.inventoryAdjustQuantities; POST /admin/api/2026-10/graphql.json Mutation.inventoryMoveQuantities; POST /admin/api/2026-10/graphql.json Mutation.inventorySetQuantities; POST /admin/api/2026-10/graphql.json QueryRoot.inventoryLevel
Job (1)POST /admin/api/2026-10/graphql.json QueryRoot.job
Location (5)POST /admin/api/2026-10/graphql.json QueryRoot.location; POST /admin/api/2026-10/graphql.json QueryRoot.locationByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.locations; POST /admin/api/2026-10/graphql.json QueryRoot.locationsCount; POST /api/2026-10/graphql.json QueryRoot.locations
Menu (2)POST /admin/api/2026-10/graphql.json Mutation.menuCreate; POST /api/2026-10/graphql.json QueryRoot.menu
Metafield definition (2)POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinition; POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinitions
Metaobject (4)POST /admin/api/2026-10/graphql.json QueryRoot.metaobject; POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectByHandle; POST /api/2026-10/graphql.json QueryRoot.metaobject; POST /api/2026-10/graphql.json QueryRoot.metaobjects
Metaobject definition (4)POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectDefinition; POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectDefinitionByType; POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectDefinitions; POST /admin/api/2026-10/graphql.json QueryRoot.metaobjects
Order (4)POST /admin/api/2026-10/graphql.json QueryRoot.order; POST /admin/api/2026-10/graphql.json QueryRoot.orderByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.orders; POST /admin/api/2026-10/graphql.json QueryRoot.ordersCount
Page (5)POST /admin/api/2026-10/graphql.json Mutation.pageCreate; POST /api/2026-10/graphql.json QueryRoot.nodes; POST /api/2026-10/graphql.json QueryRoot.page; POST /api/2026-10/graphql.json QueryRoot.pageByHandle; POST /api/2026-10/graphql.json QueryRoot.pages
Product (20)POST /admin/api/2026-10/graphql.json Mutation.productCreate; POST /admin/api/2026-10/graphql.json Mutation.productSet; POST /admin/api/2026-10/graphql.json Mutation.productUpdate; POST /admin/api/2026-10/graphql.json QueryRoot.product; POST /admin/api/2026-10/graphql.json QueryRoot.productByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.productTags; POST /admin/api/2026-10/graphql.json QueryRoot.productTypes; POST /admin/api/2026-10/graphql.json QueryRoot.productVendors; POST /admin/api/2026-10/graphql.json QueryRoot.products; POST /admin/api/2026-10/graphql.json QueryRoot.productsCount; POST /api/2026-10/graphql.json QueryRoot.node; POST /api/2026-10/graphql.json QueryRoot.predictiveSearch; POST /api/2026-10/graphql.json QueryRoot.product; POST /api/2026-10/graphql.json QueryRoot.productByHandle; POST /api/2026-10/graphql.json QueryRoot.productRecommendations; POST /api/2026-10/graphql.json QueryRoot.productTags; POST /api/2026-10/graphql.json QueryRoot.productTypes; POST /api/2026-10/graphql.json QueryRoot.products; POST /api/2026-10/graphql.json QueryRoot.search; POST /api/2026-10/graphql.json QueryRoot.sitemap
Product media (3)POST /admin/api/2026-10/graphql.json Mutation.productCreateMedia; POST /admin/api/2026-10/graphql.json Mutation.productReorderMedia; POST /admin/api/2026-10/graphql.json Mutation.productUpdateMedia
Product operation (1)POST /admin/api/2026-10/graphql.json QueryRoot.productOperation
Product option (2)POST /admin/api/2026-10/graphql.json Mutation.productOptionUpdate; POST /admin/api/2026-10/graphql.json Mutation.productOptionsCreate
Product variant (6)POST /admin/api/2026-10/graphql.json Mutation.productVariantsBulkCreate; POST /admin/api/2026-10/graphql.json Mutation.productVariantsBulkUpdate; POST /admin/api/2026-10/graphql.json QueryRoot.productVariant; POST /admin/api/2026-10/graphql.json QueryRoot.productVariantByIdentifier; POST /admin/api/2026-10/graphql.json QueryRoot.productVariants; POST /admin/api/2026-10/graphql.json QueryRoot.productVariantsCount
Public API version (1)POST /api/2026-10/graphql.json QueryRoot.publicApiVersions
Publication (1)POST /admin/api/2026-10/graphql.json QueryRoot.publications
Refund (1)POST /admin/api/2026-10/graphql.json QueryRoot.refund
Resource publication (2)POST /admin/api/2026-10/graphql.json Mutation.publishablePublish; POST /admin/api/2026-10/graphql.json Mutation.publishableUnpublish
Return (1)POST /admin/api/2026-10/graphql.json QueryRoot.return
Segment (3)POST /admin/api/2026-10/graphql.json QueryRoot.segment; POST /admin/api/2026-10/graphql.json QueryRoot.segments; POST /admin/api/2026-10/graphql.json QueryRoot.segmentsCount
Shop (3)POST /admin/api/2026-10/graphql.json QueryRoot.shop; POST /api/2026-10/graphql.json QueryRoot.paymentSettings; POST /api/2026-10/graphql.json QueryRoot.shop
URL redirect (2)POST /admin/api/2026-10/graphql.json Mutation.urlRedirectCreate; POST /api/2026-10/graphql.json QueryRoot.urlRedirects

Scenarios

The simulator ships 5 scenarios; the quickstart boots shopify_apparel_storefront.

ScenarioWhat it is forWhat it seedsClock
shopify_apparel_storefront (default)A lived-in apparel store with two locations, three products carrying five variants, three options with five option values and three images, two collections joined by three product rows beside the Home page collection, all three products published to one sales channel, three customers and three orders with their four line items and four payment transactions, alongside the storefront content a shopper sees — one blog with two articles, two pages, a four-item main menu and two redirects — one finished background job and one live cart of two lines carrying a delivery address and a cart metafield.abandoned_checkout 1, article 2, blog 1, bulk_operation 1, bulk_operation_result 1, cart 1, cart_delivery_address 1, cart_line 2, cart_metafield 1, collection 2, collection_product 3, customer 3, fulfillment 2, fulfillment_order 3, inventory_item 5, inventory_level 6, job 1, location 2, media 3, menu 1, menu_item 4, metafield_definition 1, metaobject 2, metaobject_definition 1, order 3, order_line_item 4, order_transaction 4, page 2, product 3, product_operation 1, product_option 3, product_option_value 5, product_variant 5, publication 1, refund 1, resource_publication 3, return 1, segment 1, shop 1, url_redirect 22026-02-02T00:00:00Z
shopify_custom_data_labA store whose shape lives in custom dataabandoned_checkout 1, article 1, blog 1, collection 1, customer 2, location 1, metafield_definition 3, metaobject 3, metaobject_definition 2, page 1, publication 1, resource_publication 1, segment 2, shop 12026-02-02T00:00:00Z
shopify_fresh_storeA store on its first daylocation 1, shop 12026-02-02T00:00:00Z
shopify_fulfillment_deskFive orders standing from boot in five fulfillment states — unfulfilled, partially fulfilled, delivered, returning and scheduled — with the five fulfillment orders, three fulfillments, two returns and two refunds that join them, plus seven line items and seven payment transactions, so you can read existing orders at different stages of shipping.customer 2, fulfillment 3, fulfillment_order 5, location 2, order 5, order_line_item 7, order_transaction 7, refund 2, return 2, shop 12026-02-02T00:00:00Z
shopify_inventory_ledgerOne small catalog spread over three locations — an active hub, an overflow depot and a deactivated store — with seven inventory levels including a zeroed item, two recorded stock adjustments, two product options with four values, three product images, one product update still in flight, one unfinished background job, a bulk export still running and one guest cart of two lines carrying a delivery address and a cart metafield.bulk_operation 1, bulk_operation_result 1, cart 1, cart_delivery_address 1, cart_line 2, cart_metafield 1, inventory_adjustment_group 2, inventory_item 4, inventory_level 7, job 1, location 3, media 3, product 2, product_operation 1, product_option 2, product_option_value 4, product_variant 4, shop 12026-02-02T00:00:00Z

What each scenario holds:

  • shopify_custom_data_lab: Three metafield definitions across three owner types, two metaobject definitions beside the standard one with three metaobjects between them, two saved segments, two customers and one abandoned checkout, plus a journal blog with one article, one published page and one curated collection published to one sales channel.
  • shopify_fresh_store: Shop settings still flagged setup-required and a single primary location holding no inventory, with nothing else but the records every new store holds, so a first write’s effect on an otherwise bare store is unmistakable.

Boot one by name, or load one into a running simulator:

rystic run shopify-twin -d --port 0 --scenario shopify_apparel_storefront
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"shopify_apparel_storefront"}'

What the scenarios do not hold

  • No scenario holds a webhook subscription, a discount, a payment or a theme: Shopify’s operations for them are not served.
  • Without --scenario, the simulator boots a store named Shop 1 holding only what a new Shopify store starts with. That is the three publications (Online Store, Shop and Point of Sale), one primary location, the frontpage collection (“Home page”) and the metaobject definition shopify--knowledge-base-fact. POST /_rystic/reset returns to it.
  • Only shopify_apparel_storefront and shopify_inventory_ledger hold a cart; create one with cartCreate in the others.

Seeding state

Each Shopify resource is one register, named state_<resource> (state_customer, state_product, state_cart_line and so on), holding records keyed by their global id. Seed a customer into the running simulator and read it back on the Admin API:

curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_customer": {"gid://shopify/Customer/9001": {"id": "gid://shopify/Customer/9001", "email": "dev@example.com", "displayName": "Dev Tester"}}}'
curl -s "$ADMIN" -H "X-Shopify-Access-Token: shpat_placeholder" -H "Content-Type: application/json" -d '{"query": "{ customer(id: \"gid://shopify/Customer/9001\") { id email displayName } }"}' | jq -c '.data.customer | {id, email, displayName}'
# -> {"id":"gid://shopify/Customer/9001","email":"dev@example.com","displayName":"Dev Tester"}

A record needs only the fields you read back: one you leave out reads as empty or null.

Scenarios and seeding covers the seed response, refusals and boot files.

Features

Each feature has its own page:

  • Order history window: An Admin token without the read_all_orders grant sees only orders from the last 60 days of the simulator’s clock, as in Shopify; bind grants to a token to widen it.
  • Storefront carts: A shopper’s cart on the Storefront API: create it, add and change lines, set a note, attributes, buyer and discount codes, and read it back by its id.

Determinism and reset

The ids the simulator makes are sequences per resource that restart on reset: the first page you create is gid://shopify/Page/1, the first cart gid://shopify/Cart/c000000000001?key=00000001. Records a scenario seeds keep their own ids.

The clock moves one millisecond for each API request, and a createdAt the simulator stamps is the clock’s time to the second. Moving the clock changes:

Reset and determinism covers reset, the clock and the RNG.

Limitations

Known issues.

  • The simulator does not serve __schema introspection: {__schema{queryType{name}}} returns an undefinedField error where Shopify returns QueryRoot, so GraphQL codegen tools that introspect first fail.
  • Fields the Shopify schema declares non-null can come back null from the simulator and null the whole data. On the Admin API they include Refund.totalRefundedSet and the Order fields currentTotalWeight, statusPageUrl and totalCashRoundingAdjustment. On the Storefront API they include Product.compareAtPriceRange and a variant’s weightUnit.
  • The simulator serves the Admin API at version 2026-10 only, and serves neither POST /admin/api/unstable/graphql.json nor the Admin API’s publicApiVersions and themes queries. So the Shopify CLI’s shopify theme commands fail at their first call, which reads the version list from the unstable endpoint. Workaround: Call the simulator through @shopify/admin-api-client, @shopify/shopify-api or another Admin API client pinned to version 2026-10. Such a client sends each query to the endpoint the simulator serves. Theme reads and theme file uploads have no workaround.
  • extensions.cost on the simulator does not follow Shopify’s cost rules: nested connections add nothing, and requested and actual costs can disagree. locationsCount and segment report a bucket of 1000 restoring 50, where Shopify reports 2000 and 100. customer and inventoryItem carry no cost when they find their record, metafieldDefinition and productOptionUpdate carry none, and the bucket never drains.
  • node(id:) and nodes(ids:) do not resolve every stored type that implements Node: node(id:"gid://shopify/Product/1030") returns null while product(id:) returns the product.
  • A relative date in a query: search, such as created_at:>-7d, returns a BAD_REQUEST error naming the field on Shopify. On the simulator it matches no record on the lists and counts that search dates, and customersCount counts every customer. The lists are orders, products, customers, productVariants, collections and inventoryItems.
  • Some Admin query: searches differ from Shopify. A documented field the simulator does not search, such as fulfillment_status or id, matches every record with an invalid_field warning. A tag prefix (tag:abc*) matches where Shopify matches none. A bare word on orders searches only the order name, and a bare date matches one instant rather than the day. A malformed term on products (an unclosed ( or ", or an empty vendor:) matches nothing where Shopify still returns products. collectionsCount, customersCount and productVariantsCount ignore query: and return no extensions.search warning.
  • urlRedirectCreate accepts a path another redirect already uses, where Shopify returns a CREATE_FAILED user error “Path has already been taken”.
  • Storefront locations lists every location, where Shopify lists only locations that offer in-store pickup.
  • A cart’s lines and a sitemap’s items fail to resolve their concrete type on the Storefront API, where Shopify returns CartLine and SitemapResource values. Selecting Cart.lines nulls the whole cart, so no cart id comes back. Sitemap items return an error.
  • Storefront cart(id:) with a cart’s bare id, without the ?key= suffix, returns data.cart: null from the simulator, where Shopify returns the cart.
  • An asynchronous productSet (synchronous: false) returns its operation COMPLETE at once, with the product filled in. Shopify returns it CREATED with product: null and finishes in the background.
  • A product’s totalInventory and hasOutOfStockVariants keep their create-time values on the simulator: inventorySetQuantities, inventoryAdjustQuantities and inventoryMoveQuantities do not update them, where Shopify follows the variants’ inventory levels.
  • Some product, collection, media and inventory item writes accept unknown ids and invalid input that Shopify refuses, and inventoryItemUpdate creates an unknown item. Catalog input is not normalized: tags are not sorted and deduplicated, description does not follow descriptionHtml and a duplicate handle gets no -1 suffix. The default collection sortOrder is not MOST_RELEVANT, and inventoryItem.sku does not follow the variant’s sku.
  • A Storefront products(query:) date term with a bare year, such as created_at:>2030, matches no product on the simulator, where Shopify ignores the term and returns every product.
  • The simulator ignores the @idempotent directive on inventory writes. A write without it is applied, where Shopify refuses it with a BAD_REQUEST error. A retry with the same key applies the write again, where Shopify returns the first result. On the three quantity writes, a key reused with other input is applied, where Shopify refuses it with IDEMPOTENCY_KEY_PARAMETER_MISMATCH.
  • No write stores a product metafield: productCreate, productUpdate and productSet accept a metafields input and drop it without a user error. A product’s metafield comes back null and its metafields empty. Test metafield logic against a real Shopify store.
  • On the Storefront API, collection, collectionByHandle and collections list a member product that is a draft or not published to the Online Store, which Shopify hides.

Beyond the known issues, 773 of the 905 operations in the published spec are not served, 5 GraphQL types may leave out non-null fields, 27 documented responses have no test yet and 220 behaviors are not modeled. The details follow.

Show all limitations

Not served. 773 of the 905 operations in the published spec are not served.

  • POST /admin/api/2026-10/graphql.json Mutation, 29 of 532 served: POST /admin/api/2026-10/graphql.json Mutation.abandonmentEmailStateUpdate, POST /admin/api/2026-10/graphql.json Mutation.abandonmentUpdateActivitiesDeliveryStatuses, POST /admin/api/2026-10/graphql.json Mutation.analyticsAnnotationCreate, POST /admin/api/2026-10/graphql.json Mutation.analyticsAnnotationDelete, POST /admin/api/2026-10/graphql.json Mutation.analyticsAnnotationUpdate, POST /admin/api/2026-10/graphql.json Mutation.analyticsTargetCreate, POST /admin/api/2026-10/graphql.json Mutation.analyticsTargetUpdate, POST /admin/api/2026-10/graphql.json Mutation.analyticsTargetsDelete, … and 495 more
  • POST /admin/api/2026-10/graphql.json QueryRoot, 57 of 298 served: POST /admin/api/2026-10/graphql.json QueryRoot.abandonment, POST /admin/api/2026-10/graphql.json QueryRoot.abandonmentByAbandonedCheckoutId, POST /admin/api/2026-10/graphql.json QueryRoot.analyticsTargets, POST /admin/api/2026-10/graphql.json QueryRoot.app, POST /admin/api/2026-10/graphql.json QueryRoot.appByHandle, POST /admin/api/2026-10/graphql.json QueryRoot.appByKey, POST /admin/api/2026-10/graphql.json QueryRoot.appDiscountType, POST /admin/api/2026-10/graphql.json QueryRoot.appDiscountTypes, … and 233 more
  • POST /api/2026-10/graphql.json Mutation, 15 of 41 served: POST /api/2026-10/graphql.json Mutation.cartBillingAddressUpdate, POST /api/2026-10/graphql.json Mutation.cartClone, POST /api/2026-10/graphql.json Mutation.cartGiftCardCodesAdd, POST /api/2026-10/graphql.json Mutation.cartGiftCardCodesRemove, POST /api/2026-10/graphql.json Mutation.cartGiftCardCodesUpdate, POST /api/2026-10/graphql.json Mutation.cartPaymentUpdate, POST /api/2026-10/graphql.json Mutation.cartPrepareForCompletion, POST /api/2026-10/graphql.json Mutation.cartRemovePersonalData, … and 18 more
  • POST /api/2026-10/graphql.json QueryRoot, 31 of 34 served: POST /api/2026-10/graphql.json QueryRoot.cartCompletionAttempt, POST /api/2026-10/graphql.json QueryRoot.customer, POST /api/2026-10/graphql.json QueryRoot.localization

Missing non-null fields. Selecting one can fail the whole response with Cannot return null for non-nullable field:

  • Article: contentHtml, metafields
  • Cart: appliedGiftCards, discountAllocations, discountApplications, metafields
  • Page: metafields
  • Product: defaultCursor, descriptionPlainSummary, hasOutOfStockVariants, hasVariantsThatRequiresComponents, inCollection, isGiftCard, legacyResourceId, publicationCount, … and 9 more
  • Publication: hasCollection

Not tested. 275 tests compare the simulator with the real service. 27 documented responses have no test yet:

OperationStatusWhy
POST /admin/api/2026-10/graphql.json Mutation.bulkOperationCancel200no known request reaches it
POST /admin/api/2026-10/graphql.json Mutation.bulkOperationRunQuery200no request a client sends provokes it
POST /admin/api/2026-10/graphql.json Mutation.collectionRemoveProducts200no known request reaches it
POST /admin/api/2026-10/graphql.json Mutation.collectionUpdate200no known request reaches it
POST /admin/api/2026-10/graphql.json QueryRoot.customer200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.customerByIdentifier200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.fulfillment200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.fulfillmentOrder200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.inventoryLevel200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.location200no known request reaches it
POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinition200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinition200no known request reaches it
POST /admin/api/2026-10/graphql.json QueryRoot.metaobject200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectByHandle200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.order200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.orderByIdentifier200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.refund200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.return200nothing the simulator serves creates the record it needs
POST /admin/api/2026-10/graphql.json QueryRoot.segment200nothing the simulator serves creates the record it needs
POST /api/2026-10/graphql.json Mutation.cartCreate200no known request reaches it
… and 5 more

Not modeled. Behavior the simulator does not model, by operation:

  • The blog argument (ArticleBlogInput) creates a new blog alongside the article. (POST /admin/api/2026-10/graphql.json Mutation.articleCreate)
  • A cancel of a CANCELING, FAILED or EXPIRED operation is unrecorded; the refusal is assumed to name that status as it names completed and canceled. (POST /admin/api/2026-10/graphql.json Mutation.bulkOperationCancel)
  • The LIMIT_REACHED refusal and a bulk mutation’s concurrency with a bulk query are documented in prose, but no envelope is recorded for them, so no branch is declared; the concurrency refusal (OPERATION_IN_PROGRESS) and the query-shape refusal (INVALID) are. (POST /admin/api/2026-10/graphql.json Mutation.bulkOperationRunQuery)
  • groupObjects only changes the JSONL output layout of the completed operation’s result file; its effect is not observable in this mutation’s response and is not modeled. (POST /admin/api/2026-10/graphql.json Mutation.bulkOperationRunQuery)
  • No graphql_operation capture or wire_observation accompanies this field, so the exact userErrors message text and the top-level errors envelope (an id that is not a valid gid, a collection or product id that names nothing, a token lacking write_products) are unobserved; the user-error branch’s message is compared by shape only and no top-level errors branch is declared. (POST /admin/api/2026-10/graphql.json Mutation.collectionAddProducts)
  • The field is deprecated in favour of collectionUpdate with inclusion.selectionsToAdd; whether the deprecated field answers a deprecation extension or header is unobserved. (POST /admin/api/2026-10/graphql.json Mutation.collectionAddProducts)
  • Whether adding a product to a collection whose sortOrder is not MANUAL behaves differently is not stated by this field. (POST /admin/api/2026-10/graphql.json Mutation.collectionAddProducts)
  • A request that omits the required title within the collection argument, or that supplies neither collection nor input, is a GraphQL coercion/validation failure answered in the top-level errors array. (POST /admin/api/2026-10/graphql.json Mutation.collectionCreate)
  • The 200 answer carrying a non-empty data.collectionCreate.userErrors with a null collection (an invalid or rejected input, such as a handle already in use) is a distinct outcome at the same transport status. (POST /admin/api/2026-10/graphql.json Mutation.collectionCreate)
  • The precedence between the collection and input arguments is documented (collection wins when both are provided) but unmeasured; no branch distinguishes a request that supplies both. (POST /admin/api/2026-10/graphql.json Mutation.collectionCreate)
  • No executable error branch is declared until a capture grounds the discriminator and example body. (POST /admin/api/2026-10/graphql.json Mutation.collectionRemoveProducts)
  • Whether job is ever null on an accepted request, and how a caller polls the Job to completion (Job.done) before the membership change is visible to collection reads. (POST /admin/api/2026-10/graphql.json Mutation.collectionRemoveProducts)
  • The 250-move cap is documented but its refusal envelope is unobserved; no branch is declared for a moves list above the cap. (POST /admin/api/2026-10/graphql.json Mutation.collectionReorderProducts)
  • A move naming a product that is not a manual member is the measured no-op branch, job null with no userErrors entry. (POST /admin/api/2026-10/graphql.json Mutation.collectionReorderProducts)
  • The asynchronous Job answered when membership is recomputed: no capture shows the job’s shape or how its completion is observed. (POST /admin/api/2026-10/graphql.json Mutation.collectionUpdate)
  • A key reused with different parameters (IDEMPOTENCY_KEY_PARAMETER_MISMATCH) is not modeled here; it rides the shared idempotency declaration. (POST /admin/api/2026-10/graphql.json Mutation.inventoryActivate, POST /admin/api/2026-10/graphql.json Mutation.inventoryAdjustQuantities, POST /admin/api/2026-10/graphql.json Mutation.inventoryMoveQuantities, POST /admin/api/2026-10/graphql.json Mutation.inventorySetQuantities)
  • A request without the @idempotent(key:) directive is refused with a top-level BAD_REQUEST error, data.<field> null, measured 2026-11-03. (POST /admin/api/2026-10/graphql.json Mutation.inventoryActivate, POST /admin/api/2026-10/graphql.json Mutation.inventoryAdjustQuantities, POST /admin/api/2026-10/graphql.json Mutation.inventoryMoveQuantities, POST /admin/api/2026-10/graphql.json Mutation.inventorySetQuantities)
  • The wording and field of the userErrors for an unknown item and an unknown location are unmeasured; whether an item whose tracking is off is refused, and whether either is answered in the GraphQL errors array instead, is unobserved. (POST /admin/api/2026-10/graphql.json Mutation.inventoryActivate)
  • Whether supplying both available and onHand together is accepted, and how the service reconciles them, is not stated by the field description; the example sends neither. (POST /admin/api/2026-10/graphql.json Mutation.inventoryActivate)
  • The error returned when ledgerDocumentUri is missing for a quantity name other than available, or carries a gid://shopify/* value, is documented but has no captured envelope. (POST /admin/api/2026-10/graphql.json Mutation.inventoryAdjustQuantities)
  • … and 200 more

By design.

  • One store per process. One simulator holds one store whatever shop name a request carries; run one per port for isolated tests in parallel.
  • No Shopify side effects. A mutation changes the simulator’s own state and nothing else: it sends no email, payment, webhook or app callback.

Release notes: Changelog

Last updated on