Skip to main content

Shopify

Lumio is a public app in the Shopify App Store. A merchant installs it on their store; the store's orders and new products then reach the account as shopify:order, shopify:order_paid and shopify:product events (see Webhooks → Shopify). One Lumio account can connect several stores (at most [shopify] max_shops_per_account, default 10); each event names its store in raw.shop (see Store label). The integration is gated by the plan flag integration:shopify.

Merchant setup​

Install Lumio from the Shopify App Store, open Apps › Lumio in the Shopify admin, and start Connect Lumio account. In the Lumio tab, choose the account and confirm the connection. Return to Shopify and confirm the named Lumio account there to finish. The link expires after ten minutes; an expired link starts again from Shopify. To connect another store, install the app there and link it the same way; the Shopify card's Connect another store opens the App Store listing. Each store has its own settings, label and buyer-name blocklist. See Connections › Shopify for the controls and permissions.

In the embedded app, choose how buyers may be named and which order details reach Lumio. For a buyer name to appear, place the Name on livestream app block on the cart page, or, on Shopify Plus, place the checkout block. The three real-name forms use an unticked consent checkbox; Your own name (custom_name) uses an optional text field. Let the buyer decide (buyer_choice) lets the buyer tick a box and then pick one of the forms you allow. Buyers who skip the cart through an accelerated checkout and do not see an eligible block have no name on their order event. You can hide one stored order's buyer label later from the Lumio Events feed. The order-details switches apply only to orders received after the change.

The code lives in apps/api/src/services/shopify.rs (shared by both protocols and the embedded routes), apps/api/src/services/shopify_link.rs (the link handle), apps/api/src/db/shopify.rs, apps/api/src/graphql/shopify.rs, apps/api/src/routes/shopify.rs, apps/api/src/routes/shopify_embedded.rs, the lo-shopify crate (GraphQL Admin API client, ID-token verification, token exchange) and the embedded admin page apps/shopify (Next.js, see Embedded page).

Lumio is an embedded app: its page runs inside the Shopify admin, and Shopify installs the app itself (Shopify-managed install). Scopes, the app URL and the webhook subscriptions are declared in apps/shopify/shopify.app.<env>.toml, not requested by Lumio. There is no OAuth authorization-code round and no OAuth callback. Every request from the embedded page carries an App Bridge ID token (REST → Embedded app); the backend gets the store's token by token exchange with that ID token.

