Skip to Content
SimulatorsShopify Storefront

Simulators

Shopify Storefront simulator

shopify-storefront-twin is a local, stateful simulator of the Shopify Storefront API, version 2026-10, as a buyer who is not signed in uses it — the shop, its products, variants and collections, search, predictive search and recommendations, pages, blogs, articles, menus and metaobjects, and the cart with its fifteen mutations — built from the API’s published introspection, as Shopify’s Hydrogen package vendors it.

Quickstart

You need an API key from Account → API keys  (how).

curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                                                                # your API key
rystic run shopify-storefront-twin -d --port 0 --scenario shopify-storefront_apparel_catalog   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version

Expect "service_hosts":["myshopify.com"]. The examples below use localhost:8080; use the port rystic run printed. On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).

List the products, then create a cart and read it back:

SF=(localhost:8080/api/2026-10/graphql.json -H 'X-Shopify-Storefront-Access-Token: placeholder' -H 'Content-Type: application/json')
curl -s "${SF[@]}" -d '{"query":"{ products(first: 5) { nodes { handle title } } }"}'
# -> {"data":{"products":{"nodes":[{"handle":"classic-tee","title":"Classic Tee"},…]}},…}

curl -s "${SF[@]}" -d '{"query":"mutation { cartCreate(input: {lines: [{merchandiseId: \"gid://shopify/ProductVariant/9002\", quantity: 2}]}) { cart { id totalQuantity } userErrors { code message } } }"}'
# -> {"data":{"cartCreate":{"cart":{"id":"gid://shopify/Cart/8ef910ca90bf4c9d8000000000000001?key=7c0a4019","totalQuantity":2},"userErrors":[]}},…}
curl -s "${SF[@]}" -d '{"query":"{ cart(id: \"gid://shopify/Cart/8ef910ca90bf4c9d8000000000000001?key=7c0a4019\") { totalQuantity } }"}'
# -> {"data":{"cart":{"totalQuantity":2}},…}

Point your client at it

Shopify’s clients build every URL on https://<your-store>.myshopify.com. A request left unpointed goes to your real store.

@shopify/storefront-api-client always builds an https URL from storeDomain, even when you pass http://localhost:8080, so hand it a fetch that rewrites the store’s origin to the twin’s and refuses anything else:

import { createStorefrontApiClient } from "@shopify/storefront-api-client";
const twin = "http://localhost:8080";
const client = createStorefrontApiClient({
  storeDomain: "your-store.myshopify.com",
  apiVersion: "2026-10",
  publicAccessToken: "placeholder",
  customFetchApi: (url, init) => {
    const target = String(url).replace(/^https:\/\/your-store\.myshopify\.com/, twin);
    if (!target.startsWith(twin + "/")) throw new Error(`refusing ${target}`);
    return fetch(target, init);
  },
});
const { data } = await client.request(`{ products(first: 5) { nodes { handle } } }`);

Any other GraphQL client works the same way: POST JSON to http://localhost:8080/api/2026-10/graphql.json with the token header.

Plans

shopify-storefront-twin is on Pro and Enterprise, not Free (plans); on a Free key, rystic pull shopify-storefront-twin and a start are refused with product_not_in_plan. Both plans serve the whole Storefront surface below, the whole control plane, custom latency and fault injection included, and every world. Without --scenario the twin boots an empty store — the shop Rystic Storefront, its localization, API versions and the discount code WELCOME10, with no products — and POST /_rystic/reset wipes it back to that.

Auth

Send the storefront’s public access token in the X-Shopify-Storefront-Access-Token header. The twin also accepts a delegate token in Shopify-Storefront-Private-Token in its place, as the measured store answered the shop and cart reads. It accepts any token value in either header and checks no token shape, scope or expiry.

The twin models a store whose password page is on, as the store it is measured against is. A request carrying neither header answers 400 {"errors":[{"message":"Online Store channel is locked.","extensions":{"code":"BAD_REQUEST"}}]} on every query and mutation, before any field resolves, as a password-protected Shopify store answers it. A live store with no password page serves some reads without a token; the twin does not model that store.

Coverage

1 route across 19 resources, counted from the sim’s literal registrations.

GraphQL fields answer at POST /api/2026-10/graphql.json.

