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). 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. 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. 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 store, one account 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 and one store, one account, 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. Settings and disconnect go through PUT /api/connections/shopify/settings (updateShopifySettings, any of customer_display, show_items, show_price) and DELETE /api/connections/shopify (disconnectShopify). "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.
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); pop-out sessions do not offer it.
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 1 for order webhooks; add the Name field (Level 2) only if merchants should be able to use a customer label |
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), the buyer name form (an s-choice-list with the five forms), 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.
Setup guide (only while the form is not none):
| Step | Data source | Action |
|---|---|---|
| 1. Cart block placed / missing | shopify.app.extensions(): block name-consent of name-consent-cart with status: "active" on the published theme | 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, sensitive products while Artikel anzeigen is on, record, withdrawal). 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/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(customerDisplay: String, showItems: Boolean, showPrice: Boolean) | PUT /v1/connections/shopify/settings | connections:edit | integration:shopify |
disconnectShopify | DELETE /v1/connections/shopify | 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) |
| (REST only) | /v1/shopify/embedded/* (status, link, link/{handle}, link/complete, settings, disconnect) | 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 Shopify connection of the account the store is linked to. A settings change or disconnect from the Shopify admin is also pinned to the token's store: the write needs the account's row to still hold that store, so if the account was relinked to another store in between, the answer is 404 (Shopify is not connected) and the other store's row stays untouched.
The status (ShopifyConnectionStatus / ShopifyStatusResponse) has configured (the app credentials are set), app_store_url (the listing to install from, from [shopify] app_store_url) and connection (null when no store is connected). A connection has shop_host, shop_name, status (connected or needs_reconnect), customer_display, 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) and the same connection.
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 account is already connected to another Shopify store. Disconnect it first. | 409 | The account holds a different store |
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 |
Shopify is not connected | 404 | Settings or disconnect without a connection |
Nothing to update: set at least one of customer_display, show_items, show_price. | 400 | A settings update without any field |
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 |
One shop, one account
A store can belong to one Lumio account at a time, and an account holds at most one store:
integration_configs.shop_hostis a real column, written only by the link completion, with aCHECKon the{store}.myshopify.comshape and the partial unique indexuq_integration_configs_shopify_shop_host(WHERE platform = 'shopify').- The completion inserts on
(account_id, platform)only. A unique violation on the shop index is the another account error; an existing row with a different shop is the disconnect first error. - The same checks run at confirm and again before the token exchange (
services::shopify::ensure_installable), so a refused link makes no Shopify call.
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, connected_at | From the link |
customer_display, show_items, show_price | See settings |
customer_display_synced | What the storefront metafields last confirmed: <form>:<scope>, e.g. first_initial: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 is the only refresh path. It locks the 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
updateShopifySettings / PUT /v1/connections/shopify/settings and, from the Shopify admin, PUT /v1/shopify/embedded/settings set three values. 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; see Integrations → Shopify customer label. Any other value is 400 with Config field 'customer_display' must be one of: … | 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. 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 (setting, from, to; 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 three values are written to the metafields the storefront blocks read, as two copies in one metafieldsSet (all six or none): customer_display (text) 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.
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).
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 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) 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
truewhen a name was removed andfalsewhen there was none 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; never the name).
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“) | Checkout UI extension extensions/name-consent-checkout, 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") |
| Renders nothing when | form none/missing, a flag missing, the shop has no privacy policy, the cart is empty | form none/missing, a flag missing, 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. - 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_nameonly): keylumio_stream_nameholds the typed name, written on input, removed when the field is cleared, so the field survives a reload. 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 (
disconnectShopify/DELETE /v1/connections/shopify, orPOST /v1/shopify/embedded/disconnectfrom the Shopify admin) first clears the storefront metafields (best effort, with a fresh token, so the blocks stop asking buyers), then deletes the row and writes anapp/uninstalledtombstone (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). Events of other platforms and of a store the account 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.
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 (created before its window_end): the daily sweep checks each tombstone against TimescaleDB and restarts its clock (completed_at) while such events exist. 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 / show_items / show_price), from, to, source (lumio / shopify_admin), shopify_user_id (Shopify admin only) |
account:shopify_order_name_hidden | account | The member | platform, event_id |
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) and app_store_url. 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.