local-storefront 0.1.18 → 0.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1299 -0
- package/README.md +15 -10
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1406 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1609 -0
- package/docs/cli.md +873 -0
- package/docs/compatibility.md +475 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +943 -0
- package/docs/data-reference.md +1247 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +266 -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 +210 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +11 -8
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
# Admin API reference
|
|
2
|
+
|
|
3
|
+
What lsf's Admin API does, family by family: the mutations it simulates, the queries that need
|
|
4
|
+
more than reading records, the webhooks they send, and what lsf chose where Shopify does not
|
|
5
|
+
say. To point a backend at it, get its tokens, receive its webhooks and test it, read the
|
|
6
|
+
[Admin API guide](admin.md) first.
|
|
7
|
+
|
|
8
|
+
## What the answers say
|
|
9
|
+
|
|
10
|
+
- Errors are Shopify's, word for word where they are known: parse and validation errors with
|
|
11
|
+
`locations` and `extensions.code`, `you must provide one of first or last`, `first cannot
|
|
12
|
+
exceed 250. …`, `ACCESS_DENIED`, `THROTTLED`, `MAX_COST_EXCEEDED`. A mutation's validation errors
|
|
13
|
+
are its `userErrors`, and a mutation that fails changes nothing.
|
|
14
|
+
- An input list takes 250 items at most, as on Shopify: `nodes(ids:)` with 251 ids, or any
|
|
15
|
+
longer list of an argument or of an input object, written in the query or given by a
|
|
16
|
+
variable, is refused before anything runs, with `MAX_INPUT_SIZE_EXCEEDED` (`The input array
|
|
17
|
+
size of 251 is greater than the maximum allowed of 250.`; the wording is not checked against
|
|
18
|
+
a live shop). The lists Shopify documents another limit for take theirs: 25 metafields for
|
|
19
|
+
`metafieldsSet`, 2048 variants for `productSet` and the `productVariantsBulk…` mutations,
|
|
20
|
+
2048 values for an option. A `JSON` value (`WebPixelInput.settings`) is not a list: its
|
|
21
|
+
arrays are not counted.
|
|
22
|
+
- `extensions.cost`: `requestedQueryCost`, `actualQueryCost` and `throttleStatus`, as Shopify
|
|
23
|
+
computes them, from a leaky bucket per shop and app (`--admin-throttle`). With the header
|
|
24
|
+
`Shopify-GraphQL-Cost-Debug: 1`, also `fields`, the cost of each field as Shopify lists it:
|
|
25
|
+
its `path`, `definedCost`, `requestedTotalCost` and `requestedChildrenCost`, the children
|
|
26
|
+
first.
|
|
27
|
+
- Ids: a record lsf creates has a 13-digit id, as Shopify's do (`gid://shopify/DraftOrder/
|
|
28
|
+
4…`), the same every run for the same requests. In a test shop the ids have 14 digits, are a
|
|
29
|
+
number of the shop's name, and no other shop gives them
|
|
30
|
+
([a shop per test](admin.md#a-shop-per-test)).
|
|
31
|
+
- `extensions.search`: Shopify's warnings when a `query:` names a field the connection does not
|
|
32
|
+
have ("Invalid search field for this query."); the field is then ignored, as on Shopify.
|
|
33
|
+
- Cursors are opaque, and resume as Shopify's do: right after their item in the connection's
|
|
34
|
+
sort order, even when the item was deleted in between (delete a page of products while paging,
|
|
35
|
+
and `after: endCursor` answers the next ones, not those a page further).
|
|
36
|
+
- `extensions.lsf`, only lsf's, only when there is something to say: `defaulted`, the paths of
|
|
37
|
+
the fields answered with a default because the store data does not give them (and
|
|
38
|
+
`defaultedFields`, as `Type.field`), the empty lists lsf does not compute included
|
|
39
|
+
(`Order.agreements`, `Order.events`; not `Order.returns`, which lsf fills as returns are made),
|
|
40
|
+
and `notes`, what lsf did not simulate in the request (an
|
|
41
|
+
input it ignored, a search filter it does not read, a file source it did not download), and
|
|
42
|
+
`localConfirmationUrl`, the page of a billing mutation's `confirmationUrl` on lsf's origin
|
|
43
|
+
([billing](#billing-discounts-functions-and-bulk-operations)).
|
|
44
|
+
- A deprecated field that Shopify replaced by another of the same object ("Use
|
|
45
|
+
`originalUnitPriceSet` instead.") reads that one's value: `DraftOrderLineItem
|
|
46
|
+
.originalUnitPrice`, `Order.totalPrice`, `LineItem.discountedTotal`, `Product.totalVariants`,
|
|
47
|
+
`Customer.email`, and about a hundred more, never a default `"0.00"`. Where the schema says
|
|
48
|
+
the deprecated field held something else than its replacement, lsf applies the difference:
|
|
49
|
+
`Product.descriptionPlainSummary` is `description` on one line, truncated to 60 characters
|
|
50
|
+
(the last three `...`, *chosen*: Shopify does not say how it ends); `Order.totalShippingPrice`
|
|
51
|
+
is "before discounts", the original prices of the shipping lines, where lsf's
|
|
52
|
+
`totalShippingPriceSet` is after them; `ShippingLine.discountedPrice` is "pre-tax", without the
|
|
53
|
+
line's taxes when the order's `taxesIncluded` is true. The others read their replacement as it
|
|
54
|
+
is: their descriptions differ in the currencies they name (lsf's shop and presentment amounts
|
|
55
|
+
are those of the order), or in nothing lsf models (`Customer.validEmailAddress` also checked
|
|
56
|
+
that the email's domain exists, which lsf cannot know offline: it is `validFormat`).
|
|
57
|
+
- A money input of ten trillion or more in the currency's unit (`Money`, `MoneyInput.amount`, a
|
|
58
|
+
discount's amount, …), or a line or an order whose total would be, answers a `userError`
|
|
59
|
+
("Price must be less than 10000000000000") and changes nothing. Shopify publishes no limit:
|
|
60
|
+
this one is lsf's, so that no total overflows.
|
|
61
|
+
- Nothing answers a silent success for what lsf does not do: an input it ignores is in
|
|
62
|
+
`extensions.lsf.notes`, and a mutation it did not simulate would answer an error with
|
|
63
|
+
`extensions.code: LSF_NOT_SIMULATED` (no mutation of the versions it serves does). The REST
|
|
64
|
+
Admin API (`/admin/api/2026-10/products.json`, `/admin/products.json`) answers `404` with
|
|
65
|
+
JSON that says so: lsf serves the GraphQL Admin API, and of REST only `GET
|
|
66
|
+
/admin/oauth/access_scopes.json`, the scopes of the token (`{"access_scopes": [{"handle":
|
|
67
|
+
"write_products"}, {"handle": "read_products"}]}`, a write scope followed by the read scope
|
|
68
|
+
it implies), with the same `X-Shopify-Access-Token`.
|
|
69
|
+
- A bug of lsf while a mutation runs (a panic, in the mutation or in what follows it: segments,
|
|
70
|
+
listings, product feeds) answers Shopify's internal error: `Internal error. Looks like
|
|
71
|
+
something went wrong on our end.` with `extensions.code: INTERNAL_SERVER_ERROR`, a
|
|
72
|
+
`requestId`, and what went wrong in `extensions.lsf.panic`. The shop is put back as it was
|
|
73
|
+
before the mutation, so the next request finds it intact; the terminal shows the panic.
|
|
74
|
+
|
|
75
|
+
## What lsf simulates
|
|
76
|
+
|
|
77
|
+
Every mutation of the versions lsf serves (2026-10, and [2027-01](admin.md#api-versions) when
|
|
78
|
+
asked) is simulated, and every query answered (`GET /__lsf/admin` lists the mutations,
|
|
79
|
+
`mutations`; for contributors, `crates/app/tests/coverage.rs` fails when a version brings one
|
|
80
|
+
that is not). Simulated means:
|
|
81
|
+
|
|
82
|
+
- **Checked as Shopify checks**: the input is validated, and what is wrong answers the
|
|
83
|
+
mutation's `userErrors` (Shopify's field, message and code where shopify.dev documents them,
|
|
84
|
+
lsf's words where it does not). A mutation that fails changes nothing.
|
|
85
|
+
- **Done**: the records of the shop change, and every query that follows, the storefront and the
|
|
86
|
+
checkout see it ([what they see](admin.md#what-the-storefront-and-the-checkout-see)). Work Shopify
|
|
87
|
+
does in the background is done at once: its `Job` answers `done: true`.
|
|
88
|
+
- **Delivered**: the mutation's webhooks reach the backend, with Shopify's payloads (every topic
|
|
89
|
+
but `returns/process`, `shipping_addresses/create` and `/update`, which Shopify publishes no
|
|
90
|
+
example of, and which lsf logs as not sent).
|
|
91
|
+
- **Kept local**: what Shopify would send or buy outside (an email, a label, a Flow trigger) is
|
|
92
|
+
recorded in the [outbox](#the-outbox); what waits on time happens by the [shop's
|
|
93
|
+
clock](#the-shops-clock). Where lsf cannot do what Shopify does, the answer says so
|
|
94
|
+
(`extensions.lsf.notes`), and so does the family below.
|
|
95
|
+
|
|
96
|
+
A query reads the records of the shop: a field the store data does not give is a default,
|
|
97
|
+
reported in `extensions.lsf.defaulted`. The families below list their mutations, the queries
|
|
98
|
+
that need more than reading records, and what lsf chose where Shopify does not say.
|
|
99
|
+
|
|
100
|
+
### Products, collections and inventory
|
|
101
|
+
|
|
102
|
+
A backend that manages a catalog through the Admin API works against lsf as against a store:
|
|
103
|
+
what it changes is read back by the queries, delivered as webhooks, and shown by the storefront
|
|
104
|
+
and the checkout (see "Store sync" below).
|
|
105
|
+
|
|
106
|
+
| Area | Mutations lsf simulates |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Products | `productCreate`, `productUpdate` (with `media` and `metafields`), `productDelete`, `productSet` (synchronous or not, upsert by `id`, `handle` or `customId`), `productDuplicate`, `productChangeStatus` |
|
|
109
|
+
| Bundles and combined listings | `productBundleCreate`, `productBundleUpdate`, `productVariantRelationshipBulkUpdate`, `combinedListingUpdate` |
|
|
110
|
+
| Variants | `productVariantsBulkCreate` (its `strategy` included), `productVariantsBulkUpdate` (`allowPartialUpdates` included), `productVariantsBulkDelete`, `productVariantsBulkReorder` |
|
|
111
|
+
| Options | `productOptionsCreate` (`LEAVE_AS_IS`, `CREATE`), `productOptionUpdate` (`LEAVE_AS_IS`, `MANAGE`), `productOptionsDelete` (`DEFAULT`, `NON_DESTRUCTIVE`, `POSITION`), `productOptionsReorder` |
|
|
112
|
+
| Media | `productCreateMedia`, `productUpdateMedia`, `productDeleteMedia`, `productReorderMedia`, `productVariantAppendMedia`, `productVariantDetachMedia` |
|
|
113
|
+
| Collections | `collectionCreate`, `collectionUpdate` (with the `sources` of 2026-10), `collectionDelete`, `collectionDuplicate`, `collectionAddProducts`, `collectionAddProductsV2`, `collectionRemoveProducts`, `collectionReorderProducts`, `collectionConditionsSourceCreate`, `collectionConditionsSourceUpdate`, `collectionConditionsSourceDelete` |
|
|
114
|
+
| Inventory | `inventoryAdjustQuantities`, `inventorySetQuantities`, `inventoryMoveQuantities`, `inventorySetOnHandQuantities`, `inventoryActivate`, `inventoryDeactivate`, `inventoryBulkToggleActivation`, `inventoryItemUpdate` |
|
|
115
|
+
| Locations | `locationAdd`, `locationEdit`, `locationActivate`, `locationDeactivate`, `locationDelete`, `locationLocalPickupEnable`, `locationLocalPickupDisable` |
|
|
116
|
+
| Publications | `publishablePublish`, `publishableUnpublish`; the deprecated `productPublish`, `productUnpublish`, `collectionPublish`, `collectionUnpublish` |
|
|
117
|
+
| Product feeds | `productFeedCreate`, `productFeedDelete`, `productFullSync` |
|
|
118
|
+
| Selling plans | `sellingPlanGroupCreate`, `sellingPlanGroupUpdate`, `sellingPlanGroupDelete`, `sellingPlanGroupAddProducts`, `sellingPlanGroupRemoveProducts`, `sellingPlanGroupAddProductVariants`, `sellingPlanGroupRemoveProductVariants`, `productJoinSellingPlanGroups`, `productLeaveSellingPlanGroups`, `productVariantJoinSellingPlanGroups`, `productVariantLeaveSellingPlanGroups` |
|
|
119
|
+
| Gift cards | `giftCardCreate`, `giftCardUpdate`, `giftCardCredit`, `giftCardDebit`, `giftCardDeactivate`, `giftCardProductSet` (`productSet` for a gift card product: its variants untaxed, unshipped, untracked), `giftCardSendNotificationToCustomer`, `giftCardSendNotificationToRecipient` (the email in the shop's [outbox](#the-outbox)) |
|
|
120
|
+
|
|
121
|
+
How they behave, as shopify.dev documents it:
|
|
122
|
+
|
|
123
|
+
- **Validation** answers Shopify's `userErrors` (`field`, `message`, `code`) and changes nothing:
|
|
124
|
+
"The variant 'Red / S' already exists.", "Option 'Color' already exists.", "Can't manually
|
|
125
|
+
remove products from a smart collection", "The location cannot be deleted while it is
|
|
126
|
+
active.", … Where Shopify documents an error by its code only, the message is the code's
|
|
127
|
+
description in the 2026-10 schema. A `productSet` variant whose option value is not among
|
|
128
|
+
those `productOptions` declares answers `OPTION_VALUE_DOES_NOT_EXIST` ("Option value does not
|
|
129
|
+
exist."), where `productVariantsBulkCreate` and `…BulkUpdate` add the value to its option.
|
|
130
|
+
- **Metafields** given to `productCreate`, `productUpdate`, `productSet`, the variant
|
|
131
|
+
mutations, collections, locations and selling plans (and to customers, app discounts and the
|
|
132
|
+
owners of Functions) are checked as `metafieldsSet` checks them
|
|
133
|
+
(see "Metafields, metaobjects and content"): the type and the value, a definition's type and
|
|
134
|
+
validations, `$app` and another app's namespaces. The errors are the mutation's, at the path of
|
|
135
|
+
the input (`["metafields", "0", "value"]`; `productSet`'s with the code `INVALID_METAFIELD`).
|
|
136
|
+
- **Derived values follow**: price ranges, `totalInventory`, `hasOnlyDefaultVariant`,
|
|
137
|
+
`featuredMedia`, a variant's `title`, `displayName`, `position` and `inventoryQuantity`, an
|
|
138
|
+
option value's `hasVariants`, a variant's `availableForSale` after an inventory change.
|
|
139
|
+
- **Inventory**: `on_hand` stays the sum of `available`, `committed`, `reserved`, `damaged`,
|
|
140
|
+
`safety_stock` and `quality_control`; adjusting `available` changes `on_hand` with it, moving
|
|
141
|
+
between two of them does not. `changeFromQuantity` must be given (`null` skips the check) and a
|
|
142
|
+
stale one is `CHANGE_FROM_QUANTITY_STALE`. Reasons and ledger documents are checked. The answer
|
|
143
|
+
is an `InventoryAdjustmentGroup` (`reason: "Inventory correction"`, `quantityAfterChange:
|
|
144
|
+
null`, as the Admin API answers right after the change).
|
|
145
|
+
- **`@idempotent`**: the inventory mutations, `locationActivate` and `locationDeactivate` require
|
|
146
|
+
it, as Shopify does since 2026-04. The same key with the same arguments within 24 hours answers
|
|
147
|
+
the first answer again without doing anything; with other arguments,
|
|
148
|
+
`IDEMPOTENCY_KEY_PARAMETER_MISMATCH`.
|
|
149
|
+
- **Smart collections** (`ruleSet`) list the products their rules match (`TAG`, `TITLE`, `TYPE`,
|
|
150
|
+
`VENDOR`, `VARIANT_TITLE`, `VARIANT_PRICE`, `VARIANT_COMPARE_AT_PRICE`, `VARIANT_WEIGHT`,
|
|
151
|
+
`VARIANT_INVENTORY`, `IS_PRICE_REDUCED`; all of them, or any with `appliedDisjunctively`), and
|
|
152
|
+
follow the products every mutation changes. Custom collections keep their manual order.
|
|
153
|
+
- **Publishing**: a product created through the Admin API is unpublished; `publishablePublish`
|
|
154
|
+
on the Online Store sets its `publishedAt` and `onlineStoreUrl`, `publishableUnpublish` clears
|
|
155
|
+
them.
|
|
156
|
+
- **Asynchronous work is done at once**: `productSet(synchronous: false)` answers a
|
|
157
|
+
`ProductSetOperation` already `COMPLETE`, `productReorderMedia`, `collectionAddProductsV2`,
|
|
158
|
+
`collectionRemoveProducts`, `collectionReorderProducts` a `Job` already `done`.
|
|
159
|
+
- **Bundles**: a fixed bundle's variants have components (`productVariantComponents`, each a
|
|
160
|
+
variant and a quantity; `requiresComponents` is then `true`). `productVariantRelationshipBulkUpdate`
|
|
161
|
+
gives a variant its components and prices it (`COMPONENTS_SUM`, `FIXED`, `NONE`);
|
|
162
|
+
`productBundleCreate` makes a product whose options are the components' option selections
|
|
163
|
+
(or their `consolidatedOptions`, and a component's `quantityOption`) and whose variants are
|
|
164
|
+
their combinations, priced at the sum of their components *(lsf's choice)*, and answers a
|
|
165
|
+
`ProductBundleOperation` already `COMPLETE` (`productOperation` reads it). A bundle's inventory
|
|
166
|
+
is the bundles its components make ("the minimum of the component's inventory quantities",
|
|
167
|
+
each divided by the quantity it takes), and it sells while they do: it follows every change of
|
|
168
|
+
their stock. Selling a bundle takes its components' units: an order that claims stock
|
|
169
|
+
(`orderCreate` with `DECREMENT_…`, a completed draft, an edit, a subscription's billing)
|
|
170
|
+
commits each component's quantity times the bundle's, `DECREMENT_OBEYING_POLICY` and billing
|
|
171
|
+
attempts check the components, and fulfilling, cancelling with a restock or refunding with a
|
|
172
|
+
restock move the components' units; the bundle's quantity, `availableForSale` and
|
|
173
|
+
`variants/in_stock` / `variants/out_of_stock` follow. A component deleted (its variant or its
|
|
174
|
+
product) leaves its bundles (`products/update`); one with none left no longer
|
|
175
|
+
`requiresComponents`. `Product.bundleComponents`, `productComponents`, `productParents` and
|
|
176
|
+
`ProductVariant.productParents` read the relation both ways. Nested bundles, more than 30
|
|
177
|
+
components or 3 options are refused, as Shopify documents.
|
|
178
|
+
- **Combined listings**: a product created with `combinedListingRole: PARENT` takes children
|
|
179
|
+
with `combinedListingUpdate` (`productsAdded`, `productsEdited`, `productsRemovedIds`,
|
|
180
|
+
`optionsAndValues` for the order of its options and values): a parent variant per child, the
|
|
181
|
+
child `CHILD`. `Product.combinedListing` answers the listing for the parent and its children;
|
|
182
|
+
`products(query: "combined_listing_role:PARENT")` finds the parents.
|
|
183
|
+
- **Collection sources** (2026-10): a collection is the union of its sources: conditions
|
|
184
|
+
sources (typed conditions on tags, titles, types, vendors, statuses, the variants' prices,
|
|
185
|
+
compare-at prices, inventory, titles and weights, and metafields of a definition; manual
|
|
186
|
+
selections; exclusions by tag, type, vendor, collection or selection), sub-collection sources
|
|
187
|
+
(the members of other collections, one level deep) and an app's shareable sources
|
|
188
|
+
(`collectionConditionsSourceCreate`; changing one changes every collection that links it,
|
|
189
|
+
deleting it unlinks it). A collection of the store data reads as the one source it amounts to
|
|
190
|
+
(`Collection.sources`), and keeps it when it gets more. `collectionDuplicate` copies a
|
|
191
|
+
collection with its products, sources, metafields and, unless `copyPublications: false`, its
|
|
192
|
+
publications.
|
|
193
|
+
- **Product feeds**: a feed is a country and a language of the app's channel (`channelCreate`),
|
|
194
|
+
`ACTIVE` while its market is active and its language published. `productFullSync` (and
|
|
195
|
+
`channelFullSync`) sends `product_feeds/full_sync` for each product published on the channel,
|
|
196
|
+
then `product_feeds/full_sync_finish`; a product listed, changed (its stock included) or
|
|
197
|
+
unlisted on the channel sends `product_feeds/incremental_sync` (`CREATE`, `UPDATE`,
|
|
198
|
+
`DELETE`); a feed whose status changes `product_feeds/update`. The payloads are in the shop's
|
|
199
|
+
currency, without delivery estimates.
|
|
200
|
+
- **Gift card products and notifications**: `giftCardProductSet` runs `productSet` for a gift
|
|
201
|
+
card product (with its `issuanceCurrency`, set at creation only). The notifications are not
|
|
202
|
+
sent: each is an email in the shop's [outbox](#the-outbox) (`gift_card_created` to the card's
|
|
203
|
+
customer, `gift_card_recipient` with the recipient's message); a card disabled, expired or
|
|
204
|
+
spent is refused (`INVALID`).
|
|
205
|
+
- **Media given by URL** is recorded, not downloaded: `image.url` is the URL, the size unknown
|
|
206
|
+
(an image uploaded to a staged target has the size of its header), and the medium is `READY`
|
|
207
|
+
at once (Shopify answers `UPLOADED` and processes it later).
|
|
208
|
+
- **Queries**: `products` and `productVariants` take their documented sort keys
|
|
209
|
+
(`INVENTORY_TOTAL`, `FULL_TITLE`, `SKU`, …) and search filters (`sku`, `barcode`, `price`,
|
|
210
|
+
`inventory_total`, `is_price_reduced`, `out_of_stock_somewhere`, `published_status`,
|
|
211
|
+
`option1`, `location_id`, …); `productByIdentifier` and `productVariantByIdentifier` find a
|
|
212
|
+
`customId`; `productTags`, `productTypes`, `productVendors`, `inCollection`,
|
|
213
|
+
`InventoryItem.inventoryLevel(locationId:)`, `publishedOnPublication`,
|
|
214
|
+
`resourcePublications`, the `appliesTo…` fields of selling plan groups are answered.
|
|
215
|
+
|
|
216
|
+
**Webhooks**: `products/create`, `products/update`, `products/delete`, `collections/*`,
|
|
217
|
+
`inventory_items/*`, `inventory_levels/update`, `…/connect`, `…/disconnect`, `locations/*`
|
|
218
|
+
(`activate` and `deactivate` included), `variants/in_stock` and `variants/out_of_stock` (a
|
|
219
|
+
variant whose stock crosses zero), `product_publications/create` and `…/delete` (the same for
|
|
220
|
+
`collection_publications`), `selling_plan_groups/*`, `product_feeds/*` (`create`, `update`,
|
|
221
|
+
`full_sync`, `full_sync_finish`, `incremental_sync`). A variant, option or media change is a `products/update`; an inventory
|
|
222
|
+
change is an `inventory_levels/update` only.
|
|
223
|
+
|
|
224
|
+
**Store sync**: products (with their status and Online Store publication), variants (price,
|
|
225
|
+
SKU, barcode, stock), options, images of `files/`, collections and their products, locations,
|
|
226
|
+
selling plan groups and the gift cards of the store data are written back to the store the
|
|
227
|
+
storefront and the checkout see. Not synced: smart collection rules (a changed smart collection
|
|
228
|
+
becomes a manual one with the products it has), collection publications (an unpublished
|
|
229
|
+
collection stays on the storefront), gift cards the Admin API creates (the store data keys a
|
|
230
|
+
card by its code, which Shopify answers once), media from a URL outside `files/`, SEO, the
|
|
231
|
+
category.
|
|
232
|
+
|
|
233
|
+
Inputs a simulated mutation ignores (`category`, `linkedMetafield`, a
|
|
234
|
+
future `publishDate`, …) are said in `extensions.lsf.notes`; a product category condition of a
|
|
235
|
+
collection source matches no product (lsf keeps no category), and a variant-scoped source
|
|
236
|
+
(`targetType: VARIANTS`) puts in the products one of whose variants matches. The collections of the store data are custom collections
|
|
237
|
+
for the Admin API (lsf resolves their rules into products). `productTags`,
|
|
238
|
+
`productTypes` and `productVendors` page 250 at most, as every connection of lsf (Shopify allows
|
|
239
|
+
1000 to 5000).
|
|
240
|
+
|
|
241
|
+
### Orders, fulfillments and draft orders
|
|
242
|
+
|
|
243
|
+
An order placed in the local checkout is an order of the Admin API at once (`orders/create`,
|
|
244
|
+
`order_transactions/create` for its payment, `orders/paid`), and the backend processes it as on
|
|
245
|
+
Shopify: it fulfills, refunds, edits, cancels and returns it; the order's totals, statuses,
|
|
246
|
+
fulfillment orders, transactions and stock follow, and each change fires its webhooks (the
|
|
247
|
+
specific topic first, then `orders/updated`, which Shopify sends "in addition to the more
|
|
248
|
+
specific webhooks").
|
|
249
|
+
|
|
250
|
+
- **Scopes**: reading an order takes `read_orders`; fulfilling it takes
|
|
251
|
+
`write_merchant_managed_fulfillment_orders` (`write_orders` is not enough: its
|
|
252
|
+
`fulfillmentOrders` answer `ACCESS_DENIED` with the scopes that would do).
|
|
253
|
+
- **The storefront does not show it**: orders do not [sync](admin.md#what-the-storefront-and-the-checkout-see),
|
|
254
|
+
so the account pages show no fulfillment, refund or edit the backend makes. Read them back
|
|
255
|
+
with the Admin API (`displayFulfillmentStatus`, `fulfillments { trackingInfo }`).
|
|
256
|
+
|
|
257
|
+
- **Queries**: `orders` and `ordersCount` with the filters of the reference (`financial_status`,
|
|
258
|
+
`fulfillment_status` with `shipped`, `partial`, `unshipped`, `unfulfilled`, `on_hold`, `status`
|
|
259
|
+
with `open`, `closed`, `cancelled`, `not_closed`, `name` with or without `#`, `email`,
|
|
260
|
+
`customer_id`, `sku`, `tag`, `created_at`, `processed_at`, `updated_at`, `gateway`,
|
|
261
|
+
`current_total_price`, `return_status`, `delivery_method`, `location_id`, `test`,
|
|
262
|
+
`source_name`, …) and every `OrderSortKeys`; `fulfillmentOrders` (open ones unless
|
|
263
|
+
`includeClosed`), `assignedFulfillmentOrders`, `manualHoldsFulfillmentOrders`,
|
|
264
|
+
`FulfillmentOrder.supportedActions` and `locationsForMove`, `returnableFulfillments`,
|
|
265
|
+
`Order.suggestedRefund`, `draftOrders`.
|
|
266
|
+
- **Orders**: `orderCreate` (imported orders: lines by variant or custom, customer, addresses,
|
|
267
|
+
shipping lines, tax lines, a discount code, transactions, `financialStatus`,
|
|
268
|
+
`fulfillmentStatus`, `inventoryBehaviour`), `orderUpdate`, `orderCancel` (cancelled at once,
|
|
269
|
+
its `job` done and its `jobResult` readable with `node`; authorizations voided, payments
|
|
270
|
+
refunded with `refundMethod`, units restocked with `restock`), `orderClose`, `orderOpen`,
|
|
271
|
+
`orderMarkAsPaid`, `orderCapture`, `transactionVoid`, `orderCustomerSet`,
|
|
272
|
+
`orderCustomerRemove`, `orderInvoiceSend` (the invoice in the shop's [outbox](#the-outbox)),
|
|
273
|
+
`orderDelete` (a cancelled or closed order).
|
|
274
|
+
- **Fulfillment**: the deprecated `fulfillmentCreateV2` and `fulfillmentTrackingInfoUpdateV2` run
|
|
275
|
+
their successors below. `fulfillmentCreate` (with `lineItemsByFulfillmentOrder`, partial or whole;
|
|
276
|
+
fulfillment orders close or stay in progress, the order is fulfilled or partially fulfilled),
|
|
277
|
+
`fulfillmentTrackingInfoUpdate`, `fulfillmentCancel`, `fulfillmentEventCreate`;
|
|
278
|
+
`fulfillmentOrderHold` and `ReleaseHold` (several holds, a hold of some items splits the
|
|
279
|
+
fulfillment order), `Move`, `Cancel`, `Open`, `Reschedule`.
|
|
280
|
+
- **Fulfillment services**: `fulfillmentServiceCreate`, `Update`, `Delete` (with their
|
|
281
|
+
location), and the requests between merchant and service:
|
|
282
|
+
`fulfillmentOrderSubmitFulfillmentRequest`, `AcceptFulfillmentRequest`,
|
|
283
|
+
`RejectFulfillmentRequest`, `SubmitCancellationRequest`, `AcceptCancellationRequest`,
|
|
284
|
+
`RejectCancellationRequest`, `fulfillmentOrderClose`. lsf does not call the service's
|
|
285
|
+
`callbackUrl`: the service reads `assignedFulfillmentOrders`.
|
|
286
|
+
- **Refunds**: `refundCreate` (line items with `CANCEL`, `RETURN` or `NO_RESTOCK`, shipping,
|
|
287
|
+
transactions against the order's payments, `refundMethods.storeCreditRefund` to the
|
|
288
|
+
customer's store credit; a refund without transactions moves no money, as on Shopify).
|
|
289
|
+
- **Returns**: `returnRequest`, `returnApproveRequest`, `returnDeclineRequest`, `returnCreate`
|
|
290
|
+
(with exchange line items, restocking fees and a return shipping fee), `returnProcess` (with
|
|
291
|
+
its refund, its exchanges, its dispositions), `returnClose`, `returnReopen`, `returnCancel`,
|
|
292
|
+
`removeFromReturn`, `returnLineItemRemoveFromReturn`, `returnRefund`; reverse fulfillment
|
|
293
|
+
orders: `reverseDeliveryCreateWithShipping`, `reverseDeliveryShippingUpdate`,
|
|
294
|
+
`reverseFulfillmentOrderDispose`. `returnCalculate`, `Return.suggestedFinancialOutcome` and
|
|
295
|
+
`Return.suggestedRefund` compute what a return gives back.
|
|
296
|
+
- **Order edits**: `orderEditBegin`, `orderEditAddVariant`, `orderEditAddCustomItem`,
|
|
297
|
+
`orderEditSetQuantity`, `orderEditAddLineItemDiscount`, `orderEditUpdateDiscount`,
|
|
298
|
+
`orderEditRemoveDiscount`, `orderEditRemoveLineItemDiscount`, `orderEditAddShippingLine`,
|
|
299
|
+
`orderEditUpdateShippingLine`, `orderEditRemoveShippingLine`, `orderEditCommit`
|
|
300
|
+
(`orders/edited`, with the lines added and removed); requested edits:
|
|
301
|
+
`requestedOrderEditCreate`, `requestedOrderEditResolve`, `requestedOrderEditDecline`,
|
|
302
|
+
`requestedOrderEditCalculate`.
|
|
303
|
+
- **Draft orders**: `draftOrderCreate`, `draftOrderUpdate`, `draftOrderDelete`,
|
|
304
|
+
`draftOrderCalculate`, `draftOrderComplete` (an order, paid unless `paymentPending` or payment
|
|
305
|
+
terms; each applied discount is a `ManualDiscountApplication` of the order, a line's
|
|
306
|
+
`EXPLICIT` and `EACH`, the draft's `ALL` and `ACROSS`, with its `discountAllocations` on the
|
|
307
|
+
lines and in the `orders/create` payload), `draftOrderDuplicate`, `draftOrderInvoiceSend` (the invoice in the shop's outbox),
|
|
308
|
+
`draftOrderInvoicePreview`, `draftOrderCreateFromOrder`,
|
|
309
|
+
`draftOrderBulkAddTags`, `draftOrderBulkRemoveTags`, `draftOrderBulkDelete`; B2B drafts
|
|
310
|
+
(`purchasingEntity.purchasingCompany`: the location's addresses and payment terms). Taxes are
|
|
311
|
+
at the store data's `checkout.tax_rate`; `draftOrderAvailableDeliveryOptions` offers its
|
|
312
|
+
`checkout.shipping_rates` and the locations with local pickup.
|
|
313
|
+
- **Payments**: `paymentTermsCreate`, `paymentTermsUpdate`, `paymentTermsDelete` and a draft's
|
|
314
|
+
`paymentTerms` (the nine templates of `paymentTermsTemplates`; one schedule, due `dueInDays`
|
|
315
|
+
after it is issued, on a fixed date, on receipt or on fulfillment), `orderCreateManualPayment`,
|
|
316
|
+
`orderCreateMandatePayment` (idempotent by its key), `paymentScheduleCapture` (a due schedule,
|
|
317
|
+
through the order's vaulted payment method: the card of a checkout that left a balance, the
|
|
318
|
+
customer's payment methods), `paymentReminderSend` (in the outbox). `Order.paymentTerms`,
|
|
319
|
+
`paymentCollectionDetails`, `PaymentTerms.due` and `overdue` follow the money and the clock.
|
|
320
|
+
Every sale, capture and refund is a `TenderTransaction` (`tenderTransactions`).
|
|
321
|
+
- **Store credit**: `storeCreditAccountCredit`, `storeCreditAccountDebit` (one account per owner
|
|
322
|
+
and currency, credits that expire, Shopify's messages); `orderCreate` takes a payment through
|
|
323
|
+
the gateway `shopify_store_credit` from the customer's account; refunds, returns and
|
|
324
|
+
cancellations give money back as store credit.
|
|
325
|
+
- **Risk and attribution**: `orderRiskAssessmentCreate` (`Order.risk` with its recommendation),
|
|
326
|
+
`orderAttributionDefinitionUpsert`, `orderAttributionDefinitionDelete`
|
|
327
|
+
(`orderAttributionDefinitions`, `Order.attribution` from the order's `sourceName`).
|
|
328
|
+
- **Shopify Payments**: `shopifyPaymentsAccount` (balance transactions of the money it processed,
|
|
329
|
+
daily payouts, its balance), disputes (`disputes`, `dispute`, `Order.disputes`,
|
|
330
|
+
`disputeEvidenceUpdate`), `shopifyPaymentsPayoutAlternateCurrencyCreate`.
|
|
331
|
+
- **Tax apps**: `taxAppConfigure`, `taxSummaryCreate`.
|
|
332
|
+
- **Webhooks**: `orders/create`, `/updated`, `/paid`, `/cancelled`, `/fulfilled`,
|
|
333
|
+
`/partially_fulfilled`, `/edited`, `/delete`, `/risk_assessment_changed`,
|
|
334
|
+
`/shopify_protect_eligibility_changed`, `/link_requested`; `order_transactions/create`;
|
|
335
|
+
`tender_transactions/create`; `payment_terms/create`, `/update`, `/delete`;
|
|
336
|
+
`payment_schedules/due`; `fulfillments/create`, `/update`; `fulfillment_events/create`;
|
|
337
|
+
`fulfillment_holds/added`, `/released`; `fulfillment_orders/*` (holds, moves, splits,
|
|
338
|
+
cancellations, requests to a service); `refunds/create`; `returns/request`, `/approve`,
|
|
339
|
+
`/decline`, `/close`, `/reopen`, `/cancel`, `/update` (`returns/process` fires, but neither
|
|
340
|
+
the webhook reference nor dev-mcp has an example payload to build it from: it is logged as
|
|
341
|
+
not sent); `reverse_deliveries/attach_deliverable`; `reverse_fulfillment_orders/dispose`;
|
|
342
|
+
`requested_order_edit/created`, `/declined`, `/resolved`; `draft_orders/create`, `/update`,
|
|
343
|
+
`/delete`; `disputes/create`, `/update`; `tax_services/create`, `/update`.
|
|
344
|
+
|
|
345
|
+
**Time**: a payment schedule that came due fires `payment_schedules/due` (once), store credit
|
|
346
|
+
that expired leaves its account, the days Shopify Payments processed are paid out, by the
|
|
347
|
+
[shop's clock](#the-shops-clock).
|
|
348
|
+
|
|
349
|
+
**Emails**: lsf sends none. What Shopify would email (payment reminders, invoices, gift cards,
|
|
350
|
+
store credit, return labels, a requested edit declined, a new order link) is in the shop's
|
|
351
|
+
[outbox](#the-outbox).
|
|
352
|
+
|
|
353
|
+
**What buyers, banks and Shopify do**, which no mutation makes, is a control endpoint: `POST
|
|
354
|
+
/__lsf/admin/disputes` (`{"order", "amount"?, "type"?, "reason"?}`) opens a chargeback or an
|
|
355
|
+
inquiry (`disputes/create`), `POST /__lsf/admin/disputes/<n>/won`, `/lost` or `/accepted`
|
|
356
|
+
decides it; `POST /__lsf/admin/orders/protect` (`{"order", "status"?, "eligibility"?}`) changes
|
|
357
|
+
Shopify Protect's view of an order; `POST /__lsf/admin/orders/link-request` (`{"order"}`) is a
|
|
358
|
+
buyer asking for a new order link; `POST /__lsf/admin/tax-app/activate` is the merchant turning
|
|
359
|
+
on the tax app. See [the control API](control-api.md).
|
|
360
|
+
|
|
361
|
+
What lsf chose where Shopify does not say, or where a local tool must differ:
|
|
362
|
+
|
|
363
|
+
- The local checkout keeps the stock of the store data: its orders commit nothing, so test
|
|
364
|
+
purchases never sell a product out. Orders the backend makes do: `orderCreate` with a
|
|
365
|
+
`DECREMENT_…` `inventoryBehaviour`, a completed draft order, units an edit adds. Shipping
|
|
366
|
+
takes the units the order committed; a restock puts back what the order took. An order that
|
|
367
|
+
took nothing (of the store data or the checkout) ships and restocks without moving the stock. A variant an order
|
|
368
|
+
sells out is out of stock as after an inventory mutation: `availableForSale` is `false`,
|
|
369
|
+
`variants/out_of_stock` is sent, and the storefront shows it sold out.
|
|
370
|
+
- Where an order that names no location fulfills from (`orderCreate` without
|
|
371
|
+
`fulfillment.locationId`, a completed draft order, a subscription's billing): Shopify does not
|
|
372
|
+
document it for these orders. lsf takes the shop's primary location (`location` without an id)
|
|
373
|
+
when it can give every line, else the first active location that can, else the primary one,
|
|
374
|
+
where `DECREMENT_OBEYING_POLICY` and billing then find the stock short.
|
|
375
|
+
- `orderCreate`'s `discountCode` makes a `DiscountCodeApplication` (its `value` as the
|
|
376
|
+
reference's examples answer it) and the allocations on the lines (an amount off the order is
|
|
377
|
+
spread over them by their totals) or on the shipping line. Its `allocationMethod`,
|
|
378
|
+
`targetSelection` and `targetType` are lsf's: Shopify's examples do not show them.
|
|
379
|
+
- `orderCancel` is synchronous (Shopify's runs as a job). `orderCreate` with a
|
|
380
|
+
`financialStatus` and no `transactions` records the transaction the status implies (a sale of
|
|
381
|
+
the total for `PAID`). `fulfillmentCancel` does not put the shipped units back in stock.
|
|
382
|
+
- Messages Shopify does not publish are lsf's, in Shopify's form; those it does publish (in the
|
|
383
|
+
reference's examples, or quoted from real answers) are word for word. For contributors, the
|
|
384
|
+
sources are in the doc comments of `crates/app/src/admin/domains/orders/`.
|
|
385
|
+
- The `returns/*` payloads list no return line items (Shopify's examples have none, and lsf does
|
|
386
|
+
not know their shape): read them through the API. `returns/update` lists the return line
|
|
387
|
+
items a change removed, as its example does.
|
|
388
|
+
- Money: Shopify Payments processes every payment that is not through a manual gateway (the
|
|
389
|
+
checkout's cards, mandates, the orders of the store data), at 2.9 % + 0.30 a charge and 15.00
|
|
390
|
+
a chargeback; it pays out each day once the shop's clock left it behind. Every amount is in
|
|
391
|
+
the shop's currency: an alternate currency payout of another currency finds no balance.
|
|
392
|
+
- Payment terms have one schedule; a schedule is `due` once its date passed and `overdue` from
|
|
393
|
+
the day after, by the shop's clock. A mandate is the card of a checkout that left a balance to
|
|
394
|
+
pay later, or one of the customer's payment methods; the payments through it are Shopify
|
|
395
|
+
Payments' and succeed.
|
|
396
|
+
- An exchange is processed as Shopify's returns do it: its items become line items of the order
|
|
397
|
+
and the returned units are taken back by a refund, so that the order owes (or is owed) the
|
|
398
|
+
difference. Restocking and return shipping fees are taken off what a return suggests to
|
|
399
|
+
refund.
|
|
400
|
+
- Risk recommendations are lsf's rule (cancel for a high risk, investigate for a medium one,
|
|
401
|
+
accept for a low one); lsf makes no fraud analysis of its own, nor any Shopify Protect
|
|
402
|
+
decision. A store credit balance stays below 100,000 of its currency (Shopify does not publish
|
|
403
|
+
its limit).
|
|
404
|
+
|
|
405
|
+
lsf's orders have no duties (`currentTotalDutiesSet` is `null`, as for an order without). Not
|
|
406
|
+
built: the `returns/process` payload (no example to build it from), `tax_summaries/create` (not a
|
|
407
|
+
topic of the 2026-10 enum).
|
|
408
|
+
|
|
409
|
+
### Inventory transfers, fulfillment and delivery
|
|
410
|
+
|
|
411
|
+
A backend that moves stock between locations, routes and prepares fulfillment orders, buys
|
|
412
|
+
labels or manages shipping settings works against lsf as against a store, the stock following
|
|
413
|
+
end to end.
|
|
414
|
+
|
|
415
|
+
| Area | Mutations lsf simulates |
|
|
416
|
+
|---|---|
|
|
417
|
+
| Inventory transfers | `inventoryTransferCreate`, `inventoryTransferCreateAsReadyToShip`, `inventoryTransferDuplicate`, `inventoryTransferEdit`, `inventoryTransferSetItems`, `inventoryTransferRemoveItems`, `inventoryTransferMarkAsReadyToShip`, `inventoryTransferCancel`, `inventoryTransferDelete` |
|
|
418
|
+
| Inventory shipments | `inventoryShipmentCreate`, `inventoryShipmentCreateInTransit`, `inventoryShipmentAddItems`, `inventoryShipmentRemoveItems`, `inventoryShipmentUpdateItemQuantities`, `inventoryShipmentMarkInTransit`, `inventoryShipmentReceive`, `inventoryShipmentSetTracking`, `inventoryShipmentSetBarcode`, `inventoryShipmentDelete` |
|
|
419
|
+
| Fulfillment orders | `fulfillmentOrderSplit`, `fulfillmentOrderMerge`, `fulfillmentOrderReportProgress`, `fulfillmentOrderLineItemsPreparedForPickup`, `fulfillmentOrdersReroute`, `fulfillmentOrdersSetFulfillmentDeadline` (and `fulfillmentOrderOpen` stops reported progress, from 2026-01) |
|
|
420
|
+
| Labels and rules | `shippingLabelPurchase`, `fulfillmentConstraintRuleCreate`, `…Update`, `…Delete` (a fulfillment constraints Function of the app, with its metafields; `fulfillmentConstraintRules` lists them) |
|
|
421
|
+
| Carriers and delivery | `carrierServiceCreate`, `…Update`, `…Delete`, `deliveryProfileCreate`, `…Update`, `…Remove`, `deliveryPromiseProviderUpsert`, `deliveryPromiseParticipantsUpdate`, `deliverySettingUpdate`, `deliveryShippingOriginAssign`, `shippingPackageUpdate`, `shippingPackageMakeDefault`, `shippingPackageDelete` |
|
|
422
|
+
|
|
423
|
+
How the stock moves, as the Help Center's "Creating and processing inventory transfers"
|
|
424
|
+
describes it:
|
|
425
|
+
|
|
426
|
+
- **A draft** commits nothing. **Ready to ship** commits the units at the origin (`available` to
|
|
427
|
+
`committed`). **A shipment in transit** takes them out of the origin (`committed`, else
|
|
428
|
+
`available`, and `on_hand`) and puts them in `incoming` at the destination, which is stocked
|
|
429
|
+
with the item if it was not (`inventory_levels/connect`). **Receiving** makes the accepted
|
|
430
|
+
units `available` there; rejected and cancelled ones only leave `incoming`. **Cancelling** a
|
|
431
|
+
transfer that is ready to ship gives its units back to `available` at the origin (a transfer
|
|
432
|
+
with a shipment cannot be cancelled: `SHIPMENT_EXISTS`). Each change is followed as an
|
|
433
|
+
inventory mutation is: `inventory_levels/update`, `variants/in_stock` or `variants/out_of_stock`,
|
|
434
|
+
and the storefront's stock (store sync).
|
|
435
|
+
- The transfer follows its shipments: lines `shippedQuantity`, `pickedForShipmentQuantity` (draft
|
|
436
|
+
shipments), `processableQuantity`; `IN_PROGRESS` once a shipment is in transit, `TRANSFERRED`
|
|
437
|
+
once every unit shipped and was received (`inventory_transfers/complete`).
|
|
438
|
+
- **Deactivating a location** with a `destinationLocationId` moves its stock there, and the open
|
|
439
|
+
transfers that start or end at it too: a shipment in transit to it is received at the
|
|
440
|
+
destination, and the inactive location stocks nothing.
|
|
441
|
+
- **Rerouting** and **merging** keep each order line's committed stock right: a fulfillment
|
|
442
|
+
order rerouted takes what the order committed to its new location; a split or a merge stays at
|
|
443
|
+
one location, so nothing moves.
|
|
444
|
+
|
|
445
|
+
What lsf chose where Shopify does not say, or where a local tool must differ:
|
|
446
|
+
|
|
447
|
+
- `@idempotent` is required from 2026-04 on the transfer and shipment mutations whose reference
|
|
448
|
+
says so (create, create as ready to ship, duplicate, set items; shipment create, create in
|
|
449
|
+
transit, add items, receive), optional before; a key reused with other arguments is
|
|
450
|
+
`IDEMPOTENCY_KEY_PARAMETER_MISMATCH`.
|
|
451
|
+
- A shipment's items must be on its transfer, within what the transfer has left to ship.
|
|
452
|
+
Transfers are named `#T1`, their shipments `T1-1`.
|
|
453
|
+
- **Nothing leaves the machine.** A pickup's "Ready for pickup" notice (`ready_for_pickup`,
|
|
454
|
+
`out_for_delivery` for a local delivery), a label bought (`shipping_label`), a shipping
|
|
455
|
+
confirmation (`shipping_confirmation`) are recorded in the shop's [outbox](#the-outbox), never
|
|
456
|
+
sent. `shippingLabelPurchase` answers a
|
|
457
|
+
result already `PURCHASED` with a label (its tracking number made from its id, the same every
|
|
458
|
+
run, from the carrier asked, else USPS, Canada Post or DHL Express by the origin's country), and
|
|
459
|
+
fulfills the fulfillment order with that tracking, as a label bought in the admin does.
|
|
460
|
+
- Carrier services are recorded, never called: `availableServicesForCountries` is empty (with a
|
|
461
|
+
note) and the checkout does not ask them for rates.
|
|
462
|
+
- Rerouting follows Shopify's default rule: the first active location that fulfills online
|
|
463
|
+
orders and has every unit available, within `includedLocationIds`, outside
|
|
464
|
+
`excludedLocationIds`.
|
|
465
|
+
- `fulfillmentOrderLineItemsPreparedForPickup` prepares local delivery fulfillment orders too
|
|
466
|
+
(`fulfillment_orders/line_items_prepared_for_local_delivery`): 2026-10 has no other mutation
|
|
467
|
+
for them.
|
|
468
|
+
- Every shop has the general delivery profile, made from the store data: its locations, the rest
|
|
469
|
+
of the world, the `checkout.shipping_rates` that are shipping rates (pickup and local delivery
|
|
470
|
+
are the locations' on Shopify). Shipping packages have no GraphQL type: every shop has
|
|
471
|
+
Shopify's "Sample box" (`gid://shopify/ShippingPackage/1`), which `GET /__lsf/admin` shows
|
|
472
|
+
(`shippingPackages`); a label bought without a package uses the default one.
|
|
473
|
+
- A delivery promise provider is a location of the app's fulfillment service
|
|
474
|
+
(`MUST_BELONG_TO_APP`). `deliveryShippingOriginAssign` answers that single origin shipping is no
|
|
475
|
+
longer supported, as Shopify retired it. `deliverySettingUpdate` takes no input in 2026-10 and
|
|
476
|
+
changes nothing.
|
|
477
|
+
|
|
478
|
+
**Queries**: `inventoryTransfer(s)` (sort keys `CREATED_AT`, `NAME`, `STATUS`, `ORIGIN_NAME`,
|
|
479
|
+
`DESTINATION_NAME`, `EXPECTED_SHIPMENT_ARRIVAL`; filters `status`, `tag`, `origin_id`,
|
|
480
|
+
`destination_id`, `product_id`, `product_variant_id`, `created_at`), `inventoryShipment(s)`
|
|
481
|
+
(`status`, `barcode`, `tracking_number`, `destination_id`), `deliveryProfile(s)` with their
|
|
482
|
+
items, counts and unassigned locations, `ProductVariant.deliveryProfile`,
|
|
483
|
+
`locationsAvailableForDeliveryProfiles(Connection)`, `carrierService(s)`,
|
|
484
|
+
`availableCarrierServices`, `deliveryPromiseProvider`, `deliveryPromiseParticipants`,
|
|
485
|
+
`deliveryPromiseSettings`, `fulfillmentConstraintRules`,
|
|
486
|
+
`FulfillmentOrder.fulfillmentOrdersForMerge` and the `MERGE`, `REPORT_PROGRESS`, `MARK_AS_OPEN`
|
|
487
|
+
actions, `shippingLabel` and the label's `ShippingLabelPurchaseResult` (`node`).
|
|
488
|
+
|
|
489
|
+
**Webhooks**: `inventory_transfers/add_items`, `/update_item_quantities`, `/remove_items`,
|
|
490
|
+
`/ready_to_ship`, `/cancel`, `/complete`, `/updated`; `inventory_shipments/create`, `/add_items`,
|
|
491
|
+
`/remove_items`, `/update_item_quantities`, `/mark_in_transit`, `/receive_items`,
|
|
492
|
+
`/update_tracking`, `/delete`; `fulfillment_orders/split`, `/merged`, `/moved`,
|
|
493
|
+
`/order_routing_complete`, `/progress_reported`, `/manually_reported_progress_stopped`,
|
|
494
|
+
`/line_items_prepared_for_pickup`, `/line_items_prepared_for_local_delivery`; `profiles/create`,
|
|
495
|
+
`/update`, `/delete`. `fulfillment_events/delete` and `delivery_promise_settings/update` are built
|
|
496
|
+
for `lsf app webhook trigger` (no mutation of 2026-10 deletes a fulfillment event or changes the
|
|
497
|
+
promise settings). `shipping_addresses/create` and `/update` have no example payload in
|
|
498
|
+
Shopify's reference: lsf does not build them.
|
|
499
|
+
|
|
500
|
+
**Store sync**: the general profile's method definitions that ship to the rest of the world,
|
|
501
|
+
with a flat price and no condition, are the local checkout's shipping rates: a price, a name, a
|
|
502
|
+
description changed, a rate added or deactivated shows at the checkout. The other zones and
|
|
503
|
+
profiles, conditional rates and carrier services' rates have nowhere to go in the store data and
|
|
504
|
+
do not reach it; transfers, shipments, fulfillment orders, rules, labels, promises and packages
|
|
505
|
+
do not sync (the stock they move does).
|
|
506
|
+
|
|
507
|
+
### Customers and companies
|
|
508
|
+
|
|
509
|
+
The customers and companies of the store data, and those the backend creates. What the backend
|
|
510
|
+
changes reaches the storefront and the checkout: a customer it creates logs in at the local
|
|
511
|
+
checkout by email, with the addresses, consent, tags and metafields it gave, and buys for the
|
|
512
|
+
company locations its roles give it.
|
|
513
|
+
|
|
514
|
+
**Queries**: `customers` (sort keys `NAME`, `LOCATION`, `CREATED_AT`, `UPDATED_AT`, `ID`; the
|
|
515
|
+
search filters of 2026-10: `email`, `phone`, `first_name`, `last_name`, `state`, `tag`,
|
|
516
|
+
`tag_not`, `country`, `accepts_marketing`, `orders_count`, `total_spent`, `customer_date`,
|
|
517
|
+
`order_date`, `updated_at`, `id`, and a free term on the names, email, phone and tags),
|
|
518
|
+
`customersCount` (which documents `id` only: another filter is ignored, with Shopify's warning),
|
|
519
|
+
`customer(id:)`, `customerByIdentifier` (`id`, `emailAddress`, `phoneNumber`, `customId`),
|
|
520
|
+
`companies` (sort keys `NAME`, `ORDER_COUNT`, `TOTAL_SPENT`, `SINCE_DATE`, …), `companiesCount`,
|
|
521
|
+
`company`, `companyLocations`, `companyLocation`, `companyContact`, `companyContactRole`, and the
|
|
522
|
+
contacts, locations, roles and role assignments of a company.
|
|
523
|
+
|
|
524
|
+
**Mutations**, as Shopify validates them (`userErrors` with Shopify's messages where they are
|
|
525
|
+
published, Rails' wording otherwise), with their webhooks:
|
|
526
|
+
|
|
527
|
+
| Mutations | What lsf does | Webhooks |
|
|
528
|
+
|---|---|---|
|
|
529
|
+
| `customerCreate`, `customerUpdate`, `customerSet` | A name, an email or a phone is required; email and phone are unique ("Email has already been taken") and checked ("Email is invalid", "Phone is invalid"). A new customer is `DISABLED`, not subscribed, in the shop's language. `tags` replace the tags; `metafields` set the customer's, checked as `metafieldsSet` checks them; the deprecated `addresses` replace the address book. `customerSet` creates, updates by `id`, or upserts by `email`, `phone` or `customId`. | `customers/create` or `customers/update`, `customer.tags_added` / `customer.tags_removed` for the tags that changed, the consent topics for a consent that changed |
|
|
530
|
+
| `customerDelete` | Refused for a customer with orders. Its company contacts go with it. | `customers/delete` |
|
|
531
|
+
| `customerAddressCreate`, `customerAddressUpdate`, `customerAddressDelete`, `customerUpdateDefaultAddress` | The first address is the default one; `setAsDefault` changes it; the default address cannot be deleted. `countryCode` gives the country's name; a province keeps its code when its name is not given. | `customers/update` |
|
|
532
|
+
| `customerEmailMarketingConsentUpdate`, `customerSmsMarketingConsentUpdate` | `SUBSCRIBED`, `UNSUBSCRIBED` or `PENDING`, for a customer with an email (a phone); `consentUpdatedAt` is now unless given. | `customers/update`, then `customers_email_marketing_consent/update` or `customers_marketing_consent/update` |
|
|
533
|
+
| `customerSendAccountInviteEmail` | The customer is `INVITED`. The email is not sent: `extensions.lsf.notes` says so. | `customers/update` |
|
|
534
|
+
| `customerGenerateAccountActivationUrl` | `https://<shop>/account/activate/<id>/<token>`; refused for an enabled account. | none |
|
|
535
|
+
| `customerAddTaxExemptions`, `customerRemoveTaxExemptions`, `customerReplaceTaxExemptions` | | `customers/update` |
|
|
536
|
+
| `tagsAdd`, `tagsRemove` on a customer | The webhook names only the tags added (removed). | `customers/update`, `customer.tags_added` / `customer.tags_removed` |
|
|
537
|
+
| `companyCreate` | The company with Shopify's two roles ("Location admin", "Ordering only"), a location (named after the company when none is given), and the contact given, a new customer, main contact, "Ordering only" at the location. | `companies/create`, `company_locations/create`, `customers/create`, `company_contacts/create`, `company_contact_roles/assign` |
|
|
538
|
+
| `companyUpdate`, `companyDelete`, `companiesDelete` | Deleting a company deletes its locations, contacts and roles; its customers stay. | `companies/update`; `company_contacts/delete`, `company_locations/delete`, `companies/delete` |
|
|
539
|
+
| `companyLocationCreate`, `companyLocationUpdate`, `companyLocationDelete`, `companyLocationsDelete`, `companyLocationAssignAddress`, `companyAddressDelete`, `companyLocationTaxSettingsUpdate`, `companyLocationAssignTaxExemptions`, `companyLocationRevokeTaxExemptions` | `billingSameAsShipping` (or both address types) gives one address for both. | `company_locations/create`, `/update`, `/delete` |
|
|
540
|
+
| `companyContactCreate`, `companyContactUpdate`, `companyContactDelete`, `companyContactsDelete`, `companyContactRemoveFromCompany`, `companyAssignCustomerAsContact`, `companyAssignMainContact`, `companyRevokeMainContact` | A contact's id is its customer's (the checkout's `purchasingCompany.contact.id`). Deleting a contact keeps its customer. | `company_contacts/create`, `/update`, `/delete`; `companies/update` for the main contact |
|
|
541
|
+
| `companyContactAssignRole(s)`, `companyLocationAssignRoles`, `companyContactRevokeRole(s)`, `companyLocationRevokeRoles` | One role per contact and location; the role and the location must be the contact's company's. | `company_contact_roles/assign`, `/revoke` |
|
|
542
|
+
|
|
543
|
+
A company location's `ordersCount` and `totalSpent` (and its company's) count the orders placed
|
|
544
|
+
for it at the local checkout by a B2B buyer, on top of what the store data says, as a customer's
|
|
545
|
+
`numberOfOrders` and `amountSpent` count theirs; a cancelled order costs nothing.
|
|
546
|
+
|
|
547
|
+
Every id that names nothing answers a `userError` (`Customer does not exist`, `Resource requested
|
|
548
|
+
does not exist.`), and a mutation that fails changes nothing. The order of the webhooks of one
|
|
549
|
+
mutation is lsf's: Shopify does not document it.
|
|
550
|
+
|
|
551
|
+
Merges, data erasure, the WhatsApp consent, segments and the staff and tax registrations of a
|
|
552
|
+
company location are in "Segments, marketing, analytics and privacy" below.
|
|
553
|
+
|
|
554
|
+
The WhatsApp consent of `customerCreate` is ignored, with a note.
|
|
555
|
+
|
|
556
|
+
### Segments, marketing, analytics and privacy
|
|
557
|
+
|
|
558
|
+
A backend that segments customers, runs campaigns, reports on sales or handles privacy requests
|
|
559
|
+
works against lsf as against a store: the segment query language is parsed and evaluated over the
|
|
560
|
+
customers of the shop, the marketing activities and analytics the app records are read back,
|
|
561
|
+
what Shopify would send to the outside world is recorded in the [outbox](#the-outbox), never
|
|
562
|
+
sent.
|
|
563
|
+
|
|
564
|
+
**Segments** (`segmentCreate`, `segmentUpdate`, `segmentDelete`; `segments`, `segment`,
|
|
565
|
+
`segmentsCount`, `customerSegmentMembers`, `customerSegmentMembership`,
|
|
566
|
+
`customerSegmentMembersQueryCreate` and `customerSegmentMembersQuery`, `segmentFilters`,
|
|
567
|
+
`segmentFilterSuggestions`, `segmentValueSuggestions`):
|
|
568
|
+
|
|
569
|
+
- The query language of shopify.dev's "Segment query language reference": clauses joined by
|
|
570
|
+
`AND` (tighter) and `OR`, parentheses (100 deep at most, lsf's bound), `=`, `!=`, `>`, `>=`, `<`, `<=`, `BETWEEN … AND …`,
|
|
571
|
+
`CONTAINS`, `NOT CONTAINS`, `IS NULL`, `IS NOT NULL`, `MATCHES (…)` and `NOT MATCHES (…)`;
|
|
572
|
+
quoted strings, numbers, dates, date-times, offsets (`-7d`, `-2w`, `-1m`, `+1y`, at most
|
|
573
|
+
10,000 years either way: lsf's bound, past it the value is not valid), `today`, `yesterday`,
|
|
574
|
+
the deprecated `30_days_ago`; at most 10 clauses. Dates are whole days in the
|
|
575
|
+
shop's time zone (`customerSegmentMembers(timezone:)` for that query, UTC by default).
|
|
576
|
+
- The filters: `amount_spent`, `number_of_orders`, `customer_added_date`, `first_order_date`,
|
|
577
|
+
`last_order_date`, `abandoned_checkout_date` (the local checkout's), `customer_account_status`,
|
|
578
|
+
`email_subscription_status`, `sms_subscription_status`, `customer_tags` (case ignored),
|
|
579
|
+
`customer_email_domain`, `customer_language` (`en` matches `en-GB`), `customer_countries`,
|
|
580
|
+
`customer_regions` (`US-NY`), `customer_cities` (`US-NY-NewYorkCity`), `companies`,
|
|
581
|
+
`created_by_app_id` (the app, for the customers it created), `product_subscription_status`,
|
|
582
|
+
`orders_placed MATCHES (app_id, location_id, count, amount, sum_amount, date)`,
|
|
583
|
+
`products_purchased MATCHES (id, tag, quantity, sum_quantity, date)`,
|
|
584
|
+
`store_credit_accounts MATCHES (balance, currency, next_expiry_date, last_credit_date)`,
|
|
585
|
+
`customer_within_distance MATCHES (coordinates, distance_km | distance_mi)`, and
|
|
586
|
+
`anniversary('metafields.<namespace>.<key>')`. What lsf has no data for matches no customer:
|
|
587
|
+
`predicted_spend_tier` and `rfm_group` (Shopify's models: `IS NULL` holds), `shopify_email.*`
|
|
588
|
+
(lsf sends no email), `storefront_event.*` (the storefront records no views).
|
|
589
|
+
- A bad query is a user error on `query`, in Shopify's form: `Query Line 1 Column 0:
|
|
590
|
+
'total_spent' filter cannot be found.` (the form and `'MATCHES' is unexpected.` are Shopify's,
|
|
591
|
+
quoted from real answers; the other messages are lsf's).
|
|
592
|
+
- Members: `customerSegmentMembers(segmentId: | query: | queryId:)` with `sortKey`
|
|
593
|
+
(`created_at`, `first_order_date`, `last_abandoned_order_date`, `last_order_date`,
|
|
594
|
+
`number_of_orders`, `amount_spent`), `reverse`, `totalCount` and
|
|
595
|
+
`statistics.attributeStatistics` (`amount_spent`, `number_of_orders`). A member's id is
|
|
596
|
+
`gid://shopify/CustomerSegmentMember/<customer id>`. `customerSegmentMembersQueryCreate` runs
|
|
597
|
+
at once: the query is `done`, and `queryId` reads the members it found then.
|
|
598
|
+
- **Webhooks**: `segments/create`, `/update`, `/delete`; `customer.joined_segment` and
|
|
599
|
+
`customer.left_segment` when a change of a customer (their record, an order, a company
|
|
600
|
+
contact, a checkout) moves them into or out of a segment. Creating or editing a segment moves
|
|
601
|
+
nobody, and a member whose dates move them out as time passes leaves at their next change.
|
|
602
|
+
|
|
603
|
+
**Saved searches** (`savedSearchCreate`, `savedSearchUpdate`, `savedSearchDelete`; the
|
|
604
|
+
`…SavedSearches` queries of products, collections, customers, orders, draft orders, files, URL
|
|
605
|
+
redirects, discounts and redeem codes; `segmentMigrations`): the query is split into
|
|
606
|
+
`searchTerms` and `filters` as Shopify's example shows; a connection given `savedSearchId`
|
|
607
|
+
(`orders`, `products`, `abandonedCheckouts`, …) lists what the saved search's query keeps.
|
|
608
|
+
Customer saved searches fire `customer_groups/create`, `/update`, `/delete`.
|
|
609
|
+
|
|
610
|
+
**Customers**:
|
|
611
|
+
|
|
612
|
+
| Mutation | What lsf does | Webhooks |
|
|
613
|
+
|---|---|---|
|
|
614
|
+
| `customerMerge` (with `customerMergePreview`, `customerMergeJobStatus`, `Customer.mergeable`) | Shopify's rules of the customer kept (the override's email, the only email, an enabled account, the consent, else `customerTwoId`); refused for a company contact, gift cards, a subscription, store credit, payment methods, a multipass identifier, a pending erasure, merged tags over 250 or a note over 5000 characters. The kept customer gets the other's orders, draft orders, addresses, metafields, tags and note (or the overrides); the job is done at once. | `customers/update`, `customers/delete`, `customers/merge` |
|
|
615
|
+
| `customerRequestDataErasure`, `customerCancelDataErasure` | The erasure waits 10 days, cancellable meanwhile; then (by the [shop's clock](#the-shops-clock)) the names, email, phone, note and addresses are redacted, consents `REDACTED`, and `customers/redact` is sent to the app. Each step is in the outbox. | `customers/update`, `customers/redact` |
|
|
616
|
+
| `customerWhatsAppMarketingConsentUpdate` | As the SMS consent: a phone is required, `SUBSCRIBED`, `UNSUBSCRIBED` or `PENDING`; it shows in `defaultPhoneNumber.whatsAppMarketingConsent`. | `customers/update`, `customers_whats_app_marketing_consent/update` |
|
|
617
|
+
| `dataSaleOptOut` | The customer of the email opts out (`dataSaleOptOut: true`); an email no customer has gets a customer of its own *(chosen)*. | `customers/update` or `customers/create` |
|
|
618
|
+
| `companyLocationAssignStaffMembers`, `companyLocationRemoveStaffMembers` | The shop's owner is a `StaffMember` (`Shop.accountOwner`); assignments show in `CompanyLocation.staffMemberAssignments`. | none |
|
|
619
|
+
| `companyLocationCreateTaxRegistration`, `companyLocationRevokeTaxRegistration` (deprecated) | As `companyLocationTaxSettingsUpdate`: the location's `taxSettings.taxRegistrationId`. | `company_locations/update` |
|
|
620
|
+
|
|
621
|
+
`customers/purchasing_summary` follows every change of a customer's sales (an order created,
|
|
622
|
+
paid, cancelled, edited, refunded): sent when the app subscribes to it, its numbers those of the
|
|
623
|
+
customer after the change.
|
|
624
|
+
|
|
625
|
+
**Marketing** (`marketingActivityUpsertExternal`, `…CreateExternal` (deprecated),
|
|
626
|
+
`…UpdateExternal`, `…DeleteExternal`, `marketingActivitiesDeleteAllExternal`,
|
|
627
|
+
`marketingActivityCreate` and `marketingActivityUpdate` (the deprecated extension activities),
|
|
628
|
+
`marketingEngagementCreate`, `marketingEngagementsDelete`; `marketingActivities` with
|
|
629
|
+
`marketingActivityIds`, `remoteIds` and `utm`, `marketingActivity`, `marketingEvent`,
|
|
630
|
+
`marketingEvents`): each external activity has its `MarketingEvent` (remote id, URLs, UTM
|
|
631
|
+
parameters, dates); `remoteId` names it in every mutation; the errors are
|
|
632
|
+
`MarketingActivityUserErrorCode`'s (a UTM campaign or remote id taken, UTM parameters that cannot
|
|
633
|
+
change, an activity outside a hierarchy without UTM or URL parameter, …); engagements add up to
|
|
634
|
+
the activity's `adSpend`; deleting all is a `Job` done at once. Nothing is sent to the channels.
|
|
635
|
+
|
|
636
|
+
**Abandoned checkouts**: `abandonedCheckouts` (filters `status`, `recovery_state`,
|
|
637
|
+
`email_state`, `created_at`, `updated_at`; sort keys `CUSTOMER_NAME`, `TOTAL_PRICE`, …),
|
|
638
|
+
`abandonedCheckoutsCount`, `abandonment`, `abandonmentByAbandonedCheckoutId`,
|
|
639
|
+
`abandonmentUpdateActivitiesDeliveryStatuses` and the deprecated `abandonmentEmailStateUpdate`
|
|
640
|
+
(the email state an app's marketing activity reports). See "Webhooks" for how the storefront makes
|
|
641
|
+
them.
|
|
642
|
+
|
|
643
|
+
**Analytics**: `analyticsAnnotationCreate`, `…Update`, `…Delete` (the app's annotations, of the
|
|
644
|
+
types apps may create: `campaign`, `product_launch`, …; `Shop.analyticsAnnotations`);
|
|
645
|
+
`analyticsTargetCreate`, `…Update`, `analyticsTargetsDelete` (a metric of the `sales` schema,
|
|
646
|
+
unique by metric, dates and filters; `analyticsTargets`, `presentmentExpectedValue` in the shop's
|
|
647
|
+
currency). `shopifyqlQuery` answers `FROM sales` over the shop's orders and refunds: `SHOW`,
|
|
648
|
+
`WHERE`, `GROUP BY`, `TIMESERIES` (periods filled), `SINCE`/`UNTIL`/`DURING`, `ORDER BY`,
|
|
649
|
+
`LIMIT`, `WITH TOTALS`; the metrics `gross_sales`, `discounts`, `returns`, `net_sales`,
|
|
650
|
+
`shipping_charges`, `taxes`, `total_sales`, `orders`, `average_order_value`, `quantity_ordered`,
|
|
651
|
+
`net_items_sold`, `customers`, `new_customers`, `returning_customers`, … and the dimensions of
|
|
652
|
+
time, order, customer, product and address; a column it does not know is the reference's
|
|
653
|
+
`Column Not Found: Column 'total_sale' not found`. `VISUALIZE` and `COMPARE TO` are ignored, with
|
|
654
|
+
a note; another schema than `sales` is a parse error, and so are an offset past 10,000 years
|
|
655
|
+
(`Syntax Error: '-99999999999d' is not a date`) and parentheses nested more than 100 deep in a
|
|
656
|
+
condition (lsf's bounds).
|
|
657
|
+
|
|
658
|
+
**Privacy**: `consentPolicyUpdate` (once `privacyFeaturesDisable([COOKIE_BANNER])` turned
|
|
659
|
+
Shopify's banner off, as Shopify requires), `consentPolicy`, `consentPolicyRegions`;
|
|
660
|
+
`privacyFeaturesDisable` and `privacySettings`.
|
|
661
|
+
|
|
662
|
+
**Webhooks**: also `customer_account_settings/update` (built from the shop's customer account
|
|
663
|
+
setting, sent on demand: no mutation changes it) and `audit_events/admin_api_activity` (each
|
|
664
|
+
GraphQL request, when the app subscribes to it; Shopify delivers it through Pub/Sub or
|
|
665
|
+
EventBridge only, which lsf logs as not sent).
|
|
666
|
+
|
|
667
|
+
**Store sync**: none of these records reach the storefront or the checkout (segments, saved
|
|
668
|
+
searches, marketing, analytics, consent policies, abandoned checkouts, staff assignments). A
|
|
669
|
+
merge, an erasure and a data sale opt-out change customers, which sync as customers do.
|
|
670
|
+
|
|
671
|
+
### Subscriptions and customer payment methods
|
|
672
|
+
|
|
673
|
+
A subscription app runs against lsf from the purchase to the last renewal: a customer buys with a
|
|
674
|
+
selling plan at the local checkout (or the backend creates the contract), the backend bills each
|
|
675
|
+
cycle, the order is created, and the card declines or asks for 3D Secure when the test card says
|
|
676
|
+
so. Every change is delivered as its webhook.
|
|
677
|
+
|
|
678
|
+
| Area | Mutations lsf simulates | Webhooks |
|
|
679
|
+
|---|---|---|
|
|
680
|
+
| Payment methods | `customerPaymentMethodCreditCardCreate`, `…CreditCardUpdate` (a session of lsf's [card vault](control-api.md)), `…PaypalBillingAgreementCreate`, `…PaypalBillingAgreementUpdate`, `…RemoteCreate` (Stripe, Braintree, Authorize.Net: imported at once), `…Revoke` (refused while an active contract uses it), `…GetUpdateUrl` (Shop Pay only, as on Shopify), `…SendUpdateEmail`, `paymentInstrumentSendAddEmail` | `customer_payment_methods/create`, `/update`, `/revoke` |
|
|
681
|
+
| Contracts (draft API) | `subscriptionContractCreate`, `subscriptionContractUpdate`, `subscriptionDraftUpdate`, `subscriptionDraftLineAdd`, `…LineUpdate`, `…LineRemove`, `subscriptionDraftDiscountAdd`, `…DiscountUpdate`, `…DiscountRemove`, `…DiscountCodeApply` (the shop's code discounts), `subscriptionDraftFreeShippingDiscountAdd`, `…Update`, `subscriptionDraftCommit`, `subscriptionContractAtomicCreate` | `subscription_contracts/create`, `/update` |
|
|
682
|
+
| Contracts (calculation API of 2026-10) | `subscriptionContractCreateCalculate`, `subscriptionContractUpdateCalculate`, `subscriptionBillingCycleContractEditCalculate`, `subscriptionContractCalculationCommit`; the `subscriptionContractCalculation` query | the contract's or the cycle's |
|
|
683
|
+
| Status and changes | `subscriptionContractActivate`, `…Pause`, `…Cancel`, `…Expire`, `…Fail`, `subscriptionContractProductChange`, `subscriptionContractSetNextBillingDate` | `subscription_contracts/activate`, `/pause`, `/cancel`, `/expire`, `/fail`, `/update` |
|
|
684
|
+
| Billing cycles | `subscriptionBillingCycleSkip`, `…Unskip`, `…ScheduleEdit`, `…EditDelete`, `…EditsDelete`, `subscriptionBillingCycleContractEdit`, `…ContractDraftCommit`, `…ContractDraftConcatenate` | `subscription_billing_cycles/skip`, `/unskip`, `subscription_billing_cycle_edits/create`, `/update`, `/delete` |
|
|
685
|
+
| Billing | `subscriptionBillingAttemptCreate`, `subscriptionBillingCycleCharge`, `subscriptionBillingCycleBulkCharge`, `subscriptionBillingCycleBulkSearch` | `subscription_billing_attempts/success`, `/failure`, `/challenged`; the order's |
|
|
686
|
+
|
|
687
|
+
**Queries**: `subscriptionContract`, `subscriptionContracts` (filters `status`, `customer_id`,
|
|
688
|
+
`product_id`, `product_variant_id`, `selling_plan_id`, `last_billing_attempt_error_type`, the
|
|
689
|
+
dates; sort keys `CREATED_AT`, `ID`, `STATUS`, `UPDATED_AT`), `subscriptionBillingAttempt`,
|
|
690
|
+
`subscriptionBillingAttempts` (`error_code`, `error_message`, `created_at`), `subscriptionDraft`
|
|
691
|
+
(with `linesAdded`, `discountsUpdated`, … and `deliveryOptions`, the store data's shipping rates),
|
|
692
|
+
`subscriptionBillingCycle`, `subscriptionBillingCycles` (by index or by dates),
|
|
693
|
+
`subscriptionBillingCycleBulkResults`, `customerPaymentMethod(showRevoked:)`,
|
|
694
|
+
`Customer.paymentMethods`, `Customer.subscriptionContracts`.
|
|
695
|
+
|
|
696
|
+
How they behave, as shopify.dev documents it:
|
|
697
|
+
|
|
698
|
+
- **Bought at the checkout**: an order placed in the local checkout with a recurring selling plan
|
|
699
|
+
makes one contract per plan (`subscription_contracts/create`): the plan's policies, the lines at
|
|
700
|
+
their checkout price with the plan's pricing policies, the order's address and delivery,
|
|
701
|
+
`originOrder`, and the card vaulted from the payment (`customer_payment_methods/create`). The
|
|
702
|
+
order's line items name their selling plan. A guest order makes none: Shopify requires a
|
|
703
|
+
customer account for a subscription.
|
|
704
|
+
- **Billing cycles** are computed from the billing policy and the shop's clock: cycle 1 starts
|
|
705
|
+
when the contract is created, each is billed at its end (`billingAttemptExpectedDate`, unless a
|
|
706
|
+
schedule edit moves it within the cycle), anchors (`MONTHDAY`, `WEEKDAY`, `YEARDAY`) end the
|
|
707
|
+
cycles at midnight in the shop's time zone, `maxCycles` ends them, and so does the year 9999
|
|
708
|
+
(lsf's bound: a cycle past it is `CYCLE_INDEX_OUT_OF_RANGE`); a new billing interval keeps
|
|
709
|
+
the current cycle's start and never ends it in the past. Edits are per cycle: a skip, a billing
|
|
710
|
+
date, a contract edit (one cycle bills other lines without changing the contract), a
|
|
711
|
+
concatenation (one order bills the cycles of several contracts of the customer).
|
|
712
|
+
- **A billing attempt** answers pending (`ready: false`), as Shopify's does, and is processed
|
|
713
|
+
before the answer: the next query reads its outcome. A success creates the order as the orders
|
|
714
|
+
family does (the cycle's lines, discounts, delivery, tax at the store data's rate, a sale of the
|
|
715
|
+
total, `sourceName: subscription_contract`) and commits its stock; the contract lists it and is
|
|
716
|
+
`lastPaymentStatus: SUCCEEDED`. A contract in a presentment currency bills an order presented
|
|
717
|
+
in it (`presentmentCurrencyCode`, every `presentmentMoney` in the contract's currency), its
|
|
718
|
+
`shopMoney` the contract's amounts at a market's manual rate (`baseCurrencyManualRate`), else
|
|
719
|
+
at 1.0: lsf has no exchange rates. A failure has Shopify's codes (`CARD_DECLINED`,
|
|
720
|
+
`INSUFFICIENT_FUNDS`, `INSUFFICIENT_INVENTORY` with the variants short of stock unless
|
|
721
|
+
`ALLOW_OVERSELLING`, `PAYMENT_METHOD_NOT_SPECIFIED`, `EXPIRED_CARD`, …) in its `state` and the
|
|
722
|
+
deprecated `errorCode`; the contract's status is the app's to change (dunning). The same
|
|
723
|
+
`idempotencyKey` answers the first attempt again.
|
|
724
|
+
- **Statuses**: a cancelled or expired contract is terminated ("Subscription contract status
|
|
725
|
+
cannot be changed once terminated."). Each committed change is a new `revisionId`; a draft or a
|
|
726
|
+
calculation of an older revision is `STALE_CONTRACT`.
|
|
727
|
+
- **The calculation API** answers a `SubscriptionContractCalculationPending`, and the first poll
|
|
728
|
+
reads the `…Success` (calculated contract, projected order totals, warnings) or `…Failure`.
|
|
729
|
+
Lines are restated whole: without `priceOverride` a line is priced from its variant.
|
|
730
|
+
- **Emails** are not sent: they are in the shop's [outbox](#the-outbox)
|
|
731
|
+
(`customer_payment_method_update`, `payment_instrument_add`, the link in `data.url`), and
|
|
732
|
+
`GET /__lsf/admin/subscriptions` lists them with the charges.
|
|
733
|
+
|
|
734
|
+
#### Test cards
|
|
735
|
+
|
|
736
|
+
What charging a payment method does is decided when it is made, from Shopify Payments' test
|
|
737
|
+
cards (and the Bogus gateway's numbers) for a card, deterministic for the same input:
|
|
738
|
+
|
|
739
|
+
| Card number (a vault session's) | A billing attempt |
|
|
740
|
+
|---|---|
|
|
741
|
+
| `4242 4242 4242 4242`, `5555 5555 5555 4444`, `1`, any other | succeeds |
|
|
742
|
+
| `4000 0000 0000 0002`, `2` | fails: `CARD_DECLINED` |
|
|
743
|
+
| `4000 0000 0000 9995` | fails: `INSUFFICIENT_FUNDS` |
|
|
744
|
+
| `4000 0000 0000 0069` | fails: `EXPIRED_CARD` (and so does any card past its expiry, by the shop's clock) |
|
|
745
|
+
| `4000 0000 0000 0127` | fails: `INCORRECT_CVC` |
|
|
746
|
+
| `4000 0000 0000 0119`, `3` | fails: `PROCESSING_ERROR` |
|
|
747
|
+
| `4242 4242 4242 4241` | fails: `INCORRECT_NUMBER` |
|
|
748
|
+
| `4000 0000 0000 3220`, `4000 0025 0000 3155`, `4000 0000 0000 3063` | is challenged (3D Secure): `subscription_billing_attempts/challenged`, its `nextActionUrl` lsf's page, where **Complete** bills it and **Fail** fails it (`AUTHENTICATION_FAILED`) |
|
|
749
|
+
|
|
750
|
+
A remote method (`customerPaymentMethodRemoteCreate`) reads Stripe's test payment method ids and
|
|
751
|
+
the words they are made of, in any gateway's id: `insufficient` (`INSUFFICIENT_FUNDS`), `expired`,
|
|
752
|
+
`incorrectCvc`, `processingError`, `declined` (`CARD_DECLINED`), `authentication`,
|
|
753
|
+
`threeDSecure` or `3ds` (challenged); `pm_card_visa` and any other id succeed. An `inactive`
|
|
754
|
+
PayPal agreement fails with `INVALID_CUSTOMER_BILLING_AGREEMENT`. To make a contract's next
|
|
755
|
+
attempt fail, give its method a new card: `customerPaymentMethodCreditCardUpdate` with a session
|
|
756
|
+
of a declining card, or the update page.
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
curl -s -X POST http://127.0.0.1:9292/__lsf/admin/card-sessions \
|
|
760
|
+
-H 'content-type: application/json' \
|
|
761
|
+
-d '{"credit_card": {"number": "4000000000000002", "first_name": "Jane", "last_name": "Doe", "month": 8, "year": 2030}}'
|
|
762
|
+
# {"id":"lsf-card-…"}: the sessionId of customerPaymentMethodCreditCardCreate
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
What lsf chose where Shopify does not say, or where a local tool must differ:
|
|
766
|
+
|
|
767
|
+
- Billing is synchronous: Shopify processes attempts in the background, lsf before it answers
|
|
768
|
+
(the answer still says pending). A charge moves no money; the order records a sale through the
|
|
769
|
+
method's gateway (`shopify_payments`, `paypal`, `stripe`, …).
|
|
770
|
+
- A billing attempt without a selector bills the earliest cycle that is due and not billed, else
|
|
771
|
+
the one the shop's clock is in (Shopify says "the current billing cycle"). Unanchored cycles
|
|
772
|
+
keep the time of day of the contract's creation in the shop's time zone, across changes of
|
|
773
|
+
summer time; anchored ones end at midnight there.
|
|
774
|
+
- The webhooks `subscription_contract_calculations/succeed` and `/fail` the guides name are not
|
|
775
|
+
topics of 2026-10's `WebhookSubscriptionTopic`: none is sent. The payloads of
|
|
776
|
+
`subscription_billing_attempts/*` are those of Shopify's subscriptions reference app (the
|
|
777
|
+
reference has no example of them); `contract_edit` of the cycle payloads is `null`, as in every
|
|
778
|
+
example. A PayPal agreement's `payment_instrument` has its fields in snake_case (the examples
|
|
779
|
+
show only a card's).
|
|
780
|
+
- Bundles (`productVariantParentLine`) are kept as a line of their parent variant: lsf runs no cart
|
|
781
|
+
transform on a contract. Delivery options are the store data's shipping rates, for any address.
|
|
782
|
+
- Contracts of an app that is uninstalled are not cancelled 48 hours later.
|
|
783
|
+
- Store sync: contracts, drafts, billing attempts and payment methods do not reach the storefront
|
|
784
|
+
nor the checkout (the store data has none); the stock a billing order commits does.
|
|
785
|
+
|
|
786
|
+
### Metafields, metaobjects and content
|
|
787
|
+
|
|
788
|
+
**Metafields.** `metafieldsSet` and `metafieldsDelete` work on every owner the schema allows
|
|
789
|
+
(products, variants, collections, customers, companies and their locations, orders, draft
|
|
790
|
+
orders, pages, blogs, articles, locations, markets, discounts, the owners of Functions, the
|
|
791
|
+
shop, the app's installation).
|
|
792
|
+
|
|
793
|
+
- Every type of 2026-10 is checked as Shopify checks it (`number_integer` `"330\n"` is
|
|
794
|
+
"Value must be an integer."), and kept as the Admin API returns it: JSON compact, `money` with
|
|
795
|
+
a string amount in the shop's currency, measurements as `{"value", "unit"}`, references as
|
|
796
|
+
GIDs of records that exist. `metafieldDefinitionTypes` lists the types with their categories and
|
|
797
|
+
validations.
|
|
798
|
+
- 25 inputs at most (26 is `MAX_INPUT_SIZE_EXCEEDED`, before anything runs), all or nothing:
|
|
799
|
+
one error and nothing is set. Errors have Shopify's `field`
|
|
800
|
+
(`["metafields", "0", "value"]`) and `code` (`INVALID_VALUE`, `INVALID_TYPE`, `BLANK`,
|
|
801
|
+
`APP_NOT_AUTHORIZED`, `STALE_OBJECT`, …).
|
|
802
|
+
- `namespace` omitted, `$app` and `$app:x` are the app's reserved namespaces (`app--<id>`,
|
|
803
|
+
`app--<id>--x`), in mutations and queries alike; another app's is refused.
|
|
804
|
+
- `compareDigest`: `Metafield.compareDigest` gives it; given, the value must not have changed;
|
|
805
|
+
`null`, the metafield must not exist yet.
|
|
806
|
+
- With a definition of the owner type, namespace and key, `type` may be left out, must be the
|
|
807
|
+
definition's when given, and the value must pass its validations (`min`, `max`, `regex`,
|
|
808
|
+
`choices`, `list.min`, …). Setting a metafield does not create a definition, through
|
|
809
|
+
`metafieldsSet` or the `metafields` input of a product, a customer or an app discount. The
|
|
810
|
+
store data's metafields come with definitions lsf infers (one per owner type, namespace and
|
|
811
|
+
key).
|
|
812
|
+
- The token needs the access of the owner (`write_products` for a product's).
|
|
813
|
+
- A product's metafields (or a variant's) are written to the store: the theme's Liquid and the
|
|
814
|
+
Function inputs read them, as every metafield of an owner the store data has.
|
|
815
|
+
|
|
816
|
+
**Metafield definitions.** `metafieldDefinitionCreate`, `metafieldDefinitionUpdate`,
|
|
817
|
+
`metafieldDefinitionDelete` (`deleteAllAssociatedMetafields`, required for the app's
|
|
818
|
+
namespaces and for reference types with metafields), `metafieldDefinitionPin`,
|
|
819
|
+
`metafieldDefinitionUnpin`; `metafieldDefinitions(ownerType:, namespace:, key:, pinnedStatus:,
|
|
820
|
+
sortKey:)` and `metafieldDefinition(identifier:)`. A definition created where metafields exist
|
|
821
|
+
takes them and checks them (`validationStatus`). A `metaobject_reference` (and its list) needs
|
|
822
|
+
`metaobject_definition_id`, a `mixed_reference` `metaobject_definition_ids`, naming definitions
|
|
823
|
+
the shop has: otherwise an `INVALID_OPTION` user error, for metafield definitions and the field
|
|
824
|
+
definitions of metaobjects alike (the wording of the message is lsf's). `capabilities` are kept as given; of their
|
|
825
|
+
effects, only `cartToOrderCopyable` of an ORDER definition is simulated: it copies a cart
|
|
826
|
+
metafield onto the order placed from the cart ([admin](admin.md#what-the-storefront-and-the-checkout-see)).
|
|
827
|
+
The order definitions `shopify.app.toml` declares (`[order.metafields.app.<key>]`) are
|
|
828
|
+
definitions of the shop from the start; those of other owner types are not read. Unlike
|
|
829
|
+
Shopify's, which are read-only through the Admin API, the mutations may change them.
|
|
830
|
+
Uninstalling the app deletes the definitions of its reserved namespaces, declared or created,
|
|
831
|
+
and keeps their metafields; a reinstall creates them again under new ids, with those metafields,
|
|
832
|
+
as Shopify does. The others stay ([uninstall](admin.md#tokens)).
|
|
833
|
+
|
|
834
|
+
**Metaobjects.** `metaobjectDefinitionCreate`, `metaobjectDefinitionUpdate` (field definitions
|
|
835
|
+
created, updated and deleted; the entries follow), `metaobjectDefinitionDelete` (with its
|
|
836
|
+
entries), `metaobjectCreate`, `metaobjectUpdate`, `metaobjectUpsert`, `metaobjectDelete`,
|
|
837
|
+
`metaobjectBulkDelete` (done at once: its `job` is `done`); `metaobjects(type:)` with `sortKey`
|
|
838
|
+
and `query` (`fields.<key>:value`), `metaobjectByHandle`, `metaobjectDefinitionByType`,
|
|
839
|
+
`Metaobject.field(key:)` and `values`, and `Metaobject.referencedBy`: the metafields and the
|
|
840
|
+
metaobject fields that reference the entry (`namespace` is the metaobject's type for a field).
|
|
841
|
+
|
|
842
|
+
- `$app:author` is the app's type (`app--<app id>--author` in the answers, as on Shopify). Its
|
|
843
|
+
entries reach the storefront: the theme reads them as `metaobjects['app--<app id>--author']`
|
|
844
|
+
(with its real app id, which lsf does not need to know) or, in the app's extension,
|
|
845
|
+
`metaobjects['$app:author']`; a Function as `shop.metaobject(handle: {type: "$app:author", …})`.
|
|
846
|
+
The store data writes the type `$app:author`.
|
|
847
|
+
- Field values are checked against the field definitions as
|
|
848
|
+
metafield values are; required fields must have one; `fields` patches, `values` replaces.
|
|
849
|
+
- A handle left out is made from the display name (`Jane Austen` → `jane-austen`, then
|
|
850
|
+
`jane-austen-1`).
|
|
851
|
+
- With the `publishable` capability, an entry is a `DRAFT` until it is `ACTIVE`; only active
|
|
852
|
+
entries are on the storefront.
|
|
853
|
+
|
|
854
|
+
**Online store.** `pageCreate`, `pageUpdate`, `pageDelete`; `blogCreate`, `blogUpdate`,
|
|
855
|
+
`blogDelete`; `articleCreate` (in a blog by `blogId`, or a new one from `blog: {title}`),
|
|
856
|
+
`articleUpdate`, `articleDelete`; `commentApprove`, `commentSpam`, `commentNotSpam`,
|
|
857
|
+
`commentDelete`; `menuCreate`, `menuUpdate`, `menuDelete`; `urlRedirectCreate`,
|
|
858
|
+
`urlRedirectUpdate`, `urlRedirectDelete`. Handles are made from titles, `redirectNewHandle`
|
|
859
|
+
creates the redirect, `metafields` inputs are checked as `metafieldsSet` checks them. Published
|
|
860
|
+
pages and articles, menus, and active metaobjects are written to the store the theme renders.
|
|
861
|
+
|
|
862
|
+
**Files.** `fileCreate` (up to 250, from a URL or a staged upload), `fileUpdate` (alt, filename,
|
|
863
|
+
content, the products a file is a medium of), `fileDelete`; `files` searched by name and alt,
|
|
864
|
+
sorted by `FILENAME`. A file is `READY` as soon as it is created: lsf has nothing to process, so
|
|
865
|
+
a backend that polls `fileStatus` sees it at once. Its URL is the source's. From a staged upload
|
|
866
|
+
(`originalSource` the target's `resourceUrl`, once the file is uploaded), the file has the name
|
|
867
|
+
the target was made for, the MIME type it was uploaded with, its size (`originalFileSize`,
|
|
868
|
+
`originalSource.fileSize`), and an image its `width` and `height` (read from a PNG, JPEG, GIF or
|
|
869
|
+
WebP header); its URL serves the bytes. Another URL is not downloaded: the file is `READY`
|
|
870
|
+
whatever the URL answers, an image's `width` and `height` are `null`, and
|
|
871
|
+
`extensions.lsf.notes` says so (`fileCreate: lsf did not download <url>: …`). Shopify downloads
|
|
872
|
+
it, and a URL that answers no file ends `FAILED` with `fileErrors`, which lsf does not
|
|
873
|
+
simulate: give a test the bytes through `stagedUploadsCreate`.
|
|
874
|
+
|
|
875
|
+
**Translations.** `translationsRegister` and `translationsRemove`; `translatableResource`,
|
|
876
|
+
`translatableResources(resourceType:)`, `translatableResourcesByIds`, and `translations(locale:)`
|
|
877
|
+
of the resources. lsf translates products, collections, pages, blogs, articles, metafields and
|
|
878
|
+
metaobject fields of translatable types, policies, product options and their values, and menus.
|
|
879
|
+
The digest is the SHA-256 of the content, as Shopify's; the locale must be one of the shop's
|
|
880
|
+
other than its primary one. A translation is `outdated` once its resource changed. Translations
|
|
881
|
+
are not shown on the storefront.
|
|
882
|
+
|
|
883
|
+
**Webhooks.** `metafield_definitions/create`, `/update` (also pin and unpin), `/delete`;
|
|
884
|
+
`metaobjects/create`, `/update`, `/delete`; `products/update` when a product's or a variant's
|
|
885
|
+
metafields change. Shopify has no topic for pages, blogs, articles, comments, menus, redirects,
|
|
886
|
+
files, or the metafields of other owners.
|
|
887
|
+
|
|
888
|
+
Redirects in bulk, standard definitions, `fileAcknowledgeUpdateFailed` and themes are in
|
|
889
|
+
"[Themes, script tags, redirects in bulk and
|
|
890
|
+
policies](#themes-script-tags-redirects-in-bulk-and-policies)" below. Not simulated:
|
|
891
|
+
metafield definition `constraints`, the effects of `capabilities` and `access` (kept as given),
|
|
892
|
+
and staff authors of articles (`author.userId`).
|
|
893
|
+
|
|
894
|
+
### Markets, catalogs, price lists and channels
|
|
895
|
+
|
|
896
|
+
The markets of the store data (those it declares, and a market of its own for each country in
|
|
897
|
+
none) are the shop's markets, with the primary domain's web presence in every locale; a backend
|
|
898
|
+
that manages markets, B2B catalogs or a sales channel works against lsf as against a store.
|
|
899
|
+
|
|
900
|
+
| Area | Mutations lsf simulates |
|
|
901
|
+
|---|---|
|
|
902
|
+
| Markets | `marketCreate`, `marketUpdate` (conditions to add and delete, currency settings, price inclusions, catalogs, web presences, discounts), `marketDelete`, `backupRegionUpdate`; the deprecated `marketCurrencySettingsUpdate`, `marketRegionsCreate`, `marketRegionDelete`, `marketRegionsDelete` |
|
|
903
|
+
| Localization | `marketLocalizationsRegister`, `marketLocalizationsRemove`, `shopLocaleEnable`, `shopLocaleUpdate`, `shopLocaleDisable`, `webPresenceCreate`, `webPresenceUpdate`, `webPresenceDelete`; the deprecated `marketWebPresenceCreate`, `…Update`, `…Delete` |
|
|
904
|
+
| Catalogs | `catalogCreate`, `catalogUpdate`, `catalogDelete` (`deleteDependentResources` included), `catalogContextUpdate` |
|
|
905
|
+
| Price lists | `priceListCreate`, `priceListUpdate`, `priceListDelete`, `priceListFixedPricesAdd`, `priceListFixedPricesUpdate`, `priceListFixedPricesDelete`, `priceListFixedPricesByProductUpdate`, `quantityRulesAdd`, `quantityRulesDelete`, `quantityPricingByVariantUpdate` |
|
|
906
|
+
| Publications and channels | `publicationCreate`, `publicationUpdate`, `publicationDelete`, `channelCreate`, `channelUpdate`, `channelDelete`, `channelFullSync`, `bulkProductResourceFeedbackCreate`, `shopResourceFeedbackCreate`; the deprecated `publishablePublishToCurrentChannel`, `publishableUnpublishToCurrentChannel` |
|
|
907
|
+
|
|
908
|
+
How they behave, as shopify.dev documents it:
|
|
909
|
+
|
|
910
|
+
- **Markets** match buyers by their conditions: countries (`REGION`), company locations, retail
|
|
911
|
+
locations. A country is in one active region market at most: a second answers
|
|
912
|
+
`DUPLICATE_UNIQUE_MARKET`, unless `makeDuplicateUniqueMarketsDraft` puts the other in draft.
|
|
913
|
+
The handle is made from the name, and written as a handle (`"NA"` is `na`). A market's
|
|
914
|
+
currency is the local one of its countries when they share one, else the shop's;
|
|
915
|
+
`localCurrencies` says whether each country keeps its own. The primary market (the shop's
|
|
916
|
+
country's) cannot be deleted or put in draft.
|
|
917
|
+
- **Web presences** are a domain or a subfolder of the primary domain (`/fr-ca`), with a
|
|
918
|
+
default locale and alternate ones, which must be the shop's (`DISABLED_LANGUAGE`). Their
|
|
919
|
+
`rootUrls` follow (`https://shop/fr`, `https://shop/fr-ca`). A domain whose presence or
|
|
920
|
+
locales change sends `domains/update`, its `localization` in the payload.
|
|
921
|
+
- **Locales**: `shopLocaleEnable` adds one unpublished (Shopify's 137 locales,
|
|
922
|
+
`availableLocales`), `shopLocaleUpdate` publishes it, `shopLocaleDisable` deletes it with its
|
|
923
|
+
translations. Market localizations are of metafields (`value`) and metaobjects (their
|
|
924
|
+
fields), checked by the content's digest as translations are.
|
|
925
|
+
- **Catalogs** have markets or company locations as context ("exactly one context type"), a
|
|
926
|
+
price list and a publication, both optional. `gid://shopify/Catalog/<n>` names any catalog,
|
|
927
|
+
as Shopify's examples do. The Online Store's catalog and a channel's are app catalogs, which
|
|
928
|
+
cannot be changed.
|
|
929
|
+
- **Price lists** adjust every variant's price by a percentage (`RELATIVE` prices), with fixed
|
|
930
|
+
prices in the list's currency (`FIXED`; "The specified currency does not match the price
|
|
931
|
+
list's currency."). Changing the currency removes the fixed prices. Quantity rules and price
|
|
932
|
+
breaks are B2B: a company location catalog's price list takes them, with the checks and the
|
|
933
|
+
messages of the reference's examples (the increment at most the minimum, a break at least the
|
|
934
|
+
rule's minimum, …). `quantityPricingByVariantUpdate` deletes, then adds, all or nothing.
|
|
935
|
+
- **Contextual pricing**: `Product.contextualPricing` and `ProductVariant.contextualPricing`
|
|
936
|
+
answer the price of a context (`country`, `companyLocationId`, `locationId`) from the price
|
|
937
|
+
list of its active catalog (the lowest of several, as B2B does), with the variant's quantity
|
|
938
|
+
rule and price breaks; without one, the variant's own price in the market's currency.
|
|
939
|
+
`marketsResolvedValues` answers what a buyer of a country gets.
|
|
940
|
+
- **Publications** of catalogs: empty or with every product (`ALL_PRODUCTS`), and the products
|
|
941
|
+
created later with `autoPublish`. `publicationUpdate` adds and removes products and
|
|
942
|
+
collections, 50 at most, with the events of `publishablePublish`.
|
|
943
|
+
- **Channels**: `channelCreate` makes the app's channel, with a publication and an app catalog
|
|
944
|
+
of its own; it covers every country of the shop's active region markets (lsf has no channel
|
|
945
|
+
specification to read). Publishing on it (`publishablePublish` with its publication, or
|
|
946
|
+
`publishablePublishToCurrentChannel`) is `product_listings/add` (`collection_listings/add`),
|
|
947
|
+
unpublishing or deleting the product `…/remove`, changing it while listed `…/update`. A future
|
|
948
|
+
`publishDate` on a channel schedules the listing: `scheduled_product_listings/add`, listed once
|
|
949
|
+
the [shop's clock](#the-shops-clock) passes the date. `channelFullSync` answers a trace per
|
|
950
|
+
country and published language.
|
|
951
|
+
- **Resource feedback**: `bulkProductResourceFeedbackCreate` (read by
|
|
952
|
+
`productResourceFeedback`) and `shopResourceFeedbackCreate` (read by `App.feedback` and
|
|
953
|
+
`Channel.resourceFeedback`; `ACCEPTED` clears it). Older feedback answers "Feedback for a
|
|
954
|
+
later version of this resource was already accepted."
|
|
955
|
+
- **Queries**: `markets(type:)` (by name), `marketByGeography`, `primaryMarket`,
|
|
956
|
+
`backupRegion`, `marketLocalizableResource(s)`, `catalogs(type:)`, `catalogsCount`,
|
|
957
|
+
`priceList(s)`, `publications(catalogType:)`, `publicationsCount`, `channels`,
|
|
958
|
+
`channelByHandle`, `shopLocales(published:)`, `webPresences`, and what they are read through:
|
|
959
|
+
`Market.catalogs`, `Market.webPresences`, `CompanyLocation.catalogs`, `inCatalog`, `market`,
|
|
960
|
+
`Customer.market`, `publishedOnChannel`, `publishedOnCurrentChannel`, `unpublishedChannels`.
|
|
961
|
+
|
|
962
|
+
**Webhooks**: `markets/create`, `markets/update`, `markets/delete`,
|
|
963
|
+
`markets_backup_region/update`, `locales/create`, `locales/update`, `locales/destroy`,
|
|
964
|
+
`domains/update` (and `domains/create`, `domains/destroy` on demand: Shopify makes domains, not
|
|
965
|
+
the Admin API), `channels/delete`, `product_listings/*`, `collection_listings/*`,
|
|
966
|
+
`scheduled_product_listings/*`. Shopify has no topic for catalogs, price lists, publications,
|
|
967
|
+
web presences or market localizations.
|
|
968
|
+
|
|
969
|
+
**Store sync**: a market's countries and the currency each sells in reach the store (a market
|
|
970
|
+
created, renamed, put in draft, given countries or another currency), and so do the published
|
|
971
|
+
languages (served under `/<locale>/`). Not synced: price lists and their fixed prices (the
|
|
972
|
+
storefront has one price per variant, and no exchange rates), B2B catalogs and quantity rules
|
|
973
|
+
(the local checkout gives a company location buyer the prices and rules of the data, as it
|
|
974
|
+
gives everyone), web presences (the storefront has one address), channels and their listings,
|
|
975
|
+
market localizations.
|
|
976
|
+
|
|
977
|
+
**Differences**: lsf has no exchange rates: a price converted to another currency uses the
|
|
978
|
+
market's `baseCurrencyManualRate`, else 1.0, and `extensions.lsf.notes` says so.
|
|
979
|
+
`channelFullSync` syncs the channel's product feeds (see "Products, collections and inventory"). Where shopify.dev documents a user
|
|
980
|
+
error by its code only, the message is the code's description in the schema or lsf's (the source
|
|
981
|
+
of each is in the code).
|
|
982
|
+
|
|
983
|
+
### Point of sale, cash drawers and payment sessions
|
|
984
|
+
|
|
985
|
+
Shopify POS runs on phones and tablets the Admin API cannot make: the seed gives each active
|
|
986
|
+
location one `PointOfSaleDevice`, and the shop its owner (a `StaffMember`) when nothing else
|
|
987
|
+
seeded staff, so that a backend that manages cash works against lsf as against a store with POS.
|
|
988
|
+
|
|
989
|
+
| Area | Mutations lsf simulates |
|
|
990
|
+
|---|---|
|
|
991
|
+
| Cash drawers | `cashDrawerCreate`, `cashDrawerFindOrCreate`, `cashDrawerUpdate`, `pointOfSaleDeviceAssignToCashDrawer` |
|
|
992
|
+
| Reason codes | `cashManagementReasonCodeCreate`, `cashManagementReasonCodeDelete` |
|
|
993
|
+
| Payment sessions | `pointOfSaleDevicePaymentSessionOpen`, `…Count`, `…Adjust`, `…Close` |
|
|
994
|
+
|
|
995
|
+
- **Drawers** have a name unique at their location and a `balance` in the shop's currency; a
|
|
996
|
+
device is assigned to one drawer of its location ("must be in the same location").
|
|
997
|
+
- **Sessions**: a device with a drawer opens one session at a time (`ALREADY_OPEN`). Counts
|
|
998
|
+
(opening, mid-session, closing) set the drawer's balance to what was counted and record the
|
|
999
|
+
discrepancy; adjustments add or take cash. `totalDiscrepancy`, `totalAdjustments`,
|
|
1000
|
+
`closingAdjustment`, `cashCountedAtClose` and the activities (`cashActivities`) follow, as do
|
|
1001
|
+
the drawer's `totalAdjustments(dateRange:)` and `totalDiscrepancies`, and
|
|
1002
|
+
`cashManagementLocationSummary`, `cashManagementShopSummary` (sessions opened and closed,
|
|
1003
|
+
balances at the start and the end, net cash). lsf has no POS sales: sales and refunds are 0.
|
|
1004
|
+
- **Reason codes**: Shopify's default and system codes are seeded; only custom ones can be
|
|
1005
|
+
deleted.
|
|
1006
|
+
- **Queries**: `cashDrawer(s)`, `pointOfSaleDevice`, `pointOfSaleDevicePaymentSession(s)`
|
|
1007
|
+
(`is_open`, `location_id`, `point_of_sale_device_id`, the times), `cashManagementReasonCodes`,
|
|
1008
|
+
the deprecated `cashTrackingSession(s)`, read from the same sessions.
|
|
1009
|
+
- **Nothing reaches a device**: what Shopify would push to the POS app (a drawer assigned, a
|
|
1010
|
+
session opened or closed) is in the shop's [outbox](#the-outbox) (channel `pos`, `to` the
|
|
1011
|
+
device) and said in `extensions.lsf.notes`. Shopify has no webhook topic for any of it.
|
|
1012
|
+
Nothing syncs to the storefront.
|
|
1013
|
+
|
|
1014
|
+
### Billing, discounts, Functions and bulk operations
|
|
1015
|
+
|
|
1016
|
+
**Billing.** The billing helpers of the React Router template (`billing.require`,
|
|
1017
|
+
`billing.request`, `billing.check`, `billing.cancel`, `createUsageRecord`,
|
|
1018
|
+
`updateUsageCappedAmount` of `@shopify/shopify-api`) work unchanged, in the loader of a page and
|
|
1019
|
+
in a route the front end fetches (["Billing"](admin.md#javascript-the-react-router-app-and-shopifyshopify-api)):
|
|
1020
|
+
|
|
1021
|
+
- `appSubscriptionCreate` (recurring and usage lines, a discount, `trialDays`, `test`) and
|
|
1022
|
+
`appPurchaseOneTimeCreate` make a `PENDING` charge and answer a `confirmationUrl` as
|
|
1023
|
+
Shopify's, on the shop's admin:
|
|
1024
|
+
`https://<shop>/admin/charges/<app id>/<n>/RecurringApplicationCharge/confirm_recurring_application_charge?signature=…`
|
|
1025
|
+
(`ApplicationCharge/confirm_application_charge` for a one-time purchase): the library's
|
|
1026
|
+
exit-iframe page takes no other (`https`, no port, `*.myshopify.com` under `/admin`, or
|
|
1027
|
+
`admin.shopify.com`), and its loader sends an embedded app's merchant there. lsf's admin serves
|
|
1028
|
+
that path on its origin and redirects it to lsf's charge page,
|
|
1029
|
+
`/__lsf/admin/charges/<n>?shop=…`; lsf's admin and the frame's proxy (in a redirect and in
|
|
1030
|
+
`X-Shopify-API-Request-Failure-Reauthorize-Url`) take Shopify's URL there, and the answer's
|
|
1031
|
+
`extensions.lsf.localConfirmationUrl` has the same page on lsf's origin, which a test opens as
|
|
1032
|
+
it is. An app with
|
|
1033
|
+
`embedded = false` gets the same path on lsf's origin, with `&shop=<shop>`
|
|
1034
|
+
(`http://127.0.0.1:9292/admin/charges/…?signature=…&shop=test-1.myshopify.com`): its backend
|
|
1035
|
+
redirects to it plainly, outside any frame, and it works as it is. The page shows the charge; **Approve** (or **Decline**)
|
|
1036
|
+
sends the merchant back to `returnUrl` with `charge_id=<n>`, as Shopify does; a `returnUrl`
|
|
1037
|
+
on Shopify's admin or on the app is the app in lsf's admin. In a test,
|
|
1038
|
+
`POST /__lsf/admin/charges/<n>/approve?shop=<shop>` (or `/decline`) does it without the page.
|
|
1039
|
+
`<n>` is the number of the charge's id; charges count from 1 in the default shop and from a
|
|
1040
|
+
number of its own in a test shop, so that ids are unique across shops. Use the shop of the URL (see
|
|
1041
|
+
[approve a charge](admin.md#approve-a-charge)).
|
|
1042
|
+
- Approved, a subscription is `ACTIVE` and the app's other active subscription `CANCELLED`;
|
|
1043
|
+
`currentAppInstallation { activeSubscriptions oneTimePurchases allSubscriptions }` list them. A
|
|
1044
|
+
charge left pending two days (by the shop's clock) is `EXPIRED`. Uninstalling the app
|
|
1045
|
+
(`POST /__lsf/admin/uninstall`) cancels its subscription, without a webhook.
|
|
1046
|
+
- `appUsageRecordCreate` charges a usage line of an active subscription, up to its capped amount
|
|
1047
|
+
(`"Failed to create usage charge"` past it); the same `idempotencyKey` gives back the same
|
|
1048
|
+
record. `appSubscriptionLineItemUpdate` asks for a new capped amount, which the merchant
|
|
1049
|
+
approves on the same page. `appSubscriptionTrialExtend` and `appSubscriptionCancel` too.
|
|
1050
|
+
- Webhooks: `app_subscriptions/update` (a status or a capped amount changed),
|
|
1051
|
+
`app_purchases_one_time/update`, `app_subscriptions/approaching_capped_amount` (the usage
|
|
1052
|
+
balance crossed 90%). Under managed pricing with `legacy = false` (Shopify App Pricing), no
|
|
1053
|
+
change of a subscription sends one, `appSubscriptionCancel` included
|
|
1054
|
+
([managed pricing](admin.md#managed-pricing)).
|
|
1055
|
+
- No money moves: `prorate` credits nothing. `currentPeriodEnd` is the end of the trial, else 30
|
|
1056
|
+
days (365 for an annual plan) after the approval; once [the shop's clock](#the-shops-clock)
|
|
1057
|
+
passes it, the next request starts the next cycle: `currentPeriodEnd` moves on by the
|
|
1058
|
+
interval, the usage balance starts again from nothing, a limited discount has an interval
|
|
1059
|
+
less (`remainingDurationInIntervals`), and no webhook is sent. A replacement Shopify defers to
|
|
1060
|
+
the next cycle (`APPLY_ON_NEXT_BILLING_CYCLE`, and some `STANDARD` ones) applies at once.
|
|
1061
|
+
- [Managed pricing](admin.md#managed-pricing): with plans declared (`--pricing-plans`,
|
|
1062
|
+
`PUT /__lsf/admin/pricing-plans`), the merchant picks one at
|
|
1063
|
+
`/admin/charges/<app handle>/pricing_plans` of lsf's admin; the subscription is the same record,
|
|
1064
|
+
its line's `planHandle` set, and the two creating mutations above answer "Managed Pricing Apps
|
|
1065
|
+
cannot use the Billing API (to create charges)." `POST /__lsf/admin/pricing-plans/cancel` ends
|
|
1066
|
+
the shop's plan without the app: `CANCELLED`, with the webhook unless `legacy = false`. The app
|
|
1067
|
+
reads its plan and its history from lsf's [Partner API](admin.md#read-the-plan-from-the-partner-api)
|
|
1068
|
+
(`activeSubscription`, `events`, `app { events }`), which every change of a charge, install and
|
|
1069
|
+
uninstall feeds.
|
|
1070
|
+
|
|
1071
|
+
**Discounts.** `discountAutomaticAppCreate`/`Update` and `discountCodeAppCreate`/`Update` take the
|
|
1072
|
+
Function by `functionHandle` (or the deprecated `functionId`), which must be a discount Function
|
|
1073
|
+
of the app; their `metafields` are the Function's configuration, checked as `metafieldsSet`
|
|
1074
|
+
checks them (a definition of the `DISCOUNT` owner type applies). The store sync writes an active
|
|
1075
|
+
app discount back to the store data's `checkout.discounts` (its Function, title, first code,
|
|
1076
|
+
classes and metafields), so the local checkout runs it. Shopify's own types (basic, buy X get Y,
|
|
1077
|
+
free shipping, automatic and code) are kept as records: the local checkout runs Functions only.
|
|
1078
|
+
Their `summary` is the admin's: what the discount takes off, then its minimum, in the shop's
|
|
1079
|
+
money format (`$5.00 off entire order • Minimum purchase of $50.00`, `10% off 2 products`, `Buy
|
|
1080
|
+
2 items, get 1 item free`, `Free shipping on all products`), and `shortSummary` its first part.
|
|
1081
|
+
Also: `discountAutomaticActivate`/`Deactivate`/`Delete` and the code ones (a discount's `status`
|
|
1082
|
+
follows its dates), `discountAutomaticBulkDelete`, `discountCodeBulkActivate`/`Deactivate`/
|
|
1083
|
+
`Delete`, `discountRedeemCodeBulkAdd`, `discountCodeRedeemCodeBulkDelete`,
|
|
1084
|
+
`discountBulkTagsAdd`/`Remove` (5 tags at most each): the bulk ones take exactly one of `ids`,
|
|
1085
|
+
`search` and `savedSearchId` (a saved search of discounts, `PRICE_RULE`, or of redeem codes,
|
|
1086
|
+
`DISCOUNT_REDEEM_CODE`), and are done at once (their `job` is `done`); the queries
|
|
1087
|
+
`discountNodes` (ids `DiscountNode/<n>`),
|
|
1088
|
+
`discountNodesCount`, `discountNode`, `codeDiscountNodeByCode`, `discountCodesCount`,
|
|
1089
|
+
`automaticDiscountNodes`, `codeDiscountNodes`, with their sort keys and search filters (`status`,
|
|
1090
|
+
`type`, `method`, `title`, `code`, `tag`, the dates, …). Webhooks: `discounts/create`,
|
|
1091
|
+
`discounts/update`, `discounts/delete`, `discounts/redeemcode_added`,
|
|
1092
|
+
`discounts/redeemcode_removed`.
|
|
1093
|
+
|
|
1094
|
+
**Functions.** `shopifyFunctions(apiType:)` lists the app's Functions. Payment and delivery
|
|
1095
|
+
customizations (`…Create`, `…Update`, `…Delete`, `…Activation`), validations (`validationCreate`,
|
|
1096
|
+
`Update`, `Delete`) and cart transforms (`cartTransformCreate`, `Delete`) take the Function by
|
|
1097
|
+
handle or id, check it implements their API, and keep its configuration in their metafields,
|
|
1098
|
+
which the store sync writes back as the Function's settings in `checkout.functions`, with
|
|
1099
|
+
`enabled`: the local checkout runs the Function only through an enabled owner, as Shopify does,
|
|
1100
|
+
so creating, disabling (`…Update`, `…Activation`, `validationUpdate(enable:)`) or deleting one
|
|
1101
|
+
changes the next checkout. A shop the app was just installed on has none: lsf creates no owner
|
|
1102
|
+
at install (the store data may declare some, `checkout.functions.<handle>.enabled`). Uninstalling
|
|
1103
|
+
the app deletes them all, with the app discounts and the fulfillment constraint rules, and a
|
|
1104
|
+
reinstall brings none back ([uninstall](admin.md#tokens)). Titles are the Functions' names, a
|
|
1105
|
+
`t:` key of `shopify.extension.toml` read from its default locale.
|
|
1106
|
+
|
|
1107
|
+
**Bulk operations.** `bulkOperationRunQuery` runs the query against the shop, every page of every
|
|
1108
|
+
connection, and writes JSON Lines as Shopify does (one line per node, nested connections on lines
|
|
1109
|
+
of their own with `__parentId`); Shopify's restrictions are checked (a connection, five at most,
|
|
1110
|
+
two levels deep, no top-level `node`). `bulkOperationRunMutation` runs the mutation once per line
|
|
1111
|
+
of a staged upload; a mutation the schema refuses, or one with no mutation field or more than one,
|
|
1112
|
+
is an `INVALID_MUTATION` user error (a field selected twice under one name, through a fragment,
|
|
1113
|
+
is one, as GraphQL merges it; two aliases are two) and starts nothing (the messages are lsf's). lsf does not
|
|
1114
|
+
check Shopify's list of the mutations a bulk operation may run. Both finish within the request: the payload says `CREATED`, as Shopify's
|
|
1115
|
+
does, the next query reads `COMPLETED` with its `url`, `objectCount` and `fileSize`, and
|
|
1116
|
+
`bulk_operations/finish` is delivered. The file is at
|
|
1117
|
+
`http://127.0.0.1:9292/__lsf/admin/bulk/<shop>/<n>.jsonl`. `bulkOperation`, `bulkOperations`
|
|
1118
|
+
(filtered by `status`, `operation_type`, `created_at`), `currentBulkOperation`.
|
|
1119
|
+
|
|
1120
|
+
```js
|
|
1121
|
+
// In a loader or an action of the React Router template: `const { admin } = await authenticate.admin(request);`
|
|
1122
|
+
const started = await admin.graphql(`#graphql
|
|
1123
|
+
mutation {
|
|
1124
|
+
bulkOperationRunQuery(query: "{ products { edges { node { id title variants { edges { node { id sku } } } } } } }") {
|
|
1125
|
+
bulkOperation { id status }
|
|
1126
|
+
userErrors { field message }
|
|
1127
|
+
}
|
|
1128
|
+
}`);
|
|
1129
|
+
const { bulkOperationRunQuery } = (await started.json()).data;
|
|
1130
|
+
const { id } = bulkOperationRunQuery.bulkOperation; // status CREATED
|
|
1131
|
+
|
|
1132
|
+
const polled = await admin.graphql(
|
|
1133
|
+
`#graphql
|
|
1134
|
+
query($id: ID!) { node(id: $id) { ... on BulkOperation { status url } } }`,
|
|
1135
|
+
{ variables: { id } },
|
|
1136
|
+
);
|
|
1137
|
+
const { node } = (await polled.json()).data;
|
|
1138
|
+
// COMPLETED at the first poll on lsf; on Shopify, poll until it is, or wait for bulk_operations/finish.
|
|
1139
|
+
const lines = (await (await fetch(node.url)).text()).trim().split("\n").map((line) => JSON.parse(line));
|
|
1140
|
+
// { id: "gid://shopify/Product/…", title }, then its variants, each with the product's id in __parentId
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
`bulk_operations/finish` reaches the backend only through a subscription:
|
|
1144
|
+
`topics = ["bulk_operations/finish"]` in `shopify.app.toml`, or `webhookSubscriptionCreate`.
|
|
1145
|
+
|
|
1146
|
+
**Staged uploads.** `stagedUploadsCreate` answers targets on lsf, in the form of Shopify's Google
|
|
1147
|
+
Cloud Storage ones: a `POST` target (`url` and form `parameters`, the `file` last) or a `PUT`
|
|
1148
|
+
target (the default). The bytes stay in the shop until it is reset, for
|
|
1149
|
+
`bulkOperationRunMutation` (`stagedUploadPath`) and `fileCreate` (`originalSource`, the
|
|
1150
|
+
`resourceUrl`). A target takes up to 1 GiB (Shopify's largest staged upload, a video; an image
|
|
1151
|
+
is 20 MB there, a 3D model 500 MB), and a MiB for the form around it; any other request of lsf
|
|
1152
|
+
takes 32 MiB. A larger body is answered `413`, never read as an empty one. The deprecated
|
|
1153
|
+
`stagedUploadTargetGenerate` (one target) and `stagedUploadTargetsGenerate` (`urls`) answer the
|
|
1154
|
+
same targets.
|
|
1155
|
+
|
|
1156
|
+
Credits, revenue attribution records and frozen subscriptions have no data behind them. The
|
|
1157
|
+
app's access, delegate tokens and Flow triggers are in "[The app's installation and Shopify's
|
|
1158
|
+
other services](#the-apps-installation-and-shopifys-other-services)" below.
|
|
1159
|
+
|
|
1160
|
+
### Themes, script tags, redirects in bulk and policies
|
|
1161
|
+
|
|
1162
|
+
**Themes.** The theme lsf serves (`lsf serve --theme`, the scratch theme of `lsf app dev`) is
|
|
1163
|
+
the shop's **main theme**, `gid://shopify/OnlineStoreTheme/1` (the id `content_for_header`
|
|
1164
|
+
gives `Shopify.theme`), named as its `config/settings_schema.json` says. `theme`, `themes(roles:,
|
|
1165
|
+
names:)` and `OnlineStoreTheme.files(filenames:, first:)` read its files from the directory,
|
|
1166
|
+
with their `checksumMd5` (hex MD5), `size`, `contentType` (`text/x-liquid` for Liquid) and
|
|
1167
|
+
body (text, or Base64 for the others); `filenames` takes `*`, and a name no file has is one of
|
|
1168
|
+
the connection's `userErrors` (`NOT_FOUND`).
|
|
1169
|
+
|
|
1170
|
+
- **The directory is never written to.** `themeFilesUpsert`, `themeFilesCopy` and
|
|
1171
|
+
`themeFilesDelete` on the main theme are kept in memory and served by the storefront on top of
|
|
1172
|
+
the directory: a backend that writes `templates/index.json` sees its page on the storefront,
|
|
1173
|
+
and your files stay yours. `DELETE /__lsf/admin` drops what it wrote.
|
|
1174
|
+
- `themeCreate` from a ZIP (a staged upload of `stagedUploadsCreate`, or a URL lsf downloads:
|
|
1175
|
+
see "Sources from a URL"; 50 MB at most; `INVALID_ZIP`, `ZIP_IS_EMPTY`, `ZIP_TOO_LARGE`; roles `UNPUBLISHED` and
|
|
1176
|
+
`DEVELOPMENT`), `themeDuplicate` (all the files, "Copy of …"), `themeUpdate` (the name, 50
|
|
1177
|
+
characters), `themeDelete` (not the main theme, and never the local theme, even once another
|
|
1178
|
+
is published: it is the directory, which would be gone until a reset), `themePublish`. A
|
|
1179
|
+
published theme is what the storefront serves, alone, and what the page names: `Shopify.theme`
|
|
1180
|
+
and the `theme` object have its id and name, and asset URLs are under `/cdn/shop/t/<id>/`;
|
|
1181
|
+
publishing the local theme again serves the directory, id 1.
|
|
1182
|
+
- `themeFilesUpsert`: 50 files at most, one input per filename, files in the theme's directories
|
|
1183
|
+
(no `..`, and no `\`, `:`, NUL or control character: what Windows would read as another
|
|
1184
|
+
path is refused on every system, and no file outside the directory is ever read), JSON files
|
|
1185
|
+
valid JSON (`FILE_VALIDATION_ERROR`, `DUPLICATE_FILE_INPUT`,
|
|
1186
|
+
`LESS_THAN_OR_EQUAL_TO`); `TEXT`, `BASE64` and `URL` bodies (a `URL` file comes through the
|
|
1187
|
+
`job`, done, as in Shopify's example; one over 50 MB is refused, `FILE_VALIDATION_ERROR`, never
|
|
1188
|
+
cut). Nothing is written when a file is refused.
|
|
1189
|
+
`layout/theme.liquid` cannot be deleted.
|
|
1190
|
+
- Webhooks: `themes/create`, `themes/update`, `themes/publish`, `themes/delete` (none for files,
|
|
1191
|
+
as on Shopify).
|
|
1192
|
+
|
|
1193
|
+
**Sources from a URL.** The `source` of `themeCreate`, the `URL` bodies of `themeFilesUpsert`
|
|
1194
|
+
and the `url` of `urlRedirectImportCreate` are read from lsf's staged uploads when they are
|
|
1195
|
+
one (`/__lsf/admin/uploads/…`, never over HTTP), and otherwise downloaded before the request
|
|
1196
|
+
locks the shop: a slow source holds up no other request of the shop (the storefront's carts and
|
|
1197
|
+
checkouts included). 8 are fetched at a time, each within 10 s, all of a request's within 30 s;
|
|
1198
|
+
one over 50 MB is refused. A URL that only a line of a bulk operation names is not fetched, and
|
|
1199
|
+
the line says so.
|
|
1200
|
+
|
|
1201
|
+
Only public addresses are fetched from: Shopify downloads a source from the internet and can
|
|
1202
|
+
never reach your machine, and lsf refuses the same, or a backend (yours under development, or
|
|
1203
|
+
a third party's) could read what your machine serves locally and get it back as a theme file.
|
|
1204
|
+
The loopback (`localhost`, `127.0.0.1`, `::1`), private networks (`10/8`, `172.16/12`,
|
|
1205
|
+
`192.168/16`, `100.64/10`), link-local (`169.254/16`, `fe80::/10`, cloud metadata endpoints),
|
|
1206
|
+
unique-local (`fc00::/7`), unspecified, multicast and reserved addresses are refused, IPv6 ones
|
|
1207
|
+
that embed an IPv4 address (`::ffff:127.0.0.1`) as that address. The check is made on the
|
|
1208
|
+
addresses a host resolves to, on every redirect, and proxies of the environment are not used.
|
|
1209
|
+
A refused source answers what an unreachable one does (`INVALID_ZIP` for `themeCreate`,
|
|
1210
|
+
`FILE_VALIDATION_ERROR` for a file, `NOT_FOUND` for an import), its message saying why. To serve
|
|
1211
|
+
a theme ZIP from your machine on purpose, start lsf with `--admin-fetch-local`. `file://` and
|
|
1212
|
+
the schemes other than `http` and `https` are never fetched.
|
|
1213
|
+
|
|
1214
|
+
<a id="stubbed-urls"></a>
|
|
1215
|
+
**Stubbed URLs.** A URL your backend has of its own (a theme ZIP on its CDN, a CSV a service of yours makes) has
|
|
1216
|
+
nothing behind it in a test. Stub it: lsf answers it with the bytes you give, and never
|
|
1217
|
+
downloads it, wherever it points (`localhost` too, without `--admin-fetch-local`).
|
|
1218
|
+
|
|
1219
|
+
- **From a request**, in a test's setup: the body is the file.
|
|
1220
|
+
|
|
1221
|
+
```js
|
|
1222
|
+
const url = "https://cdn.example.com/themes/comfort.zip";
|
|
1223
|
+
await fetch(`${LSF}/__lsf/admin/sources?url=${encodeURIComponent(url)}`, {
|
|
1224
|
+
method: "PUT",
|
|
1225
|
+
body: await readFile("fixtures/comfort.zip"),
|
|
1226
|
+
});
|
|
1227
|
+
// themeCreate(source: "https://cdn.example.com/themes/comfort.zip") reads it.
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
- **From a file**, for the whole run:
|
|
1231
|
+
`lsf serve --admin-source https://cdn.example.com/themes/comfort.zip=./fixtures/comfort.zip`.
|
|
1232
|
+
Repeatable. The file must exist when lsf starts, and is read at each use: an edit is seen at
|
|
1233
|
+
once.
|
|
1234
|
+
|
|
1235
|
+
A URL is matched as written, its query included; the scheme and host in any case, the fragment
|
|
1236
|
+
left out. A stub stands for the internet, not for a shop: every shop reads it, and a reset
|
|
1237
|
+
(`DELETE /__lsf/admin`) keeps it. `GET /__lsf/admin/sources` lists the stubs,
|
|
1238
|
+
`GET /__lsf/admin/sources?url=` gives one's bytes back, and `DELETE /__lsf/admin/sources`
|
|
1239
|
+
forgets those requests made (`?url=` one of them; an `--admin-source` file answers its URL
|
|
1240
|
+
again). A stub over 50 MB answers what a source that large does (`ZIP_TOO_LARGE`), and an
|
|
1241
|
+
`--admin-source` file gone since lsf started answers what an unreachable source does.
|
|
1242
|
+
|
|
1243
|
+
A request never names a file of the machine: only `--admin-source` does, or any client of
|
|
1244
|
+
lsf could read a file of yours back as a theme file. A backend that takes the URL as a
|
|
1245
|
+
parameter needs no stub: give it a staged upload of lsf
|
|
1246
|
+
(`PUT /__lsf/admin/uploads/<shop>/<key>`, then
|
|
1247
|
+
`http://127.0.0.1:9292/__lsf/admin/uploads/<shop>/<key>`), which is read from the shop.
|
|
1248
|
+
|
|
1249
|
+
**Script tags.** `scriptTagCreate`, `scriptTagUpdate`, `scriptTagDelete`; `scriptTag`,
|
|
1250
|
+
`scriptTags(src:, query:)`. A script tag shown on the online store (`ONLINE_STORE`, or the
|
|
1251
|
+
deprecated `ALL`) is loaded by the storefront's `content_for_header` as Shopify loads it, once
|
|
1252
|
+
the page has loaded, with `shop=<domain>` added to its URL. `cache` is kept (lsf serves the
|
|
1253
|
+
script from its `src` either way). Uninstalling the app deletes its script tags, as Shopify does,
|
|
1254
|
+
and the storefront stops loading them ([uninstall](admin.md#tokens)).
|
|
1255
|
+
|
|
1256
|
+
**Pixels.** `webPixelCreate`, `webPixelUpdate`, `webPixelDelete`, `webPixel`: one per app, its
|
|
1257
|
+
`settings` checked against the `[settings]` of the app's web pixel extension
|
|
1258
|
+
(`type = "web_pixel_extension"` in `extensions/*/shopify.extension.toml`): every field given
|
|
1259
|
+
("Missing keys: accountID"), no other, each value as its type and validations say. Without such
|
|
1260
|
+
an extension: `NO_EXTENSION`. The storefront does not run web pixels. Uninstalling the app
|
|
1261
|
+
deletes its web pixel: a reinstalled app creates it again. `serverPixelCreate`,
|
|
1262
|
+
`serverPixelDelete`, `eventBridgeServerPixelUpdate`, `pubSubServerPixelUpdate`, `serverPixel`:
|
|
1263
|
+
one per app, `CONNECTED` once it has an endpoint; nothing is sent to it.
|
|
1264
|
+
|
|
1265
|
+
**Storefront access tokens.** `storefrontAccessTokenCreate` (100 per shop),
|
|
1266
|
+
`storefrontAccessTokenDelete`, `shop.storefrontAccessTokens`: 32 hex digits, with the app's
|
|
1267
|
+
`unauthenticated_*` scopes. lsf serves no Storefront API, so they authenticate nothing.
|
|
1268
|
+
|
|
1269
|
+
**Redirects in bulk.** `urlRedirectBulkDeleteAll`, `urlRedirectBulkDeleteByIds`,
|
|
1270
|
+
`urlRedirectBulkDeleteBySearch` (the `query` of `urlRedirects`; a field it does not have is
|
|
1271
|
+
refused rather than deleting every redirect), `urlRedirectBulkDeleteBySavedSearch` (a [saved
|
|
1272
|
+
search](#segments-marketing-analytics-and-privacy) of redirects), each done at once (its `job`
|
|
1273
|
+
done). `urlRedirectImportCreate` reads a redirect CSV (`Redirect from,Redirect to`, RFC 4180:
|
|
1274
|
+
quoted cells may hold commas and line breaks) from a staged upload or a URL (`count`,
|
|
1275
|
+
`previewRedirects`); `urlRedirectImportSubmit`
|
|
1276
|
+
creates and updates the redirects (`createdCount`, `updatedCount`, `failedCount`, `finished`;
|
|
1277
|
+
twice is `ALREADY_IMPORTED`); `urlRedirectImport`.
|
|
1278
|
+
|
|
1279
|
+
**Policies.** `shopPolicyUpdate`: the policy of its type replaced, or created with Shopify's title
|
|
1280
|
+
and URL; the five policies of the store data (privacy, refund, shipping, terms of service,
|
|
1281
|
+
subscription) reach the storefront's `shop.policies`.
|
|
1282
|
+
|
|
1283
|
+
**Standard definitions.** `standardMetafieldDefinitionTemplates` (shopify.dev's 16, `excludeActivated`),
|
|
1284
|
+
`standardMetafieldDefinitionEnable` (by `id`, or `namespace` and `key`; the owner type must be
|
|
1285
|
+
one of the template's) and `standardMetaobjectDefinitionEnable` (`product_review`) create the
|
|
1286
|
+
definition as `metafieldDefinitionCreate` and `metaobjectDefinitionCreate` do (same checks, same
|
|
1287
|
+
webhooks), with its `standardTemplate`.
|
|
1288
|
+
|
|
1289
|
+
**Files.** `fileAcknowledgeUpdateFailed` puts a `FAILED` file back to `READY` and clears its
|
|
1290
|
+
errors (lsf's files are `READY` at once, so only a file made `FAILED` otherwise is one).
|
|
1291
|
+
|
|
1292
|
+
### The app's installation and Shopify's other services
|
|
1293
|
+
|
|
1294
|
+
**Access.** `appUninstall` uninstalls the app as `POST /__lsf/admin/uninstall` does
|
|
1295
|
+
([Uninstall](admin.md#tokens)): its Functions go with every object that runs them, its script
|
|
1296
|
+
tags, web pixel and the metafield definitions of its reserved namespaces are deleted (their
|
|
1297
|
+
metafields stay), and its UI extensions and app proxy stop until it is installed again.
|
|
1298
|
+
`appRevokeAccessScopes` revokes optional scopes the installation was granted (not the
|
|
1299
|
+
`scopes` of `shopify.app.toml`: `CANNOT_REVOKE_REQUIRED_SCOPES`; not an undeclared one; not a
|
|
1300
|
+
`read_x` its `write_x` implies): every token of the installation loses them at once, and
|
|
1301
|
+
`app/scopes_update` is delivered. `appDowngradeAccessScopes` (2027-01) turns optional `write_x`
|
|
1302
|
+
scopes into `read_x`. To grant optional scopes first: `POST /__lsf/admin/install {"scopes": …}`.
|
|
1303
|
+
|
|
1304
|
+
**Delegate access tokens.** `delegateAccessTokenCreate` makes a `shppa_…` token with a subset of
|
|
1305
|
+
the parent's scopes (`UNKNOWN_SCOPES` for one the app was not granted), expiring with its parent
|
|
1306
|
+
or sooner (`expiresIn`, `EXPIRES_AFTER_PARENT`); it authenticates the Admin API with those
|
|
1307
|
+
scopes only, and cannot make delegates of its own (`DELEGATE_ACCESS_TOKEN`), nor be exchanged
|
|
1308
|
+
at `/admin/oauth/access_token` as an offline token (`400` `invalid_subject_token`: what it would
|
|
1309
|
+
become carries every scope of the installation). `delegateAccessTokenDestroy` revokes one. A scope the installation loses goes from its delegates
|
|
1310
|
+
too.
|
|
1311
|
+
|
|
1312
|
+
**Flow triggers.** `flowTriggerReceive(handle:, payload:)` is checked against the app's
|
|
1313
|
+
`flow_trigger` extensions: the handle, every field of the trigger (a reference field by its key,
|
|
1314
|
+
`customer_id`, the `legacyResourceId` of a record of the shop), 50,000 bytes at most. lsf runs
|
|
1315
|
+
no workflow: the trigger goes to the shop's [outbox](#the-outbox) (channel `flow`, the handle as
|
|
1316
|
+
`to`, the payload as `data`).
|
|
1317
|
+
|
|
1318
|
+
**Mobile apps.** `mobilePlatformApplicationCreate`, `Update`, `Delete`,
|
|
1319
|
+
`mobilePlatformApplication(s)`: Android and Apple apps. The storefront serves their association
|
|
1320
|
+
files, as Shopify does on the shop's domain: `/.well-known/assetlinks.json` (App Links) and
|
|
1321
|
+
`/.well-known/apple-app-site-association` (Universal Links, shared web credentials, app clips).
|
|
1322
|
+
|
|
1323
|
+
**Checkout configuration.** `checkoutAndAccountsConfigurationUpdate` and the deprecated
|
|
1324
|
+
`checkoutBrandingUpsert` merge the branding they are given into the shop's default
|
|
1325
|
+
configuration and checkout profile (a field given `null` is reset); `checkoutProfile(s)`,
|
|
1326
|
+
`checkoutBranding`, `checkoutAndAccountsConfiguration(s)` read it back. The local checkout keeps
|
|
1327
|
+
its own look.
|
|
1328
|
+
|
|
1329
|
+
**Webhook subscriptions to EventBridge and Pub/Sub.** The deprecated
|
|
1330
|
+
`eventBridgeWebhookSubscriptionCreate`/`Update` (an `arn`) and `pubSubWebhookSubscriptionCreate`/`Update`
|
|
1331
|
+
(a `pubSubProject` and a `pubSubTopic`) make the same subscriptions as `webhookSubscriptionCreate`.
|
|
1332
|
+
lsf delivers nothing to them: their deliveries are in `GET /__lsf/webhooks`, `not_sent`, with
|
|
1333
|
+
their payload.
|
|
1334
|
+
|
|
1335
|
+
**Queries.** `app`, `appByHandle`, `appByKey`, `appInstallation(s)`, `currentAppInstallation`,
|
|
1336
|
+
`staffMember(s)` (the shop owner is `StaffMember/1`) and `currentStaffMember` (the user of an
|
|
1337
|
+
online token, `null` for an offline one) answer every field; what an App Store listing would give
|
|
1338
|
+
(an icon, a category) is lsf's, an app in development having none.
|
|
1339
|
+
|
|
1340
|
+
**Webhooks** without a mutation behind them, sent by `POST /__lsf/webhooks/trigger` and
|
|
1341
|
+
`lsf app webhook trigger`: `rollouts/*` (from a `Rollout`, else Shopify's example),
|
|
1342
|
+
`finance_app_staff_member/*` (from the staff member) and `finance_kyc_information/update`, which
|
|
1343
|
+
Shopify documents with an empty payload: lsf sends the record in REST's shape.
|
|
1344
|
+
|
|
1345
|
+
## The shop's clock
|
|
1346
|
+
|
|
1347
|
+
A shop's time is its clock: the system's, or the store data's `now` (then the same run gives the
|
|
1348
|
+
same answers). What waits on time happens when the clock passes it, before every Admin API
|
|
1349
|
+
request and when a test moves the clock, with its webhooks:
|
|
1350
|
+
|
|
1351
|
+
- a payment schedule that came due fires `payment_schedules/due`, once;
|
|
1352
|
+
- store credit that expired leaves its account;
|
|
1353
|
+
- Shopify Payments pays out the days the shop's day left behind;
|
|
1354
|
+
- a charge of the app left pending two days is `EXPIRED`;
|
|
1355
|
+
- a scheduled channel listing whose date passed is listed (`product_listings/add`);
|
|
1356
|
+
- a data erasure ten days old is processed (`customers/redact`);
|
|
1357
|
+
- an abandoned checkout three months old is deleted (`checkouts/delete`).
|
|
1358
|
+
|
|
1359
|
+
What is worked out when it is read follows the clock without waiting: a checkout left ten
|
|
1360
|
+
minutes is abandoned, a discount runs between its dates (the checkout too), `PaymentTerms.due`
|
|
1361
|
+
and `overdue`, a gift card's expiry. A webhook `filter` counts `now` by the shop's clock.
|
|
1362
|
+
|
|
1363
|
+
`POST /__lsf/admin/clock` moves it: `{"advance": "31d"}` (seconds, or `s`, `m`, `h`, `d`) or
|
|
1364
|
+
`{"now": "2026-12-01T00:00:00Z"}`, with `"shop"?`. The clock then stays at that instant, frozen,
|
|
1365
|
+
and what the move passed happens at once; the answer says which customers were erased and how
|
|
1366
|
+
many checkouts were deleted (`erased`, `deletedCheckouts`). `GET /__lsf/admin/clock` reads it;
|
|
1367
|
+
`DELETE /__lsf/admin` puts the store data's back.
|
|
1368
|
+
|
|
1369
|
+
## The outbox
|
|
1370
|
+
|
|
1371
|
+
lsf sends no email, buys no label and pushes nothing to a device: what Shopify would have sent,
|
|
1372
|
+
or bought, is recorded in the shop's outbox instead, for a test to read. (Webhooks go to the
|
|
1373
|
+
backend lsf is pointed at: [where they go](admin.md#webhooks).)
|
|
1374
|
+
|
|
1375
|
+
| Channel | What lands there (`what`) |
|
|
1376
|
+
|---|---|
|
|
1377
|
+
| `email` | The emails to a buyer or a customer: `payment_reminder`, `order_invoice`, `draft_order_invoice`, `gift_card_created`, `gift_card_recipient`, `store_credit_issued`, `return_label`, `requested_order_edit_declined`, `order_link_requested`, `ready_for_pickup`, `out_for_delivery`, `shipping_confirmation`, `customer_payment_method_update`, `payment_instrument_add` |
|
|
1378
|
+
| `carrier` | A shipping label bought (`shipping_label`): the carrier as `to`, the service, the tracking number and the package in `data` |
|
|
1379
|
+
| `flow` | A Flow trigger (`flow_trigger`): its handle as `to`, its payload as `data` |
|
|
1380
|
+
| `pos` | What Shopify would push to a POS device: `cash_drawer_assigned`, `payment_session_opened`, `payment_session_closed` |
|
|
1381
|
+
| `data_erasure` | A customer's data erasure: `data_erasure_requested`, `data_erasure_cancelled`, `customer_data_erased` |
|
|
1382
|
+
|
|
1383
|
+
```json
|
|
1384
|
+
{
|
|
1385
|
+
"id": 1,
|
|
1386
|
+
"at": "2026-11-10T09:00:00Z",
|
|
1387
|
+
"channel": "email",
|
|
1388
|
+
"to": "buyer@example.com",
|
|
1389
|
+
"what": "payment_reminder",
|
|
1390
|
+
"about": "gid://shopify/PaymentSchedule/6936761535489",
|
|
1391
|
+
"subject": "Payment reminder for order #1001",
|
|
1392
|
+
"body": "120.0 USD is due on 2026-12-10 for order #1001."
|
|
1393
|
+
}
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
`id` counts per shop, `at` is the shop's clock, `to` an address, a carrier, a trigger or a device,
|
|
1397
|
+
`about` the record it is about. An email has its `subject` and `body` (lsf's words: Shopify does
|
|
1398
|
+
not publish the text of its notifications); another channel says what happened in `body`, and
|
|
1399
|
+
`data` holds what else would have gone out.
|
|
1400
|
+
|
|
1401
|
+
- `GET /__lsf/admin/outbox?shop=&channel=email` lists them, oldest first, the last 1000 (`{shop,
|
|
1402
|
+
count, outbox}`); `DELETE /__lsf/admin/outbox` forgets them (`?channel=` those of one channel).
|
|
1403
|
+
- `GET /__lsf/admin/emails` and `DELETE /__lsf/admin/emails` are the same for `channel=email`
|
|
1404
|
+
(`{shop, count, emails}`).
|
|
1405
|
+
- `GET /__lsf/admin` shows each shop's outbox (`shops[].outbox`).
|
|
1406
|
+
- A mutation that fails records nothing; `DELETE /__lsf/admin` empties every outbox.
|