ResourceOperations
API version (1)QueryRoot.publicApiVersions
Article (2)QueryRoot.article; QueryRoot.articles
Blog (3)QueryRoot.blog; QueryRoot.blogByHandle; QueryRoot.blogs
Cart (6)Mutation.cartAttributesUpdate; Mutation.cartBuyerIdentityUpdate; Mutation.cartCreate; Mutation.cartDiscountCodesUpdate; Mutation.cartNoteUpdate; QueryRoot.cart
Cart delivery address (4)Mutation.cartDeliveryAddressesAdd; Mutation.cartDeliveryAddressesRemove; Mutation.cartDeliveryAddressesReplace; Mutation.cartDeliveryAddressesUpdate
Cart delivery group (1)Mutation.cartSelectedDeliveryOptionsUpdate
Cart line (3)Mutation.cartLinesAdd; Mutation.cartLinesRemove; Mutation.cartLinesUpdate
Cart metafield (2)Mutation.cartMetafieldDelete; Mutation.cartMetafieldsSet
Collection (4)QueryRoot.collection; QueryRoot.collectionByHandle; QueryRoot.collections; QueryRoot.nodes
Localization (1)QueryRoot.localization
Location (2)Location.address; QueryRoot.locations
Menu (1)QueryRoot.menu
Metaobject (2)QueryRoot.metaobject; QueryRoot.metaobjects
Page (3)QueryRoot.page; QueryRoot.pageByHandle; QueryRoot.pages
Product (15)Product.compareAtPriceRange; Product.options; Product.priceRange; Product.selectedOrFirstAvailableVariant; Product.variantBySelectedOptions; QueryRoot.node; QueryRoot.predictiveSearch; QueryRoot.product; QueryRoot.productByHandle; QueryRoot.productRecommendations; QueryRoot.productTags; QueryRoot.productTypes; QueryRoot.products; QueryRoot.search; QueryRoot.sitemap
Product variant (1)ProductVariant.storeAvailability
Shop (2)QueryRoot.paymentSettings; QueryRoot.shop
URL redirect (1)QueryRoot.urlRedirects

The catalog: product by id or handle, productByHandle and products, each with its variants and its options with their values, and only product(id:|handle:) with its price ranges in every case, which other roots can answer null, nulling the nearest field above them that may be null, and with variantBySelectedOptions, selectedOrFirstAvailableVariant and each option’s optionValues, which every other root that answers a product answers null and []; collection, collectionByHandle and collections with their products in the store’s order; products(query:) by tag:; search, predictiveSearch, productRecommendations and productTags. Content: pages, blogs and articles, menus with their nested items, metaobjects by type, URL redirects, and the shop, its payment settings, localization and API versions. Every connection pages by first, last, after and before with Shopify’s cursors, and an unknown id or handle answers the field null. The cart: its fifteen mutations, cartCreate among them, each payload answering the stored cart exactly as cart(id:) reads it, with userErrors and warnings in Shopify’s shapes and lines newest first. A code the store holds applies its percentage on every later write; an unknown code stays applicable: false and repeats its DISCOUNT_NOT_FOUND warning. A selected delivery address and a line give the cart a delivery group with Standard (8.00) and Express (15.00) options, Standard selected, and cost.totalAmount carries the discount and the selected option while checkoutChargeAmount carries neither. The twin ends at the cart’s checkoutUrl, which serves nothing.

Measured against a live Shopify store: the full-suite round of 2026-10-23 (stamp 20261023T085537Z) matched 93 of the suite’s 96 probes, one of them inconclusive, against a target of 80%.

Control plane

Every route under /_rystic/ answers on both plans; routes by plan lists them.

Scenarios

Each world is pinned at 2026-02-02T00:00:00Z and holds the discount code WELCOME10.

  • shopify-storefront_apparel_catalog: A stocked storefront — four published products with eight variants, two collections, a nested main menu, two pages, a journal with two articles, a lookbook metaobject, two redirects and two locations — so catalogue, content and navigation reads all return real pages from boot.
  • shopify-storefront_checkout_cart: A cart one step from payment — four units across three lines, a buyer identity, two saved delivery addresses (one selected), a delivery group with a chosen shipping option and two cart metafields — so cart reads and line-level updates have a populated cart without a setup write.
  • shopify-storefront_fresh_store: A storefront configured but empty — shop settings, the supported API version, a localization and a single location, plus one brand-new cart holding nothing at all — so every catalogue and cart-line listing returns zero rows and a first write’s effect is unmistakable.
  • shopify-storefront_sold_out_shelf: A shelf standing in every stock state from boot — a hoodie with in-stock, two-left, oversell-enabled and fully sold-out variants, a tee withdrawn from sale, and a pre-order mug with zero on hand — so availability-guarded reads and adds have live targets without inventory writes.

Seeding state

