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.
@@ -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.