Installing alone connects nothing. A store is bound to a Lumio account by a link, which needs both sides, because the embedded page has an ID token but no Lumio session, and the Lumio tab has a session but no ID token:

  1. Start (Shopify admin). The embedded page calls POST /v1/shopify/embedded/link. The API mints a link handle bound to the store (the token's dest) and the staff member (sub) and returns the Lumio page {web.public_url}/dashboard/settings/channel/connections/shopify#link=<handle>. The handle is in the URL fragment, so it never reaches a server, a log or a Referer.
  2. Confirm (Lumio). In the Lumio tab, a member with connections:create, integration:shopify and an interactive sign-in session sees the store (shopifyLinkRequest / POST /v1/connections/shopify/link/inspect) and confirms it for the active account (shopifyLinkConfirm / POST /v1/connections/shopify/link/confirm). Confirm re-checks one account, several stores (the store is not another account's, the account is below the cap) and refuses a browser request whose Sec-Fetch-Site is not same-origin. No token is written here.
  3. Complete (Shopify admin). The embedded page reads the handle (GET /v1/shopify/embedded/link/{handle}), shows the confirmed account's name and, after the staff member clicks, calls POST /v1/shopify/embedded/link/complete with a fresh ID token. Only the staff member who started the link can complete it. The API re-checks the account, the plan flag, the store's owner and the cap (under the account lock), runs the token exchange, checks the granted scopes, reads the store name and stores the connection. It emits account:integration_connected and sends an in-app notice (shopify_linked) to the account owner and the confirming member.

The handle lives in Redis under shopify:link:{handle} (a hash; shopify:link_shop:{store} points to the store's open handle), 10 minutes from the start, not extended. Its states are pending → confirmed → completing; every change is one Lua compare-and-set, so a double click, two tabs or two staff members cannot both win. A second start replaces the store's open handle; DELETE /v1/shopify/embedded/link/{handle} cancels it. A failed completion (an expired ID token, Shopify unreachable) puts the handle back to confirmed, so App Bridge's retry works without starting over. After a successful completion the handle is deleted. A handle of another store reads as expired.

A contested handle is burned. Two parties holding one handle means it leaked. If a confirmed handle is confirmed for a second account, or another Lumio user than the one who confirmed it opens it on the confirm screen (shopifyLinkRequest / link/inspect), the API deletes the handle and the store's pointer and answers 409 with the contested message below. This guards against a look-alike account name: whoever confirmed first chooses the name the Shopify admin shows next. The confirm screen then shows its expired view, and the embedded page's completion reads the handle as expired and offers a fresh start. A double click and the confirmer's own reload keep the handle. So the request read has a deliberate side effect, on both protocols. The OAuth handles (oauth:handle:) are a separate keyspace: the OAuth exchange can never consume a link handle.

Rate limits: 5 link starts per store in 10 minutes, 10 confirms (and request reads) per Lumio user in 10 minutes, 10 completions per store per minute, 120 other embedded requests per store per minute. A burst is logged: it is the signal of a consent-phishing attempt.

Web routes and dashboard​

The settings window's Services area loads the status (shopifyConnection, only with connections:read and integration:shopify); the short link /dashboard/settings/channel/connections/shopify opens it at its Shopify section. The Shopify card lists one row per store, oldest link first: the label, the domain, a status badge, the settings (open with one store, closed from two), the label field, the buyer-name blocklist and Disconnect (its dialog names the label and the domain). Below the list, "{count} of {max} stores connected" and Connect another store (the App Store listing; disabled with the reason once the account holds maxShops). Lumio never asks for a store domain.

Every per-store call names the store in the path: PUT /api/connections/shopify/{shopHost}/settings (updateShopifySettings, any of customer_display, buyer_choice_forms, show_items, show_price), PUT /api/connections/shopify/{shopHost}/label (updateShopifyLabel), /api/connections/shopify/{shopHost}/blocklist[/{id}|/test] and DELETE /api/connections/shopify/{shopHost} (disconnectShopify). The route handlers check the host shape before they call the API (400 otherwise). A 404 for one store (it was disconnected elsewhere) shows a toast and reloads the list. GET /api/connections/shopify returns only { shop_host, label } per store, for the events page's store filter.

"Open in Shopify" opens https://admin.shopify.com/store/<store>/apps/<handle>, where <handle> is the app handle from apps/shopify/shopify.app.<env>.toml (web env var LUMIO_SHOPIFY_APP_HANDLE, default lumio); opening the page there also renews an expired access by token exchange. While a store's access has expired (needs_reconnect, badge "Access expired"), Renew access replaces it as the row's main action and opens the same URL: Lumio cannot renew from outside Shopify, because the token exchange needs an ID token of the embedded app.

The link-confirm screen is the same path with #link=<handle>, a standalone page (route group (shopify-link), no dashboard shell):

  • The page takes the handle from the fragment and removes it from the address at once (history.replaceState). It never confirms on load.
  • Signed out (the edge lets this one path through without a session and renews an expired one), it keeps the handle in sessionStorage (lumio-shopify-link, deleted when read) and sends the browser to the ID app; back on the page it reads the handle from there.
  • Without a handle it is the card's short link and opens the settings window.
  • It names the store by the *.myshopify.com host from shopifyLinkRequest (never from the URL), the account the link goes to (picker, preselected: the active account, or the first one the member may link) and the warning line above the button. The warning names only that host, never the store display name: a display name is owner-controlled and is not known until the token exchange during link/complete. The picker knows the active account's permissions; for the others the default roles decide (Owner and Administrator may, Moderator may not) and the API's answer decides for a custom role. Picking another account switches the active account before the confirm.
  • The browser calls POST /api/connections/shopify/link/inspect and POST /api/connections/shopify/link/confirm with { "handle": … } in the body. Both refuse with 403 unless Sec-Fetch-Site is same-origin, or it is absent and Origin equals the web public origin (LUMIO_APP_URL); a same-site sibling such as the embedded host is refused.
  • apps/web/src/proxy.ts sends an enforced Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY and Referrer-Policy: no-referrer on exactly this path, next to the global report-only policy.

The events feed and the chat read raw.customer_display as the buyer's name (plain text, no user card) and render the order details of Order details: up to three items {qty}× {title}, then +N more, the unit price after an item ((je 12,50 €) for more than one), and the total as a chip. Prices are formatted with raw.currency; without one they are bare numbers. In streamer mode the total is masked (SensitiveAmount) and item prices read ••••••. "Hide name" in the feed calls POST /api/connections/shopify/events/{event_id}/hide-name (hideShopifyOrderName), also on a row that shows only an internal buyer name; pop-out sessions do not offer it. The events page, dashboard chat, chat and events popouts, Cockpit page and Cockpit popout also show the internal buyer name in their event or chat rows. Every shopify:* row shows its store label as a chip.

Shopify Dev Dashboard settings​

SettingValue
DistributionPublic (App Store)
InstallShopify-managed; scopes, app URL, redirect URL and webhook subscriptions in apps/shopify/shopify.app.<env>.toml
Scopesread_orders,read_products,read_legal_policies (the backend checks the token's scope readback against [shopify] scopes)
Embedded in Shopify adminYes (App Bridge ID tokens + token exchange)
Protected customer dataLevel 2 with the Name field: the internal buyer name is derived for every order, the customer label for consenting orders. Without it Shopify sends no name and neither is stored
Compliance webhooks (compliance_topics)customers/data_request, customers/redact, shop/redact → {server.public_url}/v1/webhooks/shopify/compliance

Embedded page (apps/shopify)​

The page the merchant sees under Apps › Lumio in the Shopify admin is its own Next.js app, apps/shopify (package @lumio/shopify), on its own origin (the app URL in shopify.app.<env>.toml). It is built from Shopify's Polaris web components (polaris.js) and App Bridge (app-bridge.js), both from cdn.shopify.com; it uses no @lumio/ui, no seasonal look and no Lumio session. Texts are en and de (apps/shopify/messages/), chosen from Shopify's locale launch parameter (en for any other language).

States: loading (a skeleton in the shape of the page plus the admin's loading bar) · not linked (one button, "Mit Lumio-Konto verbinden") · pending (the Lumio tab is open; "Ich habe bestätigt", "Lumio-Tab erneut öffnen", "Abbrechen") · second confirmation ("Mit dem Lumio-Konto „{name}“ verbinden?", from GET …/link/{handle}; only the click on "Verbinden" calls link/complete, "Abbrechen" calls DELETE …/link/{handle}) · link expired or used ("Neu starten") · linked · invalid session. While a link is pending, the page re-reads the link state when the tab gets focus or becomes visible again; reading never completes it.

Linked: the connection (account name, connected since, "In Lumio öffnen", "Trennen" with an s-modal confirmation → POST …/disconnect; with other stores on the same account the sentence "Dieses Lumio-Konto ist mit {n} weiteren Shops verbunden." from other_shops, never their names or domains), the field "Label in Lumio" (0/30 counter, own save button → PUT …/label, empty resets to the default; the hint below it says the label shows on every event of this store, in Streamer Mode too, and applies to new events), "Zugriff erneuern" while the access has expired (POST …/renew, a success banner or the reason, with the missing scopes, on failure), the buyer name form (an s-choice-list with the six forms; under Käufer entscheidet a second s-choice-list multiple with the four forms the buyer may pick, at least one, its error shown on the list), the order details (s-switch Artikel anzeigen / Preis anzeigen), the setup guide and the merchant's legal notes. Every setting saves at once through PUT …/settings and confirms with an App Bridge toast; a refused save restores the previous value. The choice lists listen to input, not onChange: an s-choice-list fires both input and change per click, and React calls onChange of a custom element for both, which would save twice. Each tick of the allowed forms sends customer_display: "buyer_choice" with the whole list in fixed order; removing the last tick sends nothing and shows the error.

Setup guide (only while the form is not none):

StepData sourceAction
1. Cart block placed / missingshopify.app.extensions(): a theme_app_extension with block name-consent and status: "active" on the published theme. Matched by block handle only, because Shopify reports the extension itself as theme-app-extension, not as name-consent-cartTheme editor deep link shopify://admin/themes/current/editor?template=cart&addAppBlockId={client_id}/name-consent&target=newAppsSection (the handle is the block's file name)
2. Checkout (Shopify Plus)shop.plan.shopifyPlus through Direct API access (fetch("shopify:admin/api/graphql.json"), [access.admin] in the app configuration, online mode)Written steps and a link to the checkout settings; Lumio cannot see whether the checkout block is placed
3. Privacy policy present / missingshop.shopPolicies (type + body, scope read_legal_policies): present = a PRIVACY_POLICY with a non-empty bodyPresent: link to shopify://admin/settings/legal. Missing: a critical banner above the steps ("Dein Shop hat keine Datenschutzerklärung", button "Datenschutzerklärung anlegen" → the same link); the step itself then has only a short pointer to that button

A value the page cannot read shows the step without a status badge instead of guessing.

Legal notes (texts from Legal, verbatim): with a name form other than none, "Rechtliche Hinweise zur Namensanzeige" (responsibility, privacy policy with both legal bases, cookie overview for Eigener Name and for Käufer entscheidet while Eigener Name is allowed, sensitive products while Artikel anzeigen is on, record, withdrawal; under Käufer entscheidet the shown form reads „vom Käufer gewählt aus: …“ with the allowed forms). With none and at least one of the two switches on, "Rechtliche Hinweise zu Bestelldetails im Stream": the privacy policy bullet for order details without a name (legitimate interest, right to object) and, while Artikel anzeigen is on, the sensitive products bullet. With none and both switches off nothing about a buyer reaches the stream, so the page shows no legal notes.

Requests: the browser calls only the app's own route handlers (/api/status, /api/link, /api/link/{handle}, /api/link/complete, /api/settings, /api/label, /api/renew, /api/blocklist[/{id}|/test], /api/disconnect), each with a fresh App Bridge ID token as Authorization: Bearer. The handlers forward that header (and the browser's user agent, but no client IP header) to /v1/shopify/embedded/* and nothing else: no cookie, no Lumio token. A 401 with X-Shopify-Retry-Invalid-Session-Request: 1 is retried once with a new token; a second one shows the invalid-session state.

Headers (apps/shopify/src/proxy.ts, on every response):

  • Content-Security-Policy (enforced): default-src 'self'; script-src 'self' 'nonce-…' https://cdn.shopify.com; connect-src 'self' https://cdn.shopify.com/shopifycloud/admin-ui-foundations/ (the path Polaris loads its icons from); object-src 'none'; base-uri 'none'; form-action 'self'; and frame-ancestors https://{shop} https://admin.shopify.com. The shop comes only from Shopify's launch query after its hmac verifies with the client secret (or the previous one), and only if it is a {store}.myshopify.com host. Otherwise frame-ancestors 'none'. Never a wildcard.
  • Cache-Control: private, no-store, because the header differs per shop. Every route is dynamic.
  • No X-Frame-Options: it cannot allow a list of origins.
  • The Cookie request header is dropped before any handler runs, and the app sets no cookie.

app-bridge.js is the first script tag the layout renders, right after <meta name="shopify-api-key">. Next.js still puts its own async chunk scripts ahead of it in <head>, so the page waits for the parsed document and shopify.ready before its first App Bridge call.

API​

GraphQLRESTPermissionFeature
shopifyConnectionGET /v1/connections/shopifyconnections:readintegration:shopify
shopifyLinkRequest(handle: String!)POST /v1/connections/shopify/link/inspectconnections:create + interactive sessionintegration:shopify
shopifyLinkConfirm(handle: String!)POST /v1/connections/shopify/link/confirmconnections:create + interactive session, not cross-siteintegration:shopify
updateShopifySettings(shopHost: String!, customerDisplay: String, buyerChoiceForms: [String!], showItems: Boolean, showPrice: Boolean)PUT /v1/connections/shopify/{shop_host}/settingsconnections:editintegration:shopify
updateShopifyLabel(shopHost: String!, label: String)PUT /v1/connections/shopify/{shop_host}/labelconnections:editintegration:shopify (see Store label)
disconnectShopify(shopHost: String!)DELETE /v1/connections/shopify/{shop_host}connections:deleteintegration:shopify
hideShopifyOrderName(eventId: UUID!, createdAt: String!)POST /v1/connections/shopify/events/{event_id}/hide-nameevents:editnone (see Hide a buyer name)
shopifyOrderInternalNames(events: [ShopifyEventRefInput!]!)POST /v1/connections/shopify/events/internal-namesevents:read, dashboard session or popout token onlyfeature:events (see Internal buyer name)
shopifyNameBlocklist(shopHost: String!)GET /v1/connections/shopify/{shop_host}/blocklistconnections:readintegration:shopify (see Buyer-name blocklist)
testShopifyNameBlocklist(shopHost: String!, name: String!)POST /v1/connections/shopify/{shop_host}/blocklist/testconnections:readintegration:shopify
addShopifyNameBlocklistEntries(shopHost: String!, kind: String!, patterns: [String!]!)POST /v1/connections/shopify/{shop_host}/blocklistconnections:editintegration:shopify
removeShopifyNameBlocklistEntry(shopHost: String!, id: UUID!)DELETE /v1/connections/shopify/{shop_host}/blocklist/{id}connections:editintegration:shopify
(REST only)/v1/shopify/embedded/* (status, link, link/{handle}, link/complete, settings, label, renew, disconnect, blocklist, blocklist/{id}, blocklist/test)Shopify ID token of a staff member; no Lumio permissionintegration:shopify checked at link completion

The embedded routes are REST-only on purpose: their caller is a Shopify staff member, not a Lumio principal, and their own principal type (routes::shopify_embedded::ShopifyEmbeddedPrincipal) has no conversion to the Lumio auth context. They are mounted outside and before the /v1 scope (routes::configure_v1_scopes, which main.rs and the mount test both call, so a Lumio token on /v1/shopify/embedded/* always gets the embedded 401), accept no Lumio token, and act only on the token's own store (its dest); no embedded route takes a store or an account from the request. Every write needs the account's row of that store to still exist: if the store was disconnected or relinked in between, the answer is 404 (Shopify store is not connected). Staff of one store never read or change another store of the same account; the only thing they learn about the account's other stores is how many there are (other_shops).

POST /v1/shopify/embedded/renew (the embedded app's "Zugriff erneuern") has no Lumio twin, on purpose: the token exchange needs a Shopify ID token, which only the embedded app has, so the Lumio card's Renew access opens the app in the Shopify admin instead. It exchanges only when the access is broken or lacks a configured scope (no forced rotation per click), never creates a row, and keeps the old token when Shopify grants fewer scopes than configured (422 Shopify did not grant the required access: …). It answers { renewed, connection } (renewed: false: the access was healthy). No audit event, as for the re-exchange on a page load.

Per-store addressing. Every Lumio field and route that acts on one store takes shopHost / {shop_host} and finds the row by (account_id, shop_host) in one query. A malformed host is 400 Not a Shopify store domain (expected <store>.myshopify.com). A store the account does not hold, whether unknown or another account's, is 404 Shopify store is not connected on both protocols, so the answer never tells whether a store belongs to someone else.

The status (ShopifyConnectionStatus / ShopifyStatusResponse) has configured (the app credentials are set), app_store_url (the listing to install from, from [shopify] app_store_url), max_shops ([shopify] max_shops_per_account) and connections (the account's stores, oldest link first; empty when none is connected). A connection has shop_host, shop_name, label (what this store's events show), custom_label (the own label, null while the default applies), status (connected or needs_reconnect), customer_display, buyer_choice_forms (the stored list in fixed order, empty when none is stored), show_items, show_price, scopes, enabled and connected_at. No token, secret or has_* flag is ever part of it. The embedded status (GET /v1/shopify/embedded/status) has linked, account_name (the only account data the store sees), the store's own connection and other_shops (how many other stores the account connects, 0 when not linked). Reading it on a linked store also re-reads the store's Shopify name and stores it sanitized when it changed.

No WebSocket channel is added: Shopify events reach the account through the existing event channels.

Errors​

Both protocols return the same messages:

MessageStatusWhen
Shopify is not configured: set [shopify].client_id and [shopify].client_secret (…)422No app credentials on this deployment
This Shopify store is already connected to another Lumio account.409Another account holds the store
This Lumio account already connects the maximum number of Shopify stores (N)409Confirm or completion of a new store while the account holds max_shops_per_account stores; a relink of a store the account already holds never counts
This Shopify store is already connected to a Lumio account.409Link start for a store that is linked
This Shopify link request has expired. Start the connection again in your Shopify admin.422Unknown, expired or cancelled handle, or another store's
This Shopify link request was cancelled because someone else had confirmed it. Start the connection again in your Shopify admin.409A confirmed handle confirmed for a second account, or read by another user than its confirmer; the handle is deleted
This Shopify link request was already confirmed for another Lumio account.409Confirm while the handle is being completed
The link has not been confirmed in Lumio yet. Confirm it in the Lumio tab first.409Completion before the confirm
This link request is being completed. Please wait a moment.409A second completion or a cancel while one runs
This link request was started by another Shopify staff member.403Completion by another staff member
This request must come from the Lumio app itself.403Confirm with Sec-Fetch-Site other than same-origin
invalid session401 + X-Shopify-Retry-Invalid-Session-Request: 1Any refused ID token, or Shopify refused it at the token exchange (embedded routes only)
Shopify did not grant the required access: …422A required scope is missing from the token's readback
Shopify could not be reached. Please try again.500Token exchange failed in transit
Not a Shopify store domain (expected <store>.myshopify.com)400A shopHost / {shop_host} that is not a {store}.myshopify.com host
Shopify store is not connected404Any per-store call for a store the account does not hold (unknown, disconnected or another account's), or an embedded write after a disconnect or relink
label may contain letters, numbers, punctuation, symbols and spaces only400A label with a control, format or invisible character, or whitespace other than the space
label must be at most 30 characters400A label longer than 30 characters or grapheme clusters
Nothing to update: set at least one of customer_display, buyer_choice_forms, show_items, show_price.400A settings update without any field
buyer_choice_forms must be a list of different forms, each one of: first_initial, first_name, first_name_last_initial, custom_name.400buyer_choice_forms is not a list, has an unknown or repeated form, or holds none / buyer_choice (the value is never echoed)
Select at least one form the buyer may choose (buyer_choice_forms), or choose another customer_display.400An empty buyer_choice_forms, or customer_display: buyer_choice with no list sent or stored
Shopify order event not found404Hide for an event that is not a Shopify order event of the account, or a created_at more than 1 s off
created_at must be an RFC 3339 timestamp400Hide with an unreadable created_at

The buyer-name blocklist has its own coded errors, the same on GraphQL (extensions.code), REST and the embedded routes:

CodeMessageStatusWhen
INVALID_SHOPIFY_BLOCKLIST_PATTERNPattern is empty, Pattern is too long (max 200 characters), Invalid pattern: …400An add without a non-blank line; per line these appear as outcome: invalid instead
SHOPIFY_BLOCKLIST_PATTERN_TOO_BROADPattern matches too broadly400Per line as outcome: invalid: a wildcard or regex that would hit ordinary names (*, .*)
INVALID_SHOPIFY_BLOCKLIST_FIELDkind must be one of: substring, word, wildcard, regex, name must be at most 256 characters400Unknown kind; test name too long
SHOPIFY_BLOCKLIST_TOO_MANY_LINESToo many lines (max 100 per call)400More lines than [shopify] name_blocklist_max_add
SHOPIFY_BLOCKLIST_ENTRY_NOT_FOUNDBlocklist entry not found404Remove of an id that is not an entry of this store's list (another store's entry included)
SHOPIFY_BLOCKLIST_LIMIT_REACHEDBlocklist is full (max 300 entries)409The add would pass name_blocklist_max_entries; nothing is written
SHOPIFY_BLOCKLIST_PATTERN_LIMIT_REACHEDToo many wildcard or regex entries (max 50)409The add would pass name_blocklist_max_pattern_entries; nothing is written
—Shopify store is not connected404Any call for a store the account does not hold now, or from the Shopify admin after the store was relinked or disconnected

One account, several stores​

A store belongs to one Lumio account at a time; an account can connect up to [shopify] max_shops_per_account stores (default 10, the same on every plan). Each store is its own integration_configs row with its own settings, label, tokens and buyer-name blocklist:

  • integration_configs.shop_host is a real column, written only by the link completion, with a CHECK on the {store}.myshopify.com shape, a CHECK that makes it mandatory for platform = 'shopify', and the partial unique index uq_integration_configs_shopify_shop_host (WHERE platform = 'shopify'): one account per store. For every other platform uq_integration_configs_account_platform_non_shopify keeps one row per (account_id, platform); their upserts name that index's predicate in ON CONFLICT (account_id, platform) WHERE platform <> 'shopify'. idx_integration_configs_account_id serves the account's store list and the cap count (migration 20261105210000_shopify_multi_shop; its down migration refuses while any account holds two stores).
  • The first link of a store is a plain INSERT. A unique violation on the shop index is the another account error. A relink of a store the account already holds patches the token keys of its row and never counts against the cap.
  • Cap under a lock. The completion takes, in this order and in one transaction: the account advisory lock (shopify_account:{account_id}), the store's advisory lock (shopify_token:{store}), then the row FOR UPDATE. Only then, when the store has no row yet, it counts the account's Shopify rows; at the cap it answers 409 before the token exchange. Two links of two stores to an account at cap − 1 therefore serialise and exactly one succeeds. Only the completion takes the account lock, and nothing takes it while it holds a store or row lock. Disconnect, app/uninstalled, shop/redact and the account cascade lower the count by deleting the row.
  • Confirm checks owner and cap too, and they are checked again before the token exchange (services::shopify::ensure_installable), so a refused link makes no Shopify call.
  • Every read or write of one store finds the row by (account_id, shop_host) (db::shopify::get_for_account_shop); the account's list is db::shopify::list_for_account (ORDER BY created_at, id). There is no account-only lookup: with several rows it would pick an arbitrary store.

Token storage and refresh​

New public apps get expiring offline tokens: the access token lives one hour, the refresh token 90 days and is replaced on every refresh. integration_configs.config of a Shopify row holds:

KeyContent
access_token_encrypted, refresh_token_encryptedAES-256-GCM ciphertext (crypto::encrypt_versioned)
access_token_expires_at, refresh_token_expires_atRFC 3339
scopesThe scope readback of the last exchange or refresh
scopes_requestedThe configured scopes at the last exchange (sorted); stops a re-exchange loop while the merchant has not approved new scopes
shop_nameThe Shopify shop name, sanitized like a label: read at the link and again whenever the embedded app's status is read
connected_atFrom the link
labelThe own store label; absent or null while the default applies
customer_display, buyer_choice_forms, show_items, show_priceSee settings
customer_display_syncedWhat the storefront metafields last confirmed: <form>:<scope>, e.g. first_initial:i, or <form>[<forms>]:<scope> once a list is stored, e.g. buyer_choice[first_initial,custom_name]:i

Token exchange (oauth::token_exchange, ID token → expiring offline token) retires every other refresh token of the app on the store. So it never runs concurrently with a refresh: every exchange takes pg_advisory_xact_lock on shopify_token:{store} and then the row FOR UPDATE, in one transaction. On the first link the row is inserted inside that transaction. On a linked store, the embedded status re-exchanges only when the stored access token is missing or unreadable, the grant is dead, or the configured scopes are newer than the last exchange; it then writes the token keys only (no new connected_at, no audit event).

Refresh: services::shopify::fresh_access_token(account_id, shop_host) is the only refresh path. It resolves the store's row and locks it by its id (db::shopify::lock_row, SELECT … FOR UPDATE) for the whole refresh, refreshes only when the access token expires within five minutes and writes every token key in one update. Shopify rotates the refresh token, so a second concurrent refresh would store a dead one. When Shopify rejects the refresh token, the row records the grant as expired; the status then reads needs_reconnect, and the next time a staff member opens the app in the Shopify admin the token exchange heals it.

Settings​

Settings belong to one store. updateShopifySettings(shopHost, …) / PUT /v1/connections/shopify/{shop_host}/settings and, from the Shopify admin, PUT /v1/shopify/embedded/settings (always the token's store) set four values with one validation (services::shopify::SettingsUpdate::validate), so all three paths accept and refuse the same input with the same messages. Every field is optional; a request without any is 400 (Nothing to update: …), and an omitted field keeps its value.

FieldValuesDefault
customer_displaynone, first_name, first_initial, first_name_last_initial, custom_name, buyer_choice; see Integrations → Shopify customer label. Any other value is 400 with Config field 'customer_display' must be one of: …. buyer_choice needs buyer_choice_forms, sent with it or storednone
buyer_choice_formsThe forms the buyer may pick under buyer_choice: 1–4 different values of first_initial, first_name, first_name_last_initial, custom_name (GraphQL buyerChoiceForms: [String!]). Stored in that order, so the same set in another order changes nothing. Kept while a fixed form is set. Omitted or null keeps the stored list on both protocols (async-graphql cannot tell an unset variable from null); nothing clears it except a disconnectnone
show_itemsBoolean: order events carry the line items (title, quantity)true
show_priceBoolean: order events carry the total with its currency, plus each item's price when items are shownfalse

A missing or non-boolean show_items / show_price in a stored config reads as its default; a missing or unreadable buyer_choice_forms reads as no allowed form. Setting the current values changes nothing. The settings apply to orders that arrive afterwards; stored events keep what they had.

Each changed value emits one account:shopify_settings_updated (shop_host, setting, from, to; for buyer_choice_forms both are lists; actor = the member from Lumio, none from Shopify with shopify_user_id). A change made in the Shopify admin also sends the account owner an in-app notice (shopify_settings_changed): these settings decide which buyer names and order details reach the stream.

All four values are written to the metafields of that store, which the storefront blocks read, as two copies in one metafieldsSet (all or none): customer_display (text), buyer_choice_forms (list.single_line_text_field, written for every form so it is in place before a switch to buyer_choice; not written while no list is stored) and the booleans show_items / show_price on the app installation (app-data metafields, namespace lumio, read by the cart block) and on the shop (app-reserved namespace $app:lumio, read by the checkout block, which cannot read app-data metafields). The write runs under the row lock, after a fresh token, and is recorded in customer_display_synced on success; a disconnect deletes both copies. A block picks its consent text by them and renders nothing without them. If Shopify fails, the Lumio value still changes (it is the only source of truth; the webhook never reads the metafields), and the embedded status or the next link completion pushes it again.

Store label​

Every store has a label: what its events show in Lumio and on stream. It is the own label when one is set, else the Shopify shop name, else the domain without .myshopify.com (lo_shopify::shop_label::effective_label). Set it with updateShopifyLabel(shopHost, label) / PUT /v1/connections/shopify/{shop_host}/label (body { "label": … }) or, from the Shopify admin, PUT /v1/shopify/embedded/label; null, an empty or a blank value resets it to the default.

  • Validation (lo_shopify::shop_label::validate_label, the same on all three paths): trimmed, NFC, then only letters, marks, numbers, punctuation, symbols and the ASCII space; at most 30 characters and 30 grapheme clusters. A control, format or invisible character (bidi overrides, zero-width characters, line breaks) is refused, never stripped. The Shopify shop name is merchant-controlled too: it is cleaned with the same allowlist (characters outside it dropped, whitespace collapsed, cut at a grapheme boundary) before it is stored or used.
  • Storage: config.label of the store's row, not the generic integration_configs.label column (that one stays "Shopify" and is writable through updateIntegration with settings:edit, without this validation).
  • On events: every shopify:* event carries raw.shop = { "domain": "<store>.myshopify.com", "label": "<label>" }, set when the webhook arrives (services::shopify::with_shop). The label is frozen there: a later change applies to new events, and old events keep theirs. Widgets, overlays, automations and extensions read it from the event without a lookup. Events stored before ZAF-3288 got raw.shop once from a one-off backfill (the store the account held at the event's time); an event that could not be attributed, and a dead-letter replay of an older event, has no raw.shop. Every reader treats the key as optional.
  • In the dashboard: a chip with a store icon and the label on every shopify:* row of the events page, the events popout, the start page's Activity, the dashboard chat and the chat popout, always (also with one store, and in Streamer Mode: the label is the merchant's, not a buyer's). Without raw.shop there is no chip. From two stores on, the events page's filter tab has a store filter; the switched-off stores are kept in the existing lumio:event-filters:* storage as shopsOff. Events without raw.shop, or of a store no longer listed, stay visible.
  • MCP: the events tool returns shop: { domain, untrusted: { label } } for Shopify events: the label is text a Shopify staff member can set.
  • Audit: a change emits account:shopify_settings_updated with setting: "label" (from / to = the own label or null); from the Shopify admin it also sends the owner the shopify_settings_changed notice. The same value emits nothing.

Order details​

show_items and show_price act when the order webhook arrives: what is off is never stored (see Webhooks → Shopify). Items off means no line_items; price off means no total_price, no currency and no item price. Together with the name form they give every combination: name only, name with items, items only, prices. The buyer's consent record carries the details its text named, and a label is shown only while the current details are covered (see Integrations → Shopify customer label).

Which lists can drop a name​

When an order event is built, the derived label passes three lists, in this order, all on the exact label the event would show: every displayed form, typed names, the buyer's pick and the first-name and initial forms alike. A hit on any of them drops the name silently: the event has no customer_display, and the buyer sees no hint in the cart or the checkout. A list that cannot be loaded drops the name too (fail closed).

  1. Lumio's global blocklist (Admin → Blocklist). It is compiled in each API process, so it costs no database query per order; a change reaches every instance within seconds, at the latest after 60 s. While an instance has not loaded the list yet, it shows no name.
  2. The store's own blocklist for buyer names, managed in that store's Shopify app and at that store on the Shopify card: words, whole words, patterns with * and regular expressions, matched with the global list's normalisation (NFKC, case, invisible characters). It applies only to Shopify buyer names of that store's orders, never to chat or to another store.
  3. The account's word filter (the enabled system-word-filter install, its bannedWords and bannedPatterns); see Integrations → Shopify customer label.

Lists 2 and 3 are read together in one database query, only when the global list let the name through, and without a cache: a word added a moment ago applies to the next order.

The internal buyer name is not checked by any of the three lists.

Buyer-name blocklist​

Each connected store keeps its own list of words and patterns that must never appear in a buyer name of its orders. It is the second list in Which lists can drop a name, applies only to Shopify buyer names (never to chat), and is managed in two places with one service (services::shopify_blocklist), so validation, messages and audit are identical:

  • Lumio: each store's row on the Shopify card in the settings window (Services › Shopify), through /api/connections/shopify/{shopHost}/blocklist[/{id}|/test] (serverGql() + transformKeys()) and the GraphQL fields in API, all with shopHost. Reading and testing need connections:read, adding and removing connections:edit; all four need integration:shopify.
  • Shopify admin: the embedded page's section under the buyer-name setting, through /v1/shopify/embedded/blocklist (GET, POST), /blocklist/{id} (DELETE) and /blocklist/test (POST). Any staff member of the linked store may change its list, like the embedded settings; staff of one store never see or change another store's list. Every call is pinned to the token's store (a relink or disconnect in between answers 404 Shopify store is not connected). A change from there sends the account owner one in-app notice per call.

Entries. kind is substring (part of a word), word (a whole word), wildcard (* for any characters) or regex. A pattern is 1–200 characters (UTF-16 code units, as the browser counts), checked on save exactly as the order path compiles it (lo_blocklist, Options::STRICT: NFKC, lowercase, invisible characters removed) plus a breadth probe that refuses rules hitting ordinary names. Two entries of the same kind whose normalised patterns are equal are one entry (Entry already exists). Caps ([shopify], installation): 300 entries per store, at most 50 of them wildcard or regex, at most 100 lines per add call. There is no edit: remove the entry and add it again.

Calls.

  • List returns { entries, count, pattern_count, max_entries, max_pattern_entries, max_lines_per_add, max_pattern_length }, entries newest first, each { id, kind, pattern, created_by, created_at } (created_by is null for an entry added in the Shopify admin).
  • Add takes { kind, patterns } (one entry per line, blank lines skipped) and returns { created, duplicate, invalid, lines } with one { line, pattern, outcome, error, entry_id } per non-blank line (outcome created, duplicate or invalid). All lines are written in one transaction; a cap refusal (409) fails the whole call. The cap check and insert run under a per-store advisory lock (shopify_name_blocklist:{account_id}:{shop_host}), so concurrent adds from Lumio and the Shopify admin cannot pass a cap.
  • Remove returns the removed entry; an id of another store or account is 404 like an unknown one.
  • Test takes { name } (at most 256 characters) and returns { blocked, entry } with the oldest entry that hits. It checks only this store's list, never Lumio's global list, which is secret: a test field over it would let any store read it one guess at a time. The name is neither stored nor logged.

Lifecycle. The list is keyed by (account_id, shop_host) (table shopify_name_blocklist, unique on (account_id, shop_host, kind, pattern_key)). Every call needs the account to hold the store now, so a disconnected store's list cannot be read or changed until the store is linked again. It stays when the store is disconnected in Lumio: a relink of the same store to the same account finds it again, and another account linking that store does not see it. It is deleted when the store sends shop/redact (patterns can be buyer names, and that is the erasure of the store's data), and together with the store's last compliance tombstone, when the account holds no row and no other tombstone of that store (about 30 days after the store's last events, see Compliance webhooks). It is deleted with the account too. No cache: a word added now applies to the next order.

No WebSocket channel, on purpose: the list is configuration that changes rarely, the order path reads it per event, and every further surface is one more place where it becomes readable.

Audit. account:shopify_name_blocklist_added (count, entry_ids, kind) and account:shopify_name_blocklist_removed (entry_id, kind), both with platform, shop_host, source (lumio or shopify_admin) and, from Shopify, shopify_user_id. The pattern is never recorded: an entry may be a person's name. A stored rule that a later matcher release no longer compiles is skipped on the order path and logged by entry id and a fixed reason class only. The metric shopify_name_blocklist_compile_seconds (no labels) times the per-order compile of a non-empty account list; an empty list is not compiled and not recorded.

Hide a buyer name​

hideShopifyOrderName(eventId, createdAt) / POST /v1/connections/shopify/events/{event_id}/hide-name (body { "created_at": "<RFC 3339>" }) removes the buyer label and the internal buyer name from one order. It needs events:edit (Owner and Administrator by default). It is not gated on integration:shopify, so a stored name can still be hidden after the plan loses Shopify.

  • created_at is the event's timestamp from the feed and must match within 1 s. The customer_display key is removed (not blanked) and internal_name is set to NULL from that event and from the other event of the same order (order:{id} / order_paid:{id}) within 7 days of it. Each update touches only the compressed batch that holds the row.
  • The events themselves stay, so a redelivery from Shopify cannot bring the name back.
  • The answer is true when a name was removed and false when neither name was left (no audit event then). An event that is not a Shopify order event of the account is 404.
  • The name is hidden in Lumio: what was already shown on the overlay, sent to an automation or to a widget is not recalled.

A hide emits account:shopify_order_name_hidden (platform, event_id, shop_host from the event's raw.shop, null for an event without one; never the name).

Internal buyer name​

Besides the public label, every order event can carry an internal buyer name for the merchant's team (Legal ZAF-3265 §B, ZAF-3269 §1.7/§2; Art. 6(1)(f) GDPR, no buyer consent). It is a separate column, platform_event_logs.internal_name, never part of raw, so it does not reach the WebSocket, alerts, overlays, widgets, automations, extensions or the events API (C-1).

  • Derivation, once at order time (lo_shopify::order_internal_name, the four-form label function of the public label): a fixed real-name customer_display gives that form; none and custom_name give first name + last-name initial (from Shopify's name, never the typed name); buyer_choice gives the most revealing real-name form in buyer_choice_forms (first name + initial > first name > initial), or first name + initial when the list holds only custom_name. Never the full name, email, phone or customer id. No first name from Shopify (no customer, or no Level 2 Name access) means no internal name; there is no fallback. The value is capped at 64 graphemes. A later settings change never rewrites stored internal names.
  • Lookup: shopifyOrderInternalNames(events) / POST /v1/connections/shopify/events/internal-names takes 1–100 { id, created_at } refs and returns { event_id, state, internal_name, matches_public } for the order events that have one. Both protocols share one gate (services::shopify::lookup_internal_names): an account, events:read, a dashboard session or popout token (internal_name_viewer; every other credential is 403 with no catch-all), feature:events, then the refs. No integration:shopify gate, no cache, no audit event (a read). matches_public is true when the internal name equals raw.customer_display.
  • Streamer Mode masks server-side: the user's streamer_mode (a popout token's bound user) masks the value (state: "masked", internal_name: null); a popout token without a bound user, an unknown user or a failed read also masks (P-4).
  • Surfaces (P-3): the events page, dashboard chat, chat popout, events popout, Cockpit page and Cockpit popout mount ShopifyInternalNamesProvider (apps/web/src/lib/shopify-internal-names.tsx; __tests__/shopify-internal-names-surfaces.test.ts pins the six surfaces). In the Cockpit the provider wraps its Chat and Events tiles. Each surface batches the order rows it shows into the lookup through POST /api/connections/shopify/events/internal-names (popout-session capable). The start page's Activity, /popout/obs, /popout/music, /popout/test-events, overlays and widgets have no provider and send no lookup. A live row that found nothing is retried once after 30 s, since its insert may still be in flight.
  • What the row shows: without a public name, the internal name takes the name slot with an "Intern" / "Internal" chip (lock icon). With a different public name, the chip reads "Intern: Jonas K." next to it. With the same name (matches_public), there is no chip. Masked rows show •••••• without a reveal eye. Switching Streamer Mode on or off updates the rows at once, without a reload: turning it on hides every shown internal name immediately, and both directions repeat the lookup.
  • OBS browser source block (P-2): obs-browser injects window.obsstudio into docks and browser sources alike, but only a browser source answers getControlLevel(). Inside obs-browser the provider waits 1.5 s before the first lookup; an answer, even a late one, blocks the value for the life of the page and the slot reads "Interner Name verdeckt (OBS-Browserquelle)" / "Internal name hidden (OBS browser source)". In an OBS dock the name appears after the probe.
  • Retention and erasure: the internal name lives as long as its event. Hide a buyer name removes it together with the public label, customers/redact and shop/redact delete the whole events, and the customers/data_request notice names "the internal name, if one is stored" as a category, never the value. The account data export includes it with the events.

Storefront blocks​

The buyer's consent for a name on the stream comes from two blocks of the Shopify app project apps/shopify/ (Shopify CLI). Both write the same two private cart attributes, which reach the order's note_attributes and show on the order in the Shopify admin (the consent record), but not on the storefront or the checkout: __lumio_name_consent and __lumio_stream_name (see Integrations → Shopify customer label).

Cart blockCheckout block
ExtensionTheme app extension extensions/name-consent-cart, app block name-consent (theme editor name "Name on livestream" / „Name im Livestream“; the extension itself is named "Name on livestream")Checkout UI extension extensions/name-consent-checkout (checkout editor name "Name on livestream" in every admin language: the checkout editor shows a t: key in the TOML name literally), target purchase.checkout.block.render, default placement INFORMATION2
ShopsEvery plan with an Online Store 2.0 theme; only on the cart templateShopify Plus only
Readsapp.metafields.lumio.* (Liquid)$app:lumio shop metafields (owner_type = "SHOP")
WritesPOST /cart/update.jsapplyAttributeChange (extension api_version = "2026-10"; Shopify marks it deprecated in favour of cart metafields but names no version that removes it, checked up to 2027-01-rc on 2026-11-03)
Renders nothing whenform none/missing, a flag missing, buyer_choice without a valid list, the shop has no privacy policy, the cart is emptyform none/missing, a flag missing, buyer_choice without a valid list, canUpdateAttributes false, an accelerated checkout (Apple Pay, Google Pay), a failed write
  • Forms first_initial, first_name, first_name_last_initial: an unticked checkbox. Unticked writes __lumio_name_consent = "0" (also on render), ticked writes <form>:2:<scope> for the text on screen. Form custom_name: an optional text field with a n/25 counter. A valid name writes __lumio_stream_name and __lumio_name_consent = custom_name:2:<scope>; an empty, too long or disallowed name writes "0" and an empty name and shows why. The block checks the name like the server does (NFKC, letters/marks/digits, space, - ' . _, at most 25 characters); the server check stays authoritative. On render the checkout block shows the buyer's own record again, for example the one the cart block wrote: a record equal to the value it would write (same form, text version 2, same scope) ticks the box, and for custom_name also refills the field with the stored name if that name still passes the check. Any other record starts unticked or empty and writes "0", so what is on screen is what is stored. This relies on Shopify passing the private cart attributes to the extension's shopify.attributes; without them the block starts unticked or empty.
  • Form buyer_choice: an unticked checkbox (text version 3), never pre-ticked; unticked writes "0". Once ticked, a select „So erscheint mein Name“ / "How my name is shown" appears (cart: native <select>, checkout: s-select). It starts on the placeholder „Form wählen“ / "Choose a form" (record still "0") and lists only the forms in the buyer_choice_forms metafield, in fixed order; „Kein Name“ is not an option, leaving the box unticked is. A picked real-name form writes <form>:3:<scope>; Eigener Name opens the custom_name text field and writes custom_name:3:<scope> with a valid name. Unticking drops the pick, writes "0", removes __lumio_stream_name and, in the cart, the sessionStorage entry; ticking again starts on the placeholder. On render a block restores only the buyer's own :3: record whose form is still allowed and whose scope matches the text on screen, never a :2: record (the cart keeps it in page memory only, so a reload starts unticked). A missing, empty or invalid list (an unknown or repeated form, none or buyer_choice in it) renders nothing, the same rule as the server's parser.
  • Texts are fixed (no theme-editor setting) and come from the extensions' locales/ (en and de; every other storefront language gets English). The explanation names the form and the order details from the store's show_items / show_price. No app name appears in either block; each links the shop's privacy policy (cart: shop.privacy_policy.url, checkout: {storefront}/policies/privacy-policy).
  • sessionStorage (cart block, custom_name, or Eigener Name picked under buyer_choice): key lumio_stream_name holds the typed name, written on input, removed when the field is cleared or the buyer_choice box is unticked, so a custom_name field survives a reload (under buyer_choice a reload starts unticked and removes the entry). Nothing else is stored in the browser.
  • Network: the cart block calls only the shop's cart/update.js, the checkout block makes no request. The app configuration declares [network_access] with empty allowed_sources and allowed_navigation_hosts.

App configuration​

apps/shopify/shopify.app.staging.toml and shopify.app.production.toml hold one Shopify app per environment. They set embedded = true, the app URL (https://lumio.shopify.staging.zaflun.dev / https://shopify.lumio.vision), scopes = "read_orders,read_products,read_legal_policies" (must equal [shopify] scopes), one never-called redirect URL, the webhook subscriptions (orders/create + orders/paid and products/create with include_fields limited to what lo_shopify::Order / Product parse, app/uninstalled, the compliance topics) on api_version = "2026-10", [access.admin] (Direct API access in online mode, for the embedded page's Plus check) and [network_access]. client_id is a placeholder until the app exists in the Dev Dashboard; it must equal [shopify] client_id of that environment.

Build and check locally from apps/shopify: npx @shopify/cli@5.0.0 app build --config staging (bundles both extensions and runs theme check). Release: shopify app deploy --config staging|production creates and releases an app version with the configuration and both extensions; it does not deploy the embedded web page.

Embedded page image​

The embedded web page ships as the container image ghcr.io/zaflun/lumio/shopify, built from docker/shopify.Dockerfile (Next.js standalone output, port 4004, health check GET /api/health) by release-images.yml for a release tagged shopify@{version} (also part of all-web and all), signed and attested like the other web images (Deployment). CI's Next Build job builds apps/shopify whenever it changes. Runtime environment variables, all read at request time:

VariablePurpose
LUMIO_API_URLLumio API base including /v1 (e.g. https://api.lumio.vision/v1); the route handlers forward to /v1/shopify/embedded/*
LUMIO_WEB_URL (fallback NEXT_PUBLIC_WEB_URL)Web app origin for "In Lumio öffnen" and the link tab
LUMIO__SHOPIFY__CLIENT_IDThe app's client id, same value as the API's [shopify] client_id
LUMIO__SHOPIFY__CLIENT_SECRETClient secret for the launch hmac check (secret, ENV only)
LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUSPrevious client secret during a rotation (optional, secret)
SENTRY_DSN (fallbacks LUMIO_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN)Optional. Server-side error reports to Sentry; without a DSN Sentry stays off. No browser SDK, see Error Tracking

Disconnect and uninstall​

  • Disconnect removes one store; the account's other stores, their settings, events and blocklists stay. disconnectShopify(shopHost) / DELETE /v1/connections/shopify/{shop_host}, or POST /v1/shopify/embedded/disconnect from the Shopify admin (always the token's store), first clears that store's storefront metafields (best effort, with a fresh token, so the blocks stop asking buyers), then deletes its row and writes exactly one app/uninstalled tombstone for it (webhook_id = local:<uuid>) into shopify_compliance_requests in one transaction. A disconnect from the Shopify admin also sends the account owner an in-app notice (shopify_disconnected). The app stays installed in the store until the merchant removes it in the Shopify admin. The webhook subscriptions are app-level, so Shopify keeps sending order webhooks for the store after a disconnect; they are dropped on arrival (no linked row: 200, nothing stored).
  • Uninstall in Shopify delivers app/uninstalled; the webhook deletes the row and writes the tombstone under Shopify's webhook id (see Webhooks → Shopify).

The tombstone records the account, the store and the moment (window_end). A later shop/redact from Shopify uses it to find whose events to erase, even after the store was reinstalled elsewhere.

Compliance webhooks​

Every App Store app must take Shopify's three mandatory compliance webhooks, answer an invalid hmac with 401 and a valid delivery with a fast 2xx, and finish the work within 30 days. Lumio receives them at POST /v1/webhooks/shopify/compliance (a compliance topic sent to /v1/webhooks/shopify is handled the same way). See Webhooks → Shopify compliance topics for the receipt.

Queue, never inline. A verified delivery becomes one row in shopify_compliance_requests (unique on X-Shopify-Webhook-Id, so a redelivery adds nothing) and is answered 200 at once. The row holds the store, the order ids from the payload and the account that holds the store at receipt, whether or not the integration is paused. The payload itself (customer id, e-mail, phone) is never stored. The compliance worker (workers::shopify_compliance, always on, every minute, safe on several replicas) does the work in services::shopify_compliance:

TopicWhat Lumio doesDone
customers/redactDeletes the listed orders' shopify:order / shopify:order_paid events, with or without a customer label: through the order number the rest of the event (amount, line items, time) is still the customer's data for the merchant. Other orders, products and other accounts stay. No cache keeps a deleted event: the live broadcast stores nothing, and the dead-letter copy is caught by the second pass.After the second pass
shop/redactDeletes the store's shopify events of every account that held it, up to the moment it stopped holding it (window_end of the tombstone), and that store's buyer-name blocklist in each of those accounts. Only events of this store go: raw->'shop'->>'domain' = <store>, plus events without raw.shop in that account and window (an unattributed older event goes with the redacted store rather than stay behind). Events of the account's other stores, of other platforms and of a store connected later stay. A connection row that app/uninstalled never reached is removed first, like an uninstall.After the second pass
customers/data_requestLooks up the listed orders, writes the audit event (counts only) and sends the channel owner an in-app notice.At once

Whose data. The tenants of a store are the account that holds it now and every account named in an app/uninstalled tombstone of that store. A store Lumio never knew completes as a no-op.

Second pass. A failed event insert is parked in Redis for up to 72 hours and replayed later, so a parked copy could land after a redaction. A redaction therefore runs on receipt and again 73 hours after receipt, and completes only on that second pass.

Events without raw.shop. Events stored before several stores per account existed got raw.shop once from a one-off backfill (ZAF-3288 F5): back then an account held one store at a time, so an event belongs to the store of the first tombstone whose window_end is after it, else to the live row's store. The backfill ran once per environment and is no longer part of the worker. What it could not attribute (an account with no tombstone and none or several live stores), and a dead-letter replay of an older event, stays without raw.shop; the shop/redact rule above deletes such events with the redacted store.

Retries. A failed pass is retried with a backoff of 2^attempts minutes, capped at six hours, and never dropped. After ten passes it is logged as an error. The metric shopify_compliance_passes_total{topic, outcome} counts completed, recheck, failed and stuck.

What the data request tells the merchant. The merchant (the streamer) answers the customer; Lumio does not contact the customer or Shopify. The owner's notice (in the app only, no e-mail) names the store and the order ids Lumio holds data for, and says what that is: order number, amount, currency, payment status, line items (product, quantity, price) and time, and the customer label if one is set. Order ids with no stored event are listed as having no data. When nothing matches, or the request lists no orders, the notice says Lumio holds no data for the request. The label value itself is never shown in the notice or the audit log. The notice links to the events page, where the orders are.

What the queue keeps. A completed request keeps no order ids: only the store, the account and the timestamps. Completed requests are deleted 30 days after completion (Shopify API Terms §6.2.3: Merchant Data goes within 30 days); open requests are never swept. An app/uninstalled tombstone goes 30 days after its account last held shopify events of that tenancy (events of that store, or without raw.shop, created before its window_end): the daily sweep checks each tombstone against TimescaleDB and restarts its clock (completed_at) while such events exist. Another store's events of the same account never keep it alive. When the sweep deletes the last trace of a store in an account (no row and no other tombstone of that store), it deletes that store's buyer-name blocklist in the same step. After a disconnect in Lumio the app stays installed, so no shop/redact comes, the events stay for their normal retention, and Shopify sends customers/redact up to six months after the customer's last order; the tombstone is what still names the old account then. After a real uninstall shop/redact deletes the events within about five days, so the tombstone goes about 30 days after that. A tombstone whose account was deleted goes 30 days after completion. The audit events (24 months, store address and counts only) are the lasting record.

Audit​

EventScopeActorMetadata
account:integration_connectedaccountThe member who confirmed the linkplatform, shop_host, scopes, source (shopify_admin), shopify_user_id
account:integration_disconnectedaccountThe member (disconnect in Lumio) or none (from the Shopify admin, app/uninstalled)platform, shop_host, reason (user / shopify_admin / app_uninstalled), shopify_user_id (Shopify admin only)
account:shopify_settings_updatedaccountThe member (Lumio) or none (Shopify admin)One event per changed value: platform, shop_host, setting (customer_display / buyer_choice_forms / show_items / show_price / label), from, to (lists for buyer_choice_forms; the own label or null for label), source (lumio / shopify_admin), shopify_user_id (Shopify admin only)
account:shopify_order_name_hiddenaccountThe memberplatform, event_id, shop_host (null for an event without raw.shop)
account:shopify_data_requestaccountnoneplatform, shop_host, orders_requested, orders_found, orders_with_label
account:shopify_customer_redactedaccountnoneplatform, shop_host, orders (count)
account:shopify_shop_redactedaccountnoneplatform, shop_host

No token, customer label or customer contact ever appears in the metadata. Events written from the Shopify admin (/v1/shopify/embedded/*) record the browser's user agent but no IP address and no location; shopify_user_id and shop_host identify who acted. See Audit log.

Configuration​

[shopify] in the API config (LUMIO__SHOPIFY__*): client_id, client_secret and client_secret_previous (both ENV only), api_version (default 2026-10), scopes (default read_orders,read_products,read_legal_policies), app_store_url and max_shops_per_account (default 10, at least 1; the API's maxShops). See Installation → Configuration and OAuth setup → Shopify.

Client-secret rotation​

Every Shopify signature check tries client_secret first and client_secret_previous second: the embedded page's ID token, the launch hmac in apps/shopify, and the HMAC of every webhook delivery (/v1/webhooks/shopify and /v1/webhooks/shopify/compliance). Shopify signs webhooks with the app's oldest unrevoked secret, so during a rotation order and compliance webhooks still carry the old signature (Manage your app credentials → Rotate your client secret). Rotate without downtime in this order:

  1. In the Dev Dashboard (app → Settings → Credentials) click Rotate next to the client secret and generate a new one. The old secret stays active.
  2. Set LUMIO__SHOPIFY__CLIENT_SECRET to the new secret and LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS to the old one, on the API and on apps/shopify, and restart both. The token calls (exchange, refresh) now use the new secret; the verifiers accept both.
  3. Let the access tokens move to the new secret. A store's token is re-pinned to the new secret when it next refreshes (fresh_access_token refreshes on the first use after the one-hour access token runs out) or when a staff member next opens the app (token exchange). A store that is not used before step 4 reads needs_reconnect afterwards and heals the next time the app is opened in its Shopify admin.
  4. Revoke the old secret in the Dev Dashboard. From then on Shopify signs with the new secret.
  5. Watch shopify_client_secret_matches_total{secret="previous"} (both verifier="id_token" and verifier="webhook"). Once it stops increasing, remove LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS on both services and restart.

If a secret is being actively misused, revoke it first (step 4) and accept the downtime: until the new secret is set, ID tokens and webhooks signed with it are refused with 401, and Shopify retries the webhooks.

Monitoring​

The API exposes two Shopify series on its internal /metrics port (Metrics):

MetricLabelsMeaning
shopify_client_secret_matches_totalverifier (id_token, webhook), secret (current, previous)Verified Shopify signatures, by the client secret that signed them. secret="previous" is non-zero only during a rotation.
platform_api_requests_total{platform="shopify"}endpoint (/admin/api/{version}/graphql.json, /admin/oauth/access_token), outcome (ok, unauthorized, forbidden, rate_limited, client_error, server_error, unexpected_status, transport_error)Every response of an outgoing Admin API or token-endpoint call.

outcome="forbidden" (HTTP 403) on either endpoint usually means the Shopify IP allowlist does not contain the egress address of the host that made the call. The best-effort calls (settings sync to the storefront metafields, metafield cleanup on disconnect) do not fail the user's action, so this counter and the matching WARN log line (Shopify Admin API answered 403 Forbidden, with the endpoint only, never the store or a token) are the signal. No label carries a store or account.