> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twinbay.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Shopify

> The GraphQL Admin API with its calculated-cost bucket, its three error channels and an order stuck half-shipped

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

| Area                | Modelled                                                                                                                                                                                                                                                                                                                                                                                                           |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The shop            | One store per container with constant domain, currency, timezone and plan, and **one location**, because inventory cannot exist without one. `shop { … }` is every integration's first call and it answers the container's constants with a full `extensions.cost`                                                                                                                                                 |
| Authentication      | `X-Shopify-Access-Token: shpat_…` and nothing else — there is no `Authorization` header on this API. A missing token, an unrecognised one and an uninstalled app share Shopify's one `401`, because Shopify publishes no distinction and the twin invents none                                                                                                                                                     |
| Scopes              | The app's granted access scopes as container state, with `HTTP 200`, `ACCESS_DENIED` and a `requiredAccess` naming the scope the call needed. `write_x` implies `read_x`, which is the rule the pinned SDK's own `ApiAccess` encodes                                                                                                                                                                               |
| Rate limits         | Calculated query cost, measured: `2 + steps(first) × node cost` for a connection, 1 per object, 0 for scalars and enums, 10 for a mutation, with the requested cost charged before execution and the difference refunded after. `THROTTLED` at `HTTP 200`, the 1000-point single-query limit, and bucket sizes per plan                                                                                            |
| Versions            | The dated version in the path, four stable versions served, and a retired one **falling forward** to the oldest supported — with `X-Shopify-API-Version` naming which was used, so a caller finds out                                                                                                                                                                                                              |
| Products            | `productCreate`, `productUpdate`, `productSet`, `productVariantsBulkCreate`/`Update`, options and option values, `DRAFT` by default, a handle derived from the title and uniquified — while a *supplied* handle that collides is refused — and `price` as a decimal string, never a float                                                                                                                          |
| Inventory           | `available`, `committed`, `incoming` and `on_hand` per location, with `on_hand` **derived** so it cannot disagree with its parts. Adjust and set with Shopify's own refusals, `changeFromQuantity` honoured as the compare-and-swap it is rather than accepted and ignored, a negative set refused while a negative *quantity* is reachable by directive, and an order that commits stock rather than consuming it |
| Customers           | Create, update, addresses through `customerAddressCreate` — there is no `addresses` field on the input in this version — marketing consent per state, and a duplicate email refused with the field that collided                                                                                                                                                                                                   |
| Orders              | `draftOrderCreate` then `draftOrderComplete`, `#1001`-shaped names from the shop's own sequence, a separate `confirmationNumber`, money as `MoneyBag` in two currencies, and `displayFinancialStatus` and `displayFulfillmentStatus` **derived from the rows beneath them every time they are read** — including `RESTOCKED`, which twins usually miss                                                             |
| Fulfillment         | One fulfillment order per location with its own `status` and `requestStatus`, `fulfillmentCreate` for a **subset** of line items, `#1001.1` names that are not reused, tracking updated and cancelled, and an order left `PARTIALLY_FULFILLED` in one call                                                                                                                                                         |
| Refunds and returns | `refundCreate` with and without restock, the maximum still refundable in the refusal's message, a refund refused because the order is genuinely too old, and returns that go `REQUESTED` → `OPEN` → `CLOSED` rather than opening straight away                                                                                                                                                                     |
| Peripheral          | Discount codes with a window and a usage limit whose status is a projection of both, metafields on five owner types with `jsonValue` parsed by the declared type — and a value that type cannot hold refused with Shopify's own `INVALID_VALUE` rather than stored — a batch failure carrying its row, webhook subscriptions as **recorded state only**, and bulk operations with their poll-then-fetch lifecycle  |
| Global ids          | `gid://shopify/<Type>/<numeric>` everywhere, with the type segment load-bearing: a wrong-type id is a top-level error, a string that is not an id is a coercion error against the *document*, and a right-type id nothing holds is a `null` with the cost block present. All three verified                                                                                                                        |
| Paging              | Relay connections with Shopify's own cursor encoding, both directions — and `last` without `before` refused, which contradicts Shopify's documentation and matches its API. A page size above 250 refused rather than clamped, and a malformed cursor a top-level error rather than an empty page                                                                                                                  |
| Sorting             | Every `sortKey` the projection can order on, and each connection's **own** default out of the pinned schema — `orders` is `PROCESSED_AT` at the root and `ID` on a customer, which are two different pages. A key that ranks against a search term or is derived from the rows beneath the record is refused by name rather than served in the default order                                                       |
| Arguments           | Coerced the way Shopify coerces them, and a value that arrived in `variables` gets exactly the pass a literal gets — the unknown input field, the enum value, the required field and the global id are all checked on the path a caller's SDK actually sends                                                                                                                                                       |

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
`CREATED` → `RUNNING` → `COMPLETED` 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.
