local-storefront 0.2.0 → 0.3.0-rc.2
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/CHANGELOG.md +1384 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1412 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1612 -0
- package/docs/cli.md +917 -0
- package/docs/compatibility.md +502 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +1161 -0
- package/docs/data-reference.md +1272 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +267 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +251 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +18 -9
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×tamp=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 and their translations, 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 of other resources or for a market, 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).
|