POST /_rystic/seed takes a register map — state_product and state_product_variant (keyed by gid; a variant names its productId), state_collection (its productIds list its members), state_discount_code (keyed by code, with a percentage), state_cart and its lines, addresses, delivery groups and metafields, and the content registers GET /_rystic/registers lists — and merges it into the running store; Worlds covers seed files, GET /_rystic/registers and the seed gate. A product a cart can hold is a state_product and a state_product_variant naming it:

curl -X POST localhost:8080/_rystic/seed -H 'Content-Type: application/json' -d '{
  "state_product": {"gid://shopify/Product/1": {"id": "gid://shopify/Product/1", "handle": "test-mug",
    "title": "Test Mug", "availableForSale": true}},
  "state_product_variant": {"gid://shopify/ProductVariant/11": {"id": "gid://shopify/ProductVariant/11",
    "productId": "gid://shopify/Product/1", "title": "Default Title", "availableForSale": true,
    "quantityAvailable": 5, "price": {"amount": "12.00", "currencyCode": "USD"}}}
}'
# -> 204; product(handle: "test-mug") then answers it

Determinism & reset

Cart, line, delivery-group and delivery-option ids are sequences the reset rewinds, and a cart’s ?key= comes from the same seeded sequence, so the first cart after a reset has the same id every time. The clock starts at a pinned instant — 2026-01-01T00:00:00Z empty, 2026-02-02T00:00:00Z in every world — and stands still until POST /_rystic/clock sets or advances it. Two identical runs from the same reset give byte-identical answers. Reset and determinism covers reset, the clock and the RNG.

Egress

The Storefront API declares no outbound effects: no request sends an email or calls a webhook, and GET /_rystic/egress is always empty.

Limitations

  • Every answer carries extensions.cost.requestedQueryCost, always 1; Shopify computes it from the query. The twin enforces no cost or rate limit: never a THROTTLED answer or a 429.
  • A request body holds up to 1 MiB (blob_cap_bytes on /_rystic/version).
  • sitemap is served but answers no items.

Left out on purpose: cart completion and payment, gift cards, Shop Pay payment requests, the legacy customer operations and Multipass, cartClone, cartRemovePersonalData and cartBillingAddressUpdate, and the Customer Account API. @defer is ignored.

