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

# Shopify

> Admin GraphQL API, OAuth, and webhooks.

Stateful Shopify emulation of the GraphQL Admin API (`/admin/api/<version>/graphql.json`), validated against Shopify's published schema for API version `2026-10`. It covers products with options, variants, and media, inventory items and levels, customers, orders with transactions, refunds, and cancellation, fulfillment orders, fulfillments, and holds, the shop, apps, staff, and locations, with Shopify's search syntax in `query:` arguments, Relay cursor pagination, `userErrors`, and `extensions.cost` with a leaky bucket that answers `THROTTLED` when it runs dry. It also installs apps over OAuth (authorization code grant, offline, online, and expiring tokens, token exchange of session tokens, client credentials) and sends HMAC-signed webhooks. The tests drive it with [`@shopify/shopify-api`](https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/shopify-api) (15.0.1) and [`@shopify/admin-api-client`](https://github.com/Shopify/shopify-app-js/tree/main/packages/api-clients/admin-api-client) (2.0.1).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/shopify` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `shopify` |
| HTTP operations | 9, browsable at `http://localhost:3999/docs/shopify` while it runs ([API reference](/api-reference.md)) |
| GraphQL root fields | 68 |

```bash CLI theme={null}
npx @deploybaseline/cli start --service shopify
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const shopify = await createEmulator({ service: "shopify" });
// shopify.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "shopify": {
    "shop": {
      "domain": "baseline-store.myshopify.com",
      "name": "Baseline Store",
      "currency": "USD"
    },
    "apps": [
      {
        "name": "My app",
        "api_key": "my-app-key",
        "api_secret_key": "my-app-secret",
        "scopes": ["write_products", "write_orders", "write_customers"],
        "redirect_urls": ["http://localhost:3000/auth/callback"],
        "application_url": "http://localhost:3000",
        "access_token": "shpat_baseline_admin_token"
      }
    ],
    "products": [
      {
        "title": "Snowboard",
        "options": [
          {
            "name": "Size",
            "values": ["154", "158"]
          }
        ],
        "variants": [
          {
            "options": ["154"],
            "price": "600.00",
            "sku": "SB-154",
            "inventory_quantity": 10
          },
          {
            "options": ["158"],
            "price": "650.00",
            "sku": "SB-158",
            "inventory_quantity": 4
          }
        ]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service shopify
```

Shopify starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. The emulator is one shop, `baseline-store.myshopify.com` unless `shop.domain` says otherwise. Without config, the shop has one location, an owner staff member, and a default app (`baseline-shopify-app`).

Shopify's SDKs always call `https://{shop}`. Point them at the emulator with a fetch that swaps the origin. With `@shopify/admin-api-client`:

```ts theme={null}
import { createAdminApiClient } from "@shopify/admin-api-client";

const shop = "baseline-store.myshopify.com";
const client = createAdminApiClient({
  storeDomain: shop,
  apiVersion: "2026-10",
  accessToken: "shpat_baseline_admin_token",
  customFetchApi: (url, init) => fetch(String(url).replace(`https://${shop}`, process.env.SHOPIFY_EMULATOR_URL!), init),
});
const { data } = await client.request(`{ products(first: 5) { nodes { id title } } }`);
```

With `@shopify/shopify-api`, wrap the runtime's fetch once after importing the adapter:

```ts theme={null}
import "@shopify/shopify-api/adapters/web-api";
import { setAbstractFetchFunc } from "@shopify/shopify-api/runtime";

setAbstractFetchFunc((input, init) => {
  const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
  if (url.hostname.endsWith(".myshopify.com")) {
    const emulator = new URL(process.env.SHOPIFY_EMULATOR_URL!);
    url.protocol = emulator.protocol;
    url.host = emulator.host;
  }
  return fetch(url, init);
});
```

## URL mapping

| Real Shopify URL | Emulator URL |
| - | - |
| `https://{shop}.myshopify.com/admin/api/{version}/graphql.json` | `$SHOPIFY_EMULATOR_URL/admin/api/{version}/graphql.json` |

## Auth

Admin API requests carry `X-Shopify-Access-Token`.

* Tokens seeded as an app's `access_token` act as that app (installed, with its `scopes`).
* Any other token the CLI accepts acts as the first app with every scope, so a quick start needs no setup.
* An unknown, revoked, or expired token answers HTTP 401 `{"errors":"[API] Invalid API key or access token (unrecognized login or wrong password)"}`.
* A field outside the token's scopes answers `ACCESS_DENIED` (`Access denied for productCreate field. Required access: \`write\_products\` access scope.\`). A write scope includes its read scope.

