# Shopify simulator

A local Shopify store for integration tests: the Admin and Storefront GraphQL APIs, catalog, customers, orders, fulfillment, inventory, content and carts.

`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](#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

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs shopify; do not edit) -->

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](https://www.rystic.ai/account/api-keys).

```bash
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](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

### 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:

```bash
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:

```js
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:

```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:

```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.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs shopify; do not edit) -->

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 | — |

<!-- END generated: known-clients -->

## 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](https://www.rystic.ai/docs/simulators/shopify/features/order-window.md)).

## 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](#limitations)).

[Network profiles](https://www.rystic.ai/docs/network-profiles.md) 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](#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](#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.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs shopify; do not edit) -->

<!-- coverage-claim -->
2 routes, counted from the simulator's route table.

| 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` |

<!-- END generated: served-operations -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs shopify; do not edit) -->

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:

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

<!-- END generated: scenarios -->

### 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:

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md) covers the seed response, refusals and boot files.

## Features

<!-- BEGIN generated: features (go run ./ingest/cmd/twindocs shopify; do not edit) -->

Each feature has its own page:

- [Order history window](https://www.rystic.ai/docs/simulators/shopify/features/order-window.md): 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](https://www.rystic.ai/docs/simulators/shopify/features/storefront-carts.md): 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.

<!-- END generated: features -->

## 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](https://www.rystic.ai/docs/simulators/shopify/features/order-window.md)).

[Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs shopify; do not edit) -->

**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.

<!-- gaps-claim -->
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.

**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:

| 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 `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

<!-- END generated: known-gaps -->

**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](https://www.rystic.ai/docs/simulators/shopify/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/shopify · Markdown: https://www.rystic.ai/docs/simulators/shopify.md