Known issues.

  • A cart mutation on a cart id that names no cart answers cart: null with no userErrors, where Shopify answers a CartUserError INVALID (“The specified cart does not exist”); only cartLinesAdd does
  • The twin accepts a query deeper than 17 and an id that is not a gid://, answers a wrongly typed variable with an error lacking extensions {value, problems}, and answers request errors with its own status and wording, where Shopify answers MAX_QUERY_DEPTH_EXCEEDED, refuses the id, and answers an empty body 400, malformed JSON 415 and a body with no query 200; treat any errors entry as the refusal, whatever its status or wording
  • Three of the seeded tee’s six variants answer no sku, and twelve of the sixteen seeded variants answer quantityAvailable: null, where Shopify answers both; and four products answer availableForSale: false while each of their variants answers true, where Shopify derives a product’s availableForSale from its variants
  • A cart mutation on a cart id without its ?key= applies the change and answers no error, where Shopify applies it and also answers a CartUserError INVALID; a mutation with the wrong key is not yet measured
  • cartMetafieldsSet answers success for an ownerId that names no cart, where Shopify answers INVALID_OWNER with metafields: null, and for a one-character key, where Shopify answers TOO_SHORT
  • A cart line write ignores Shopify’s stock rules: the same variant twice on cartCreate gives two lines, cartLinesUpdate above stock is not capped with MERCHANDISE_NOT_ENOUGH_STOCK, and a sold-out variant adds a 0-quantity line; an oversell or pre-order variant can’t be added, which is not yet measured on Shopify
  • The deprecated priceV2, compareAtPriceV2, weightUnit, Cart.estimatedCost and CartLine.estimatedCost answer null, which nulls the product or cart, where Shopify answers a value for each
  • sitemap(type: PRODUCT) answers pagesCount: null and no items, where Shopify answers a count of 1 and every product; the other sitemap types are not yet measured
  • blog.articleByHandle, metaobject.field(key:), cart.attribute(key:) and cart.metafields(identifiers:) answer null or empty, cart.metafield(namespace, key) ignores the key, and a cart metafield’s createdAt and updatedAt answer null, nulling it, where Shopify answers by the arguments
  • List arguments are partly ignored or misread: query: on articles, collections and pages, price-range terms on products, sortKey and reverse on collection.products, and near on locations(sortKey: DISTANCE), which Shopify documents as ordering by distance (not yet measured), are ignored; OR and NOT on products and search, parentheses and a wildcard on products, AND, -, quoted phrases and a price term on search match the wrong records or none; a connection with neither first nor last, first above 250 on any connection but products, and a predictiveSearch limit above 10 answer data, where Shopify answers an error; send plain terms only, one query per alternative, then filter and sort the returned list in your agent
  • Delivery options are fixed at Standard 8.00 and Express 15.00 whatever the cart holds, where Shopify’s rates come from the shop’s delivery profile and can be free
  • A GraphQL object type can render differently depending on the field that returns it, and nothing checks the twin’s answers against the schema, so a null in a non-null field or an enum value outside its type can reach your agent
  • The twin answers GraphQL only as a POST of JSON at the one declared endpoint: GET ?query=, application/graphql and form bodies, other API versions and the unversioned path answer 400 or 404, where Shopify answers 200
  • productTypes answers a fixed list of six types unioned with the types of the store’s products, whatever the store holds (an empty store still answers the six), where Shopify lists only the types of the store’s products
  • cartLinesRemove with a line id the cart does not hold answers HTTP 501 with X-Rystic-Marker: unmodeled_guard and a body that does not parse (a runtime defect), and cartLinesUpdate with one answers success with no error, where Shopify answers the payload with a CartUserError (INVALID_MERCHANDISE_LINE on cartLinesRemove); send only line ids cart.lines lists
  • A cart’s delivery group answers cartLines: [] and a deliveryAddress holding only city and zip, and a CartDeliveryAddress answers formatted: [] with formattedArea and name null, where Shopify answers the group’s lines and the full address; read lines from cart.lines and address fields from cart.delivery.addresses
  • search’s productFilters answers a fixed availability and price filter set whatever filters the store configures, where Shopify answers the store’s own filters (option, type, vendor), and a price filter in the productFilters input can treat a product as priced 0; filter by price from each product’s priceRange instead
  • node answers null for a collection or a page id, which nodes(ids:) resolves on Shopify, and node and nodes both answer null for a location id, though a location is a Node in Shopify’s schema; nodes(ids:) resolves collections and pages, collection(id:) and page(id:) read them directly, and locations lists the store’s locations
  • The cart’s line merge, stock cap, discount codes, delivery groups and totals match Shopify only where the cart scenarios compared against Shopify reach them; a combination outside those scenarios is unchecked
  • On a cart with a code discount, selecting targetType, sourceDiscountApplication or discountApplication on a CartCodeDiscountAllocation answers cart: null with a non-null error, where Shopify answers the cart; select code and discountedAmount instead
  • cartLinesUpdate takes a negative quantity (the line holds it and the cart’s totals drop) and cartLinesAdd takes one without an error (subtracting it from a line the cart holds for that item, removing the line at 0 or below), where Shopify answers a CartUserError INVALID and leaves the cart unchanged; send quantities of 0 or more
  • In a world whose localization countries carry no market (every shipped world but apparel_catalog and fresh_store), a Country’s market under localization answers an empty id and handle, which names no market; read the serving market from localization.market
  • In a world whose location record stores no country name, a pickup Location’s address.country answers null while countryCode and formatted name the country; read address.countryCode or address.formatted

Not served. 28 of the 75 operations in the published spec are not served.

  • Mutation, 15 of 41 served: Mutation.cartBillingAddressUpdate, Mutation.cartClone, Mutation.cartGiftCardCodesAdd, Mutation.cartGiftCardCodesRemove, Mutation.cartGiftCardCodesUpdate, Mutation.cartPaymentUpdate, Mutation.cartPrepareForCompletion, Mutation.cartRemovePersonalData, … and 18 more
  • QueryRoot, 32 of 34 served: QueryRoot.cartCompletionAttempt, QueryRoot.customer

Not measured. The fidelity suite runs 157 probes, and 6 documented branches have none. 1 needs a credential the measuring account does not hold (401 ×1). The other 5:

OperationStatusWhy
QueryRoot.cart200the model describes no input that reaches it
QueryRoot.pageByHandle200the model describes no input that reaches it
QueryRoot.product200the model describes no input that reaches it
QueryRoot.products200the model describes no input that reaches it
QueryRoot.shop200the model describes no input that reaches it

