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).
Install and link
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:
- Start (Shopify admin). The embedded page calls
POST /v1/shopify/embedded/link. The API mints a link handle bound to the store (the token'sdest) 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 aReferer. - Confirm (Lumio). In the Lumio tab, a member with
connections:create,integration:shopifyand 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 whoseSec-Fetch-Siteis notsame-origin. No token is written here. - 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, callsPOST /v1/shopify/embedded/link/completewith 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 emitsaccount:integration_connectedand 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.comhost fromshopifyLinkRequest(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 duringlink/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/inspectandPOST /api/connections/shopify/link/confirmwith{ "handle": … }in the body. Both refuse with403unlessSec-Fetch-Siteissame-origin, or it is absent andOriginequals the web public origin (LUMIO_APP_URL); a same-site sibling such as the embedded host is refused. apps/web/src/proxy.tssends an enforcedContent-Security-Policy: frame-ancestors 'none',X-Frame-Options: DENYandReferrer-Policy: no-referreron 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
| Setting | Value |
|---|---|
| Distribution | Public (App Store) |
| Install | Shopify-managed; scopes, app URL, redirect URL and webhook subscriptions in apps/shopify/shopify.app.<env>.toml |
| Scopes | read_orders,read_products,read_legal_policies (the backend checks the token's scope readback against [shopify] scopes) |
| Embedded in Shopify admin | Yes (App Bridge ID tokens + token exchange) |
| Protected customer data | Level 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):
| Step | Data source | Action |
|---|---|---|
| 1. Cart block placed / missing | shopify.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-cart | Theme 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 / missing | shop.shopPolicies (type + body, scope read_legal_policies): present = a PRIVACY_POLICY with a non-empty body | Present: 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'; andframe-ancestors https://{shop} https://admin.shopify.com. The shop comes only from Shopify's launch query after itshmacverifies with the client secret (or the previous one), and only if it is a{store}.myshopify.comhost. Otherwiseframe-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
Cookierequest 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
| GraphQL | REST | Permission | Feature |
|---|---|---|---|
shopifyConnection | GET /v1/connections/shopify | connections:read | integration:shopify |
shopifyLinkRequest(handle: String!) | POST /v1/connections/shopify/link/inspect | connections:create + interactive session | integration:shopify |
shopifyLinkConfirm(handle: String!) | POST /v1/connections/shopify/link/confirm | connections:create + interactive session, not cross-site | integration:shopify |
updateShopifySettings(shopHost: String!, customerDisplay: String, buyerChoiceForms: [String!], showItems: Boolean, showPrice: Boolean) | PUT /v1/connections/shopify/{shop_host}/settings | connections:edit | integration:shopify |
updateShopifyLabel(shopHost: String!, label: String) | PUT /v1/connections/shopify/{shop_host}/label | connections:edit | integration:shopify (see Store label) |
disconnectShopify(shopHost: String!) | DELETE /v1/connections/shopify/{shop_host} | connections:delete | integration:shopify |
hideShopifyOrderName(eventId: UUID!, createdAt: String!) | POST /v1/connections/shopify/events/{event_id}/hide-name | events:edit | none (see Hide a buyer name) |
shopifyOrderInternalNames(events: [ShopifyEventRefInput!]!) | POST /v1/connections/shopify/events/internal-names | events:read, dashboard session or popout token only | feature:events (see Internal buyer name) |
shopifyNameBlocklist(shopHost: String!) | GET /v1/connections/shopify/{shop_host}/blocklist | connections:read | integration:shopify (see Buyer-name blocklist) |
testShopifyNameBlocklist(shopHost: String!, name: String!) | POST /v1/connections/shopify/{shop_host}/blocklist/test | connections:read | integration:shopify |
addShopifyNameBlocklistEntries(shopHost: String!, kind: String!, patterns: [String!]!) | POST /v1/connections/shopify/{shop_host}/blocklist | connections:edit | integration:shopify |
removeShopifyNameBlocklistEntry(shopHost: String!, id: UUID!) | DELETE /v1/connections/shopify/{shop_host}/blocklist/{id} | connections:edit | integration: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 permission | integration: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:
| Message | Status | When |
|---|---|---|
Shopify is not configured: set [shopify].client_id and [shopify].client_secret (…) | 422 | No app credentials on this deployment |
This Shopify store is already connected to another Lumio account. | 409 | Another account holds the store |
This Lumio account already connects the maximum number of Shopify stores (N) | 409 | Confirm 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. | 409 | Link start for a store that is linked |
This Shopify link request has expired. Start the connection again in your Shopify admin. | 422 | Unknown, 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. | 409 | A 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. | 409 | Confirm while the handle is being completed |
The link has not been confirmed in Lumio yet. Confirm it in the Lumio tab first. | 409 | Completion before the confirm |
This link request is being completed. Please wait a moment. | 409 | A second completion or a cancel while one runs |
This link request was started by another Shopify staff member. | 403 | Completion by another staff member |
This request must come from the Lumio app itself. | 403 | Confirm with Sec-Fetch-Site other than same-origin |
invalid session | 401 + X-Shopify-Retry-Invalid-Session-Request: 1 | Any refused ID token, or Shopify refused it at the token exchange (embedded routes only) |
Shopify did not grant the required access: … | 422 | A required scope is missing from the token's readback |
Shopify could not be reached. Please try again. | 500 | Token exchange failed in transit |
Not a Shopify store domain (expected <store>.myshopify.com) | 400 | A shopHost / {shop_host} that is not a {store}.myshopify.com host |
Shopify store is not connected | 404 | Any 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 only | 400 | A label with a control, format or invisible character, or whitespace other than the space |
label must be at most 30 characters | 400 | A 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. | 400 | A 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. | 400 | buyer_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. | 400 | An empty buyer_choice_forms, or customer_display: buyer_choice with no list sent or stored |
Shopify order event not found | 404 | Hide 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 timestamp | 400 | Hide 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:
| Code | Message | Status | When |
|---|---|---|---|
INVALID_SHOPIFY_BLOCKLIST_PATTERN | Pattern is empty, Pattern is too long (max 200 characters), Invalid pattern: … | 400 | An add without a non-blank line; per line these appear as outcome: invalid instead |
SHOPIFY_BLOCKLIST_PATTERN_TOO_BROAD | Pattern matches too broadly | 400 | Per line as outcome: invalid: a wildcard or regex that would hit ordinary names (*, .*) |
INVALID_SHOPIFY_BLOCKLIST_FIELD | kind must be one of: substring, word, wildcard, regex, name must be at most 256 characters | 400 | Unknown kind; test name too long |
SHOPIFY_BLOCKLIST_TOO_MANY_LINES | Too many lines (max 100 per call) | 400 | More lines than [shopify] name_blocklist_max_add |
SHOPIFY_BLOCKLIST_ENTRY_NOT_FOUND | Blocklist entry not found | 404 | Remove of an id that is not an entry of this store's list (another store's entry included) |
SHOPIFY_BLOCKLIST_LIMIT_REACHED | Blocklist is full (max 300 entries) | 409 | The add would pass name_blocklist_max_entries; nothing is written |
SHOPIFY_BLOCKLIST_PATTERN_LIMIT_REACHED | Too many wildcard or regex entries (max 50) | 409 | The add would pass name_blocklist_max_pattern_entries; nothing is written |
| — | Shopify store is not connected | 404 | Any 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_hostis a real column, written only by the link completion, with aCHECKon the{store}.myshopify.comshape, aCHECKthat makes it mandatory forplatform = 'shopify', and the partial unique indexuq_integration_configs_shopify_shop_host(WHERE platform = 'shopify'): one account per store. For every other platformuq_integration_configs_account_platform_non_shopifykeeps one row per(account_id, platform); their upserts name that index's predicate inON CONFLICT (account_id, platform) WHERE platform <> 'shopify'.idx_integration_configs_account_idserves the account's store list and the cap count (migration20261105210000_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 rowFOR UPDATE. Only then, when the store has no row yet, it counts the account's Shopify rows; at the cap it answers409before 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/redactand 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 isdb::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:
| Key | Content |
|---|---|
access_token_encrypted, refresh_token_encrypted | AES-256-GCM ciphertext (crypto::encrypt_versioned) |
access_token_expires_at, refresh_token_expires_at | RFC 3339 |
scopes | The scope readback of the last exchange or refresh |
scopes_requested | The configured scopes at the last exchange (sorted); stops a re-exchange loop while the merchant has not approved new scopes |
shop_name | The Shopify shop name, sanitized like a label: read at the link and again whenever the embedded app's status is read |
connected_at | From the link |
label | The own store label; absent or null while the default applies |
customer_display, buyer_choice_forms, show_items, show_price | See settings |
customer_display_synced | What 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.
| Field | Values | Default |
|---|---|---|
customer_display | none, 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 stored | none |
buyer_choice_forms | The 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 disconnect | none |
show_items | Boolean: order events carry the line items (title, quantity) | true |
show_price | Boolean: order events carry the total with its currency, plus each item's price when items are shown | false |
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.labelof the store's row, not the genericintegration_configs.labelcolumn (that one stays"Shopify"and is writable throughupdateIntegrationwithsettings:edit, without this validation). - On events: every
shopify:*event carriesraw.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 gotraw.shoponce 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 noraw.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). Withoutraw.shopthere 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 existinglumio:event-filters:*storage asshopsOff. Events withoutraw.shop, or of a store no longer listed, stay visible. - MCP: the
eventstool returnsshop: { domain, untrusted: { label } }for Shopify events: the label is text a Shopify staff member can set. - Audit: a change emits
account:shopify_settings_updatedwithsetting: "label"(from/to= the own label ornull); from the Shopify admin it also sends the owner theshopify_settings_changednotice. 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).
- 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.
- 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. - The account's word filter (the enabled
system-word-filterinstall, itsbannedWordsandbannedPatterns); 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 withshopHost. Reading and testing needconnections:read, adding and removingconnections:edit; all four needintegration: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 answers404 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_byisnullfor 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 (outcomecreated,duplicateorinvalid). 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
404like 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_atis the event's timestamp from the feed and must match within 1 s. Thecustomer_displaykey is removed (not blanked) andinternal_nameis set toNULLfrom 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
truewhen a name was removed andfalsewhen neither name was left (no audit event then). An event that is not a Shopify order event of the account is404. - 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-namecustomer_displaygives that form;noneandcustom_namegive first name + last-name initial (from Shopify's name, never the typed name);buyer_choicegives the most revealing real-name form inbuyer_choice_forms(first name + initial > first name > initial), or first name + initial when the list holds onlycustom_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-namestakes 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 is403with no catch-all),feature:events, then the refs. Nointegration:shopifygate, no cache, no audit event (a read).matches_publicistruewhen the internal name equalsraw.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.tspins 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 throughPOST /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.obsstudiointo docks and browser sources alike, but only a browser source answersgetControlLevel(). 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/redactandshop/redactdelete the whole events, and thecustomers/data_requestnotice 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 block | Checkout block | |
|---|---|---|
| Extension | Theme 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 |
| Shops | Every plan with an Online Store 2.0 theme; only on the cart template | Shopify Plus only |
| Reads | app.metafields.lumio.* (Liquid) | $app:lumio shop metafields (owner_type = "SHOP") |
| Writes | POST /cart/update.js | applyAttributeChange (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 when | form none/missing, a flag missing, buyer_choice without a valid list, the shop has no privacy policy, the cart is empty | form 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. Formcustom_name: an optional text field with an/25counter. A valid name writes__lumio_stream_nameand__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 version2, same scope) ticks the box, and forcustom_namealso 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'sshopify.attributes; without them the block starts unticked or empty. - Form
buyer_choice: an unticked checkbox (text version3), 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 thebuyer_choice_formsmetafield, 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 thecustom_nametext field and writescustom_name:3:<scope>with a valid name. Unticking drops the pick, writes"0", removes__lumio_stream_nameand, in the cart, thesessionStorageentry; 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,noneorbuyer_choicein it) renders nothing, the same rule as the server's parser. - Texts are fixed (no theme-editor setting) and come from the extensions'
locales/(enandde; every other storefront language gets English). The explanation names the form and the order details from the store'sshow_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 underbuyer_choice): keylumio_stream_nameholds the typed name, written on input, removed when the field is cleared or thebuyer_choicebox is unticked, so acustom_namefield survives a reload (underbuyer_choicea 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 emptyallowed_sourcesandallowed_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:
| Variable | Purpose |
|---|---|
LUMIO_API_URL | Lumio 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_ID | The app's client id, same value as the API's [shopify] client_id |
LUMIO__SHOPIFY__CLIENT_SECRET | Client secret for the launch hmac check (secret, ENV only) |
LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS | Previous 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}, orPOST /v1/shopify/embedded/disconnectfrom 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 oneapp/uninstalledtombstone for it (webhook_id = local:<uuid>) intoshopify_compliance_requestsin 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:
| Topic | What Lumio does | Done |
|---|---|---|
customers/redact | Deletes 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/redact | Deletes 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_request | Looks 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
| Event | Scope | Actor | Metadata |
|---|---|---|---|
account:integration_connected | account | The member who confirmed the link | platform, shop_host, scopes, source (shopify_admin), shopify_user_id |
account:integration_disconnected | account | The 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_updated | account | The 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_hidden | account | The member | platform, event_id, shop_host (null for an event without raw.shop) |
account:shopify_data_request | account | none | platform, shop_host, orders_requested, orders_found, orders_with_label |
account:shopify_customer_redacted | account | none | platform, shop_host, orders (count) |
account:shopify_shop_redacted | account | none | platform, 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:
- In the Dev Dashboard (app → Settings → Credentials) click Rotate next to the client secret and generate a new one. The old secret stays active.
- Set
LUMIO__SHOPIFY__CLIENT_SECRETto the new secret andLUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUSto the old one, on the API and onapps/shopify, and restart both. The token calls (exchange, refresh) now use the new secret; the verifiers accept both. - 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_tokenrefreshes 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 readsneeds_reconnectafterwards and heals the next time the app is opened in its Shopify admin. - Revoke the old secret in the Dev Dashboard. From then on Shopify signs with the new secret.
- Watch
shopify_client_secret_matches_total{secret="previous"}(bothverifier="id_token"andverifier="webhook"). Once it stops increasing, removeLUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUSon 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):
| Metric | Labels | Meaning |
|---|---|---|
shopify_client_secret_matches_total | verifier (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.