Skip to main content

Shopify

Lumio is a public app in the Shopify App Store. A merchant installs it on their store; the store's orders and new products then reach the account as shopify:order, shopify:order_paid and shopify:product events (see Webhooks → Shopify). 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).

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

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

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

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

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

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

Web routes and dashboard​

The settings window's Services area loads the status (shopifyConnection, only with connections:read and integration:shopify); the short link /dashboard/settings/channel/connections/shopify opens it at its Shopify section. 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.com host from shopifyLinkRequest (never from the URL), the account the link goes to (picker, preselected: the active account, or the first one the member may link) and the warning line above the button. The warning names only that host, never the store display name: a display name is owner-controlled and is not known until the token exchange during link/complete. The picker knows the active account's permissions; for the others the default roles decide (Owner and Administrator may, Moderator may not) and the API's answer decides for a custom role. Picking another account switches the active account before the confirm.
  • The browser calls POST /api/connections/shopify/link/inspect and POST /api/connections/shopify/link/confirm with { "handle": … } in the body. Both refuse with 403 unless Sec-Fetch-Site is same-origin, or it is absent and Origin equals the web public origin (LUMIO_APP_URL); a same-site sibling such as the embedded host is refused.
  • apps/web/src/proxy.ts sends an enforced Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY and Referrer-Policy: no-referrer on exactly this path, next to the global report-only policy.

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

Shopify Dev Dashboard settings​

SettingValue
DistributionPublic (App Store)
InstallShopify-managed; scopes, app URL, redirect URL and webhook subscriptions in apps/shopify/shopify.app.<env>.toml
Scopesread_orders,read_products,read_legal_policies (the backend checks the token's scope readback against [shopify] scopes)
Embedded in Shopify adminYes (App Bridge ID tokens + token exchange)
Protected customer dataLevel 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):

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

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

