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.
| Client | Version | Kind | Works with | Fails with | Checked | Known gap |
|---|---|---|---|---|---|---|
@shopify/admin-api-client (npm) | v2.0.1 | sdk | proxy | — | 2026-11-06 | — |
shopify_api (RubyGems) | v16.3.1 | sdk | proxy | — | 2026-11-06 | — |
@shopify/cli (npm) | v5.0.0 | cli | — | proxy | 2026-11-06 | 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. |
shopify-mcp (npm) | v1.0.8 | mcp-server | proxy | — | 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:
| Error | Status | Each errors entry carries | data |
|---|---|---|---|
| a body that is not JSON, a form body included | 400 | message (Bad Request) only | absent |
| a syntax error | 200 | message, locations | absent |
| a field the schema doesn’t have | 200 | message, locations, path, extensions.code undefinedField | absent |
| a wrongly typed variable | 200 | message, locations and extensions with value and problems; on the Admin API also extensions.code INVALID_VARIABLE | absent |
| a cursor the simulator didn’t issue (unconfirmed) | 200 | message, locations, path | null |
first or last above a connection’s cap (250 on most), or a connection given neither | 200 | message, locations, path; on the Admin API also extensions.code BAD_REQUEST | null up to the nearest nullable parent |
a global id of another type, such as an order id passed to product(id:) | 200 | message, 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 error | that field null, the others kept |
| a global id that does not parse, written into the query | 200 | message (Invalid global id '<id>'), locations, path, extensions.code argumentLiteralsIncompatible | absent |
| a null in a field the schema declares non-null | 200 | message, locations, path | null 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 changeFromQuantity | 200 | message, locations, path, extensions.code INVALID_FIELD_ARGUMENTS | that 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:
userErrors | Mutations |
|---|---|
field, message and code | every 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 only | bulkOperationCancel, 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
| Resource | Operations |
|---|---|
| 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.
| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
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 2 | 2026-02-02T00:00:00Z |
shopify_custom_data_lab | A store whose shape lives in custom data | abandoned_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 1 | 2026-02-02T00:00:00Z |
shopify_fresh_store | A store on its first day | location 1, shop 1 | 2026-02-02T00:00:00Z |
shopify_fulfillment_desk | Five 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 1 | 2026-02-02T00:00:00Z |
shopify_inventory_ledger | One 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 1 | 2026-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 namedShop 1holding only what a new Shopify store starts with. That is the three publications (Online Store, Shop and Point of Sale), one primary location, thefrontpagecollection (“Home page”) and the metaobject definitionshopify--knowledge-base-fact.POST /_rystic/resetreturns to it. - Only
shopify_apparel_storefrontandshopify_inventory_ledgerhold a cart; create one withcartCreatein 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:
- which orders a token can read, as an order ages out of its 60-day window (Order history window).
Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
- The simulator does not serve
__schemaintrospection:{__schema{queryType{name}}}returns anundefinedFielderror where Shopify returnsQueryRoot, 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 includeRefund.totalRefundedSetand the Order fieldscurrentTotalWeight,statusPageUrlandtotalCashRoundingAdjustment. On the Storefront API they includeProduct.compareAtPriceRangeand a variant’sweightUnit. - The simulator serves the Admin API at version 2026-10 only, and serves neither
POST /admin/api/unstable/graphql.jsonnor the Admin API’spublicApiVersionsandthemesqueries. So the Shopify CLI’sshopify themecommands 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.coston the simulator does not follow Shopify’s cost rules: nested connections add nothing, and requested and actual costs can disagree.locationsCountandsegmentreport a bucket of 1000 restoring 50, where Shopify reports 2000 and 100.customerandinventoryItemcarry no cost when they find their record,metafieldDefinitionandproductOptionUpdatecarry none, and the bucket never drains.node(id:)andnodes(ids:)do not resolve every stored type that implementsNode:node(id:"gid://shopify/Product/1030")returns null whileproduct(id:)returns the product.- A relative date in a
query:search, such ascreated_at:>-7d, returns aBAD_REQUESTerror naming the field on Shopify. On the simulator it matches no record on the lists and counts that search dates, andcustomersCountcounts every customer. The lists areorders,products,customers,productVariants,collectionsandinventoryItems. - Some Admin
query:searches differ from Shopify. A documented field the simulator does not search, such asfulfillment_statusorid, matches every record with aninvalid_fieldwarning. A tag prefix (tag:abc*) matches where Shopify matches none. A bare word onorderssearches only the order name, and a bare date matches one instant rather than the day. A malformed term onproducts(an unclosed(or", or an emptyvendor:) matches nothing where Shopify still returns products.collectionsCount,customersCountandproductVariantsCountignorequery:and return noextensions.searchwarning. urlRedirectCreateaccepts a path another redirect already uses, where Shopify returns aCREATE_FAILEDuser error “Path has already been taken”.- Storefront
locationslists every location, where Shopify lists only locations that offer in-store pickup. - A cart’s
linesand a sitemap’sitemsfail to resolve their concrete type on the Storefront API, where Shopify returnsCartLineandSitemapResourcevalues. SelectingCart.linesnulls the whole cart, so no cart id comes back. Sitemapitemsreturn an error. - Storefront
cart(id:)with a cart’s bare id, without the?key=suffix, returnsdata.cart: nullfrom the simulator, where Shopify returns the cart. - An asynchronous
productSet(synchronous: false) returns its operationCOMPLETEat once, with the product filled in. Shopify returns itCREATEDwithproduct: nulland finishes in the background. - A product’s
totalInventoryandhasOutOfStockVariantskeep their create-time values on the simulator:inventorySetQuantities,inventoryAdjustQuantitiesandinventoryMoveQuantitiesdo 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
inventoryItemUpdatecreates an unknown item. Catalog input is not normalized: tags are not sorted and deduplicated,descriptiondoes not followdescriptionHtmland a duplicate handle gets no-1suffix. The default collectionsortOrderis notMOST_RELEVANT, andinventoryItem.skudoes not follow the variant’ssku. - A Storefront
products(query:)date term with a bare year, such ascreated_at:>2030, matches no product on the simulator, where Shopify ignores the term and returns every product. - The simulator ignores the
@idempotentdirective on inventory writes. A write without it is applied, where Shopify refuses it with aBAD_REQUESTerror. 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 withIDEMPOTENCY_KEY_PARAMETER_MISMATCH. - No write stores a product metafield:
productCreate,productUpdateandproductSetaccept ametafieldsinput and drop it without a user error. A product’smetafieldcomes back null and itsmetafieldsempty. Test metafield logic against a real Shopify store. - On the Storefront API,
collection,collectionByHandleandcollectionslist 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,metafieldsCart:appliedGiftCards,discountAllocations,discountApplications,metafieldsPage:metafieldsProduct:defaultCursor,descriptionPlainSummary,hasOutOfStockVariants,hasVariantsThatRequiresComponents,inCollection,isGiftCard,legacyResourceId,publicationCount, … and 9 morePublication:hasCollection
Not tested. 275 tests compare the simulator with the real service. 27 documented responses have no test yet:
| Operation | Status | Why |
|---|---|---|
POST /admin/api/2026-10/graphql.json Mutation.bulkOperationCancel | 200 | no known request reaches it |
POST /admin/api/2026-10/graphql.json Mutation.bulkOperationRunQuery | 200 | no request a client sends provokes it |
POST /admin/api/2026-10/graphql.json Mutation.collectionRemoveProducts | 200 | no known request reaches it |
POST /admin/api/2026-10/graphql.json Mutation.collectionUpdate | 200 | no known request reaches it |
POST /admin/api/2026-10/graphql.json QueryRoot.customer | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.customerByIdentifier | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.fulfillment | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.fulfillmentOrder | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.inventoryLevel | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.location | 200 | no known request reaches it |
POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinition | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.metafieldDefinition | 200 | no known request reaches it |
POST /admin/api/2026-10/graphql.json QueryRoot.metaobject | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.metaobjectByHandle | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.order | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.orderByIdentifier | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.refund | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.return | 200 | nothing the simulator serves creates the record it needs |
POST /admin/api/2026-10/graphql.json QueryRoot.segment | 200 | nothing the simulator serves creates the record it needs |
POST /api/2026-10/graphql.json Mutation.cartCreate | 200 | no 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
titlewithin thecollectionargument, or that supplies neithercollectionnorinput, is a GraphQL coercion/validation failure answered in the top-levelerrorsarray. (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
collectionandinputarguments is documented (collectionwins 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