Not modeled. What the model’s operations state they leave out:

  • address1, province, zip, phone, latitude and longitude on the oracle store: its one location holds no street address, so only countryCode, formatted and the null city and provinceCode are measured; the shipped worlds’ values are answered as their records hold them. (Location.address)
  • The answer to a cartId naming no cart (Shopify reports cart-level failures as userErrors inside a 200 payload, with data.cartAttributesUpdate.cart null): no capture or wire observation of that envelope is in the bundle, so no error branch with a body discriminator can be grounded. Needs a recorded unknown-cart mutation response. (Mutation.cartAttributesUpdate)
  • The answer to more than 250 attributes, which the field description forbids: no recorded error envelope, so the rejection’s code, message and field path are unknown. (Mutation.cartAttributesUpdate)
  • Whether attributes are replaced wholesale or merged with existing cart attributes is not stated by the documentation in the bundle. (Mutation.cartAttributesUpdate)
  • extensions.cost.requestedQueryCost on this mutation (the capture shows 14) is a cost-analysis value the model declares service-wide; it is ignored by this branch’s field policies rather than claimed. (Mutation.cartAttributesUpdate)
  • Invalid buyerIdentity values (a countryCode outside the CountryCode enum, a malformed email or phone, an unrecognised customerAccessToken, a companyLocationId the buyer may not use): documented as possible by the input type, but no recorded userErrors payload is in the bundle, so the userErrors-populated outcome is unmodeled. (Mutation.cartBuyerIdentityUpdate)
  • The 250-value limits documented on deliveryAddressPreferences, preferences.delivery.deliveryMethod, preferences.delivery.pickupHandle and preferences.wallet have no recorded rejection, so no threshold guard or violating example is declared. (Mutation.cartBuyerIdentityUpdate)
  • Unknown or malformed cartId: no capture or wire observation in the bundle shows how the service answers (CartUserError envelope with cart null, or a top-level errors entry), so no error branch, discriminator or existence guard is declared. Needs a recorded failing request before the branch can be modelled. (Mutation.cartBuyerIdentityUpdate)
  • deliveryAddressPreferences (deprecated since 2025-01), and preferences.delivery.coordinates and pickupHandle values: typed in arguments_shape, but only deliveryMethod and wallet were measured (asm_cart_buyer_preferences, 2026-10-24); what coordinates or a pickupHandle do to the cart’s delivery groups and to checkout prefill is unmodeled. (Mutation.cartBuyerIdentityUpdate)
  • The upper bound on the number of lines/attributes/metafields/addresses (250 per input field, per CartInput) and the error the service answers above it are documented but not measured. (Mutation.cartCreate)
  • buyerIdentity.countryCode effects on international pricing, and the @inContext directive: ExtensionsIncontextCart sends @inContext(country: CA) and the answer carries extensions.context country US and buyerIdentity.countryCode US, so the directive’s effect on this shop is unresolved. (Mutation.cartCreate)
  • input.discountCodes / input.giftCardCodes acceptance and the applicable flag, input.sourceName channel attribution, buyerIdentity.customerAccessToken and companyLocationId: no capture exercises them. (Mutation.cartCreate)
  • quantity 0 on a line: the cart is created with totalQuantity 0 and no line, no userError and no warning (CartWarningsQuantityZero). It is a value variant of br_200_created with no body discriminator telling it apart, so it is not sealed as its own branch. (Mutation.cartCreate)
  • Cart address cap: ‘A cart can have up to 20 delivery addresses.’ No observation shows the refusal of the 21st address. (Mutation.cartDeliveryAddressesAdd)
  • Input list cap: ‘The input must not contain more than 250 values.’ No observation shows the refusal envelope. (Mutation.cartDeliveryAddressesAdd)
  • Unknown or malformed cartId: the field documents a cart lookup but no capture or wire observation in the bundle shows the resulting envelope (a CartUserError code in userErrors, or a top-level errors entry), so no executable error branch and no existence guard are declared. Needs a recorded failing operation before an error branch with x_body_discriminator and x_status_equivalent can be sealed. (Mutation.cartDeliveryAddressesAdd)
  • copyFromCustomerAddressId alternative of CartAddressInput: no in-scope operation creates a customer address and no capture exercises it. (Mutation.cartDeliveryAddressesAdd)
  • validationStrategy STRICT: documented to reject an invalid address and leave the cart unchanged; no observation of the rejection payload is in the bundle. (Mutation.cartDeliveryAddressesAdd)
  • The addressIds argument documents a 250-value cap; the service’s answer to a list above that cap (a GraphQL errors envelope or a userError) is not observed in the bundle, so no refusal branch is declared. (Mutation.cartDeliveryAddressesRemove)
  • The payload declares a userErrors list ([CartUserError!]!), but the bundle holds no recorded envelope for any failing case (unknown cartId, an addressId naming no address of that cart, a cart key that does not gate the delivery addresses). No executable error branch is declared until a capture or boundary observation grounds its discriminator pointer, code value and body. (Mutation.cartDeliveryAddressesRemove)
  • … and 151 more

Release notes: Changelog

Last updated on