local-storefront 0.1.18 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/admin.md ADDED
@@ -0,0 +1,1751 @@
1
+ # Admin API
2
+
3
+ The backend of a Shopify app talks to Shopify through the **GraphQL Admin API** and receives
4
+ **webhooks**. While it runs, local-storefront serves a local Admin API (version **2026-10**, and
5
+ [2027-01 on demand](#api-versions)) over the store data, with OAuth, token exchange and session tokens, and delivers the webhooks of
6
+ the app to its backend, signed as Shopify signs them. The backend runs and is tested offline: no
7
+ development store, no tunnel, the same data every run, and the failures that are hard to provoke
8
+ on a real store (a missing scope, throttling, an uninstall) on demand.
9
+
10
+ A product the backend creates through the Admin API is a product of the local shop, on the
11
+ theme's pages at once ([what the storefront and the checkout
12
+ see](#what-the-storefront-and-the-checkout-see)); an order placed in the [local
13
+ checkout](checkout.md) is an order the Admin API lists, and its `orders/create` webhook reaches
14
+ the backend.
15
+
16
+ The embedded app's own pages open in [lsf's admin](#preview-the-embedded-app): an admin around
17
+ the app, with a local App Bridge, so that its session tokens, title bar, navigation, save bar,
18
+ toasts and resource picker work offline too.
19
+
20
+ This guide starts lsf for a backend, points Shopify's libraries at it, previews the embedded app,
21
+ and tests the backend with it. What each mutation does, family by family, is in the [Admin API
22
+ reference](admin-reference.md).
23
+
24
+ ## Start
25
+
26
+ ```bash
27
+ npx local-storefront app dev --app ../my-app --app-url http://localhost:3000
28
+ ```
29
+
30
+ ```text
31
+ serving: checkout, admin
32
+ admin: http://127.0.0.1:9292/admin/api/2026-10/graphql.json
33
+ X-Shopify-Access-Token: shpat_4f… (shop: local-supply-co.myshopify.com)
34
+ client secret: from SHOPIFY_API_SECRET of ../my-app/.env
35
+ GraphiQL: http://127.0.0.1:9292/__lsf/graphiql (queries in the browser, the token filled in)
36
+ webhooks: 3 subscriptions of shopify.app.toml → http://localhost:3000
37
+ embedded: http://127.0.0.1:9292/admin/apps/my-app (the app in lsf's admin; its pages load http://127.0.0.1:9292/__lsf/admin/app-bridge.js)
38
+ ready: http://127.0.0.1:9292/ (extensions and checkout scenarios; …)
39
+ ```
40
+
41
+ With `--app`, the client id, the scopes and the webhooks are those of the app's
42
+ `shopify.app.toml`, and the start lines of the Admin API come before `ready:`. Without
43
+ `--app-url`, the `webhooks:` line says when they lead off this machine:
44
+ `→ https://my-app.trycloudflare.com, not this machine: not sent (give --app-url http://localhost:<port>)`.
45
+ The parts lsf reads:
46
+
47
+ ```toml
48
+ client_id = "…"
49
+ application_url = "https://my-app.trycloudflare.com" # the tunnel of shopify app dev: --app-url replaces it
50
+
51
+ [access_scopes]
52
+ scopes = "read_products,write_products,read_orders" # the token has these, and no others
53
+
54
+ [webhooks]
55
+ api_version = "2026-10"
56
+
57
+ [[webhooks.subscriptions]]
58
+ topics = ["products/create", "orders/create"]
59
+ uri = "/webhooks" # a path: joined to --app-url
60
+
61
+ [[webhooks.subscriptions]]
62
+ topics = ["app/uninstalled"]
63
+ uri = "/webhooks/app/uninstalled"
64
+ ```
65
+
66
+ - **The shop** is the store data's: its `shop.permanent_domain`, `local-supply-co.myshopify.com`
67
+ for the demo store (`defaultShop` of `GET /__lsf/admin`), seeded from the products,
68
+ collections, customers, orders, metafields, metaobjects, markets, discounts and Functions of
69
+ the data. The ids are those the storefront, the checkout and the Function inputs use.
70
+ - **The token** printed is an offline token the app is installed with, with the scopes of
71
+ `[access_scopes] scopes`: no scope, no access. It is the same every run for a shop and an app:
72
+ a test can hard-code it.
73
+ - **The client id** is `client_id` of `shopify.app.toml`. The app template ships
74
+ `client_id = ""`: lsf then takes `SHOPIFY_API_KEY` of the environment or of the app's `.env`
75
+ (the backend's apiKey, which the session tokens' `aud` must be), and says so in a
76
+ `client id:` line. With neither, it uses `lsf-local-client-id`, and warns at start, on the
77
+ home page and in lsf's admin. The admin names the app by `handle`, else by `name` in lower
78
+ case with dashes (`Gift Notes` is `/admin/apps/gift-notes`), else by the client id.
79
+ - **The client secret** signs webhooks, OAuth answers and session tokens. Give lsf the backend's:
80
+ `--client-secret`, or `SHOPIFY_API_SECRET` in the environment or in the app's `.env` (where
81
+ `shopify app env pull` writes it, and the app templates read it). Without one, lsf signs with
82
+ `lsf-local-client-secret`. The `client secret:` line says which: `from --client-secret`,
83
+ `from SHOPIFY_API_SECRET of the environment`, `from SHOPIFY_API_SECRET of <app>/.env`, or
84
+ `lsf-local-client-secret (lsf's own: …)`.
85
+
86
+ | Option | Environment variable | What it does |
87
+ |---|---|---|
88
+ | `--app-url <url>` | `LSF_APP_URL` | Where the backend listens. Webhooks with a relative `uri` go there, and an absolute `uri` on the origin of `application_url` (often the tunnel of a `shopify app dev` that is not running) is moved there. Without it, `application_url`, where lsf sends only when it is on this machine ([where webhooks go](#webhooks)). |
89
+ | `--client-secret <secret>` | `LSF_CLIENT_SECRET` | The client secret. |
90
+ | `--admin-throttle <plan>` | `LSF_ADMIN_THROTTLE` | The cost bucket: `store` (default), `standard`, `advanced`, `plus`, `enterprise`, `legacy`, `off`. |
91
+ | `--admin-versions <list>` | `LSF_ADMIN_VERSIONS` | The [API versions](#api-versions) served: `2026-10` alone by default, `2026-10,2027-01` with the release candidate. |
92
+ | `--admin-fetch-local` | `LSF_ADMIN_FETCH_LOCAL` | Sources from a URL (`themeCreate`, `themeFilesUpsert`, `urlRedirectImportCreate`) may be on this machine or its network. Off by default. |
93
+ | `--admin-source <url>=<file>` | | Answers a URL with a file instead of downloading it ([stubbed URLs](admin-reference.md#stubbed-urls)). Repeatable. |
94
+
95
+ `app dev` serves the app alone: its checkout, its customer account, the Admin API and the app
96
+ proxy, but no theme, so no storefront pages (`/products/…` answers `404`). To see on the theme's
97
+ pages what the backend changes, serve the theme with the app:
98
+
99
+ ```bash
100
+ npx local-storefront serve --theme ../my-theme --app ../my-app --app-url http://localhost:3000
101
+ ```
102
+
103
+ The Admin API is the admin part of lsf, and needs `--app`: `serve` without `--app` serves the
104
+ theme alone, and its `/admin/…` and `/__lsf/admin` answer `404` with an `X-LSF-Reason` that says
105
+ so. A backend whose tests need no theme and no checkout runs the Admin API alone:
106
+
107
+ ```bash
108
+ npx local-storefront serve --app ../my-app --only admin --app-url http://localhost:3000
109
+ ```
110
+
111
+ `--only checkout` runs the checkout without the Admin API, and `--only theme,admin` the theme
112
+ and the Admin API without the checkout ([what runs](cli.md#what-runs)). An option of the Admin
113
+ API (`--app-url`, `--pricing-plans`, …) with an `--only` that leaves it out stops the start:
114
+ `error: --app-url needs the admin part, which --only leaves out`.
115
+
116
+ [`examples/admin-backend`](https://github.com/0xtlt/local-storefront/tree/main/examples/admin-backend)
117
+ is a backend written with Shopify's libraries (the React Router package and
118
+ `@shopify/shopify-api`), with a script that runs it against lsf end to end
119
+ (`mise run admin:e2e` from the root of the repository): the JavaScript recipes of "[Point the
120
+ backend at lsf](#point-the-backend-at-lsf)" are checked there.
121
+
122
+ ## Point the backend at lsf
123
+
124
+ Shopify's libraries build the URL of a shop as `https://<shop>/admin/api/<version>/graphql.json`
125
+ (and `https://<shop>/admin/oauth/access_token` for tokens), and some force `https` on whatever
126
+ host they are given. lsf listens on `http://127.0.0.1:9292` and serves every shop there: the
127
+ token, not the host, picks the shop. So the way in is the library's HTTP layer: send the
128
+ requests for `*.myshopify.com` to lsf. Use API version `2026-10` (or a version you serve with
129
+ `--admin-versions`); lsf answers another with a `404` that says so, and one Shopify no longer
130
+ supports with 2026-10, as Shopify does ([API versions](#api-versions)).
131
+
132
+ The recipes read the address of lsf from an environment variable of your own (`LSF_URL` here),
133
+ so that the same code talks to Shopify when it is not set. Those that take an access token read
134
+ `SHOPIFY_ACCESS_TOKEN`: the token printed at start, also `shops[0].token` of `GET /__lsf/admin`
135
+ ([tokens](#tokens)).
136
+
137
+ ### JavaScript: `@shopify/admin-api-client`
138
+
139
+ A backend that only calls the Admin API (a sync job, a worker, a script) needs this recipe
140
+ alone: the token is the one printed at start, and there is no OAuth to set up.
141
+
142
+ `createAdminApiClient` takes a `customFetchApi` (`dist/graphql/client.mjs` of 2.0.1). It always
143
+ builds an `https://` URL from `storeDomain` (`validateDomainAndGetStoreUrl` of
144
+ `@shopify/graphql-client` sets the protocol to `https`), so rewrite the URL in the fetch:
145
+
146
+ ```js
147
+ import { createAdminApiClient } from "@shopify/admin-api-client";
148
+
149
+ const lsf = process.env.LSF_URL; // http://127.0.0.1:9292
150
+ const client = createAdminApiClient({
151
+ storeDomain: "local-supply-co.myshopify.com",
152
+ apiVersion: "2026-10",
153
+ accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
154
+ customFetchApi: lsf
155
+ ? (url, init) => fetch(String(url).replace(/^https:\/\/[^/]+/, lsf), init)
156
+ : fetch,
157
+ });
158
+
159
+ const { data, errors, extensions } = await client.request(
160
+ `query { products(first: 3) { nodes { id title } } }`,
161
+ );
162
+ ```
163
+
164
+ ### JavaScript: the React Router app and `@shopify/shopify-api`
165
+
166
+ Every request of `@shopify/shopify-api` (the Admin API clients, token exchange, OAuth) goes
167
+ through its runtime's fetch function, which `setAbstractFetchFunc` of
168
+ `@shopify/shopify-api/runtime` replaces (`lib/clients/admin/graphql/client.ts`,
169
+ `lib/utils/fetch-request.ts` of 15.0.1). `@shopify/shopify-app-react-router/server` sets it to
170
+ `fetch` when it is imported (it imports `@shopify/shopify-api/adapters/web-api`; the adapters of
171
+ `@shopify/shopify-api` do the same), so replace it in the body of `app/shopify.server.ts`, which
172
+ runs after every import. A module of its own that sets it, imported before `/server`, is undone
173
+ without a word, and the backend talks to Shopify:
174
+
175
+ ```ts
176
+ import "@shopify/shopify-app-react-router/adapters/node";
177
+ import { setAbstractFetchFunc } from "@shopify/shopify-api/runtime";
178
+ import { ApiVersion, shopifyApp } from "@shopify/shopify-app-react-router/server";
179
+
180
+ // Local development against lsf: the Admin API and OAuth of every shop go to it.
181
+ const lsf = process.env.LSF_URL; // http://127.0.0.1:9292
182
+ if (lsf) {
183
+ setAbstractFetchFunc((input, init) => {
184
+ const url = new URL(input instanceof Request ? input.url : String(input));
185
+ if (!url.hostname.endsWith(".myshopify.com")) return fetch(input, init);
186
+ const headers = new Headers(init?.headers);
187
+ headers.set("x-forwarded-host", url.host); // the shop, for OAuth
188
+ return fetch(`${lsf}${url.pathname}${url.search}`, { ...init, headers });
189
+ });
190
+ }
191
+
192
+ const shopify = shopifyApp({
193
+ apiVersion: ApiVersion.October26,
194
+ // … the rest of the template: apiKey, apiSecretKey, scopes, appUrl, sessionStorage
195
+ });
196
+ ```
197
+
198
+ `@shopify/shopify-api` is a dependency of `@shopify/shopify-app-react-router`; with a package
199
+ manager that does not hoist (pnpm), add it to the app's dependencies, at the same version.
200
+
201
+ Run the backend with the app's values (what `shopify app dev` would give it), without the
202
+ Shopify CLI: the template's `npm run dev` is `shopify app dev`, which talks to Shopify. In the
203
+ React Router template, after `npm run setup` (its Prisma session storage):
204
+
205
+ ```bash
206
+ SHOPIFY_API_KEY=<client_id of shopify.app.toml> SHOPIFY_API_SECRET=<its secret> \
207
+ SCOPES=read_products,write_products SHOPIFY_APP_URL=http://localhost:3000 PORT=3000 \
208
+ LSF_URL=http://127.0.0.1:9292 npx react-router dev
209
+ ```
210
+
211
+ Then open the app as the admin would: `http://127.0.0.1:9292/__lsf/admin/launch?path=/app`
212
+ redirects to `http://localhost:3000/app` with `embedded=1`, `shop`, `host`, `hmac` and a session
213
+ token in `id_token`, valid for a minute: follow the redirect at once. The template's
214
+ `authenticate.admin` reads the session token, exchanges it for an access token at lsf, and the
215
+ loader's `admin.graphql(…)` queries lsf. To see the page as the merchant does, with its App
216
+ Bridge working (the session token of `fetch()`, the title bar, the navigation), open it in
217
+ [lsf's admin](#preview-the-embedded-app). A test calls a route the front end fetches with
218
+ `Authorization: Bearer <session token>`: mint one with `POST /__lsf/admin/session-token`, one
219
+ per request. And `authenticate.admin` answers `410 Gone`
220
+ to a client whose `User-Agent` looks like a bot (the `isbot` package): curl's, Node's `fetch`'s
221
+ and Playwright's headless Chromium (`HeadlessChrome`) do; send a browser's. In lsf's frame, the
222
+ frame's proxy says Chrome for headless Chromium.
223
+
224
+ lsf reads the template: an `app/shopify.server.ts` without `setAbstractFetchFunc`, or an
225
+ `app/routes/app.tsx` without `boundary.headers`, is a warning at start and in lsf's admin, which
226
+ says what to add. The admin reads the files again at each load: the banner goes once the file is
227
+ fixed, without a restart. The fetch function does nothing without `LSF_URL`, and the backend
228
+ then sends its token exchange and its Admin API calls to Shopify. lsf sees the pages fail
229
+ instead: a request of the frame with a session token that fails (`500` when the token exchange
230
+ fails, Shopify's `404` when an Admin API call does; not the library's `401`), while lsf has
231
+ received no token exchange and no Admin API call from the backend yet, gets a banner in lsf's
232
+ admin and a line in its terminal (`admin: The app's backend answered 404 to /app.data, and lsf
233
+ has received nothing from it yet … If it was started without LSF_URL … start it with
234
+ LSF_URL=http://127.0.0.1:9292 …`). Only the backend's own calls count: a token
235
+ exchange, OAuth, an Admin API call with a token one of them issued. A test that calls the Admin
236
+ API with the printed token, or the `/__lsf/` endpoints, does not hide a backend that bypasses
237
+ lsf. A backend that talked to lsf once is not blamed for a page that fails later (it may reuse a
238
+ stored session and fail for a reason of its own); one the frame finds stopped is watched again,
239
+ so a backend restarted without `LSF_URL` is told too. The banner goes with the backend's first call
240
+ to lsf. Three things of the template to know:
241
+
242
+ - **Export the route's `headers`.** `export const headers: HeadersFunction = (headersArgs) =>
243
+ boundary.headers(headersArgs);` on a route (the template has it on `app.tsx`) passes the
244
+ library's headers on. Without it, a `401` of `billing.request` loses
245
+ `X-Shopify-API-Request-Failure-Reauthorize-Url`, and App Bridge has nowhere to send the
246
+ merchant: the Upgrade button does nothing.
247
+ - **Another port.** `vite.config.ts` reads `PORT` before React Router loads `.env`, and its HMR
248
+ port is `64999`: give both on the command line, `PORT=3001 SHOPIFY_APP_URL=http://localhost:3001
249
+ npx react-router dev`, and change `64999` (to `Number(process.env.HMR_PORT || 64999)`) to run
250
+ two apps at once.
251
+ - **Versions.** The template ships `@shopify/shopify-app-react-router` 3.x (`^3.0.2`), whose
252
+ `AppProvider` loads Polaris 1 (`polaris.js`); 4.0.0 loads `polaris-2.js`. lsf serves both, and
253
+ nothing else here differs between them.
254
+
255
+ - **Billing.** `billing.require` and `billing.request` work in the loader of a page (the
256
+ template's gate in `app.tsx`) and in a route the front end fetches. The `confirmationUrl` lsf
257
+ answers is Shopify's, on the shop's admin
258
+ (`https://<shop>/admin/charges/<app id>/<n>/RecurringApplicationCharge/confirm_recurring_application_charge?signature=…`),
259
+ which the template's exit-iframe page accepts; lsf's admin serves that path and shows lsf's
260
+ charge page. A fetched route answers `401` with it in
261
+ `X-Shopify-API-Request-Failure-Reauthorize-Url`, which App Bridge opens. After the approval,
262
+ a `returnUrl` on Shopify's admin (the library's default) or on the app comes back to the app
263
+ in lsf's admin. In a test, approve it without the page ([approve a charge](#approve-a-charge)).
264
+ - **OAuth.** The React Router package has no authorization code grant (it installs through
265
+ token exchange). `auth.begin` of `@shopify/shopify-api` redirects the browser to
266
+ `https://<shop>/admin/oauth/authorize?…`, which no fetch function sees: send it to lsf
267
+ instead, the same path and query. lsf approves and redirects to the callback, whose
268
+ `auth.callback` gets the token through the fetch function.
269
+
270
+ ```js
271
+ const response = await shopify.auth.begin({ shop, callbackPath: "/auth/callback", isOnline: false, rawRequest: request });
272
+ if (process.env.LSF_URL) {
273
+ const authorize = new URL(response.headers.get("location"));
274
+ response.headers.set("location", `${process.env.LSF_URL}${authorize.pathname}${authorize.search}`);
275
+ }
276
+ return response;
277
+ ```
278
+
279
+ `authenticate.webhook` verifies the webhooks lsf delivers with `apiSecretKey`: give lsf the same
280
+ secret.
281
+
282
+ ### Ruby: the `shopify_api` gem
283
+
284
+ Every request of the gem (`Clients::Graphql::Admin`, token exchange, OAuth, refresh, client
285
+ credentials) is made by `ShopifyAPI::Clients::HttpClient`, whose base URI is
286
+ `https://<api_host or shop>` (`lib/shopify_api/clients/http_client.rb` of 16.3.1): `api_host`
287
+ cannot reach a plain-HTTP server. Rewrite the URL of each request instead, in an initializer:
288
+
289
+ ```ruby
290
+ # config/initializers/lsf.rb
291
+ if ENV["LSF_URL"] # http://127.0.0.1:9292
292
+ module LsfAdminApi
293
+ private
294
+
295
+ def request_url(request)
296
+ super.sub(%r{\Ahttps://[^/]+}, ENV.fetch("LSF_URL"))
297
+ end
298
+ end
299
+ ShopifyAPI::Clients::HttpClient.prepend(LsfAdminApi)
300
+ end
301
+ ```
302
+
303
+ ```ruby
304
+ ShopifyAPI::Context.setup(api_key: ENV["SHOPIFY_API_KEY"], api_secret_key: ENV["SHOPIFY_API_SECRET"],
305
+ host: "http://localhost:3000", scope: "read_products", is_embedded: true, is_private: false,
306
+ api_version: "2026-10")
307
+ session = ShopifyAPI::Auth::Session.new(shop: "local-supply-co.myshopify.com", access_token: token)
308
+ client = ShopifyAPI::Clients::Graphql::Admin.new(session: session)
309
+ client.query(query: "{ shop { name } }").body
310
+ ```
311
+
312
+ ### Python: the `ShopifyAPI` package
313
+
314
+ The `ShopifyAPI` package (12.7.0) builds `https://<shop>.myshopify.com/admin/api/<version>`, but
315
+ its `GraphQL` resource reads its `endpoint` at each `execute` (`shopify/resources/graphql.py`):
316
+ point it at lsf.
317
+
318
+ ```python
319
+ import os, shopify
320
+
321
+ session = shopify.Session("local-supply-co.myshopify.com", "2026-10", os.environ["SHOPIFY_ACCESS_TOKEN"])
322
+ shopify.ShopifyResource.activate_session(session)
323
+ graphql = shopify.GraphQL()
324
+ if os.environ.get("LSF_URL"):
325
+ graphql.endpoint = os.environ["LSF_URL"] + "/admin/api/2026-10/graphql.json"
326
+ print(graphql.execute("{ shop { name } }"))
327
+ ```
328
+
329
+ Without a library, a POST is all it takes (with `requests`):
330
+
331
+ ```python
332
+ import os, requests
333
+
334
+ LSF_URL = os.environ.get("LSF_URL", "http://127.0.0.1:9292")
335
+ token = requests.get(LSF_URL + "/__lsf/admin").json()["shops"][0]["token"] # the default shop's
336
+
337
+ answer = requests.post(
338
+ LSF_URL + "/admin/api/2026-10/graphql.json",
339
+ headers={"X-Shopify-Access-Token": token},
340
+ json={"query": "query($n: Int!) { products(first: $n) { nodes { title } } }", "variables": {"n": 3}},
341
+ ).json()
342
+ ```
343
+
344
+ ### Any HTTP client: `fetch` and `curl`
345
+
346
+ ```js
347
+ const response = await fetch("http://127.0.0.1:9292/admin/api/2026-10/graphql.json", {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": token },
350
+ body: JSON.stringify({ query: "{ shop { name myshopifyDomain } }" }),
351
+ });
352
+ ```
353
+
354
+ ```bash
355
+ curl -s http://127.0.0.1:9292/admin/api/2026-10/graphql.json \
356
+ -H 'Content-Type: application/json' -H "X-Shopify-Access-Token: $TOKEN" \
357
+ -d '{"query": "{ shop { name } }"}'
358
+ ```
359
+
360
+ `application/graphql` bodies (the query alone) and `GET ?query=` work too, as on Shopify.
361
+
362
+ ### GraphiQL in the browser
363
+
364
+ `http://127.0.0.1:9292/__lsf/graphiql` is a query editor for the local Admin API: the shop, its
365
+ token and the API version are filled in from `GET /__lsf/admin` (pick another shop or a served
366
+ version), **Docs** browses the schema of the version, **Cost debug** sends
367
+ `Shopify-GraphQL-Cost-Debug: 1` (`extensions.cost.fields`), and Ctrl+Enter (⌘+Enter) runs the
368
+ query. It is lsf's own page, not Shopify's GraphiQL app: it loads nothing from the internet. The
369
+ home page of the app links to it (**More**), and a browser that opens the GraphQL endpoint
370
+ itself gets the `401` Shopify answers without a token, with an `X-LSF-Reason` that points there.
371
+
372
+ ### Types from the schema (GraphQL Codegen)
373
+
374
+ `@graphql-codegen/cli` reads the schema from lsf's introspection, as from Shopify's: point it at
375
+ `http://127.0.0.1:9292/admin/api/2026-10/graphql.json` with the `X-Shopify-Access-Token`
376
+ header. Use `graphql@16`, as `@shopify/api-codegen-preset` does: `graphql@17` refuses Shopify's
377
+ 2026-10 schema itself ("Interface field HasMetafields.metafield is not deprecated, so
378
+ implementation field MediaImage.metafield must not be deprecated", and the same of
379
+ `ChannelDefinition.id`, which `Node.id` is not). Those deprecations are Shopify's: lsf's schema
380
+ is the introspection of Shopify's Admin API, unchanged, and the same codegen fails against a
381
+ development store.
382
+
383
+ ```bash
384
+ npm install --save-dev graphql@16
385
+ ```
386
+
387
+ ## Preview the embedded app
388
+
389
+ `http://127.0.0.1:9292/admin/apps/<handle>` is lsf's admin with the app open: the shop's name and
390
+ the app's navigation on the left, the app's title bar, and the app in a frame, at the app's URL
391
+ (`--app-url` on the path of `application_url`) with the query the admin signs. Each load is a
392
+ new launch: a new `hmac`, a new session token in `id_token`. The page's path follows the app's:
393
+ `/admin/apps/my-app/app/settings` opens the app at `/app/settings`, and reloading it opens the
394
+ app there again. The `serve` and `app dev` banners print its URL (`embedded:`), and the home
395
+ page of the app has an **Admin** button to it.
396
+
397
+ Under **Preview**, the navigation links to what the server runs, each in a new tab: **Online
398
+ Store** (`/`) with the theme, and **Checkout** (`/__lsf/checkouts`, the scenarios and the
399
+ extensions of the app) with the checkout. So a change the app makes, such as a payment
400
+ customization created, is one click from the checkout that shows it. They are the default
401
+ shop's: the admin of [another shop](#a-shop-per-test) has none, and `--only admin` has none
402
+ ([what runs](cli.md#what-runs)). The navigation has no other section of Shopify's admin
403
+ (Orders, Products, Customers, Settings): lsf has no page for them, and a `shopify:admin/…` link
404
+ of the app lands on a page that says so.
405
+
406
+ | Query | What it does |
407
+ |---|---|
408
+ | `shop` | The shop: `test-1` or `test-1.myshopify.com` ([a shop per test](#a-shop-per-test)). The store data's by default. |
409
+ | `user` | The staff member the session token is for: `1`, the account owner, by default. |
410
+ | `locale` | The `locale` the app is opened with: the shop's primary locale by default. |
411
+
412
+ The other parameters go to the app. `<handle>` is `handle` of `shopify.app.toml`, or the client
413
+ id; `/admin`, `/admin/` and `/admin/apps` redirect there. An app with `embedded = false` gets a page that
414
+ links to its launch instead, as Shopify opens it in a tab of its own.
415
+
416
+ ### Load lsf's App Bridge
417
+
418
+ The app's page runs App Bridge, Shopify's script, which talks to Shopify's admin only. lsf
419
+ serves its own, `/__lsf/admin/app-bridge.js`, which talks to lsf's admin: load it first in the
420
+ page's `<head>`, before any other script.
421
+
422
+ ```html
423
+ <script src="http://127.0.0.1:9292/__lsf/admin/app-bridge.js"></script>
424
+ <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js" data-api-key="…"></script>
425
+ ```
426
+
427
+ Shopify's script, when it loads after it, finds `shopify.ready` and does nothing (its first line
428
+ returns when App Bridge is already there): the page keeps its tag, and loads nothing else of
429
+ Shopify's. Offline, the tag does not load, and lsf's App Bridge is all there is. lsf's reads its
430
+ configuration where Shopify's does (`data-api-key` of the CDN tag, `<meta name="shopify-api-key">`,
431
+ `<script type="shopify/config">`, the page's `shop`, `host` and `locale`, then
432
+ `sessionStorage`), and takes the admin's origin from its own `src`: open the admin on the host
433
+ name the page loads it from (`127.0.0.1` and `localhost` are two origins).
434
+
435
+ Give `--app-url` an address on this machine (`http://localhost:3000`), not a tunnel: Chrome
436
+ treats a page served from a public address as public, and blocks its requests to `127.0.0.1` or
437
+ `localhost` (lsf's App Bridge, Polaris from `/__lsf/cdn/`) until the browser grants it local
438
+ network access.
439
+
440
+ **The frame goes through a proxy.** lsf's admin opens the app's frame on a port of its own
441
+ (`embedded:` names it: `its frame is http://127.0.0.1:52011, a proxy of http://localhost:3000`),
442
+ which forwards to `--app-url`. Shopify's libraries let only Shopify's admin frame the app: every
443
+ page `@shopify/shopify-app-react-router` answers has `Content-Security-Policy: frame-ancestors
444
+ https://<shop> https://admin.shopify.com …`, those it writes itself included (the bounce page
445
+ `/auth/session-token`, the page of `redirect(url, {target: "_top"})`, the exit-iframe page), which
446
+ a Response thrown from a loader carries past `entry.server.tsx`. No local origin is
447
+ `https://<shop>`. The proxy changes, in what the backend answers:
448
+
449
+ - `frame-ancestors` leaves `Content-Security-Policy`, and `X-Frame-Options` goes;
450
+ - `https://cdn.shopify.com/shopifycloud/app-bridge.js` is lsf's App Bridge, and
451
+ `https://cdn.shopify.com/shopifycloud/polaris…` is lsf's `/__lsf/cdn/` (in the pages and in
452
+ `Link`): Shopify's CDN online, lsf's copy offline. Each URL it changes ends with `#lsf-cdn`:
453
+ once those scripts have started loading, lsf's App Bridge puts the CDN's URL back in their
454
+ `src`, before React hydrates the page. So React finds the `src` it rendered, and the console
455
+ has no "Prop `src` did not match";
456
+ - each directive of `Content-Security-Policy` that lets the page load from Shopify's CDN
457
+ (`script-src`, `style-src`, `connect-src`, `font-src`, `img-src`, their `-elem` forms,
458
+ `default-src`) lets it load from lsf too, since lsf's App Bridge and Polaris come from there;
459
+ the other directives stay as they are;
460
+ - the backend's origin is the proxy's (in the pages, `Location`, the `shopify-reload` of the
461
+ bounce page, `X-Shopify-API-Request-Failure-Reauthorize-Url`), so that the frame stays on it;
462
+ - a URL of Shopify's admin in `Location` or `X-Shopify-API-Request-Failure-Reauthorize-Url`
463
+ (`https://<shop>/admin/charges/…`, the `confirmationUrl` of a charge) is lsf's admin's, with
464
+ `shop`: a backend that redirects its frame there plainly reaches lsf's charge page, which then
465
+ takes the whole window, as Shopify's admin page would.
466
+
467
+ The backend sees each request as if it came to it (`Host`, `Origin` and `Referer` are its own).
468
+ So the React Router template runs in lsf's admin as it ships: no edit of `entry.server.tsx`,
469
+ `app.tsx` or `polarisUrl`, and no `APP_BRIDGE_URL`. Its one edit is
470
+ [the fetch function](#javascript-the-react-router-app-and-shopifyshopify-api), for the Admin API.
471
+ The proxy forwards HTTP, not WebSockets: an HMR socket on the app's own port does not pass (the
472
+ template's has a port of its own). `LSF_ADMIN_FRAME=direct` opens the backend in the frame
473
+ without the proxy.
474
+
475
+ A page opened outside lsf's frame (`/__lsf/admin/launch`, a test that fetches `--app-url`) gets
476
+ none of this: it loads lsf's App Bridge itself, as above, with
477
+ `APP_BRIDGE_URL=http://127.0.0.1:9292/__lsf/admin/app-bridge.js` read by
478
+ `@shopify/shopify-app-react-router/adapters/node` for the pages the library writes, and a
479
+ `<script src={appBridgeUrl} />` before `AppProvider`.
480
+ [`examples/admin-backend`](https://github.com/0xtlt/local-storefront/tree/main/examples/admin-backend)
481
+ does it by hand (`server/embedded.mjs`), and leaves the library's `frame-ancestors` as they are.
482
+
483
+ When the app's page runs Shopify's App Bridge instead (`LSF_ADMIN_FRAME=direct`), lsf's admin
484
+ sees what it posts and says so in a banner, with the line to add; when no App Bridge answers (the backend is down, the page
485
+ failed, the frame was refused), it says that. Inside lsf's frame, Shopify's script redirects
486
+ nothing (it sends the page to Shopify's admin only when it is not in a frame), but it listens to
487
+ Shopify's admin only: `shopify.idToken()` and every authenticated `fetch()` wait, for a minute
488
+ and for ever.
489
+
490
+ Outside an admin (the page opened alone, as `/__lsf/admin/launch` does), lsf's App Bridge sends
491
+ the page to lsf's admin, as Shopify's sends it to Shopify's, unless `shopify.config` has
492
+ `disabledFeatures: ["auto-redirect"]`.
493
+
494
+ ### Polaris without the internet
495
+
496
+ Polaris, the web components of the app's pages (`<s-page>`, `<s-button>`, …), is a script of
497
+ Shopify's CDN, and loads its icons and its fonts from the CDN too. lsf serves the CDN's paths at
498
+ `/__lsf/cdn/<path>`, `<path>` being the path on `https://cdn.shopify.com/`: the page loads Polaris
499
+ from `http://127.0.0.1:9292/__lsf/cdn/shopifycloud/polaris-2.js`, and lsf's Polaris mode decides
500
+ what it gets.
501
+
502
+ | Mode | What `/__lsf/cdn/<path>` answers |
503
+ |---|---|
504
+ | `online`, the default | A `302` to `https://cdn.shopify.com/<path>`, the query kept: Shopify's Polaris, as without lsf. |
505
+ | `offline` | lsf's copy, which is in the binary: Polaris 2.0.0 (`shopifycloud/polaris-2.js`, `polaris-2.0.js`) and 1.1.0 (`shopifycloud/polaris.js`, `polaris-1.js`, `polaris-1.1.js`), every icon they name, and their fonts. In the scripts and stylesheets, `https://cdn.shopify.com/` becomes `http://<lsf>/__lsf/cdn/`: the icons and the fonts come from lsf too. A path lsf has no copy of is a `404` that says so. |
506
+
507
+ The mode is `--polaris offline` (or `LSF_POLARIS=offline`) of `serve` and `app dev`, and lsf
508
+ prints a `polaris:` line when it starts offline. While lsf runs, the
509
+ [control API](control-api.md) reads and changes it, for every page at once: `GET /__lsf/polaris`,
510
+ `PUT /__lsf/polaris` with `{"mode": "offline"}`, and `DELETE /__lsf/polaris` for the mode lsf
511
+ started in. `DELETE /__lsf/admin` and `DELETE /__lsf/sessions` keep it, as they keep stubbed URLs.
512
+ The copy is what the CDN served on 2026-11-08 (`crates/cli/assets/shopify-cdn`, whose README has
513
+ its hashes): lsf never downloads Polaris, and offline is the copy of the lsf you run. Online, the
514
+ page gets Shopify's current one.
515
+
516
+ In lsf's frame, the proxy sends the page's Polaris (`polaris.js` of the template's 3.x,
517
+ `polaris-2.js` of 4.0.0) and its `Link` preload to `/__lsf/cdn/`: nothing to change. Outside it,
518
+ `polarisUrl` (`@shopify/shopify-app-react-router` 2.1.0 or later) goes on `AppProvider` and on
519
+ `shopifyApp` (for the `Link` header), set to `http://127.0.0.1:9292/__lsf/cdn/shopifycloud/polaris.js`
520
+ (the same file the app loads). A page with a `Content-Security-Policy` allows lsf's origin where
521
+ it allows `cdn.shopify.com`: `script-src` (Polaris), `style-src` (the stylesheets of its fonts),
522
+ `font-src` (the fonts) and `connect-src` (Polaris fetches its icons). lsf answers with
523
+ `Access-Control-Allow-Origin: *`, and answers the preflight of a `fetch()` with headers
524
+ (`OPTIONS`: `204`, the headers asked for allowed, kept a day), so the app's origin may differ
525
+ from lsf's.
526
+
527
+ A page that still loads Polaris from `cdn.shopify.com` runs offline too: when that script fails
528
+ to load and lsf is `offline`, lsf's App Bridge loads lsf's copy of the same path, once, and says
529
+ so in the console. It never loads one when Polaris is already defined, nor when lsf is `online`.
530
+ [`examples/admin-backend`](https://github.com/0xtlt/local-storefront/tree/main/examples/admin-backend)
531
+ loads Polaris from `POLARIS_URL`, and its end-to-end run checks both in Chromium, with every
532
+ request to `cdn.shopify.com` refused.
533
+
534
+ ### What App Bridge does in lsf's admin
535
+
536
+ lsf's App Bridge has every name of `@shopify/app-bridge-types` 0.7.4 (the types of the CDN
537
+ script). In lsf's admin:
538
+
539
+ | API | What it does |
540
+ |---|---|
541
+ | `shopify.idToken()` | A session token of the admin's shop and user, minted by lsf and signed with the client secret, valid a minute. |
542
+ | `fetch()` | Adds `Authorization: Bearer <session token>`, `X-Requested-With: XMLHttpRequest` and `Accept-Language` to the requests to the page's host (on any port) or its subdomains, over the page's protocol, and to `appOrigins`; not to others, nor when the request has an `Authorization` already, nor to lsf's own origin (the admin the script comes from, its `/__lsf/cdn/` included), which on Shopify is never the app's host, unless the page itself is on lsf's origin. Asks a new token once when the answer has `X-Shopify-Retry-Invalid-Session-Request`, and sends the admin to `X-Shopify-API-Request-Failure-Reauthorize-Url` when there is one. `disabledFeatures: ["fetch"]` leaves `fetch` alone. As the CDN script 0.41.4 does. |
543
+ | `shopify.config`, `shopify.origin`, `shopify.ready`, `shopify.environment` | The configuration as the page gives it; lsf's admin's origin; ready once the admin answered; `{embedded: true, mobile: false, pos: false, intent: false}`. |
544
+ | `shopify.user()` | `{id, name, email, accountAccess, locale}` of the admin's user: `"Account owner"` for user 1, `"Full access"` for another. |
545
+ | `shopify.toast.show()`, `.hide()` | Toasts at the bottom of the admin, five seconds by default, `isError`, an `action` that calls `onAction`; `onDismiss` when the toast goes. |
546
+ | `shopify.loading()` | The loading bar at the top of the admin. |
547
+ | Title bar | The admin's title bar: `<ui-title-bar title>` and its buttons and links (`variant="primary"`, `"breadcrumb"`, `tone="critical"`, `<section label>` menus), else `<s-page heading>` and its slots (`primary-action`, `secondary-actions` with an `s-menu` by `commandFor`, `breadcrumb-actions`, `accessory`), else `document.title`. A click in the admin clicks the page's element. |
548
+ | Navigation | The admin's navigation, under the app's name: the links of `<s-app-nav>` or `<ui-nav-menu>` (`a`, `s-link`), relative, `app:` or of the page's origin; `rel="home"` is the app's name. A click in the admin clicks the page's link, so that the app's router follows it. The admin's URL follows the app's path (`history.pushState`, `replaceState`). |
549
+ | Save bar | `<form data-save-bar>` shows the admin's save bar while a field differs from what it was (Save submits the form, Discard resets it; `data-discard-confirmation` asks first); `<ui-save-bar id>` with `shopify.saveBar.show()`, `.hide()`, `.toggle()` (its `variant="primary"` button is Save, the other Discard; `discardConfirmation`). While it shows, a link or a nav item asks the merchant first, and `shopify.saveBar.leaveConfirmation()` resolves when they leave. |
550
+ | `shopify.resourcePicker()` | A picker of the shop's products (with their variants), variants or collections, as the Admin API has them: `action`, `multiple` (a number is a maximum), `query`, `filter` (`query`, `variants`, `draft`, `archived`, `hidden`), `selectionIds`. Answers `Product[]`, `ProductVariant[]` or `Collection[]` of `@shopify/app-bridge-types`, a product's `variants` the ones picked; `undefined` when cancelled. |
551
+ | `shopify.picker()` | A picker of the app's own items: `heading`, `items`, `headers`, `multiple`; `selected` resolves with their ids. |
552
+ | `shopify.scopes.query()` | `granted` (the scopes installed on the shop), `required` and `optional` (those of `shopify.app.toml`). |
553
+ | Modals | `<ui-modal>` (its children, or `src`; its `<ui-title-bar>` is its title and buttons), `<s-app-window src>`, and `<s-modal>` while Polaris is not there (offline): drawn in the app's frame. `shopify.modal.show(id)`, `.hide()`, `.toggle()`; Polaris's `s-modal` opens itself. While one shows, the frame covers the whole window, its page in its box and the rest transparent: the modal is over the whole admin, the admin dimmed behind it, as on Shopify. A page of a modal's `src` shares the frame's App Bridge. |
554
+ | `shopify.intents.invoke()` | `edit:shopify/Product`, `edit:shopify/ProductVariant` and `edit:shopify/Collection` with a `value` (the GID) show what lsf's Admin API has of it in a dialog. lsf has no editor: closing it completes the activity with `{code: 'closed'}`. Another intent completes with `{code: 'error'}`, whose `message` says lsf does not simulate it, and the admin lists it in its banner. |
555
+ | Links | `shopify:admin/products` (and `shopify://admin/…`, `window.open`) goes to lsf's page of it, which says lsf has no admin page there. `app:/path` is a path of the app. `target="_top"` leaves the admin. A plain `<a>` the app's router does not follow reloads the frame without the session token, on Shopify too: the console says so. Without Polaris, an `s-link`, `s-button` or `s-clickable` with `href` dispatches `shopify:navigate`, as Polaris does, for `AppProvider` to route. |
556
+ | The bounce page | `?shopify-reload=<url>` (the library's `/auth/session-token`) loads `<url>` again with a new `id_token`. |
557
+
558
+ What lsf does not simulate rejects with, or warns, `lsf does not simulate shopify.<name>`, and
559
+ the admin lists it in a banner: `shopify.scanner`, `shopify.pos`, `shopify.intents.register`, the
560
+ intents other than `edit:` (above), `shopify.webVitals`, `shopify.support`, `shopify.reviews`, `shopify.scopes.request`
561
+ and `.revoke`, `shopify.app.extensions()`, `shopify.shopifyQL`, `shopify.sidekick`,
562
+ `shopify.tools`, `fetch()` of `shopify:admin/api/…` (Direct API access), and `extension:` links.
563
+ `shopify.intents.request.value` is `null`: the page never runs as an intent.
564
+
565
+ Polaris web components (`s-page`, `s-button`, `s-text-field`, …) are Shopify's own, which lsf
566
+ serves rather than simulates: Shopify's CDN through `/__lsf/cdn/`, or lsf's copy of it
567
+ ([without the internet](#polaris-without-the-internet)). When no Polaris loads (a page on the
568
+ CDN, no internet, lsf `online`), they show as plain elements with their text, and lsf's App
569
+ Bridge reads what it needs of them all the same (the title bar of `s-page`, the links of
570
+ `s-app-nav`), opens `s-modal`s and follows `s-link`s itself.
571
+
572
+ ## Tokens
573
+
574
+ | Token | How to get it |
575
+ |---|---|
576
+ | The default token of a shop | Printed at start, in `GET /__lsf/admin` (`shops[].token`), and the answer of `POST /__lsf/admin/shops`. Offline, never expiring, the scopes of the app. |
577
+ | A session token (App Bridge's `id_token`) | `POST /__lsf/admin/session-token` `{"shop"?, "user"?}`: HS256 with the client secret, the claims of shopify.dev's "ID tokens". |
578
+ | An access token by token exchange | `POST /admin/oauth/access_token` with the session token, as the React Router template and the libraries do: offline or online, expiring or not. |
579
+ | An access token by the authorization code grant | `GET /admin/oauth/authorize?client_id=&scope=&redirect_uri=&state=` approves at once and redirects to `redirect_uri` with `code`, `hmac`, `host`, `shop`, `state`, `timestamp`; then `POST /admin/oauth/access_token`. `redirect_uri` must be one of `[auth] redirect_urls`, or one of them on the origin of `application_url` moved to the origin of `--app-url`, the same path: with `https://my-app.trycloudflare.com/auth/callback` in the file and `--app-url http://localhost:3000`, `http://localhost:3000/auth/callback` is accepted, and `http://localhost:3000/other/callback` is not. An app with no `[auth] redirect_urls` accepts none. |
580
+ | Refresh tokens, client credentials | `grant_type=refresh_token`, `grant_type=client_credentials`, as Shopify. |
581
+ | A delegate access token | `delegateAccessTokenCreate`: `shppa_…`, a subset of the parent's scopes, expiring with it or sooner (see "[The app's installation](admin-reference.md#the-apps-installation-and-shopifys-other-services)"). |
582
+
583
+ OAuth without a library (in Python, say): send the browser to
584
+ `$LSF_URL/admin/oauth/authorize?client_id=…&scope=…&redirect_uri=…&state=…` (with `&shop=` for a
585
+ shop other than the default one), and POST the code of the callback to
586
+ `$LSF_URL/admin/oauth/access_token`, `{"client_id", "client_secret", "code"}`: the code knows its
587
+ shop.
588
+
589
+ **Tokens survive a restart of lsf.** lsf keeps its tokens in memory, and the backend keeps the
590
+ ones it got in its session storage. So every token lsf issues (offline, expiring offline and its
591
+ refresh token, client credentials) is sealed with a key of the client id and the client secret:
592
+ it holds the shop, the kind, the rank and the epoch of the installation it was issued in, which
593
+ only lsf reads. Until a reset or an uninstall, the installation is in the same epoch every run,
594
+ so the first offline token of a run is the first of every run. A token lsf does not know is
595
+ opened, and one of an earlier run is taken back, in whatever order the backend presents them:
596
+ the backend's stored session works after lsf restarts, with the scopes the app is installed with
597
+ now (an expiring one for an hour from then), and the terminal says
598
+ `admin: took back an access token of an earlier run of lsf (shpat_3cbbea…)`.
599
+
600
+ A reset (`DELETE /__lsf/admin`), a shop deleted or an uninstall closes the installation: from
601
+ then on, no token of an earlier run is taken back for it, those the backend had not presented
602
+ yet included, and its refresh tokens are refused too; the `401` says what revoked them. The
603
+ tokens issued after it are of a new epoch, which no earlier run issued, and they are taken back
604
+ after the next restart. A token of this run that expired, was migrated or was retired is never
605
+ taken back; a token of another secret is unknown. Online tokens are not taken back: they last a
606
+ day, and the libraries exchange a new session token after a `401`.
607
+
608
+ Every access token is `shpat_` and 32 hex digits, as Shopify's. A token belongs to one shop. A
609
+ request without a valid token is answered `401`
610
+ `{"errors": "[API] Invalid API key or access token (unrecognized login or wrong password)"}`, with
611
+ an `X-Lsf-Reason` header saying why in plain words. A token or a code that expired, and a refresh
612
+ token that was retired, are refused as such for a day; then lsf forgets them, and refuses them as
613
+ tokens it never issued. For a revoked token, the reason says what revoked it and how to get
614
+ another: `this access token of test-1.myshopify.com was revoked when the shop was deleted
615
+ (DELETE /__lsf/admin/shops/test-1): run the token exchange or OAuth again …` (an uninstall and
616
+ a reset say so too). The server prints it once per token, `--quiet` or not:
617
+ `admin: 401 for a revoked access token: …`.
618
+
619
+ **Scopes are enforced.** A field or a mutation the token's scopes do not cover answers
620
+ `ACCESS_DENIED` with Shopify's message ("Access denied for products field. Required access:
621
+ `read_products` access scope."), for root fields, nested ones (an order's `customer` needs
622
+ `read_customers`) and `node`. `write_x` covers `read_x`. To see what the backend does with fewer
623
+ scopes: `POST /__lsf/admin/install {"scopes": "read_orders"}` installs the app again with them
624
+ (every token of the app on the shop now reads less, the printed one and those of token exchange
625
+ and OAuth alike, and `app/scopes_update` is delivered).
626
+
627
+ **Scopes changed in `shopify.app.toml`** while lsf runs do not change the installation: the app
628
+ keeps the scopes lsf started with, as an installed app keeps what the merchant granted on
629
+ Shopify until they approve the new ones. The terminal says so at the next request (`admin:
630
+ shopify.app.toml changed its scopes (added …)`): restart lsf, or grant them with
631
+ `POST /__lsf/admin/install {"scopes": "…"}` on each shop. With `--static`, the file is not looked
632
+ at again.
633
+
634
+ **Uninstall.** `POST /__lsf/admin/uninstall` does what Shopify does when a merchant uninstalls
635
+ the app: it revokes the app's tokens on the shop, cancels its subscription, removes its
636
+ Functions with every object that runs them, its script tags and its web pixel, stops rendering
637
+ its UI extensions (below), delivers `app/uninstalled` (through the
638
+ subscriptions the shop had), then deletes the subscriptions the backend made with
639
+ `webhookSubscriptionCreate` ("Shopify automatically performs cleanup tasks, such as deleting
640
+ WebhookSubscription objects", [`appUninstall`](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/appUninstall)).
641
+ From then on nothing is delivered for the shop: an order placed at the checkout is recorded, and
642
+ no `orders/create` goes out, not even through `shopify.app.toml`, whose subscriptions apply to
643
+ "every shop that installs your app". Installing it again (`POST /__lsf/admin/install`, OAuth, or a
644
+ token exchange) brings back the subscriptions of `shopify.app.toml`; those of the shop are gone,
645
+ for the backend to make again, as on Shopify. It brings back the shop's default token too (the
646
+ one printed at start), by design: a script or a test that read it keeps working. The tokens of
647
+ token exchange and OAuth stay revoked, and the backend gets new ones, as on Shopify. `shop/redact`, which Shopify sends 48 hours after
648
+ an uninstall, is sent on demand: `POST /__lsf/webhooks/trigger {"topic": "shop/redact"}`.
649
+
650
+ **The app's Functions stop with the uninstall.** Shopify removes an uninstalled app's
651
+ extensions, Functions included, and a Function goes "including all associated function owners"
652
+ ([About Shopify Functions](https://shopify.dev/docs/apps/build/functions)). So the uninstall
653
+ deletes, with their metafields, every object that runs one of the app's Functions: its payment
654
+ and delivery customizations, validations, cart transforms, fulfillment constraint rules, and its
655
+ app discounts (`DiscountAutomaticApp`, `DiscountCodeApp`), whether the app created them or the
656
+ store data declared them (`checkout.functions.<handle>.enabled`, `checkout.discounts`). The
657
+ answer counts them (`removedFunctionOwners`). The discounts of Shopify's own types (a
658
+ `DiscountCodeBasic` the app made) stay. While the app is uninstalled, the shop's checkout runs
659
+ none of its Functions, whatever a scenario declares or names in its `extensions`: the
660
+ checkout's Functions dialog lists each with "the app is not installed on the shop". A reinstall
661
+ gets none of those objects back, as on Shopify, where a reinstalled app "had to register them
662
+ again through the API" ([community](https://community.shopify.com/t/shopify-function-doesnt-work-when-reinstalling-the-app/261798)):
663
+ the app creates them again, as on a first install. Shopify removes them in the background
664
+ after the uninstall ("the job that deletes the price rules and their associated codes runs
665
+ asynchronously", Shopify's staff on [community.shopify.dev](https://community.shopify.dev/t/discountredeemcodebulkadd-phantom-redemption-codes/32591));
666
+ lsf removes them at once.
667
+
668
+ **The app's own metafield definitions go too; their metafields stay.** "When a user uninstalls
669
+ your app, Shopify deletes its app-owned metafield definitions and temporarily retains the
670
+ metafields and their values without a definition. This applies to any app-owned definition,
671
+ whether declared in `shopify.app.toml` or created with the GraphQL Admin API", and "If the same app
672
+ is reinstalled while that data is still retained, then Shopify recreates the definitions and
673
+ associates the existing metafields with them" ([Manage metafield definitions, App uninstall and
674
+ reinstall](https://shopify.dev/docs/apps/build/metafields/definitions#app-uninstall-and-reinstall)).
675
+ lsf does the same:
676
+
677
+ | Definition | On Shopify | In lsf |
678
+ |---|---|---|
679
+ | In the app's reserved namespace (`$app`, `$app:<name>`, stored `app--<app id>…`), declared in `shopify.app.toml` | Deleted during the uninstall; created again on reinstall, under a new GID ("a definition ID from before the uninstall no longer resolves") | Deleted by the uninstall (`removedMetafieldDefinitions`); the reinstall creates it again under a new id, with its metafields |
680
+ | In the app's reserved namespace, made with `metafieldDefinitionCreate` | Deleted too, in the background ("can persist after uninstall until asynchronous cleanup removes them"); created again by a reinstall while the data is retained | Deleted at once, like the declared ones; the reinstall creates it again under a new id |
681
+ | In any other namespace (`custom`, one the app chose) | The merchant's definition: nothing says an uninstall touches it | Stays, id and metafields included |
682
+ | The metafields (values) of the app's definitions | Kept a while without a definition ("Shopify doesn't guarantee how long") | Kept, without a definition, until the store data is seeded again; a new seed while the app is uninstalled keeps the declared definitions deleted and drops the ones the app made |
683
+
684
+ While the app is uninstalled, an order does not copy the cart metafields of a deleted `$app`
685
+ definition (`cart_to_order_copyable`): the definition is gone. Look definitions up by owner type,
686
+ namespace and key after a reinstall, as shopify.dev advises, not by an id kept from before. The
687
+ app-data metafields of its installation (`AppInstallation`) stay: no shopify.dev page says an
688
+ uninstall deletes them.
689
+
690
+ **The app's own metaobject definitions go too; their metaobjects are kept.** Shopify says the
691
+ same of metaobjects: "When a user uninstalls your app, Shopify deletes its app-owned metaobject
692
+ definitions and temporarily retains the metaobjects and their values without a definition", and a
693
+ reinstall "recreates the definitions and associates the existing metaobjects with them", under new
694
+ GIDs ([Manage metaobject definitions, App uninstall and
695
+ reinstall](https://shopify.dev/docs/apps/build/metaobjects/manage-metaobject-definitions#app-uninstall-and-reinstall)).
696
+ lsf does the same:
697
+
698
+ | Definition | On Shopify | In lsf |
699
+ |---|---|---|
700
+ | Of the app's reserved types (`$app:<type>`, stored `app--<app id>--<type>`), from the store data or `metaobjectDefinitionCreate` | Deleted (the GraphQL ones in the background); created again on reinstall, under a new GID | Deleted by the uninstall (`removedMetaobjectDefinitions`); the reinstall creates it again under a new id |
701
+ | Of any other type (`author`, `size_chart`) | The merchant's | Stays, id and entries included |
702
+ | The metaobjects of the app's definitions | Kept a while without a definition | Kept aside, ids and values included (`retainedMetaobjects`), until the store data is seeded again; the reinstall gives them back to the new definition |
703
+
704
+ While the app is uninstalled, its metaobjects are out of reach: a `Metaobject` has a non-null
705
+ `definition`, and `metaobjects(type:)` takes the type of "a definition that already exists in the
706
+ shop". So no query lists them, and the storefront does not render them. lsf does not read
707
+ `[metaobjects.app.<type>]` of `shopify.app.toml`: the app's types come from the store data and from
708
+ the Admin API. A metafield definition whose validation names the deleted definition
709
+ (`metaobject_definition_id`) names the new one after the reinstall. Look definitions up by type
710
+ after a reinstall, as shopify.dev advises.
711
+
712
+ **The app's extensions go with it too.** While the app is not installed on the default shop, lsf
713
+ renders none of its UI extensions, as Shopify removes an uninstalled app's extensions. Its theme
714
+ app blocks are files of the theme, which lsf leaves as they are:
715
+
716
+ | Extension | On Shopify | In lsf while uninstalled | After a reinstall |
717
+ |---|---|---|---|
718
+ | Checkout UI extensions, thank-you and order status targets | They stop rendering; a reinstall within 24 hours restores the active ones with their settings ([staff on community.shopify.dev](https://community.shopify.dev/t/activated-checkout-extensions-do-not-deactivate-after-app-uninstall/35571)); later, the merchant places them again in the checkout editor | The checkout and its thank-you page load none, an order placed before the uninstall included | They render again: lsf has no checkout editor, so it always restores them |
719
+ | Customer account UI extensions | Placed in the same checkout and accounts editor | The customer account loads none; an extension's full page (`/account/pages/<handle>`) answers `404` with the reason | They render again |
720
+ | Theme app extensions (app blocks, app embeds) | "Blocks associated with the apps are automatically and entirely removed from online store themes" ([UX guidelines](https://shopify.dev/docs/apps/build/online-store/theme-app-extensions/ux)); app embeds are off after an install until the merchant turns them on | They still render: they are the theme's files (and `app.embeds` of the store data), which lsf never writes ([theme app extensions](storefront.md#theme-app-extensions)) | Nothing changes |
721
+ | Web pixel | Removed with the app; a reinstalled app calls `webPixelCreate` again | The uninstall deletes the `WebPixel` record (`removedWebPixels`); the storefront runs no web pixel anyway | The app creates it again |
722
+ | Script tags | "When an app is uninstalled from a shop, all of the script tags that it created are automatically removed" ([`ScriptTag`](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/ScriptTag)) | The uninstall deletes them (`removedScriptTags`): `content_for_header` stops loading them at once | The app creates them again |
723
+ | Admin UI extensions, admin links | Admin links are deleted ([`appUninstall`](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/appUninstall)) | lsf's admin renders neither | - |
724
+ | App proxy | A store gets it with the app: a change of `prefix` or `subpath` applies "only to new installations" (on a dev store, "uninstall and reinstall the app", [About app proxies](https://shopify.dev/docs/apps/build/online-store/app-proxies)), so the uninstall removes it and the storefront answers the path with its `404` page | `/<prefix>/<subpath>` is the theme's `404` page (with `X-LSF-Reason`), and nothing reaches the backend ([App proxy](#app-proxy)) | Forwarded again, on the path of `shopify.app.toml` |
725
+
726
+ Each place that shows extensions says why they are gone, "the app is not installed on the shop":
727
+
728
+ - the checkout: a banner (`gift-message, order-notice: not loaded. …`), the "Extensions not
729
+ loaded" list of the Functions dialog, and the panel of `?targets=all`; its state has
730
+ `"extensions": []` and `"uninstalled": {"reason", "extensions"}` (`null` while installed);
731
+ - the customer account: the same banner and panel;
732
+ - the home page (`/` with `--app` and no theme, `/__lsf/checkouts` otherwise): a banner, a line in
733
+ Problems, and `app not installed` on each Function and UI extension; its JSON has
734
+ `"installed": false` and `"uninstalled"`;
735
+ - the terminal, unless `--quiet`: `admin: local-supply-co.myshopify.com: the app is not installed
736
+ on the shop: its UI extensions do not render and its Functions do not run until it is installed
737
+ again (…)`, then, for an app with an `[app_proxy]`, `… uninstalling it removed its app proxy, so
738
+ /apps/reviews is the storefront's 404 …`; and `… the app is installed again: its UI extensions
739
+ render again, …; its 2 metafield definitions are back, under new ids; its metaobject definition
740
+ is back, under a new id, with its metaobjects; /apps/reviews goes to its app proxy again` after a
741
+ reinstall.
742
+
743
+ ## Webhooks
744
+
745
+ - **Subscriptions**: those of `shopify.app.toml` (`[[webhooks.subscriptions]]`, every shop the
746
+ app is installed on), and those the backend makes with `webhookSubscriptionCreate` (per shop,
747
+ deleted when the app is uninstalled; listed by `webhookSubscriptions`, which, as on Shopify,
748
+ does not list those of the file). A subscription
749
+ made through the Admin API takes `https://` and, locally, `http://` to this machine.
750
+ - **Where they go**: a `uri` that is a path joins `--app-url` (else `application_url`); an
751
+ absolute one on the origin of `application_url` (the tunnel of a `shopify app dev` that is not
752
+ running) moves to `--app-url`; another goes where it says. **Webhooks stay on this machine**:
753
+ lsf sends one to the loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), to the
754
+ scheme, host and port of `--app-url`, or to an address given on purpose (`--address`,
755
+ `address` of `POST /__lsf/webhooks/trigger`). A name under `localhost` (`app.localhost`) is
756
+ sent to the loopback whatever your system's resolver says, with its own `Host` header: the
757
+ name is reserved for the loopback (RFC 6761). `0.0.0.0` and `::ffff:127.0.0.1` do not count as
758
+ this machine. Anything else (a production backend, a stale tunnel) is not sent, and
759
+ `GET /__lsf/webhooks` logs it `not_sent`, with the reason `lsf sends webhooks to this machine
760
+ or to --app-url only: <url> is neither; start lsf with --app-url http://localhost:<port> (your
761
+ backend)`. So give `--app-url`: without it, the subscriptions of `shopify.app.toml` point at
762
+ `application_url`, usually a tunnel, and lsf sends nothing there.
763
+ - **Shopify's Events** (`[events]`, `[[events.subscription]]`, which the app template ships for
764
+ `webhooks.products.update.tsx`): lsf delivers those of topic `Product` (`create`, `update`,
765
+ `delete`), when a product or a variant changes through the Admin API. An `update` is sent
766
+ when a field of `triggers` changed (`product.variants.price`, `product.variants.*`,
767
+ `product.*`; the product's `title`, `handle`, `status`, `tags`, `vendor`, `productType`,
768
+ `descriptionHtml`, `templateSuffix`, and the variants' `title`, `price`, `compareAtPrice`,
769
+ `sku`, `barcode`, `inventoryPolicy`, `taxable`, `position`), one delivery per variant changed.
770
+ The body has `topic`, `action`, `handle`, `fields_changed` and `query_variables`
771
+ (`productId`, `variantsId`), and `data` (and `errors`), what `query` answers with them, with
772
+ the app's scopes; a `query_filter` that does not match sends nothing (`not_sent`, with why).
773
+ The headers are Shopify's for Events: `Shopify-Topic: Product`, `Shopify-Action`,
774
+ `Shopify-Handle`, `Shopify-Hmac-Sha256`, …, without `X-`; `authenticate.webhook` reads them
775
+ (`topic` is `PRODUCT`). `GET /__lsf/webhooks?topic=Product` lists them. Another topic is
776
+ warned about at start, and nothing is sent for it.
777
+ - **Events**: what the Admin API changes (`productCreate` → `products/create`,
778
+ `inventory_items/create`, `inventory_levels/connect`; any other change of a product, such as
779
+ `productUpdate`, `tagsAdd` or `metafieldsSet` on it → `products/update`; `productDelete` →
780
+ `products/delete`), an order placed at the checkout
781
+ (`orders/create`, `order_transactions/create`, then `orders/paid` when it is paid in full), an
782
+ uninstall, a change of scopes.
783
+ - **The storefront's own events**, as the online store's cart and checkout fire them on
784
+ Shopify, through the default shop:
785
+ - `carts/create` when a cart of the theme gets its first line (`/cart/add.js`, the cart
786
+ form, `/cart/update.js`), `carts/update` at each change of its lines, note or attributes;
787
+ the payload's `id` and `token` are the cart's token (`/cart.js`'s), its prices the
788
+ variants' in the Admin API;
789
+ - `checkouts/create` when the [local checkout](checkout.md) of a cart is first shown,
790
+ `checkouts/update` when what the buyer sees changes (lines, email, phone, address, delivery,
791
+ payment, discount codes) and when the order is placed (`completed_at`); reading the checkout
792
+ again sends nothing;
793
+ - a checkout with contact information and no order for more than ten minutes ("A checkout is
794
+ considered abandoned if it remains incomplete for more than ten minutes", Help Center) is in
795
+ `abandonedCheckouts`, and, for a customer of the shop, has an `Abandonment`; three months on,
796
+ the admin deletes it (`checkouts/delete`). The time is the shop's: with a store data `now`,
797
+ move it with `POST /__lsf/admin/clock {"advance": "11m"}` ([the shop's
798
+ clock](admin-reference.md#the-shops-clock)).
799
+ - **Payloads** are Shopify's JSON for the topic (the REST representation of the resource), built
800
+ from the record as the change left it, cut to `include_fields`, and checked against `filter`.
801
+ A topic whose payload lsf does not build is logged as not sent, never sent with a made-up body.
802
+ - **`filter` and `include_fields`**: Shopify requires every field a filter reads in
803
+ `include_fields` when it is set. A subscription that breaks the rule is created (with a note in
804
+ `extensions.lsf`), and its deliveries are `not_sent`, their `reason` naming the fields to add.
805
+ - **Metafields**: a subscription made with `metafieldNamespaces` (or `metafields`) gets the
806
+ record's metafields of those namespaces in its payload, as `metafields`, in the REST shape (`id`,
807
+ `namespace`, `key`, `value`, `type`, `owner_id`, `owner_resource`, …). `$app` and `$app:<name>`
808
+ name the app's namespaces. A record with no metafields (an inventory level) gets none, and the
809
+ delivery's notes say so. With `include_fields`, name `metafields` there too. Shopify does not
810
+ document the shape; lsf follows the REST `Metafield` resource.
811
+ - **Headers**: `X-Shopify-Topic`, `X-Shopify-Hmac-Sha256` (base64 HMAC-SHA256 of the body with the
812
+ client secret), `X-Shopify-Shop-Domain`, `X-Shopify-API-Version` (the version the payload is
813
+ built in: the subscription's, `2026-10` by default),
814
+ `X-Shopify-Webhook-Id`, `X-Shopify-Event-Id`, `X-Shopify-Triggered-At`, `User-Agent:
815
+ Shopify-Captain-Hook`. Every library's verification accepts them with the shared secret
816
+ (`authenticate.webhook` of the React Router package).
817
+ - **Delivery** is from a background worker, in order, retried after 1, 5 and 15 seconds when the
818
+ backend does not answer `2xx` within 5 seconds. The delivery stays `pending` through the
819
+ retries, its `reason` the last failure, and becomes `failed` after the last attempt, about 21
820
+ seconds after the first.
821
+
822
+ Without a library, verify the HMAC on the raw bytes of the body, before parsing it: the JSON
823
+ parsed and serialized again is not what was signed. In Python:
824
+
825
+ ```python
826
+ import base64, hashlib, hmac
827
+
828
+ def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
829
+ digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
830
+ return hmac.compare_digest(base64.b64encode(digest).decode(), header or "")
831
+
832
+ # Flask: verify_webhook(request.get_data(), request.headers.get("X-Shopify-Hmac-Sha256"), SECRET)
833
+ ```
834
+
835
+ **The backend answers `401`** (or `403`): the two secrets differ. lsf signs with
836
+ `--client-secret` (`LSF_CLIENT_SECRET`), else `SHOPIFY_API_SECRET` of the environment, else the
837
+ one of the app's `.env`, else `lsf-local-client-secret`. lsf says so where you look, naming the secret it signed with as the start line `client secret:`
838
+ does:
839
+
840
+ - the server prints one line at the first refusal of each delivery, `--quiet` or not:
841
+ `webhooks: products/create → http://localhost:3000/webhooks answered 401: lsf signed it with
842
+ the client secret from SHOPIFY_API_SECRET of ../my-app/.env: does the backend verify with the
843
+ same one?`;
844
+ - the delivery's `reason` in `GET /__lsf/webhooks` says the same: `answered 401: lsf signed it
845
+ with …` while it is `pending`, `answered 401 (4 attempts): lsf signed it with …` once `failed`;
846
+ - `lsf app webhook trigger` prints `failed: answered 401 (1 attempt): lsf signed it with …`.
847
+
848
+ On demand: `POST /__lsf/webhooks/trigger {"topic": "orders/create", "id"?: "<gid>"}` sends the
849
+ webhook of a record of the shop (a representative one without `id`: for `orders/create`, the
850
+ first order), and the command line does it without a server. In the demo store, the first order,
851
+ `#1001`, is fulfilled already: its fulfillment order is `CLOSED`, and a handler's
852
+ `fulfillmentCreate` answers a `userError`. To test a fulfillment handler, [place an
853
+ order](#place-an-order), or give the `id` of an unfulfilled one:
854
+ `{"topic": "orders/create", "id": "gid://shopify/Order/7888001690128"}` (`#1002`).
855
+
856
+ ```bash
857
+ npx local-storefront app webhook trigger --topic app/uninstalled --app ../my-app --app-url http://localhost:3000
858
+ ```
859
+
860
+ The topic must be one `shopify.app.toml` subscribes to (or give `--address <url>`). Without
861
+ `--app-url`, a subscription that leads off this machine is not sent: the command sends nothing,
862
+ prints `error: lsf sends webhooks to this machine or to --app-url only: <url> is neither; give
863
+ --app-url http://localhost:<port> (your backend) or --address <url>` and exits `2`.
864
+ `--format json` prints each delivery as `GET /__lsf/webhooks` lists it. See the
865
+ [control API](control-api.md) and the [CLI](cli.md#app-webhook-trigger).
866
+
867
+ ## Test your backend
868
+
869
+ A test of the backend runs lsf and the backend side by side. It gives itself a shop, calls the
870
+ backend as the app's front end does, places orders as a buyer, and waits for the webhooks lsf
871
+ delivers back. Tests that run in parallel each take a shop of their own.
872
+
873
+ ### Start lsf and the backend
874
+
875
+ Fix both ports: the backend reads lsf's address when it starts (`LSF_URL`), and lsf delivers the
876
+ webhooks to the backend's (`--app-url`). Both need the same client secret. lsf reads
877
+ `SHOPIFY_API_SECRET` of the app's `.env` (its start line `client secret:` says where it found
878
+ one); the backend reads that file only when something loads it:
879
+
880
+ - `npx react-router dev`, the React Router template's dev server, loads it into the backend
881
+ (`SHOPIFY_API_KEY`, `SHOPIFY_API_SECRET`, `SCOPES`), after `npm run setup` (its Prisma session
882
+ storage);
883
+ - `react-router-serve` (`npm run start`, a build's server) reads no `.env`: the Playwright config
884
+ below loads it with `process.loadEnvFile`;
885
+ - a plain Node server reads it with `node --env-file=.env server.mjs`.
886
+
887
+ With Playwright, the config of the React Router template is the same in every page of lsf's
888
+ docs ([quickstart](quickstart-app.md#7-a-playwright-test), [testing](testing.md#an-embedded-app)):
889
+
890
+ ```ts
891
+ // playwright.config.ts
892
+ import { defineConfig } from '@playwright/test';
893
+
894
+ // The app's values (SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SCOPES): react-router-serve reads no .env.
895
+ process.loadEnvFile('.env');
896
+
897
+ export default defineConfig({
898
+ testDir: 'e2e',
899
+ workers: 1, // one lsf, one shop: the tests change it in turn
900
+ use: {
901
+ // For the test's own requests to the backend: the template answers 410 to HeadlessChrome.
902
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
903
+ },
904
+ webServer: [
905
+ {
906
+ command: 'npx local-storefront app dev --app . --port 9393 --app-url http://localhost:3001 --polaris offline --static --quiet',
907
+ url: 'http://127.0.0.1:9393/__lsf/status',
908
+ },
909
+ {
910
+ // A build, not the dev server: on a cold start, Vite's dev server optimizes the
911
+ // dependencies a page asks for and reloads the page, under the test.
912
+ command: 'npx prisma migrate deploy && npx react-router build && npx react-router-serve ./build/server/index.js',
913
+ url: 'http://localhost:3001/',
914
+ env: { PORT: '3001', SHOPIFY_APP_URL: 'http://localhost:3001', LSF_URL: 'http://127.0.0.1:9393' },
915
+ timeout: 120_000,
916
+ },
917
+ ],
918
+ });
919
+ ```
920
+
921
+ The backend runs from a build: on a cold checkout (CI), Vite's dev server optimizes the
922
+ dependencies the first page asks for, answers `504 Outdated Optimize Dep`, and reloads the page
923
+ under the test. The build takes a few seconds, and the tests run next to your dev server: other
924
+ ports, no HMR. Two changes for the tests of this section:
925
+
926
+ - they each make a shop of their own ([a shop per test](#a-shop-per-test)), so they may run in
927
+ parallel: `fullyParallel: true` in place of `workers: 1`;
928
+ - another backend gets its own command and port in place of the second server, the same
929
+ `LSF_URL` and `--app-url`.
930
+
931
+ The backend's `url` must answer without a session token: Playwright waits for a `2xx`, `3xx` or
932
+ `400` to `403`. Without Playwright, start both, then wait until `GET /__lsf/status` answers (or
933
+ `ready:` is printed on stderr) and the backend's port does: with pytest, a fixture does it
934
+ ([in Python](#in-python)).
935
+
936
+ ### A shop per test
937
+
938
+ Each test makes a shop of its own (`POST /__lsf/admin/shops`), seeded from the same store data,
939
+ with the app installed and the subscriptions of `shopify.app.toml`. It calls the backend with a
940
+ session token of that shop, as App Bridge does, and a browser's `User-Agent`:
941
+
942
+ ```js
943
+ // tests/lsf.mjs: plain fetch, for Playwright, node:test or Vitest alike.
944
+ export const LSF = "http://127.0.0.1:9393";
945
+ export const BACKEND = "http://localhost:3001";
946
+ // The React Router package answers 410 Gone to a bot's User-Agent, Node's included.
947
+ const BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
948
+
949
+ export async function lsf(method, path, body) {
950
+ const answer = await fetch(LSF + path, {
951
+ method,
952
+ headers: { "Content-Type": "application/json" },
953
+ body: body && JSON.stringify(body),
954
+ });
955
+ if (!answer.ok) throw new Error(`${method} ${path}: ${answer.status} ${await answer.text()}`);
956
+ return answer.json();
957
+ }
958
+
959
+ // { shop: "<name>.myshopify.com", token, graphql }
960
+ export const newShop = (name) => lsf("POST", "/__lsf/admin/shops", { shop: name });
961
+
962
+ // A request of the app's front end, with a session token of the shop (valid for a minute).
963
+ export async function callBackend(shop, method, path, body) {
964
+ const { authorization } = await lsf("POST", "/__lsf/admin/session-token", { shop });
965
+ return fetch(BACKEND + path, {
966
+ method,
967
+ headers: { Authorization: authorization, "User-Agent": BROWSER, "Content-Type": "application/json" },
968
+ body: body && JSON.stringify(body),
969
+ });
970
+ }
971
+
972
+ // The deliveries that match the filters, once `wait` of them are done. Throws on a `408`
973
+ // (fewer were done than `wait` asks) and on a `422` (a wait that could only time out).
974
+ export const webhooks = (filters) => lsf("GET", `/__lsf/webhooks?${new URLSearchParams(filters)}`);
975
+ ```
976
+
977
+ ```js
978
+ // tests/products.spec.mjs: `/api/products` stands for a route of your backend.
979
+ import { test, expect } from "@playwright/test";
980
+ import { callBackend, lsf, newShop, webhooks } from "./lsf.mjs";
981
+
982
+ test("a product the backend creates comes back as a webhook", async () => {
983
+ const { shop } = await newShop(`products-${crypto.randomUUID().slice(0, 8)}`);
984
+ try {
985
+ const answer = await callBackend(shop, "POST", "/api/products", { title: "Mug" });
986
+ expect(answer.status).toBe(200);
987
+
988
+ const { deliveries } = await webhooks({ topic: "products/create", shop, wait: 1 });
989
+ expect(deliveries[0].state).toBe("delivered");
990
+ expect(deliveries[0].attempts.at(-1).status).toBe(200); // what the backend answered
991
+ } finally {
992
+ await lsf("POST", "/__lsf/admin/uninstall", { shop }); // app/uninstalled: the backend drops its session
993
+ await lsf("DELETE", `/__lsf/admin/shops/${shop}`);
994
+ }
995
+ });
996
+ ```
997
+
998
+ The default shop's token, for a test that calls the Admin API itself, is `shops[0].token` of
999
+ `GET /__lsf/admin`; a test shop's is in the answer of `POST /__lsf/admin/shops`. Only the
1000
+ default shop [syncs to the storefront](#what-the-storefront-and-the-checkout-see): what a test
1001
+ shop changes stays in the Admin API.
1002
+
1003
+ **Ids are unique across shops**, as on Shopify: a product, an order or a charge a test shop
1004
+ creates has an id no other shop gives, so a backend that keeps every shop in one database does
1005
+ not mix them up. The ids of a shop are a number of its name alone: the same name gives the same
1006
+ ids every run, whatever other shops the run makes and in whatever order. A test shop's ids have
1007
+ 14 digits; the records of the store data keep their ids in every shop.
1008
+
1009
+ ### Wait for a webhook
1010
+
1011
+ `GET /__lsf/webhooks?topic=products/create&shop=test-1&wait=1` answers once one delivery that
1012
+ matches the filters is done: `delivered`, `failed` or `not_sent` (a `pending` one is still being
1013
+ tried). The log keeps every delivery since the last `DELETE /__lsf/webhooks`, those of other
1014
+ tests and of earlier runs included, so `wait=1` can answer with an old one at once. `wait`
1015
+ counts only the deliveries that match every filter. Filter it:
1016
+
1017
+ - `shop=`: a test shop, by its name (`test-1`, in any case) or its domain.
1018
+ - `resource=<gid>`: the deliveries about one record (the order a test placed, the product it
1019
+ made), as each delivery's `resource` gives it. Two shops never create the same id, but a
1020
+ record of the store data has the same id in every shop: give `shop=` with it. Ids come back
1021
+ after a reset: after `DELETE /__lsf/admin`, the next order has the id of the run before's,
1022
+ whose delivery the log still has. Give `since=` with it, or reset the log as well.
1023
+ - `since=<id>`: the deliveries after that one. Every answer has `last`, the newest id of the
1024
+ log (`0` before the first). Ids are never given twice, even after a `DELETE`: read `last`
1025
+ before the action, and `since=<last>` waits for what the action sends only. For the second
1026
+ webhook of a topic, pass the `id` of the first: a `last` read after both were sent skips the
1027
+ second.
1028
+
1029
+ A delivery the backend fails is retried after 1, 5 and 15 seconds, and stays `pending` until its
1030
+ last attempt, about 21 seconds on, its `reason` the last failure (`answered 401: lsf signed it
1031
+ with …` names the secret). `wait` gives up after `timeout` (10 seconds by default) with a `408`
1032
+ and the same body: to see a failure's last attempt, give `timeout=25000`. Then assert `state`,
1033
+ and `attempts.at(-1).status` for what the backend answered.
1034
+
1035
+ A wait that could only time out answers `422` at once, when the log has nothing that matches: a
1036
+ `shop=` lsf does not have (`there is no shop nosuchshop.myshopify.com: …`), or a topic no
1037
+ subscription of the app covers on that shop, in `shopify.app.toml` or through the Admin API
1038
+ (``no subscription to `inventory_items/create` for test-1.myshopify.com, …``). Such a topic is
1039
+ never logged: subscribe to it to wait on it.
1040
+
1041
+ ### Place an order
1042
+
1043
+ The [local checkout](checkout.md) is the storefront's: its orders land in the default shop
1044
+ (`defaultShop` of `GET /__lsf/admin`), whatever shops a test made, and their `orders/create`
1045
+ goes to the backend. A test that places one names its own session (`x-lsf-session`) and waits
1046
+ for the webhook about its order:
1047
+
1048
+ ```js
1049
+ const headers = { "x-lsf-session": `order-${crypto.randomUUID()}`, "Content-Type": "application/json" };
1050
+ const buyer = (method, path, body) =>
1051
+ fetch(LSF + path, { method, headers, body: body && JSON.stringify(body), redirect: "manual" });
1052
+
1053
+ const { last: since } = await webhooks({}); // the newest delivery before this order
1054
+ const session = await buyer("PUT", "/__lsf/session", { cart: { items: [{ variant: "TEE-BLK-S", quantity: 1 }] } });
1055
+ if (!session.ok) throw new Error(JSON.stringify((await session.json()).diagnostics)); // a SKU of your data?
1056
+ const checkout = (await buyer("GET", "/checkout")).headers.get("location"); // /checkouts/cn/<token>
1057
+ await buyer("POST", `${checkout}/update`, {
1058
+ operations: [
1059
+ { type: "email", value: "buyer@example.com" },
1060
+ { type: "shippingAddress", address: { lastName: "Buyer", address1: "1 Main St", city: "New York", countryCode: "US", provinceCode: "NY", zip: "10001" } },
1061
+ { type: "paymentMethod", handle: "card" },
1062
+ ],
1063
+ });
1064
+ const completed = await buyer("POST", `${checkout}/complete`);
1065
+ const { order, errors } = await completed.json(); // order: { id, number, name }
1066
+ if (!completed.ok) throw new Error(JSON.stringify(errors)); // what is missing, or the Function that failed
1067
+ const { deliveries } = await webhooks({ topic: "orders/create", resource: order.id, since, wait: 1 });
1068
+ ```
1069
+
1070
+ - `TEE-BLK-S` is a SKU of the demo store; use one of your data. A SKU the data lacks answers
1071
+ `422`, and the `diagnostics` say so (`"MUG-RED" is neither the SKU of a variant nor the handle
1072
+ of a product`, `did you mean "MUG-RST"?`).
1073
+ - `complete` needs an email, a payment method and, for a cart that ships, an address with a
1074
+ last name, a first line and a city. Otherwise it answers `422` with the state, whose `errors`
1075
+ say what is missing.
1076
+ - `order` is the order as the Admin API recorded it. `order.id` is the `admin_graphql_api_id`
1077
+ of its `orders/create`: match by it, never by the number. The thank-you page and the
1078
+ checkout's `.json` keep the session's number: each session numbers its orders after the store
1079
+ data's (`#1004` in every session), while the Admin API gives the next number no order has
1080
+ (`#1004`, then `#1005`, …). So `complete` can answer `#1005` for a thank-you page that says
1081
+ `#1004`.
1082
+ - A Function of the app that fails blocks `complete`: its error is in `errors`. A test that
1083
+ only needs an order serves the Functions it needs and no other (`--extension <handle>`,
1084
+ repeated), or none: `--no-functions` serves the UI extensions and the Admin API, and runs no
1085
+ Function.
1086
+ - The backend processes the order in the default shop: fulfilling it takes the scope
1087
+ `write_merchant_managed_fulfillment_orders` ([orders](admin-reference.md#orders-fulfillments-and-draft-orders)).
1088
+ An order in a test shop is one the test makes there with `orderCreate`.
1089
+
1090
+ ### Approve a charge
1091
+
1092
+ The backend's billing request answers `401` with the charge's `confirmationUrl` in
1093
+ `X-Shopify-API-Request-Failure-Reauthorize-Url`. For an embedded app it is Shopify's, on the
1094
+ shop's admin, as the library's exit-iframe page requires (it refuses a URL with a port, or on
1095
+ `http`, so it would refuse lsf's):
1096
+ `https://test-1.myshopify.com/admin/charges/5662588352328/1/RecurringApplicationCharge/confirm_recurring_application_charge?signature=…`.
1097
+ lsf takes it to its own page where it passes through lsf: in lsf's admin (the exit-iframe page,
1098
+ `redirect(…, {target: "_top"})`, App Bridge's `401`), and in the frame's proxy, which answers
1099
+ it on lsf's origin in a redirect and in the header. A request through the frame's origin (the
1100
+ `embedded:` line names it), as the page's `fetch` makes it, gets
1101
+ `http://127.0.0.1:9292/admin/charges/…?signature=…&shop=test-1.myshopify.com`, which works as
1102
+ it is. An app with `embedded = false` gets that form from the API itself, wherever it is
1103
+ followed. Its fourth segment is the charge, the number of its id (`AppSubscription/<n>`). Charges
1104
+ count from 1 in the default shop; a test shop counts from a number of its own, so that no two
1105
+ shops have the same charge id. Approve that one, on its shop:
1106
+
1107
+ ```js
1108
+ const answer = await callBackend(shop, "POST", "/api/billing/request");
1109
+ const confirmation = new URL(answer.headers.get("x-shopify-api-request-failure-reauthorize-url"));
1110
+ const charge = confirmation.pathname.split("/")[4];
1111
+ const shop = confirmation.searchParams.get("shop") ?? confirmation.host;
1112
+ await fetch(`${LSF_URL}/__lsf/admin/charges/${charge}/approve?shop=${shop}`, { method: "POST" });
1113
+ ```
1114
+
1115
+ In a browser, open the URL as it is when it is on lsf's origin. A test that calls the Admin API
1116
+ itself finds the same page on lsf's origin in the answer's `extensions.lsf.localConfirmationUrl`,
1117
+ next to Shopify's `confirmationUrl`, and opens that one as it is:
1118
+
1119
+ ```js
1120
+ const { data, extensions } = await graphql(SUBSCRIPTION_CREATE, variables);
1121
+ await page.goto(extensions.lsf.localConfirmationUrl); // lsf's charge page
1122
+ ```
1123
+
1124
+ Shopify's form, which a test that calls the backend gets for an embedded app (the library does
1125
+ not pass `extensions` on), opens on lsf's origin
1126
+ (`confirmation.href.replace(confirmation.origin, LSF_URL)`): `https://<shop>` is Shopify's.
1127
+ lsf's admin redirects it to the charge page, `/__lsf/admin/charges/<n>?shop=`. The
1128
+ subscription is then `ACTIVE`, and `app_subscriptions/update` is delivered if the app subscribes
1129
+ to it. `/decline` declines it.
1130
+
1131
+ **Billing cycles follow the shop's clock.** Once `currentPeriodEnd` passes (the trial's end,
1132
+ then every 30 days, or 365 for an annual plan), the next request starts the next cycle: the end
1133
+ moves on, the usage balance starts again from nothing, and a limited discount has an interval
1134
+ less. No webhook is sent for it: Shopify sends `app_subscriptions/update` for a change of status
1135
+ or of capped amount. Move the clock ([the shop's clock](admin-reference.md#the-shops-clock)) to
1136
+ see a renewal.
1137
+
1138
+ ### Managed pricing
1139
+
1140
+ An app on managed pricing (Shopify now calls it Shopify App Pricing) has no billing code: its
1141
+ plans are in the Partner Dashboard, Shopify hosts the page where the merchant picks one, and the
1142
+ app sends the merchant there:
1143
+
1144
+ ```js
1145
+ // The template's loader, or `open("shopify://admin/charges/my-app/pricing_plans", "_top")` in the page.
1146
+ return redirect(`shopify://admin/charges/${appHandle}/pricing_plans`, { target: "_top" });
1147
+ ```
1148
+
1149
+ lsf has no Partner Dashboard: declare the plans in a file and start lsf with
1150
+ `--pricing-plans pricing.toml` (or `LSF_PRICING_PLANS`; JSON for a `.json` file):
1151
+
1152
+ ```toml
1153
+ # pricing.toml: the plans of the Partner Dashboard.
1154
+ [[plans]]
1155
+ handle = "free" # plan_handle: in the subscription, the webhook and the welcome link
1156
+ name = "Free" # the subscription's name
1157
+ price = 0 # 0 (or free = true): a free plan
1158
+ features = ["One market"]
1159
+
1160
+ [[plans]]
1161
+ handle = "pro"
1162
+ name = "Pro"
1163
+ price = 4.99 # USD unless `currency` says otherwise
1164
+ interval = "monthly" # or "annual"
1165
+ trial_days = 7
1166
+ features = ["Every market", "Rules by customer tag"]
1167
+ welcome_link = "/welcome" # a path of the app, or a full URL; the app's home without it
1168
+ ```
1169
+
1170
+ A test sets them while lsf runs: `PUT /__lsf/admin/pricing-plans` with
1171
+ `{"plans": [{"handle": "pro", "name": "Pro", "price": 4.99, "trial_days": 7}]}` (the same fields),
1172
+ `DELETE` puts back those of the file, read again. Unless `--static`, lsf reads the file again when
1173
+ it changes, before the next request (`pricing plans reloaded`); plans a `PUT` set stay until the
1174
+ `DELETE`. lsf prints the page
1175
+ at start (`pricing: 2 plans …`).
1176
+
1177
+ The page is at `/admin/charges/<app handle>/pricing_plans` of [lsf's admin](#preview-the-embedded-app).
1178
+ A `shopify:admin/charges/…` link of the app, and Shopify's own URL of the page
1179
+ (`https://admin.shopify.com/store/<store>/charges/<app handle>/pricing_plans`, which the
1180
+ template's `redirect` opens), land there. Build the link with the app's handle (`handle` of
1181
+ `shopify.app.toml`): on Shopify a client id in its place fails, so lsf answers it with a `404`
1182
+ and an `X-LSF-Reason` header. Only an app whose `shopify.app.toml` has no handle gets its page at
1183
+ its client id, since lsf knows no other name for it. The merchant chooses a plan and approves it,
1184
+ as on Shopify:
1185
+
1186
+ - the shop gets a new `ACTIVE` subscription named after the plan, `test: true` (lsf's shops are
1187
+ development stores), with the plan's trial and one `AppRecurringPricing` line
1188
+ (`price`, `interval`, `planHandle`). `currentAppInstallation.activeSubscriptions` lists it;
1189
+ - the plan it replaces is `CANCELLED` at once: a shop has one subscription. Down to a free plan,
1190
+ the new one keeps the paid plan's `currentPeriodEnd`;
1191
+ - `app_subscriptions/update` is delivered for each (`plan_handle` in the payload);
1192
+ - the browser goes to the plan's welcome link with `charge_id` (the subscription's number) and
1193
+ `plan_handle`: a path is the app's page in lsf's admin
1194
+ (`/admin/apps/<handle>/welcome?shop=…&charge_id=3&plan_handle=pro`), a full URL gets `shop`
1195
+ too.
1196
+
1197
+ While plans are declared, `appSubscriptionCreate` and `appPurchaseOneTimeCreate` answer the user
1198
+ error Shopify gives an app on managed pricing: "Managed Pricing Apps cannot use the Billing API
1199
+ (to create charges)." `appSubscriptionCancel` and uninstalling the app still cancel the
1200
+ subscription: `appSubscriptionCancel` delivers `app_subscriptions/update` (`CANCELLED`), the
1201
+ uninstall nothing (the app's webhook subscriptions go with it). A test chooses a plan without
1202
+ the page:
1203
+
1204
+ ```js
1205
+ await fetch(`${LSF}/__lsf/admin/pricing-plans/pro/select`, { method: "POST", body: JSON.stringify({ shop }) });
1206
+ // → { ok, subscription: { id, chargeId, name, status, … }, replaced: [...], redirect }
1207
+ ```
1208
+
1209
+ A test ends the shop's plan the same way, as if it ended outside the app (the app's
1210
+ `appSubscriptionCancel` needs its token; this does not):
1211
+
1212
+ ```js
1213
+ await fetch(`${LSF}/__lsf/admin/pricing-plans/cancel`, { method: "POST", body: JSON.stringify({ shop }) });
1214
+ // → { ok, plan, subscription: { id, status: "CANCELLED", … }, webhook: "app_subscriptions/update" }
1215
+ ```
1216
+
1217
+ The shop's active subscription is `CANCELLED` at once, `currentAppInstallation.activeSubscriptions`
1218
+ is empty, and `app_subscriptions/update` (`CANCELLED`, the plan's `plan_handle`) is delivered,
1219
+ none with `legacy = false` (`webhook: null`): the case of an app that has to notice by itself that
1220
+ the merchant has no plan. A `409` when the shop has no active subscription. Shopify has no exact
1221
+ equivalent. Its plan page has no button to end a plan: the merchant chooses another plan
1222
+ (a free one, if the app has it) or uninstalls the app, and Shopify's help center says that "app
1223
+ plan changes are managed within the app itself or through the app developer's website, not from
1224
+ Settings > Apps". The route stands for a plan that ends while the app stays installed (Shopify's
1225
+ support, a store closed), which Shopify App Pricing counts among the "cancellations, freezes, or
1226
+ plan expirations" it sends no webhook for. The status is `CANCELLED`, which the Admin API gives
1227
+ to every subscription that ends ("the app being uninstalled, a new app subscription being
1228
+ activated, or a direct cancellation by the app"); `EXPIRED` is for a charge never approved. No
1229
+ button on lsf's page either.
1230
+
1231
+ **Shopify changed this in April 2026.** Its page now says that for the apps enrolled since,
1232
+ and for every app after April 28, 2026, the welcome link gets `plan_handle` alone, no
1233
+ `app_subscriptions/update` is sent, and the app reads its plan from the Partner API's
1234
+ `activeSubscription`. Its "legacy features" section still lists `charge_id`, the webhook and test
1235
+ subscriptions for the apps enrolled before. lsf does what those apps get, unless the declaration
1236
+ says `legacy = false`: then `plan_handle` alone, no webhook, `test: false`. No webhook means none
1237
+ for any change of the subscription: a plan chosen, `appSubscriptionCancel` from the app, an
1238
+ uninstall by the merchant. Shopify's page: "Shopify App Pricing doesn't use webhooks to notify
1239
+ your app of subscription changes", and for "status changes that happen outside of a redirect
1240
+ (such as cancellations or freezes), query the Partner API"; Partner support told a developer
1241
+ that `app_subscriptions/update` "belongs to the older Billing API and is not supported for App
1242
+ Pricing" ([community](https://community.shopify.dev/t/app-pricing-app-events-usage-billing-request-for-documented-answers-on-undocumented-behaviors/36911)).
1243
+ lsf still records the subscription in the Admin API either way, and serves the Partner API
1244
+ (below) in both modes.
1245
+
1246
+ #### Read the plan from the Partner API
1247
+
1248
+ An app on Shopify App Pricing reads its plan from the [Partner
1249
+ API](https://shopify.dev/docs/api/partner): `activeSubscription(appId:, shopId:)` for the plan now,
1250
+ and `events` for the history ([Shopify App
1251
+ Pricing](https://shopify.dev/docs/apps/launch/billing/shopify-app-pricing#query-subscription-data)).
1252
+ lsf serves it at Shopify's path, on its own origin. The app changes the origin alone:
1253
+
1254
+ ```text
1255
+ Shopify: POST https://partners.shopify.com/{partner_id}/api/2026-10/graphql.json
1256
+ lsf: POST http://127.0.0.1:9292/{partner_id}/api/2026-10/graphql.json
1257
+ X-Shopify-Access-Token: {partner API client token}
1258
+ ```
1259
+
1260
+ lsf prints the URL and the token at start, after the `pricing:` line:
1261
+
1262
+ ```text
1263
+ partner: http://127.0.0.1:9292/9880163/api/2026-10/graphql.json (Partner API: activeSubscription and events of gid://shopify/App/3662721844947; token prtapi_lsf_9c17…)
1264
+ ```
1265
+
1266
+ `GET /__lsf/admin/partner` has the same, with each shop's GID: a test reads them there.
1267
+
1268
+ - **The Partner ID** is seven digits, and **the token** is `prtapi_lsf_` and 32 hex digits.
1269
+ Shopify's real tokens are `prtapi_` and 32 hex digits, which GitHub's push protection refuses in
1270
+ a commit; the `lsf_` keeps lsf's token out of that shape, so your repository can hold it. Both
1271
+ are the same every run for the app's client id: a test can hard-code them, or read them from
1272
+ `GET /__lsf/admin/partner` (`partnerId`, `token`). Another token, or another number in the
1273
+ path, answers Shopify's `401` `{"errors": [{"message": "Invalid access token", "extensions":
1274
+ {"code": "401"}}]}`; `X-Lsf-Reason` says which part is wrong.
1275
+ - **The app's GID** is the Admin API's: `gid://shopify/App/<n>`, what
1276
+ `currentAppInstallation { app { id } }` answers. **A shop's GID** is its `shop { id }`. So the
1277
+ app can read both from the Admin API, as the [example
1278
+ backend](https://github.com/0xtlt/local-storefront/tree/main/examples/admin-backend) does
1279
+ (`GET /api/billing/partner` in `server/index.mjs`). The older `app(id:)` takes
1280
+ `gid://partners/App/<n>`, the same number.
1281
+ - **Versions**: 2026-07, 2026-10 and 2027-01, which have the same schema. Another version answers
1282
+ `404`. The schema is Shopify's whole 2026-10 one, so introspection and validation work as on
1283
+ Shopify.
1284
+
1285
+ ```js
1286
+ // The app's own code: only the origin changes.
1287
+ const PARTNER_API = process.env.PARTNER_API_URL ?? "https://partners.shopify.com";
1288
+ const answer = await fetch(`${PARTNER_API}/${process.env.PARTNER_ID}/api/2026-10/graphql.json`, {
1289
+ method: "POST",
1290
+ headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": process.env.PARTNER_API_TOKEN },
1291
+ body: JSON.stringify({
1292
+ query: `query($appId: ID!, $shopId: ID!) {
1293
+ activeSubscription(appId: $appId, shopId: $shopId) { trialEndsAt items { handle } } }`,
1294
+ variables: { appId, shopId },
1295
+ }),
1296
+ });
1297
+ ```
1298
+
1299
+ What lsf answers, from the plans chosen, changed and ended, the trials, the installs and the
1300
+ uninstalls of every shop:
1301
+
1302
+ | Query | What lsf answers |
1303
+ |---|---|
1304
+ | `activeSubscription(appId:, shopId:)` | The shop's active subscription of managed pricing: one item, the plan (`handle`, the plan's name as `description`, a `FlatRatePrice`); `billingPeriod`; `trialEndsAt` while the trial runs, then `null`; `currentBillingCycle` `null` during the trial, then the cycle the shop's clock is in (periods of 30 days, or 365 for an annual plan, from the end of the trial or the approval); `legacySubscriptionId` the Admin API's `AppSubscription` GID while the plans keep the legacy features, `null` with `legacy = false`; `cancelAtEndOfCycle: false` and `pendingUpdate: null`. `null` without a plan, and for a subscription of the Billing API (below). "Shop not found" for a shop lsf does not have. |
1305
+ | `events(filter:, orderBy:, first:, after:, …)` | The history of every shop, newest first (`OCCURRED_AT_ASC` turns it), the last 30 days by each shop's clock unless `occurredAtMin` or `occurredAtMax` says otherwise (365 days at most), 250 a page at most: `RELATIONSHIP_INSTALLED` and `RELATIONSHIP_UNINSTALLED` (`Relationship`); `SUBSCRIPTION_CREATED` (a first plan), `SUBSCRIPTION_UPDATED` (a change of plan, one event) and `SUBSCRIPTION_CANCELED` (the plan ended, with `cancelEffectiveOn`), each with its `plan` (`SubscriptionStatus`). `shopId` needs `subjectId`, as on Shopify ("You must specify a subjectId when filtering by shopId."). |
1306
+ | `app(id:) { events(types:, shopId:, …) }` | The older system, newest first, with no default window: the relationship events, and one per change of a charge, the Billing API's included (`SUBSCRIPTION_CHARGE_ACCEPTED` and `…_ACTIVATED` on an approval, `…_CANCELED`, `…_DECLINED`, `…_EXPIRED`, `ONE_TIME_CHARGE_…`), each with its `charge` (`id`, `name`, `amount`, `test`, `billingOn`). |
1307
+
1308
+ An uninstall records the end of the plan, then `RELATIONSHIP_UNINSTALLED`; a reinstall
1309
+ `RELATIONSHIP_INSTALLED`. Each shop starts with the install of the seed, dated 2024-01-01 12:00
1310
+ UTC like the seed's records: outside the default window. `DELETE /__lsf/admin` puts each shop's
1311
+ history back to that one event.
1312
+
1313
+ Where Shopify's pages disagree, lsf follows the reference. The Shopify App Pricing page says
1314
+ `activeSubscription` covers a subscription "through Shopify App Pricing or the Billing API"; the
1315
+ [Active subscription](https://shopify.dev/docs/api/partner/2026-07/active-subscription) reference
1316
+ says "The shop must have an active managed pricing contract for the app". So lsf answers `null`
1317
+ for a subscription the Billing API made. The 2026-10 reference names the number of the path the
1318
+ Partner ID, "the number in your Partner Dashboard URL", not the Dev Dashboard's organization ID;
1319
+ older pages call it `{organization_id}`.
1320
+
1321
+ Shopify's [Historical events](https://shopify.dev/docs/api/partner/2026-07/historical-events)
1322
+ example selects `state` on both `Relationship` and `SubscriptionStatus`. Their types differ
1323
+ (`RelationshipState!`, `SubscriptionStatusState!`), and lsf refuses that as the GraphQL spec and
1324
+ graphql-ruby do (`Field 'state' has a return_type conflict`). Alias one:
1325
+ `... on Relationship { relationship: state }`.
1326
+
1327
+ The rest of the Partner API (`transactions`, `transaction`, `publicApiVersions`, the mutations
1328
+ `appSubscriptionCancel` and `appCreditCreate`) answers an error with `extensions.code:
1329
+ LSF_NOT_SIMULATED`. No `CHARGE_…`, `EARNING_…` or `CREDIT_…` event either: no money moves. lsf
1330
+ does not throttle (Shopify allows four requests a second).
1331
+
1332
+ lsf does not simulate: the App Events API (usage meters), usage charges, private plans and their
1333
+ stores, the eight-plan limit, prices in local currencies, plans that toggle between monthly and
1334
+ yearly (declare two), proration and deferred billing (every change is at once, no money moves,
1335
+ and a plan ended is ended at once: no `cancelAtEndOfCycle`, no `pendingUpdate`), freezes, the
1336
+ Partner API's other queries and mutations (above), and the 180-day memory of a trial used before
1337
+ (each plan's trial applies every time).
1338
+
1339
+ ### Reset between runs
1340
+
1341
+ ```bash
1342
+ curl -X DELETE http://127.0.0.1:9292/__lsf/admin # every shop back to the data: records, clock, outbox, tokens
1343
+ curl -X DELETE http://127.0.0.1:9292/__lsf/webhooks # the delivery log
1344
+ curl -X DELETE http://127.0.0.1:9292/__lsf/sessions # carts, checkouts and the order numbers of sessions
1345
+ ```
1346
+
1347
+ `DELETE /__lsf/admin` keeps the test shops (each a copy of the seed, about 2 MB for the demo
1348
+ store); `DELETE /__lsf/admin/shops/<shop>` deletes one. After the three, a run places the same
1349
+ orders, with the same ids, as the run before: a test can hard-code them.
1350
+
1351
+ **A reset revokes the backend's tokens.** `DELETE /__lsf/admin`, and deleting a shop, revoke
1352
+ every token the backend got by token exchange or OAuth, and send no `app/uninstalled`. After
1353
+ `DELETE /__lsf/admin`, the printed token, `shops[].token`, keeps working: the reset installs the
1354
+ app again with it. The backend's session storage still holds the old token, which the Admin API
1355
+ now answers `401` (its `X-Lsf-Reason` says why). Shopify's React Router package then deletes the
1356
+ session, so its next request exchanges a new token: only one call fails. Code that keeps its
1357
+ tokens itself fails until they are gone. So do one of these:
1358
+
1359
+ - empty the backend's session storage after a reset (the React Router template's: the `Session`
1360
+ table of its Prisma database);
1361
+ - name the test shops anew in each run (`products-${crypto.randomUUID()}`), as above;
1362
+ - end each test with `POST /__lsf/admin/uninstall {"shop"}`, then delete the shop: the backend's
1363
+ `app/uninstalled` handler deletes the sessions, as on Shopify.
1364
+
1365
+ ### Dates, throttling and the outbox
1366
+
1367
+ - **Dates follow the system clock** unless the store data sets `now`: a record the backend
1368
+ creates is dated at the time it ran (`createdAt`, `updatedAt`, to the second), so two runs
1369
+ give two dates. With `now` in the store data, every record created or changed is dated then,
1370
+ and the same run gives the same answers.
1371
+ - **Moving the clock freezes it.** [`POST /__lsf/admin/clock`](admin-reference.md#the-shops-clock)
1372
+ `{"advance": "31d"}` or `{"now": "…"}` moves the shop's time on (a checkout abandoned, a
1373
+ payment due, a charge expired) and leaves it there: the answer says `"frozen": true`, and every
1374
+ record after it is dated at that instant until the next move. A test that moves the clock so
1375
+ reads the same dates every run. `DELETE /__lsf/admin` puts back the store data's clock (the
1376
+ system's, or `now`).
1377
+ - **Throttling**: `--admin-throttle legacy` (1000 points, 50 a second) reaches `THROTTLED`
1378
+ sooner; `--admin-throttle off` never does.
1379
+ - **What the backend would have sent** (emails, labels, Flow triggers) is in the shop's
1380
+ [outbox](admin-reference.md#the-outbox), for a test to read.
1381
+ - The store data edited while the server runs seeds the shops again: what the Admin API changed
1382
+ is dropped, and the server says so.
1383
+
1384
+ ### In Python
1385
+
1386
+ The same calls with `requests`. The checkout belongs to the session's cookie: keep it in a
1387
+ `requests.Session`.
1388
+
1389
+ ```python
1390
+ import os, requests
1391
+
1392
+ LSF_URL = os.environ.get("LSF_URL", "http://127.0.0.1:9292")
1393
+
1394
+ def place_order(sku="TEE-BLK-S", email="buyer@example.com"):
1395
+ buyer = requests.Session()
1396
+ session = buyer.put(LSF_URL + "/__lsf/session", json={"cart": {"items": [{"variant": sku, "quantity": 1}]}})
1397
+ if not session.ok: # 422: a SKU of your data?
1398
+ raise RuntimeError(session.json()["diagnostics"])
1399
+ checkout = LSF_URL + buyer.get(LSF_URL + "/checkout", allow_redirects=False).headers["location"]
1400
+ buyer.post(checkout + "/update", json={"operations": [
1401
+ {"type": "email", "value": email},
1402
+ {"type": "shippingAddress", "address": {"lastName": "Buyer", "address1": "1 Main St",
1403
+ "city": "New York", "countryCode": "US", "provinceCode": "NY", "zip": "10001"}},
1404
+ {"type": "paymentMethod", "handle": "card"},
1405
+ ]})
1406
+ completed = buyer.post(checkout + "/complete")
1407
+ if not completed.ok: # 422: what is missing, or the Function that failed
1408
+ raise RuntimeError(completed.json()["errors"])
1409
+ return completed.json()["order"] # id, number, name
1410
+
1411
+ def webhooks(**filters): # topic=, shop=, resource=, since=, wait=, timeout=
1412
+ answer = requests.get(LSF_URL + "/__lsf/webhooks", params=filters)
1413
+ if not answer.ok: # 408: fewer are done than `wait` asks; 422: it could only time out
1414
+ raise RuntimeError(f"{answer.status_code} {answer.text}")
1415
+ return answer.json()
1416
+
1417
+ since = webhooks()["last"] # the newest delivery before the order
1418
+ order = place_order()
1419
+ [delivery] = webhooks(topic="orders/create", resource=order["id"], since=since, wait=1)["deliveries"]
1420
+ assert delivery["state"] == "delivered"
1421
+ ```
1422
+
1423
+ The storefront does not show what the backend did to the order ([orders do not
1424
+ sync](admin-reference.md#orders-fulfillments-and-draft-orders)): read it back with the Admin
1425
+ API. Here, after a backend that fulfills the order before it answers its `orders/create`:
1426
+
1427
+ ```python
1428
+ token = requests.get(LSF_URL + "/__lsf/admin").json()["shops"][0]["token"]
1429
+ answer = requests.post(LSF_URL + "/admin/api/2026-10/graphql.json", headers={"X-Shopify-Access-Token": token}, json={
1430
+ "query": "query($id: ID!) { order(id: $id) { displayFulfillmentStatus fulfillments { trackingInfo { number } } } }",
1431
+ "variables": {"id": order["id"]},
1432
+ })
1433
+ assert answer.json()["data"]["order"]["displayFulfillmentStatus"] == "FULFILLED"
1434
+ ```
1435
+
1436
+ With pytest, a fixture starts lsf once for the run, and resets it before each test ([reset
1437
+ between runs](#reset-between-runs)). Start the backend the same way, on the port of
1438
+ `--app-url`:
1439
+
1440
+ ```python
1441
+ # tests/conftest.py: lsf for the whole run, and a clean state for each test.
1442
+ import subprocess, time, requests, pytest
1443
+
1444
+ LSF_URL = "http://127.0.0.1:9393"
1445
+
1446
+ @pytest.fixture(scope="session")
1447
+ def lsf():
1448
+ server = subprocess.Popen(["npx", "local-storefront", "app", "dev", "--app", ".", "--port", "9393",
1449
+ "--app-url", "http://localhost:3001", "--static", "--quiet"])
1450
+ for _ in range(100):
1451
+ try:
1452
+ if requests.get(LSF_URL + "/__lsf/status").ok:
1453
+ break
1454
+ except requests.ConnectionError:
1455
+ pass
1456
+ time.sleep(0.1)
1457
+ else:
1458
+ server.terminate()
1459
+ raise RuntimeError("lsf did not start: its lines above say why")
1460
+ yield LSF_URL
1461
+ server.terminate()
1462
+ server.wait()
1463
+
1464
+ @pytest.fixture(autouse=True)
1465
+ def clean(lsf):
1466
+ for path in ("/__lsf/admin", "/__lsf/webhooks", "/__lsf/sessions"):
1467
+ requests.delete(lsf + path).raise_for_status()
1468
+ ```
1469
+
1470
+ ## App proxy
1471
+
1472
+ With `--app` and an `[app_proxy]` in `shopify.app.toml`, the storefront forwards
1473
+ `/<prefix>/<subpath>` and every path below it to the backend, signed as Shopify signs it, and
1474
+ renders the answer in the theme when it is Liquid. A backend that serves storefront pages or JSON
1475
+ through its app proxy (reviews, loyalty pages, wishlists) is tested on the local storefront.
1476
+
1477
+ ```toml
1478
+ [app_proxy]
1479
+ url = "/proxy" # or https://<the tunnel of shopify app dev>/proxy
1480
+ prefix = "apps" # a, apps, community or tools
1481
+ subpath = "reviews"
1482
+ ```
1483
+
1484
+ ```text
1485
+ proxy: http://127.0.0.1:9292/apps/reviews → http://localhost:3000/proxy
1486
+ ```
1487
+
1488
+ - **Where it goes**: a `url` that is a path joins `--app-url` (else `application_url`), as
1489
+ Shopify's CLI prepends the app URL; one on the origin of `application_url` moves to
1490
+ `--app-url`, as webhooks do; another is used as given. `/apps/reviews/product/1?page=2` goes to
1491
+ `http://localhost:3000/proxy/product/1?page=2&shop=local-supply-co.myshopify.com&logged_in_customer_id=&path_prefix=%2Fapps%2Freviews&timestamp=1793440800&signature=…`.
1492
+ - **The query**: the browser's, then Shopify's: `shop`, `logged_in_customer_id` (the customer the
1493
+ session is logged in as, empty when logged out), `path_prefix`, `timestamp` (now: Shopify's
1494
+ libraries refuse one more than 90 seconds off) and `signature`, the hex HMAC-SHA256 of the
1495
+ others with the client secret.
1496
+ - **The request**: its method and body, and the browser's headers without `Cookie` (Shopify strips
1497
+ it), with `X-Forwarded-For` and `X-Forwarded-Host`. A localized URL (`/fr/apps/reviews`) is
1498
+ proxied too, with `Accept-Language: fr;q=1.0, en;q=0.5`; `path_prefix` stays `/apps/reviews`.
1499
+ - **The answer**: `Content-Type: application/liquid` is rendered as the template of a page, with
1500
+ the objects of the request (`shop`, `customer`, `cart`, `request.locale`, `settings`), inside
1501
+ `layout/theme.liquid`; `{% layout none %}` (what `liquid(body, {layout: false})` of the
1502
+ templates sends) renders it alone, and so does `lsf app dev` without a theme. Anything else is
1503
+ passed on with its status and its headers, redirects included, but those Shopify strips:
1504
+ `Set-Cookie`, `Server`, `X-Powered-By`, `Date` and the rest of shopify.dev's list.
1505
+ - **When the backend does not answer**: nothing listening is a `502` page, no answer within 10
1506
+ seconds a `504` page, each saying what failed and to start the backend on `--app-url`.
1507
+ Shopify documents no timeout for app proxies; 10 seconds is what it documents for its APIs.
1508
+ - **While the app is uninstalled** (`POST /__lsf/admin/uninstall`, `appUninstall`): no proxy. A
1509
+ store gets the proxy with the app ("Configuring an app proxy requires the `write_app_proxy`
1510
+ access scope"; a change of `prefix` or `subpath` applies "only to new installations", and on a
1511
+ dev store means "uninstall and reinstall the app", [About app
1512
+ proxies](https://shopify.dev/docs/apps/build/online-store/app-proxies)), so uninstalling the app
1513
+ removes it, and the storefront answers the path as any path it has no page for. lsf answers the
1514
+ theme's `404` page (a `POST`, `404 Not found`), sends nothing to the backend, and says why in
1515
+ `X-LSF-Reason` and the terminal: `app proxy: the app is not installed on the shop: uninstalling
1516
+ it removed its app proxy, so /apps/reviews is the storefront's 404 and no request reaches the
1517
+ backend until it is installed again (…)`. A reinstall forwards it again. Shopify documents no
1518
+ page for this case: the 404 is what a storefront answers on a path no app proxy holds.
1519
+
1520
+ In a React Router app, `authenticate.public.appProxy` verifies the request and `liquid` answers
1521
+ in the theme:
1522
+
1523
+ ```ts
1524
+ // app/routes/proxy.$.tsx
1525
+ export const loader = async ({ request }: LoaderFunctionArgs) => {
1526
+ const { liquid } = await authenticate.public.appProxy(request);
1527
+ return liquid("<h1>Reviews</h1><p>{{ shop.name }}, {{ customer.first_name | default: 'guest' }}</p>");
1528
+ };
1529
+ ```
1530
+
1531
+ Without a library, a handler verifies the signature as shopify.dev's "Authenticate app proxies"
1532
+ does: every parameter but `signature`, the values of a repeated name joined with commas,
1533
+ `name=value` sorted and concatenated.
1534
+
1535
+ ```js
1536
+ import crypto from "node:crypto";
1537
+ import express from "express";
1538
+
1539
+ function verifyAppProxy(url, secret) {
1540
+ const params = new URL(url, "http://localhost").searchParams;
1541
+ const signature = params.get("signature") ?? "";
1542
+ const values = new Map();
1543
+ for (const [name, value] of params) {
1544
+ if (name !== "signature") {
1545
+ values.set(name, values.has(name) ? `${values.get(name)},${value}` : value);
1546
+ }
1547
+ }
1548
+ const message = [...values].map(([name, value]) => `${name}=${value}`).sort().join("");
1549
+ const expected = crypto.createHmac("sha256", secret).update(message).digest("hex");
1550
+ return (
1551
+ expected.length === signature.length &&
1552
+ crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) &&
1553
+ Math.abs(Date.now() / 1000 - Number(params.get("timestamp"))) <= 90
1554
+ );
1555
+ }
1556
+
1557
+ const app = express();
1558
+ app.get("/proxy/*rest", (req, res) => {
1559
+ if (!verifyAppProxy(req.originalUrl, process.env.SHOPIFY_API_SECRET)) {
1560
+ return res.status(401).send("bad signature");
1561
+ }
1562
+ res.type("application/liquid").send("<h1>Reviews of {{ shop.name }}</h1>");
1563
+ });
1564
+ app.listen(3000);
1565
+ ```
1566
+
1567
+ In Python, parse the query with `keep_blank_values=True`: `parse_qsl` drops empty values by
1568
+ default, and `logged_in_customer_id=` is empty for every visitor who is not logged in, so the
1569
+ signature would never match. Flask's `request.args.to_dict()` loses repeated names the same way:
1570
+ read `request.query_string`.
1571
+
1572
+ ```python
1573
+ import hashlib, hmac, time
1574
+ from urllib.parse import parse_qsl
1575
+
1576
+ def verify_app_proxy(query_string: str, secret: str) -> bool:
1577
+ values, signature = {}, ""
1578
+ for name, value in parse_qsl(query_string, keep_blank_values=True):
1579
+ if name == "signature":
1580
+ signature = value
1581
+ else:
1582
+ values[name] = f"{values[name]},{value}" if name in values else value
1583
+ message = "".join(sorted(f"{name}={value}" for name, value in values.items()))
1584
+ expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
1585
+ fresh = abs(time.time() - int(values.get("timestamp", 0))) <= 90
1586
+ return hmac.compare_digest(expected, signature) and fresh
1587
+ ```
1588
+
1589
+ To answer in the theme, send the Liquid with its content type. With Flask:
1590
+
1591
+ ```python
1592
+ from flask import Flask, Response, request
1593
+
1594
+ app = Flask(__name__)
1595
+
1596
+ @app.route("/proxy", defaults={"rest": ""}, methods=["GET", "POST"])
1597
+ @app.route("/proxy/<path:rest>", methods=["GET", "POST"])
1598
+ def proxy(rest):
1599
+ if not verify_app_proxy(request.query_string.decode(), SECRET):
1600
+ return "bad signature", 401
1601
+ return Response("<h1>Reviews of {{ shop.name }}</h1>", content_type="application/liquid")
1602
+ ```
1603
+
1604
+ Not simulated: the prefix and subpath a merchant can change in the admin (lsf serves those of
1605
+ the file). Shopify does not document what `template` and `request.page_type` are on a proxied
1606
+ page; lsf leaves them empty.
1607
+
1608
+ ## What the storefront and the checkout see
1609
+
1610
+ What the backend changes through the Admin API is what the storefront and the
1611
+ [local checkout](checkout.md) of the running server show, from the next request on:
1612
+
1613
+ - a product it creates is on the theme's pages and in the collections it joined, once it is
1614
+ `ACTIVE` and published on the Online Store; set to `DRAFT` or `ARCHIVED`, or unpublished, it
1615
+ leaves them. **A product the Admin API creates is published nowhere**, as on Shopify:
1616
+ `publishablePublish` on the Online Store's publication puts it on the storefront, and needs
1617
+ `write_publications` (and `read_publications` to find the publication with `publications`).
1618
+ An app whose `[access_scopes]` leaves them out creates products the storefront never shows:
1619
+ `productCreate` and `productSet` then say so in `extensions.lsf.notes`;
1620
+ - a customer it creates can log in, at `/__lsf/login?customer=<email>` and at the checkout;
1621
+ - a metafield it sets is read by Liquid (`product.metafields.custom.care`, `shop.metafields…`)
1622
+ and by the Function inputs: the `$app:config` settings of a discount, a validation, a
1623
+ customization or a cart transform; one it sets on an order is read by the customer account UI
1624
+ extensions on the order status page (`appMetafields`, `owner_type = "ORDER"`);
1625
+ - an app discount it creates (`discountAutomaticAppCreate`, `discountCodeAppCreate`) runs its
1626
+ Function at the checkout, with the metafields it was given, while it is active;
1627
+ - the shipping rates of the general delivery profile it changes are the checkout's delivery
1628
+ options (see [delivery](admin-reference.md#inventory-transfers-fulfillment-and-delivery));
1629
+ - a theme file it writes to the main theme (`themeFilesUpsert`, `themeFilesCopy`,
1630
+ `themeFilesDelete`) is served on top of the theme directory, which is never written to; a theme
1631
+ it publishes is served instead of the directory;
1632
+ - a script tag it creates (`scriptTagCreate`, `ONLINE_STORE`) is loaded by `content_for_header`;
1633
+ - a mobile app it declares (`mobilePlatformApplicationCreate`) is in
1634
+ `/.well-known/assetlinks.json` or `/.well-known/apple-app-site-association`;
1635
+ - the open pages reload, with `--live-reload`.
1636
+
1637
+ | Syncs | Does not sync |
1638
+ |---|---|
1639
+ | Products: title, handle, description, vendor, type, tags, status and publication, options, variants (price, SKU, barcode, inventory policy, weight), stock per location, media from `files/`, collections and selling plan groups they are in | Orders and what belongs to them (`orderCreate`, draft orders, fulfillments, refunds, returns): the account pages show the orders of the data and of the checkout |
1640
+ | Collections: title, handle, description, image, products in order | Discounts that are not app discounts (`DiscountCodeBasic`, …): the local checkout runs Functions only; of an app discount, `combinesWith`, usage limits and every code but the first |
1641
+ | Customers: email, names, phone, tags, marketing consent, account state, addresses, their company | Gift cards the Admin API creates (changes to the data's cards sync) |
1642
+ | Companies and their locations, pages, blogs and articles, menus, locations, metaobjects, selling plan groups | SEO, product categories, bundle components and combined listings (a bundle is a product of its own there), collection rules and sources (a changed automated collection becomes a manual one), product feeds, point of sale |
1643
+ | Metafields of all of these, of the shop and of markets; markets, their countries and their currency; the published languages | Metafield and metaobject definitions, files, translations, publications other than the Online Store; catalogs, price lists, quantity rules, web presences, channels |
1644
+ | Shop: name, email, description, policies | Domains, currency, time zone, plan |
1645
+
1646
+ Each family of the [reference](admin-reference.md#what-lsf-simulates) says what of it syncs.
1647
+ For contributors, the full list, field by field, is in `crates/app/docs/admin/design.md` ("Store
1648
+ sync").
1649
+
1650
+ - **Only the default shop syncs.** The shops of `POST /__lsf/admin/shops` exist to isolate tests:
1651
+ what they change stays in the Admin API.
1652
+ - **The files do not change.** lsf builds the store from the files with the Admin API's changes on
1653
+ top; nothing is written to disk. `DELETE /__lsf/admin` (and the store data edited, unless
1654
+ `--static`, or `POST /__lsf/reload`) puts the storefront back to the files, and the theme back
1655
+ to the directory, without the files and script tags the Admin API added. A checkout scenario
1656
+ added or edited (`checkouts/`) is not store data: lsf reads the scenarios again and keeps what
1657
+ the Admin API changed ([when the data changes on disk](cli.md#when-the-data-changes-on-disk)).
1658
+ - **A session with its own data** (`PUT /__lsf/session` with `data`) sees its data on top of the
1659
+ Admin API's changes.
1660
+ - **An order placed at the checkout** is recorded in the Admin API, and the store is not built
1661
+ again for it: orders do not sync (the account pages show it from the checkout, as they do
1662
+ without the Admin API). Its number is the session's: each session numbers its orders after
1663
+ the store data's, whatever the others do, so a test reads the same number on the thank-you
1664
+ page every run ([testing](testing.md)). The Admin API records it under that number when none
1665
+ of its orders has it, else under its next one, that of `orderCreate` and draft orders: no two
1666
+ orders share a name there. Two sessions so show `#1004` on their thank-you pages while the
1667
+ Admin API names their orders `#1004` and `#1005` (`#1005` and `#1006` after an
1668
+ `orderCreate`); `complete` answers the Admin API's number. A
1669
+ guest whose email is a customer's places the order as that customer, as on Shopify: the
1670
+ customer's `numberOfOrders` and `amountSpent` count it.
1671
+ - **The cart's metafields** (those the checkout UI extensions wrote) are copied onto the order,
1672
+ as Shopify does since 2026-04, when an ORDER definition has the same namespace and key with
1673
+ `cartToOrderCopyable` enabled; without one, nothing is copied. The definition comes from
1674
+ `metafieldDefinitionCreate` (`ownerType: ORDER`, `capabilities: {cartToOrderCopyable:
1675
+ {enabled: true}}`) or from `shopify.app.toml`, which lsf reads for this
1676
+ (`[order.metafields.app.gift_message]`, `type`, `capabilities.cart_to_order_copyable = true`;
1677
+ `app` is the app's `$app`). The order's metafield has the definition's type; a value the
1678
+ definition refuses (its type, its validations) is not copied. `order { metafield(namespace:
1679
+ "$app", key: "gift_message") }` reads it, and so does `appMetafields` on the order status
1680
+ page ([checkout](checkout.md#customer-account)).
1681
+ - **When the store data refuses a change** (an email it does not take, two variants with the
1682
+ same options, two companies of one name, which Shopify allows), the storefront shows the rest,
1683
+ the terminal says what was refused (once), and so does `GET /__lsf/admin`
1684
+ (`storeSync.built.diagnostics`).
1685
+ - **What it costs**: a request of the backend only counts the change. The next request that
1686
+ reads the store builds it again, once for a burst of mutations: under 1 ms on the demo store,
1687
+ about 0.1 s for 3,000 products of 12 variants. It builds on the files as the server read them
1688
+ at start (or at the last reload), never on the files on disk: with `--static`, a data file
1689
+ edited since is not seen until a reload (`POST /__lsf/reload`). Pages served without a change
1690
+ in between cost nothing more. `GET /__lsf/admin` gives `storeSync`: the generation of the changes, whether a
1691
+ change waits for the next page (`pending`), what the overlay writes and removes, and how long
1692
+ the last build took.
1693
+
1694
+ ## API versions
1695
+
1696
+ | Version | Shopify's status | Served |
1697
+ |---|---|---|
1698
+ | `2026-10` | latest stable | by default |
1699
+ | `2027-01` | release candidate | with `--admin-versions 2026-10,2027-01` |
1700
+
1701
+ Each version is served at `/admin/api/<version>/graphql.json` with its own schema: its fields,
1702
+ mutations and enum values (a field of another version answers Shopify's `Field '…' doesn't
1703
+ exist on type '…'`), its introspection, its access scopes, its search filters and its webhook
1704
+ topics. `X-Shopify-API-Version` says which version answered.
1705
+
1706
+ ```bash
1707
+ npx local-storefront app dev --app ../my-app --admin-versions 2026-10,2027-01
1708
+ ```
1709
+
1710
+ ```text
1711
+ admin: http://127.0.0.1:9292/admin/api/2026-10/graphql.json
1712
+ also API version 2027-01 (release candidate)
1713
+ ```
1714
+
1715
+ - **A version Shopify no longer supports** (`2025-10`, `2024-01`, …) is answered by the oldest
1716
+ stable version served, as Shopify [falls forward](https://shopify.dev/docs/api/usage/versioning)
1717
+ to "the oldest accessible stable version": `X-Shopify-API-Version` says `2026-10`, and
1718
+ `X-Lsf-Reason` why.
1719
+ - **A version lsf does not serve** answers `404` with a JSON message that says which ones it
1720
+ serves: a version Shopify supports but lsf has no data for (`2026-07`), `unstable`, and
1721
+ `2027-01` without the option, whose message says how to serve it. lsf does not answer them
1722
+ with another version's schema.
1723
+ - **Webhooks** are built in the version of their subscription: `[webhooks] api_version` of
1724
+ `shopify.app.toml`, or the version of the request that made it with
1725
+ `webhookSubscriptionCreate`. A version the server does not serve is built in 2026-10, and the
1726
+ delivery's notes (`GET /__lsf/webhooks`) say so. `lsf app webhook trigger --api-version
1727
+ 2027-01` builds its payload in 2027-01.
1728
+ - **The records are the same** in every version: what a 2027-01 request creates, a 2026-10 one
1729
+ reads. Where 2027-01 shows a record differently, lsf adapts it: a metaobject definition whose
1730
+ admin access is `PUBLIC_READ` or `PRIVATE` (removed in 2027-01, "Use MERCHANT_READ instead")
1731
+ reads `MERCHANT_READ` in 2027-01, and a collection source condition made with
1732
+ `metafieldInteger` (removed in 2027-01) reads as a
1733
+ `CollectionSourceInclusionConditionMetafieldInt`, its `value` a string.
1734
+ - `GET /__lsf/admin` lists the versions lsf knows, their status and whether they are served
1735
+ (`apiVersions`).
1736
+
1737
+ For contributors: adding another version is a data drop and a line of code,
1738
+ `mise run admin:version -- <version>` writes its files from Shopify's introspection and says what
1739
+ is left (`crates/app/docs/admin/design.md`, "Add a version").
1740
+
1741
+ ## Reference
1742
+
1743
+ The [Admin API reference](admin-reference.md) has the rest:
1744
+
1745
+ - [what the answers say](admin-reference.md#what-the-answers-say): errors, `extensions.cost`,
1746
+ `extensions.lsf`;
1747
+ - [what lsf simulates](admin-reference.md#what-lsf-simulates), family by family: products,
1748
+ orders and fulfillments, delivery, customers, subscriptions, metafields, markets, point of
1749
+ sale, billing, discounts and bulk operations, themes, the app's installation;
1750
+ - [the shop's clock](admin-reference.md#the-shops-clock) and [the
1751
+ outbox](admin-reference.md#the-outbox).