This page documents the as-built request/response shapes and deployment contract for self-hosters wiring up their own Shopify app deployment. If you’re a merchant using the hosted KaireonAI Personalization app, see the Shopify integration guide instead.
Deployment topology
The Shopify surface runs on the same container image as the rest of KaireonAI, in one of two supported topologies:SHOPIFY_EDGE=1(recommended) — a dedicated second deployment whose only job is Shopify traffic.isBlockedInEdgeMode404s every path except/api/shopify/*and the two health probes (/api/health,/api/ready) — so a public, unauthenticated, internet-facing surface (webhooks, App Proxy calls, the embedded admin, the checkout web pixel) can never reach the rest of the platform’s routes through this deployment, even under a traffic flood or a bug in the Shopify surface itself. It queries the same Postgres database directly (reads/writesShopifyShop,ShopifyAppConfig, webhook dedupe records, GDPR erasure state, etc.) and forwards every recommend/respond call to the core deployment over HTTP, authenticated as the relevant tenant’s own scoped edge API key.SHOPIFY_SERVE_ON_CORE=1(single-service opt-in, the quickstart default) — the same core deployment additionally answers the credential-less Shopify routes itself, with no additional blast-radius isolation:/api/shopify/*traffic runs in the same process as every/api/v1/*route and the Studio UI, sharing the same resources and attack surface. This is a genuine tradeoff, not a free simplification — it removes the isolation the dedicated edge provides in exchange for running one fewer service. Appropriate for a low-traffic or trusted-network deployment where a second service isn’t worth the operational overhead; the dedicated edge remains the recommended default for anything internet-facing at scale.
isShopifyEdgeDeployment() returns SHOPIFY_EDGE === "1" || SHOPIFY_SERVE_ON_CORE === "1", and setting both is redundant, not additive. isBlockedInEdgeMode’s 404-everything-else behavior is gated on SHOPIFY_EDGE alone — SHOPIFY_SERVE_ON_CORE=1 deliberately does not narrow the deployment to Shopify-only, since its entire point is to keep serving every other core route too. Whichever flag is set is also what gates the embedded admin’s static bundle at /shopify-admin — see Embedded admin static serving below.
Shopify credentials are no longer environment variables. Each tenant enters its own app’s Client ID/Secret in Settings → Integrations → Shopify (see the integration guide), encrypted at rest in the ShopifyAppConfig table. Every Shopify request-verification path (webhook HMAC, App Proxy signature, session-token JWT) resolves the relevant tenant’s own secret from that table instead of a global env var — see Auth: lookup-then-verify below.
Required environment variables
SHOPIFY_API_KEY and SHOPIFY_API_SECRET have been removed — there is no longer a global Shopify credential env var anywhere in the codebase; every credential is per-tenant (see Auth: lookup-then-verify).
Required app scopes
Declared inshopify/shopify.app.toml’s [access_scopes] block (scopes = "read_products,read_orders,read_inventory,read_customers") — pushed to the Partner Dashboard on shopify app deploy (the deploy kit’s deploy.sh). No write scope is requested anywhere; the app never mutates a Shopify-owned resource.
Scope alone doesn’t unlock customer PII.
read_customers gets customers/create/customers/update webhooks flowing and customer GraphQL queries resolving, but Shopify additionally requires the app to have Protected Customer Data access approved (Partner Dashboard → the app → API access → Protected customer data) before email, first_name/last_name, tags, and address fields come through non-null — without approval, Shopify redacts those fields to null on both the REST webhook payloads (mapCustomerWebhookPayload in webhook-handlers.ts) and every GraphQL customer query (customer-sync.ts’s backfill and per-order spend-sync queries alike), while amountSpent/numberOfOrders and every other non-PII aggregate still resolve normally. This is a Shopify-platform-level redaction, not something this app’s code branches on — there is no code path here that behaves differently based on Protected Customer Data status; it simply receives whatever Shopify sends. See the integration guide’s Protected Customer Data note for the merchant-facing setup step.Embedded admin static serving
Theshopify/admin Vite SPA is built with VITE_BASE_PATH=/shopify-admin/ by tools/scripts/build-shopify-admin.sh and staged into platform/public/shopify-admin/ (wired into build-and-deploy.sh, before the Docker build) — the platform serves it itself under /shopify-admin/* rather than requiring a separate static host (S3+CloudFront, Vercel, etc., which remains supported for those who prefer it: build shopify/admin on its own, with VITE_BASE_PATH left at its default /).
- Gate: exactly the same as every other Shopify route —
isShopifyEdgeDeployment()(SHOPIFY_EDGE=1orSHOPIFY_SERVE_ON_CORE=1). Neither flag set → every/shopify-admin/*path 404s (isShopifyAdminAssetBlockedinmiddleware.ts), matching the playground default. - SPA fallback:
next.config.jsrewrites any extensionless path under the prefix (e.g./shopify-admin/offers, or the bare/shopify-admin) to/shopify-admin/index.html, so client-side routes survive a hard refresh. Genuine static assets (/shopify-admin/assets/*.js/.css) are excluded from the rewrite and served directly. - Framing: the document response’s
X-Frame-Optionsis dropped and the CSP’sframe-ancestorsdirective is scoped to the requesting shop’s own domain (read from the response’s own?shop=query param, validated against*.myshopify.com) plushttps://admin.shopify.com— Shopify iframes the app insideadmin.shopify.com, and per-shop scoping is Shopify’s documented requirement, not a blanket wildcard. - App Bridge script: the document response’s CSP
script-srcalso carrieshttps://cdn.shopify.com, the only origin App Bridge v4 loads from (shopify/admin/src/lib/bootstrap.ts’s<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js">) — confirmed against Shopify’s ownshopify_appgem CSP helper docs, which document adding exactly that URL toscript-srcfor App Bridge and don’t document anyconnect-srcwidening; this deployment’s own API calls from the SPA are same-origin, soconnect-srcstays'self'. This admin-scoped CSP is rebuilt from scratch for the document response and always wins, even when an operator has setCSP_POLICYfor the rest of the app (lib/shopify/admin-csp.ts’sapplyShopifyAdminFrameHeaders) — App Bridge’sscript-srcneed and the per-shopframe-ancestorsare both non-negotiable for this one surface;CSP_POLICYstill governs every non-/shopify-adminresponse unchanged. Static asset responses (JS/CSS bundles) aren’t documents, so onlyframe-ancestorsis appended to whatever CSP is already present there. - Caching: the document response (any extensionless path — the one that carries the per-shop
frame-ancestorsCSP above) is served withCache-Control: no-store. This is deliberate: that response is NOT safely cacheable behind a shared/CDN cache without aVaryon the shop, since a cached response for one shop could otherwise be served to a different shop with the wrongframe-ancestors, breaking that shop’s iframe embed. Content-hashed static assets under the same prefix aren’t touched by this and remain cacheable.
GET /api/v1/settings/integrations/shopify/deploy-kit
Streams a downloadable zip containing a ready-to-deploy Shopify CLI project for the calling tenant — this is step 3 of the connect checklist (see the integration guide).
Auth: requireRole(req, "admin") + requireTenant(req) — same admin-only pattern as the sibling Settings API below. Every call is audit-logged (action: "export", entityType: "ShopifyAppConfig") — the audit row carries the tenant’s clientId (public, not a secret) and the resolved public base URL, never a secret.
Response 200: Content-Type: application/zip, Content-Disposition: attachment; filename="kaireonai-shopify-kit.zip". The zip contains:
shopify.app.toml— the canonical file (shopify/shopify.app.toml) with its maintainer-only header comment stripped,client_idsubstituted with this tenant’s real Client ID, and all 5edge-pending.kaireonai.comsentinel occurrences replaced with this deployment’s resolved public base URL (a straight substring substitution, so whatever path suffix already follows the sentinel in the canonical file survives unchanged —application_urlkeeps its/shopify-adminsuffix, the other 4 keep their own/api/shopify/*suffixes).extensions/— every storefront/checkout extension source, unmodified, bundled at image-build time bytools/scripts/bundle-shopify-kit.sh.package.json/package-lock.json/.gitignore— the npm workspace root the extensions build under.deploy.sh—npm install && npx shopify app deploy, plus a printed reminder about the one-time network-access approval (see the integration guide). Ships with the executable bit set;README.md(also injected) documentsbash deploy.shas a fallback for unzip clients that don’t preserve it, and notes the zip has no wrapping top-level folder — unzip into an empty directory.
404 — no ShopifyAppConfig saved for the tenant yet ("Connect a Shopify app ... before downloading the deploy kit.") — connect credentials first (step 2 of the checklist).
Response 403 — non-admin caller.
Response 503 — KIT_NOT_BUNDLED: this deployment’s image was built without running tools/scripts/bundle-shopify-kit.sh (wired into build-and-deploy.sh for production builds; a bare next dev checkout that’s never run it hits this too). Run the script and retry.
Public base URL resolution (
resolvePublicBaseUrl in deploy-kit.ts): KAIREON_PUBLIC_URL if set, else NEXTAUTH_URL (the platform’s existing conventional public-origin var — see lib/email.ts’s outbound-email BASE_URL, which falls back to it the same way), else the download request’s own origin. The request-origin fallback is correct by construction under SHOPIFY_SERVE_ON_CORE=1 (you download from the same host that serves Shopify traffic), but is WRONG under the dedicated SHOPIFY_EDGE=1 topology — this route is a normal /api/v1/* route (session/API-key auth, not part of isShopifyPublicPath’s edge-served surface), so under SHOPIFY_EDGE=1 it is actually 404-blocked on the edge deployment itself by isBlockedInEdgeMode (which allows only /api/shopify/*, the health probes, and the admin static bundle) — you can only reach it on the CORE deployment, yet the kit’s URLs need to point at the separate EDGE deployment’s hostname. Production self-hosters on the dedicated-edge topology must set KAIREON_PUBLIC_URL on the CORE deployment to the edge service’s public hostname before downloading the kit, or it silently bakes in the wrong (core) hostname.Auth: lookup-then-verify
Every Shopify entry point that looks unauthenticated actually verifies a signature — none of them trust a caller-supplied identity outright. Because credentials are per-tenant rather than one shared secret, each route follows the same two-step pattern:- Lookup — extract an UNVERIFIED shop identifier from the request (the App Proxy’s
shopquery param, the webhook’sX-Shopify-Shop-Domainheader, or a JWT’sdest/audclaim decoded without checking its signature) and use it purely as a key intoShopifyAppConfig(resolveCredentialsForShop/resolveCredentialsByClientId) to find which tenant’s secret to try. - Verify — check the request’s actual signature (HMAC, App Proxy signature, or JWT signature) against THAT tenant’s own resolved secret. A forged/spoofed identifier at worst picks the wrong tenant’s secret to verify against, and verification then fails exactly as it would for any other invalid signature — the unverified lookup step grants no authority by itself.
A consequence of tying verification to a per-tenant secret: if the looked-up shop/tenant has no
ShopifyAppConfig at all (never configured, or disconnected), there is no secret to verify against, so verification fails outright — it can’t “succeed against nothing.” For the session-token routes (embedded admin, Thank You page) this means a request naming a never-provisioned shop 401s at the auth layer itself, rather than passing auth and being told “unknown shop” further downstream — see the embedded-admin and Thank You page sections below for exactly which cases this covers.
Rate limits
Every storefront-facing route (proxy recommend/outcome, ingest, thank-you offer/outcome) degrades to a 200 with an empty/no-op body when its rate limit is exceeded — never a 429 — and sets
x-kaireon-limited: 1 so it’s observable in logs/metrics without ever surfacing an error to the shopper. GET /api/shopify/app-config is the one exception: it returns a real 429, since it isn’t part of the storefront’s own request chain — a failed embedded-admin bootstrap just shows the admin bundle’s own fallback message, not a broken checkout.
Never-5xx storefront contract
Every route a live storefront or checkout calls (proxy/recommend, proxy/outcome, ingest, ingest/thankyou-offer, ingest/thankyou-outcome) catches every internal error — including the core deployment being unreachable or returning a 5xx — and degrades to a 200 response with an empty result ({cards: []}, {ok: false}, etc.). The exceptions: malformed JSON on POST /api/shopify/ingest 400s (there’s no meaningful degraded response to a request that didn’t parse at all), and a missing/invalid session token on GET /api/shopify/ingest/thankyou-offer or POST /api/shopify/ingest/thankyou-outcome is a genuine 401 on both (same lookup-then-verify reasoning — see each route’s own section for exactly which shop states this covers vs. which still degrade to 200). This guarantees a KaireonAI outage — or an unconfigured/never-provisioned shop — never breaks checkout or storefront rendering with anything worse than an empty card.
Install
POST /api/shopify/install
Provisions the merchant’s tenant. Called by the embedded admin on first load (and automatically retried by the admin UI’s install-gate whenever any admin call 401s, to self-heal a fresh install).
Auth: public, lookup-then-verify — the body’s sessionToken carries a JWT aud claim, decoded WITHOUT checking its signature and used purely as a lookup key (resolveCredentialsByClientId) to find which tenant’s Shopify app this install claims to belong to. Authenticity comes from exchangeSessionToken posting the raw token to Shopify’s own token-exchange endpoint using that tenant’s resolved credentials — Shopify rejects a forged, expired, or wrong-shop token outright, so a successful exchange IS the proof of authenticity. A forged aud at worst picks the wrong tenant’s credentials to exchange with, and that exchange then fails the same way an invalid token always would.
Request body:
200:
401 — {"error": {"code": "app_not_configured", ...}} when the token’s aud doesn’t match any tenant’s ShopifyAppConfig.clientId (no KaireonAI tenant has this Shopify app configured yet); {"error": {"code": "UNAUTHORIZED", "message": "Invalid session token", ...}} when Shopify’s own token-exchange rejects the token.
Response 409 — {"error": {"code": "shop_owned_by_other_tenant", ...}} when shopDomain is already installed under a different tenant’s ShopifyAppConfig than the one the token’s aud resolved to. Reconnecting the same store to a different KaireonAI tenant requires disconnecting/uninstalling it from the original tenant first.
Idempotent: re-installing an already-installed shop under the SAME tenant is a no-op that returns the existing tenant. Re-installing a previously-uninstalled shop (or one with no live edge key) mints a fresh edge API key.
Webhooks
POST /api/shopify/webhooks
Single receiver for every subscribed topic; dispatches on the X-Shopify-Topic header.
Auth: public, lookup-then-verify (see Auth: lookup-then-verify above) — self-authorizes via X-Shopify-Hmac-Sha256, verified against the RESOLVED shop’s own tenant’s ShopifyAppConfig secret (computed over the exact raw request bytes). The X-Shopify-Shop-Domain header is only the lookup key, and the two ways it can fail to resolve a secret are handled differently: an unknown shop domain (no ShopifyShop row at all) gets logged + 200 (nothing to dedupe or dispatch against, and this avoids handing an unauthenticated caller a shop-existence oracle). A known shop whose tenant has since deleted/disconnected its ShopifyAppConfig gets logged + 401 instead — Shopify already knows this shop installed the app (it’s the one delivering the webhook), so a 401 leaks nothing, and it keeps the delivery retry-eligible so a GDPR-mandated compliance webhook (customers/redact, shop/redact, customers/data_request) arriving during a temporary disconnect isn’t permanently lost the way a 200 would lose it.
Subscribed topics:
Every delivery is deduped on
X-Shopify-Webhook-Id before dispatch (a repeat delivery short-circuits to 200 with no second side effect). All topics except app/uninstalled and the three GDPR compliance topics are skipped (still 200, still deduped) once a shop’s status is no longer installed. A handler that throws is logged and still returns 200 — Shopify’s own redelivery-on-non-2xx would otherwise retry-storm an already-recorded delivery; the reconciliation cron closes any resulting gap instead.
Attribution
A storefront surface stamps a_kaireon_rid cart-line property (the recommended item’s KaireonAI Creative id) on any line item added from a recommended offer. orders/create reads line_items[].properties for that key; revenue = unit price × quantity, minus line-level discount allocations, floored at 0. refunds/create reads the embedded original line item’s properties the same way, using the refund’s subtotal (already net of that line’s proportional discount) as the (negative) revenue. A guest order with no customer.id is skipped (there’s no KaireonAI customer identity to attribute to); a refund with no matching original purchase interaction is logged (shopify.refund.unattributed) for the reconciliation sweep.
Customer sync
customers/create/customers/update and the customer-facing side of orders/create write into a per-tenant ds_shopify_customers table (customer-sync.ts), provisioned as an ordinary DataSchema/entityType: "customer" at install time (customer-schema.ts’s ensureShopifyCustomerSchema) so the decisioning Enrich stage can read it exactly like any user-created schema table — no engine changes needed to consume it.
Table: ds_{tenantIdShort}_shopify_customers, primary key customer_id (Shopify’s own numeric customer id, as a string — shared across every store under the tenant; there is no per-shop compound key, so a colliding customer_id across two of a tenant’s stores resolves to one row, last-write-wins).
Write paths:
upsertShopifyCustomer(customers/create/customers/update, and the install-time backfill below) —INSERT ... ON CONFLICT (customer_id) DO UPDATEthat refreshes identity + aggregates + tiers but deliberately never touches the three affinity columns, so a profile refresh can’t clobber affinity accumulated by order webhooks.applyOrderAffinity(orders/create) — refreshes recency + tiers + affinity arrays only; NEVER touchestotal_spent/orders_count/avg_order_valueon the row-exists path, since those are owned exclusively by the authoritativecustomers/*upsert (Shopify’s ownamountSpent/numberOfOrdersalready include every order) — incrementing them here would double-count. If no row exists yet (the order arrived before anycustomers/createwebhook or backfill), it bootstraps a stub row from that one order’s total instead of dropping the signal; the nextcustomers/*upsert overwrites it with the real absolute value.syncCustomerFromOrder(orders/create, immediately afterapplyOrderAffinity) — fetches that SAME customer’s own authoritativeamountSpent/numberOfOrdersvia a single-customer Admin GraphQL query and writes them throughupsertShopifyCustomer’s absolute-overwrite path. Exists because Shopify does not reliably follow an order with acustomers/updatewebhook carrying the recomputed lifetime spend (most notably orders placed from the Shopify admin) — without this fetch,total_spent/orders_count(and their derivedspend_tier/frequency_bucket) could go stale even though the order landed and Shopify’s own customer page shows the real total. A fresh read, not an increment, so a webhook retry or duplicate delivery converges to the same authoritative number rather than compounding. Isolated the same way asapplyOrderAffinity: a GraphQL failure here is logged and swallowed, never undoing attribution or affinity that already succeeded, and never fails the webhook. Only runs for an order with a resolvable customer id (a guest order has nothing to sync). Subject to the same Protected Customer Data gating as every other customer GraphQL call — a shop without approval getsemail/firstName/lastName/defaultAddressnulled on this query, butamountSpent/numberOfOrdersare never PII-gated and still come through (see the integration guide’s Protected Customer Data note).- Install-time backfill (
backfillCustomers) — fires in the background (not awaited byPOST /api/shopify/install, so it never delays the install response) on every fresh install AND every reprovision/reinstall: paginated GraphQL sync of the full customer catalog (authoritative aggregates), then a second pass replaying the last 90 days of orders for recency/affinity. Capped atMAX_BACKFILL_PAGES(200) pages per pass (50 records/page) — a truncation is logged, never silent, and the ongoing webhooks continue filling in the gap over time.
shopify-nba-flow’s Enrich node reads spend_tier/recency_bucket/frequency_bucket/tags/top_categories under the customer. prefix; the default shopify-nba-model (Naive Bayes) declares these plus offer.categoryId as its predictors — see How offers are ranked. A customer with no synced row (guest, or a logged-in customer not yet synced) simply resolves no enrichment data; the model scores on its priors alone, and the request never fails.
GDPR coverage: customers/redact already reaches ds_shopify_customers for free — eraseSubjectData’s dynamic-schema sweep deletes WHERE customer_id = $1 on every entityType: "customer" schema, and this table is exactly that. shop/redact additionally does a direct DELETE ... WHERE shopify_shop_domain = $1 sweep (eraseShopifyCustomerProfilesForShop) — needed because a backfilled customer who never triggered a decisioning interaction has no InteractionHistory row for the per-customer erasure sweep to enumerate from.
App Proxy (storefront)
Shopify signs these requests’ query parameters (not a header) and forwards them from/apps/kaireon/* on the storefront domain to this app’s /api/shopify/proxy/* routes (configured via shopify.app.toml’s [app_proxy] block).
GET /api/shopify/proxy/recommend
Auth: public, lookup-then-verify — self-authorizes via verifyProxySignature, checked against the RESOLVED shop’s own tenant secret (the shop query param is only the lookup key; see Auth: lookup-then-verify above).
Query params:
Response
200:
PublicOfferCard is the ONLY shape ever exposed to the storefront — every internal field (scores, raw offer metadata, personalization, trace data) is stripped:
POST /api/shopify/proxy/outcome
Auth: public, lookup-then-verify — same as recommend above: verifyProxySignature checked against the RESOLVED shop’s own tenant secret.
Query params:
Body:
logged_in_customer_id (query param, same as recommend) is used instead of anon when present. Response 200: { "ok": true } (or { "ok": false } on failure/rate-limit — this route never fails the storefront request). Each call mints its own idempotency key server-side — a genuine duplicate click can double-count; accepted, since engagement telemetry isn’t a financial ledger the way order/refund attribution is.
Ingest (checkout web pixel + Thank You page)
POST /api/shopify/ingest
CORS-open (no credentials, Access-Control-Allow-Origin: *) — called from the sandboxed web-pixel sandbox, which has no way to sign a request.
Body:
checkout_started is accepted but never forwarded (received-only telemetry). checkout_completed forwards to the respond API ONLY when a matching in-flight recommendation exists (an impression or click for that anon identity within the last hour) — always with conversionValue: 0 (this is corroboration, not the revenue signal; the orders/create webhook is authoritative for revenue). Response: {ok: true, forwarded: <n>} on success; {ok: false} (still 200) for a schema-invalid body (wrong shape, unknown event name, >20 events); {ok: true, forwarded: 0} (still 200) when rate-limited or the shop is unknown. A body that isn’t parseable JSON at all is the exception and returns 400 (a missing/non-JSON Content-Type header likewise gets a 415) — see the Never-5xx storefront contract above.
GET /api/shopify/ingest/thankyou-offer
Auth: session-token only, lookup-then-verify — the checkout UI extension’s own useSessionToken() JWT (same verification scheme as the embedded-admin routes, verifySessionToken). Two distinct “nothing to show” states resolve to two different statuses:
401— the token is missing/malformed, its signature doesn’t verify, OR itsdestnames a shop with noShopifyShoprow or whose tenant has noShopifyAppConfigat all. Under per-tenant secrets there’s no way to verify a token for a shop that was never provisioned, so this case 401s rather than silently degrading — closing what used to be a way to distinguish “never provisioned” from “provisioned but not installed” using nothing but a validly-signed token.200 { "cards": [] }— the token verifies fine, but the resolved shop’sstatusisn’tinstalled(or the request has no resolvable customer identity, e.g. a guest checkout). This is the storefront-safe degrade path: a real store mid-uninstall never breaks the Thank You page with an error.
customerId (numeric Shopify customer id, from a logged-in shopper) or anon. Response 200: { "cards": [PublicOfferCard] } — at most one card (limit: 1), for the shopify_thank_you placement. Renders nothing for a guest checkout (no identity to resolve) — this is expected, not an error.
POST /api/shopify/ingest/thankyou-outcome
Records an impression/click engagement outcome for the card the kaireon-thankyou checkout extension rendered — the write-side counterpart to GET /api/shopify/ingest/thankyou-offer above, feeding the same learning loop the App Proxy’s proxy/outcome route feeds for the other three surfaces.
Auth: session-token only, lookup-then-verify — the identical verifySessionToken scheme as thankyou-offer. CORS-enabled (Access-Control-Allow-Origin: *) for the same reason POST /api/shopify/ingest is: the checkout extension’s sandboxed fetch() cannot reach POST /api/shopify/proxy/outcome — it has no storefront origin to sign an App Proxy request with, and that route has no CORS support for a cross-origin call anyway.
Query params: customerId (numeric Shopify customer id) or anon — same identity resolution as thankyou-offer’s GET.
Body:
{rid, outcome} keys proxy/outcome’s OutcomeBodySchema defines, minus add_to_cart/dismiss (theme-block-only concepts that don’t apply here) — rid maps to coreRespond’s creativeId, same as every other outcome-recording route. Identity (customerId/anon) is a query param here, not a body field, matching thankyou-offer’s GET.
Response 200: { "ok": true } on success; { "ok": false } (still 200) on any other failure — schema-invalid body, no resolvable identity, rate-limited, unknown/uninstalled shop, or the core deployment unreachable. Response 401 for a missing/invalid/unverifiable session token, mirroring thankyou-offer’s own 401 case (see Never-5xx storefront contract below). Response 404 when this deployment isn’t Shopify-edge-serving (isShopifyEdgeDeployment() is false).
The kaireon-thankyou extension calls this once per rendered card (outcome: "impression", guarded so a re-render of the same card never double-fires) and once per CTA press (outcome: "click") — both fire-and-forget, never awaited by the render or navigation path, so a slow or failed call can never delay or break the Thank You page.
Admin bundle bootstrap
GET /api/shopify/app-config
Public — the ONE exception in the embedded-admin family below. The admin SPA bundle is a single build shared by every tenant (it bakes in no tenant-specific value); before it can even load App Bridge, it needs to know which tenant’s Client ID to boot with. It reads ?shop= off its own location.search (Shopify always supplies shop when embedding an app) and calls this endpoint to resolve it — there is no session token yet at this point, since App Bridge itself hasn’t loaded.
Query params: shop (required, *.myshopify.com). Response 200: { "clientId": "..." } — the tenant’s PUBLIC Client ID only, resolved via the same resolveCredentialsForShop lookup every other Shopify surface uses; the secret is never exposed here. Response 400 for a missing/malformed shop param. Response 404 for an unknown shop, a shop whose tenant has no ShopifyAppConfig, or any unexpected internal error (this route never 5xxs — a transient failure looks identical to “unknown shop” to an unauthenticated caller). Response 429 when rate-limited (see Rate limits above).
Any ShopifyShop status resolves here (including uninstalled) — this endpoint deliberately runs BEFORE the embedded admin’s own install-gate self-heal flow, so gating it on status === "installed" would strand a reinstalling shop before it ever gets the chance to call POST /api/shopify/install.
Embedded admin
Every route below is authenticated the same way:Authorization: Bearer <App Bridge session token>, lookup-then-verify (see Auth: lookup-then-verify above) — the token’s dest claim is decoded WITHOUT checking its signature purely to resolve which tenant’s ShopifyAppConfig secret to try, and only then is the signature verified against that tenant’s own secret with audience pinned to that tenant’s own clientId. A dest that doesn’t resolve to any ShopifyShop row, or whose tenant has no ShopifyAppConfig at all, has no secret to verify against, so the request 401s at this layer. Each route then separately re-checks the resolved shop’s status === "installed" and 401s if not. Both failure reasons collapse to the same 401 response — the embedded admin’s install-gate treats any 401 here as “needs POST /api/shopify/install,” so this collapse is deliberate and actively helps a fresh or never-provisioned install self-heal, rather than something a caller needs to disambiguate.
GET / PUT /api/shopify/admin/offers
GET lists Shopify-imported offers with their curation fields (paginated, cursor-based):
PUT bulk-updates curation fields (activate, surfaces, capPerWeek) for up to 200 offers per call:
{ "processed": 1, "succeeded": 1, "failed": 0, "errors": [] }.
Both curation knobs drive real behavior, not just stored metadata:
surfacesdecides which of the four storefront channels have an active creative for that offer. Every imported offer already has one creative per surface; toggling a surface flips its creative betweenactiveanddraft(creatives are never deleted, so re-enabling is instant). An offer with no active creative on a surface can’t be recommended there — the recommend pipeline drops any candidate that has no creative for the requested channel.capPerWeekmaps to a per-offerfrequency_capcontact policy (scope="offer",config.maxPerWeek) that the decisioning engine enforces per candidate. Clearing it to0removes the policy (no cap); it is never written asmaxPerWeek: 0, which the engine would read as “block entirely.”
discountPct field — the app holds no write_discounts scope, so it cannot apply a discount at checkout, and a display-only percentage that never reached the cart would mislead shoppers. (An unknown discountPct key in a PUT body is silently ignored, not rejected.)
Multi-shop:
GET scopes strictly to the CALLER’s own shop — the query filters on metadata.shopifyShopDomain exact-matching the shop resolved server-side from the verified session token (never a client-supplied value), so a two-store tenant’s Offers tab never shows a sibling store’s catalog. PUT enforces the same scope on lookup: targeting an offerId that belongs to a different shop under the same tenant returns a per-item "Offer not found" error rather than applying — indistinguishable from a genuinely unknown id, so this route never reveals that a sibling shop’s offer id exists.GET /api/shopify/admin/surfaces
Returns the status of the four provisioned channels, grouped by store (each store under a tenant provisions its own four channels):
status: "not_configured" / enabled: false for that surface rather than a 500. Read-only — activating/deactivating a channel is done in the main KaireonAI Studio, not here.
GET /api/shopify/admin/performance
Query param: days (1–365, default 7). Reuses the same aggregation as /api/v1/dashboard-data’s offer performance, reshaped:
Multi-shop: scoped the same way as
admin/offers — the offer-id allowlist passed into the shared aggregation is built from ONLY the caller’s own shop’s offers (metadata.shopifyShopDomain exact match, resolved from the session token), so a two-store tenant’s Performance tab never shows a sibling store’s numbers, and a sibling shop’s higher-priority offers can’t starve the caller’s own offers out of the top-20 window.GET / PUT /api/shopify/admin/settings
PUT body: { "autoDraftNew": boolean }. See Curation and defaults — as of this writing, this setting is persisted but not yet consumed by the import pipeline (every import currently creates new offers as drafts regardless of its value).
POST / GET /api/shopify/admin/import
POST body: { "shopDomain": "my-store.myshopify.com" } (must match the session token’s own shop). Runs the catalog import synchronously to completion within the request and returns:
GET ?shopDomain=... is currently a stub ({ "shopDomain": "...", "status": "idle" }) — there is no persisted async job model yet, since POST already runs to completion.
Settings API (per-tenant credentials + store list)
Unlike every route above, this one lives under/api/v1/* — it’s a normal, session/API-key-authenticated KaireonAI platform route (not a public Shopify surface), used by Settings → Integrations → Shopify to manage a tenant’s own Shopify app credentials.
Auth: requireRole(req, "admin") + requireTenant(req) — admin-only, same pattern as /api/v1/platform-settings. Every write is recorded via logAudit (entityType: "ShopifyAppConfig") — the audit row always carries clientId, never the secret.
GET /api/v1/settings/integrations/shopify
Response 200:
clientId/secretSet/lastVerifiedAt are omitted entirely when configured is false (no config saved yet). offersImported counts offers stamped metadata.shopifyShopDomain for that store; if the count takes longer than 200ms (a very large catalog) it returns -1 so the UI can render ”—” instead of blocking the page load on a slow aggregate.
PUT /api/v1/settings/integrations/shopify
Body: { "clientId": "<32-char lowercase hex>", "clientSecret"?: "<20-200 chars>" } — clientSecret is required on first save, optional on every later call (omit it to rename clientId only; the stored secret is left untouched — this is how “Rotate” and a plain rename differ in the UI).
Response 200: same shape as the credential fields above (configured: true, clientId, secretSet, lastVerifiedAt). Response 400 ("clientSecret is required when connecting Shopify for the first time") when clientSecret is omitted on a tenant’s first-ever save. Response 409 ({"error": {"code": "CONFLICT", "message": "Shopify clientId \"...\" is already in use by another tenant", ...}}) when clientId collides with a DIFFERENT tenant’s config — clientId carries a global-uniqueness constraint since it’s how POST /api/shopify/install resolves a tenant from a bare JWT aud.
DELETE /api/v1/settings/integrations/shopify
Body: { "confirm": "disconnect" } — the literal string is required; any other value (or a missing field) is a 400, so disconnecting (which suspends every store under the tenant) can’t happen from an empty/near-empty body by accident.
Response 200: { "ok": true }. Deletes the ShopifyAppConfig row and sets every one of the tenant’s ShopifyShop rows to status: "suspended". Every store stops resolving credentials immediately — the ShopifyShop row still exists (so a future reconnect recognizes the store), but resolveCredentialsForShop finds no ShopifyAppConfig to decrypt a secret from and returns null, so webhooks/App Proxy/session-token verification all fail lookup-then-verify exactly as they would for a fully unconfigured tenant. The reconciliation cron additionally filters to status === "installed" up front, so a suspended store is skipped there before credential resolution is even attempted. Idempotent: deleting when nothing is configured is a no-op 200, not an error.
POST /api/v1/settings/integrations/shopify/resync-customers
Operator-triggered re-run of the install-time customer backfill (backfillCustomers) — the backfill otherwise runs exactly once, fire-and-forget, at install/reprovision, and nothing else re-populates ds_shopify_customers if its rows are lost (the reconciliation sweep covers orders/refunds/products only). Backs the Resync customers button in Settings → Integrations → Shopify.
Auth: requireRole(req, "admin") + requireTenant(req), same as the sibling routes above. Every per-shop run (success or failure) is audit-logged (action: "resync", entityType: "ShopifyShop").
Body: { "shopDomain"?: "my-store.myshopify.com" } — strict schema (unknown fields rejected). With shopDomain, resyncs that one store after a tenant-scoped lookup (another tenant’s domain is a 404, lookup-then-verify); a store that isn’t status: "installed" is a 400. With no body fields, resyncs every installed store for the tenant.
Response 200: { "ok": true, "shops": [{ "shopDomain": "...", "synced": 123 }, { "shopDomain": "...", "error": "..." }] } — the call is awaited (unlike the install-time fire-and-forget) and per-shop failures are folded into that shop’s entry rather than failing the request, so one store’s expired credentials never abort the rest. Internally bounded by the same MAX_BACKFILL_PAGES cap as the install-time run.
Reconciliation
GET /api/v1/cron/shopify-reconcile
Runs every 360 minutes (6 hours) for every currently-installed shop. Re-derives purchase/refund outcomes a missed or failed webhook never recorded, and upserts draft offers for any product created/updated directly in Shopify since the shop’s last reconcile (there is no products/create webhook). Cursor: ShopifyShop.lastReconciledAt.
Auth: CRON_SECRET, via Authorization: Bearer <secret> or x-cron-secret header. Fails closed if CRON_SECRET is unset.
Response 200: { ok: true, shops: <n>, purchasesSent, refundsSent, productsCreated, productsUpdated, errors, summaries: [...] }.
Storefront extensions reference
The theme blocks call the App Proxy (
/apps/kaireon/recommend, /apps/kaireon/outcome) using a first-party _kaireon_anon cookie for anonymous visitors. The web pixel reads that same cookie (via the Web Pixels API’s browser.cookie, which operates on the top frame) so its checkout corroboration event correlates with the same anonymous identity, rather than minting a second, uncorrelated one.