Skip to main content
A Shopify store over the API an ecommerce agent actually calls: products with option-bound variants and decimal-string prices, inventory as the four quantity names across two locations, orders whose financial and fulfillment statuses are derived rather than stored, fulfillments that leave an order half-shipped, refunds that restock or do not, and returns that are requested before they are approved. A Partners development store is free, so the usual objection gets raised here more than anywhere. Here is what one cannot do: it is shared mutable state, its cost-based rate limiter cannot be driven to a chosen point on purpose, and no test can put an order into “one line item shipped and the other open, a refund past the window, one variant oversold to minus three” and find it there tomorrow. Slug: shopify

The calculated-cost bucket is state you can set

Shopify meters the GraphQL Admin API by calculated query cost rather than by request count, and every answer carries extensions.cost with a throttleStatus a well-written client paces itself off. A twin that omitted that block would break every such client; one that answered 429 instead of HTTP 200 with a THROTTLED entry would send every Shopify backoff down the wrong branch. So the bucket is container state. drain_cost_bucket puts it where you want it, and advancing the sandbox restores it a step at a time — which is the only way backoff code runs on the first query instead of never. The cost model is not invented. Shopify publishes the shape of the rule and not its sizing function, so the function was measured against Shopify’s own endpoint at every boundary. It reproduces Shopify’s numbers exactly, including the 1729 that Shopify’s own MAX_COST_EXCEEDED message quotes for the deepest legal query.

Three error channels, each carrying what Shopify puts in it

The Admin API has three, they carry different failures, and callers routinely check one:
  1. A 401 with a string-valued {"errors": …} for authentication. Verified against a live shop, where a missing token and an unrecognised one turn out to be the same answer — and where authentication happens before the version in the path is looked at.
  2. An errors[] array at HTTP 200 for everything the GraphQL layer refuses: a field the schema lacks, a malformed cursor, a connection argument out of range, a global id of the wrong type, a query over the cost limit, a missing access scope.
  3. userErrors[] inside a 200 with no top-level errors for a business-rule rejection: a handle that collides, a refund over the maximum, a fulfillment for a line item already shipped.
Each refusal is in the channel Shopify uses for it, and nothing is promoted from one to another.

The schema is a pinned artefact

ShopifyAPI, Shopify’s own Python client, is a thin transport: it returns the raw response body as a string and parses nothing, so it can tell a caller nothing about shape. A twin conformed only against it could answer with any field name at all and pass. So Shopify’s Admin GraphQL schema is pinned beside the client, the twin’s modelled surface is written as SDL, and a test walks all 245 types — every field, every argument, every nullability, every argument default, every enum value — against Shopify’s own. Narrowing is incompleteness and says so through the not-modelled refusal; widening is invention and fails the build.

What it models

Advancing the sandbox steps two things and no more, because a Shopify store has no other lifecycle: the cost bucket restores, and a bulk operation moves along CREATEDRUNNINGCOMPLETED with its result URL fetchable only at the end. Orders do not self-fulfil and drafts do not expire.

Scenarios

stocked-store, partially-fulfilled, refund-window-closed, out-of-stock, throttled, narrow-scopes.

Not modelled

The REST Admin API, refused whole rather than half-built — a layer that worked for two paths and 404’d on the third would be worse than none. The OAuth install flow, app billing, session tokens and App Bridge. Webhook delivery: subscriptions are recorded and nothing is sent. The Storefront and Customer Account APIs, Checkout extensibility and Shopify Payments. Collections, markets, B2B companies, subscription contracts, gift cards, shipping profiles, carrier services, translations and Shopify Functions. Order editing, multi-location fulfillment routing, and productSet against a product that already exists. Introspection, and @skip and @include — which are the language’s own rather than Shopify’s, so the document they are written in parses here and the refusal names the directive instead of the @. The refusal is settled deliberately, and against Shopify’s own schema rather than by guesswork. A field Shopify does not have gets Shopify’s validator verbatim, undefinedField included, because that claim is true. A field Shopify has and this twin has not built gets a code Shopify never sends, because telling a caller the schema lacks a field that is right there would send them off to write a workaround for a problem that does not exist. Never a null node, and never a routing 404. The twin’s own README explains every such decision and where each number came from.