## API versions

Every supported version (the four stable ones, the release candidate, and `unstable`) is served from the pinned `2026-10` schema. An older version falls forward to the oldest supported version, as Shopify does; a version that is not a quarterly release (`2026-05`, `latest`) or is not out yet answers 404 `{"errors":"Not Found"}`. `X-Shopify-API-Version` on every response names the version that answered. `/admin/api/graphql.json` without a version is served as the oldest supported version.

The schema is vendored from Shopify's `@shopify/dev-mcp` package (ISC). A weekly drift check moves the pin when Shopify releases a new stable version.

## OAuth

Apps install the way they do on a real shop. `shopify.auth.begin`, `auth.callback`, `auth.tokenExchange`, `auth.refreshToken`, `auth.clientCredentials`, and `auth.migrateToExpiringToken` all work against the emulator.

* `GET /admin/oauth/authorize` — Install screen: the app, the scopes it asks for, and a button per staff member. `client_id` must be a seeded app and `redirect_uri` one of its `redirect_urls` (or under its `application_url` when it has none). `grant_options[]=per-user` asks for an online token.
* `POST /admin/oauth/authorize` — Approve install: installs the app with the scopes and redirects to `redirect_uri` with `code`, `host`, `shop`, `state`, `timestamp`, and `hmac`.
* `POST /admin/oauth/access_token` — Get access token: JSON or form body with `client_id` and `client_secret`. Grants below.
* `GET /admin/oauth/access_scopes.json` — List access scopes: `{"access_scopes":[{"handle":"write_products"}]}` for the token in `X-Shopify-Access-Token`.
* `GET /admin/apps/<api key or handle>` — Open app: as the Shopify admin does, a redirect to its `application_url` with the signed query, plus `embedded=1`, `id_token` (a session token), `session`, and `locale` for embedded apps. An app that is not installed yet is installed with its configured scopes first (managed installation). `?staff=<email>` picks the user.

The access token call grants:

* **Authorization code**: `code` from the redirect, once, within ten minutes. An offline token (`shpat_`) never expires; with `expiring=1` it lasts an hour and comes with a `refresh_token` that lasts 90 days. An online token (`shpua_`) lasts a day and carries `associated_user` and `associated_user_scope`.
* **Token exchange** (`grant_type=urn:ietf:params:oauth:grant-type:token-exchange`): a session token as `subject_token` with `subject_token_type=urn:ietf:params:oauth:token-type:id_token`, for an online or offline token (`requested_token_type`). The session token must be signed with the app's client secret (HS256), be for the app's client ID (`aud`) and this shop (`dest`), and not be expired. A non-expiring offline token as `subject_token` with the offline token type migrates it to an expiring one and revokes the old token.
* **Refresh token** (`grant_type=refresh_token`): a new expiring offline token; the old token and refresh token stop working.
* **Client credentials** (`grant_type=client_credentials`): a 24 hour offline token for an installed app.

Errors use OAuth's shape, as `{"error":"invalid_subject_token","error_description":"Session token signature is invalid"}`, with 401 for a wrong client secret and 400 otherwise.

Shopify signs the queries it sends to an app with `hmac`: the hex HMAC-SHA256 of the other parameters, sorted by name and URL encoded, keyed with the client secret. `shopify.utils.validateHmac` accepts the emulator's.

## GraphQL