Legal notes (texts from Legal, verbatim): with a name form other than none, "Rechtliche Hinweise zur Namensanzeige" (responsibility, privacy policy with both legal bases, cookie overview for Eigener Name, 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'; and frame-ancestors https://{shop} https://admin.shopify.com. The shop comes only from Shopify's launch query after its hmac verifies with the client secret (or the previous one), and only if it is a {store}.myshopify.com host. Otherwise frame-ancestors 'none'. Never a wildcard.
  • Cache-Control: private, no-store, because the header differs per shop. Every route is dynamic.
  • No X-Frame-Options: it cannot allow a list of origins.
  • The Cookie request header is dropped before any handler runs, and the app sets no cookie.

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

API​

GraphQLRESTPermissionFeature
shopifyConnectionGET /v1/connections/shopifyconnections:readintegration:shopify
shopifyLinkRequest(handle: String!)POST /v1/connections/shopify/link/inspectconnections:create + interactive sessionintegration:shopify
shopifyLinkConfirm(handle: String!)POST /v1/connections/shopify/link/confirmconnections:create + interactive session, not cross-siteintegration:shopify
updateShopifySettings(customerDisplay: String, showItems: Boolean, showPrice: Boolean)PUT /v1/connections/shopify/settingsconnections:editintegration:shopify
disconnectShopifyDELETE /v1/connections/shopifyconnections:deleteintegration:shopify
hideShopifyOrderName(eventId: UUID!, createdAt: String!)POST /v1/connections/shopify/events/{event_id}/hide-nameevents:editnone (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 permissionintegration:shopify checked at link completion

The embedded routes are REST-only on purpose: their caller is a Shopify staff member, not a Lumio principal, and their own principal type (routes::shopify_embedded::ShopifyEmbeddedPrincipal) has no conversion to the Lumio auth context. They are mounted outside and before the /v1 scope (routes::configure_v1_scopes, which main.rs and the mount test both call, so a Lumio token on /v1/shopify/embedded/* always gets the embedded 401), accept no Lumio token, and act only on the 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:

MessageStatusWhen
Shopify is not configured: set [shopify].client_id and [shopify].client_secret (…)422No app credentials on this deployment
This Shopify store is already connected to another Lumio account.409Another account holds the store
This account is already connected to another Shopify store. Disconnect it first.409The account holds a different store
This Shopify store is already connected to a Lumio account.409Link start for a store that is linked
This Shopify link request has expired. Start the connection again in your Shopify admin.422Unknown, expired or cancelled handle, or another store's
This Shopify link request was cancelled because someone else had confirmed it. Start the connection again in your Shopify admin.409A confirmed handle confirmed for a second account, or read by another user than its confirmer; the handle is deleted
This Shopify link request was already confirmed for another Lumio account.409Confirm while the handle is being completed
The link has not been confirmed in Lumio yet. Confirm it in the Lumio tab first.409Completion before the confirm
This link request is being completed. Please wait a moment.409A second completion or a cancel while one runs
This link request was started by another Shopify staff member.403Completion by another staff member
This request must come from the Lumio app itself.403Confirm with Sec-Fetch-Site other than same-origin
invalid session401 + X-Shopify-Retry-Invalid-Session-Request: 1Any refused ID token, or Shopify refused it at the token exchange (embedded routes only)
Shopify did not grant the required access: …422A required scope is missing from the token's readback
Shopify could not be reached. Please try again.500Token exchange failed in transit
Shopify is not connected404Settings or disconnect without a connection
Nothing to update: set at least one of customer_display, show_items, show_price.400A settings update without any field
Shopify order event not found404Hide for an event that is not a Shopify order event of the account, or a created_at more than 1 s off
created_at must be an RFC 3339 timestamp400Hide with an unreadable created_at

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_host is a real column, written only by the link completion, with a CHECK on the {store}.myshopify.com shape and the partial unique index uq_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:

KeyContent
access_token_encrypted, refresh_token_encryptedAES-256-GCM ciphertext (crypto::encrypt_versioned)
access_token_expires_at, refresh_token_expires_atRFC 3339
scopesThe scope readback of the last exchange or refresh
scopes_requestedThe configured scopes at the last exchange (sorted); stops a re-exchange loop while the merchant has not approved new scopes
shop_name, connected_atFrom the link
customer_display, show_items, show_priceSee settings
customer_display_syncedWhat 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.

FieldValuesDefault
customer_displaynone, 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_itemsBoolean: order events carry the line items (title, quantity)true
show_priceBoolean: order events carry the total with its currency, plus each item's price when items are shownfalse

A missing or non-boolean show_items / show_price in a stored config reads as its default. 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_at is the event's timestamp from the feed and must match within 1 s. The customer_display key 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 true when a name was removed and false when there was none left (no audit event then). An event that is not a Shopify order event of the account is 404.
  • The name is hidden in Lumio: what was already shown on the overlay, sent to an automation or to a widget is not recalled.

A hide emits account:shopify_order_name_hidden (platform, event_id; 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 blockCheckout block
ExtensionTheme 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
ShopsEvery plan with an Online Store 2.0 theme; only on the cart templateShopify Plus only
Readsapp.metafields.lumio.* (Liquid)$app:lumio shop metafields (owner_type = "SHOP")
WritesPOST /cart/update.jsapplyAttributeChange (extension api_version = "2026-10")
Renders nothing whenform none/missing, a flag missing, the shop has no privacy policy, the cart is emptyform 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. Form custom_name: an optional text field with a n/25 counter. A valid name writes __lumio_stream_name and __lumio_name_consent = custom_name:2:<scope>; an empty, too long or disallowed name writes "0" and an empty name and shows why. The block checks the name like the server does (NFKC, letters/marks/digits, space, - ' . _, at most 25 characters); the server check stays authoritative.
  • Texts are fixed (no theme-editor setting) and come from the extensions' locales/ (en and de; every other storefront language gets English). The explanation names the form and the order details from the store's show_items / show_price. No app name appears in either block; each links the shop's privacy policy (cart: shop.privacy_policy.url, checkout: {storefront}/policies/privacy-policy).
  • sessionStorage (cart block, custom_name only): key lumio_stream_name holds 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 empty allowed_sources and allowed_navigation_hosts.

App configuration​

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

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

Embedded page image​

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

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

Disconnect and uninstall​

  • Disconnect (disconnectShopify / DELETE /v1/connections/shopify, or POST /v1/shopify/embedded/disconnect from 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 an app/uninstalled tombstone (webhook_id = local:<uuid>) into shopify_compliance_requests in one transaction. A disconnect from the Shopify admin also sends the account owner an in-app notice (shopify_disconnected). The app stays installed in the store until the merchant removes it in the Shopify admin. The webhook subscriptions are app-level, so Shopify keeps sending order webhooks for the store after a disconnect; they are dropped on arrival (no linked row: 200, nothing stored).
  • Uninstall in Shopify delivers app/uninstalled; the webhook deletes the row and writes the tombstone under Shopify's webhook id (see Webhooks → Shopify).

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

Compliance webhooks​

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

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

TopicWhat Lumio doesDone
customers/redactDeletes the listed orders' shopify:order / shopify:order_paid events, with or without a customer label: through the order number the rest of the event (amount, line items, time) is still the customer's data for the merchant. Other orders, products and other accounts stay. No cache keeps a deleted event: the live broadcast stores nothing, and the dead-letter copy is caught by the second pass.After the second pass
shop/redactDeletes the store's shopify events of every account that held it, up to the moment it stopped holding it (window_end of the tombstone). 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_requestLooks up the listed orders, writes the audit event (counts only) and sends the channel owner an in-app notice.At once

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

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

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​

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

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

Configuration​

[shopify] in the API config (LUMIO__SHOPIFY__*): client_id, client_secret and client_secret_previous (both ENV only), api_version (default 2026-10), scopes (default read_orders,read_products,read_legal_policies) 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:

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

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

Monitoring​

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

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

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