Queries are validated against the schema and answered with graphql-ruby's wording (`Field 'nope' doesn't exist on type 'Shop'`, `Field 'product' is missing required arguments: id`), on HTTP 200.

* **Products**: `products`, `productsCount`, `product`, `productByHandle`, `productByIdentifier`, `productVariants`, `productVariantsCount`, `productVariant`, `inventoryItem`.
* **Product mutations**: `productCreate` (with `productOptions` and `media`), `productUpdate`, `productDelete`, `productVariantsBulkCreate` (with the `REMOVE_STANDALONE_VARIANT` and `PRESERVE_STANDALONE_VARIANT` strategies), `productVariantsBulkUpdate`, `productVariantsBulkDelete`, `productOptionsCreate`. Problems come back in `userErrors` with Shopify's messages and codes (`Title can't be blank`, `VARIANT_ALREADY_EXISTS`, `CANNOT_DELETE_LAST_VARIANT`).
* **Customers**: `customers`, `customersCount`, `customer`, `customerByIdentifier`, `customerCreate`, `customerUpdate`, `customerDelete`, `customerAddressCreate`, `customerAddressUpdate`, `customerAddressDelete`, `customerUpdateDefaultAddress`.
* **Orders**: `orders`, `ordersCount`, `order`, `orderByIdentifier`, `orderCreate`, `orderUpdate`, `orderCancel`, `orderClose`, `orderOpen`, `orderMarkAsPaid`, `orderCapture`, `refundCreate`, `tagsAdd`, `tagsRemove`.
* **Fulfillment**: `fulfillmentOrders`, `fulfillmentOrder`, `fulfillment`, `fulfillmentCreate`, `fulfillmentCancel`, `fulfillmentTrackingInfoUpdate`, `fulfillmentOrderHold`, `fulfillmentOrderReleaseHold`.
* **Shop**: `shop`, `app`, `appInstallation`, `currentAppInstallation`, `currentStaffMember`, `staffMember`, `locations`, `locationsCount`, `location`, `node`, `nodes`, `job`, `appUninstall`.
* **Webhooks**: `webhookSubscriptions`, `webhookSubscriptionsCount`, `webhookSubscription`, `webhookSubscriptionCreate`, `webhookSubscriptionUpdate`, `webhookSubscriptionDelete`. See [Webhooks](#webhooks).

A product starts with the `Title` option and its `Default Title` variant, or with the first value of each option given. Handles come from the title and get `-1`, `-2` suffixes when taken. Media accepts `IMAGE` and `EXTERNAL_VIDEO` sources and is `READY` at once.

### Customers

A customer needs a name, an email address, or a phone number (`A name, phone number, or email address must be present`). Emails are stored lowercase and, like phone numbers (kept in E.164), are unique (`Email has already been taken`). A customer with orders cannot be deleted. `numberOfOrders`, `amountSpent` (payments received less refunds), `lastOrder`, and `orders` come from the customer's orders.

### Orders

`orderCreate` takes line items for variants (title, SKU, vendor, and price come from the variant unless given) or custom line items with a title and `priceSet`, shipping lines, tax lines (with a `priceSet`, or a `rate` applied to the line total), transactions, `financialStatus`, `fulfillmentStatus` or `fulfillment`, addresses, tags, and a customer to associate (`toAssociate` by ID or email) or upsert (`toUpsert`). Orders are numbered from `#1001`. With `options.inventoryBehaviour` set to `DECREMENT_IGNORING_POLICY` or `DECREMENT_OBEYING_POLICY`, the variants' inventory goes down, and the second refuses (`INVENTORY_CLAIM_FAILED`) when a `DENY` variant lacks stock.

The financial status is derived from the transactions, as Shopify does: `PAID` once sales and captures cover the total, `PARTIALLY_PAID` before that, `AUTHORIZED` with uncaptured authorizations, `REFUNDED` or `PARTIALLY_REFUNDED` once refunds exist, `VOIDED` when the authorizations were voided, else `PENDING`. A `financialStatus` of `PAID` or `AUTHORIZED` without transactions records a manual sale or authorization for the total; other statuses are kept as given until a transaction is recorded.

* `orderCapture` captures part or all of an authorization (`finalCapture` voids the rest) and refuses more than is left.
* `orderMarkAsPaid` records a manual sale for the outstanding amount.
* `refundCreate` refunds line items (`RETURN`, `CANCEL`, and `LEGACY_RESTOCK` put stock back), shipping, and money through `REFUND` transactions, up to the net payment received.
* `orderCancel` returns a `Job` (done when polled with `job`), and with `refundMethod: { originalPaymentMethodsRefund: true }` refunds the net payment, voids open authorizations, restocks when asked, closes the order and its fulfillment orders, and sets `cancelledAt`, `cancelReason`, and `cancellation.staffNote`. Orders with fulfillments cannot be cancelled.
* `orderClose` and `orderOpen` archive and unarchive; a cancelled order cannot be reopened.

### Fulfillment orders

Each order gets one fulfillment order at the shop's first location. `fulfillmentCreate` fulfills whole fulfillment orders or chosen quantities (`Invalid fulfillment order line item quantity requested.`), with tracking; a fulfillment order with nothing left is `CLOSED`, a partly fulfilled one `IN_PROGRESS`. `fulfillmentCancel` gives the quantities back. `fulfillmentOrderHold` puts a fulfillment order `ON_HOLD` with a reason and handle (unique per app, at most 10 holds); holding only some quantities splits the rest into a new fulfillment order, returned as `remainingFulfillmentOrder`. `fulfillmentOrderReleaseHold` releases some or all holds. The order's `displayFulfillmentStatus` follows: `UNFULFILLED`, `ON_HOLD`, `IN_PROGRESS`, `PARTIALLY_FULFILLED`, `FULFILLED`.

Orders search on `name`, `status` (`open`, `closed`, `cancelled`, `not_closed`, `any`), `financial_status`, `fulfillment_status` (`shipped`, `partial`, `unshipped`, `unfulfilled`, `on_hold`), `email`, `tag`, `tag_not`, `customer_id`, `sku`, `test`, `source_name`, `po_number`, `confirmation_number`, `created_at`, `updated_at`, and `processed_at`. Customers search on `email`, `phone`, `first_name`, `last_name`, `state`, `tag`, `tag_not`, `country`, `accepts_marketing`, `created_at`, and `updated_at`.

Order reads need `read_orders`, customer reads `read_customers`, and fulfillment orders one of the `read_*_fulfillment_orders` scopes (`write_merchant_managed_fulfillment_orders` to fulfill or hold).

### Unmodeled fields

Fields Baseline does not model answer as an empty shop would: `metafield` is null and lists and connections are empty. Any other root field answers `NOT_EMULATED` with its name.

### Pagination

Connections take `first` or `last` (one is required, at most 250) with `after` and `before` cursors, `sortKey`, and `reverse`, and return `nodes`, `edges` with cursors, and `pageInfo`.

### Search syntax

`query:` arguments take Shopify's [search syntax](https://shopify.dev/docs/api/usage/search-syntax): free text, `field:value`, `field:<10`, `field:>=2024-01-01`, `field:*` (has a value), trailing `*` prefixes, comma lists (`status:active,draft`), `AND`, `OR`, `NOT`, a leading `-`, parentheses, and quoted phrases. A field the resource does not support filters nothing and is reported in `extensions.search`:

```json theme={null}
{
  "path": ["products"],
  "query": "colour:red",
  "parsed": { "field": "default", "match_all": "colour:red" },
  "warnings": [{ "field": "colour", "message": "Invalid search field for this query." }]
}
```

Products search on `title`, `handle`, `vendor`, `product_type`, `status`, `tag`, `tag_not`, `sku`, `barcode`, `gift_card`, `inventory_total`, `price`, `variant_id`, `created_at`, `updated_at`, `published_at`, `published_status`, and `out_of_stock_somewhere`.

### Query cost

Every response reports the cost:

```json theme={null}
{
  "extensions": {
    "cost": {
      "requestedQueryCost": 12,
      "actualQueryCost": 2,
      "throttleStatus": { "maximumAvailable": 2000, "currentlyAvailable": 1998, "restoreRate": 100 }
    }
  }
}
```

Scalars cost 0, objects 1, connections 2 plus `first` (or `last`) times their nodes' cost, and mutations 10. The requested cost is taken from the bucket before the query runs, and the difference from the actual cost is returned after. When the bucket holds less than the requested cost, the response is `{"errors":[{"message":"Throttled","extensions":{"code":"THROTTLED",...}}]}`. A query over 1000 points answers `MAX_COST_EXCEEDED`. Set the bucket with `shop.throttle`.

## Webhooks

Subscribe with `webhookSubscriptionCreate` (`uri`, or the older `callbackUrl`), `shopify.webhooks.register`, or the `webhooks` list on a seeded app. When a request changes something, each matching subscription of an installed app gets one POST of the topic's REST-shaped JSON payload with these headers:

| Header | Value |
| - | - |
| `X-Shopify-Topic` | `orders/create` |
| `X-Shopify-Hmac-Sha256` | base64 HMAC-SHA256 of the raw body, keyed with the app's client secret |
| `X-Shopify-Shop-Domain` | `baseline-store.myshopify.com` |
| `X-Shopify-API-Version` | the version the subscription was made with |
| `X-Shopify-Webhook-Id` | unique per delivery, the same on retries |
| `X-Shopify-Event-Id` | the same for every delivery of one event |
| `X-Shopify-Triggered-At` | when the change happened |

`shopify.webhooks.validate` accepts these deliveries. Topics sent: `products/create`, `products/update`, `products/delete`, `customers/create`, `customers/update`, `customers/delete`, `orders/create`, `orders/updated`, `orders/paid`, `orders/cancelled`, `orders/fulfilled`, `orders/partially_fulfilled`, `order_transactions/create`, `refunds/create`, `fulfillments/create`, `fulfillments/update`, `fulfillment_orders/placed_on_hold`, `fulfillment_orders/hold_released`, and `app/uninstalled`. Other topics can be subscribed to and are never sent.

* `includeFields` cuts the payload to the named fields; nested fields use dots (`line_items.title`).
* `filter` takes the search syntax over the payload's fields, with dots for nested fields (`total_price:>100`, `variants.price:>=10.00`). A payload that does not match is not sent.
* A subscription needs the topic's read scope (`read_orders` for orders, transactions, refunds, and fulfillments), else `userErrors` explains which scope is missing. The same address twice for a topic is `Address for this topic has already been taken`. Pub/Sub and EventBridge addresses and the XML format are refused.
* A delivery fails when the address does not answer 2xx within five seconds. Failed deliveries are retried eight times, after 1, 2, 4, 8, 16, 32, 60, and 60 seconds (Shopify spreads them over four hours).
* An app sees and changes only its own subscriptions.
* Uninstalling an app (`appUninstall`, or the inspector's Uninstall button) sends its `app/uninstalled` webhooks with the shop as payload, then revokes its tokens and deletes its subscriptions.

## Seed config

```json theme={null}
{
  "shopify": {
    "shop": {
      "domain": "baseline-store.myshopify.com",
      "name": "Baseline Store",
      "currency": "USD",
      "throttle": {
        "maximum": 2000,
        "restore_rate": 100
      }
    },
    "apps": [
      {
        "name": "My app",
        "api_key": "my-app-key",
        "api_secret_key": "my-app-secret",
        "scopes": ["write_products", "write_orders", "write_customers"],
        "redirect_urls": ["http://localhost:3000/auth/callback"],
        "application_url": "http://localhost:3000",
        "embedded": true,
        "access_token": "shpat_baseline_admin_token",
        "webhooks": [
          {
            "topic": "orders/create",
            "uri": "http://localhost:3000/webhooks",
            "include_fields": ["id", "total_price"]
          }
        ]
      }
    ],
    "locations": [
      {
        "name": "Warehouse",
        "city": "Ottawa",
        "country_code": "CA"
      }
    ],
    "products": [
      {
        "title": "Snowboard",
        "vendor": "Hydrogen",
        "tags": ["winter"],
        "options": [
          {
            "name": "Size",
            "values": ["154", "158"]
          }
        ],
        "variants": [
          {
            "options": ["154"],
            "price": "600.00",
            "sku": "SB-154",
            "inventory_quantity": 10
          },
          {
            "options": ["158"],
            "price": "650.00",
            "sku": "SB-158"
          }
        ],
        "images": [
          {
            "src": "https://cdn.example.com/snowboard.png",
            "alt": "Snowboard"
          }
        ]
      }
    ],
    "customers": [
      {
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "ada@example.com",
        "tags": ["vip"],
        "addresses": [
          {
            "address1": "12 Analytical Row",
            "city": "London",
            "country_code": "GB"
          }
        ]
      }
    ],
    "orders": [
      {
        "customer": "ada@example.com",
        "line_items": [
          {
            "sku": "SB-154",
            "quantity": 1
          },
          {
            "title": "Wax",
            "price": "15.00",
            "quantity": 2
          }
        ],
        "shipping_lines": [
          {
            "title": "Standard",
            "price": "10.00"
          }
        ],
        "financial_status": "PAID",
        "fulfillment_status": "FULFILLED",
        "tags": ["seeded"]
      }
    ]
  }
}
```

An app with `access_token` is installed with that offline token, as a custom app is; without one it is installed through OAuth. `webhooks` topics are written as `orders/create` or `ORDERS_CREATE`.

Seeded order line items name a variant by `sku` or give a `title` and `price`. Orders are seeded only into an empty store, so a restored state is not seeded twice.

## Inspector

`GET /_inspector` shows products with their variants and inventory, customers, orders with their payment and fulfillment status and holds, apps with their granted scopes and tokens (with Open app and Uninstall buttons), webhook subscriptions and deliveries with their status and retries, and the shop settings, locations, and staff.

## Limits

`@shopify/shopify-api` talks to the Admin API through GraphQL; its REST resources are deprecated and need `restResources` to be passed in. Baseline serves no REST Admin resources; the only REST calls the SDK makes are the OAuth ones above, which are served.

## Conformance

| | |
| - | - |
| Routes | 9 supported or partial, 0 planned |
| GraphQL root fields | 68 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `@shopify/admin-api-client@2.0.1`, `@shopify/shopify-api@15.0.1` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.