Skip to main content
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. isBlockedInEdgeMode 404s 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/writes ShopifyShop, 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.
Either flag alone is sufficient — 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 in shopify/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.
read_customers was added after the app’s initial release. An already-installed shop’s granted scopes do not retroactively widen when shopify.app.toml changes — the merchant must reinstall the app (Shopify’s standard re-consent flow for a scope-set change) before customers/create/customers/update deliver or any customer GraphQL call succeeds. See the integration guide’s setup warning for the merchant-facing version of this.
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

The shopify/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=1 or SHOPIFY_SERVE_ON_CORE=1). Neither flag set → every /shopify-admin/* path 404s (isShopifyAdminAssetBlocked in middleware.ts), matching the playground default.
  • SPA fallback: next.config.js rewrites 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-Options is dropped and the CSP’s frame-ancestors directive is scoped to the requesting shop’s own domain (read from the response’s own ?shop= query param, validated against *.myshopify.com) plus https://admin.shopify.com — Shopify iframes the app inside admin.shopify.com, and per-shop scoping is Shopify’s documented requirement, not a blanket wildcard.
  • App Bridge script: the document response’s CSP script-src also carries https://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 own shopify_app gem CSP helper docs, which document adding exactly that URL to script-src for App Bridge and don’t document any connect-src widening; this deployment’s own API calls from the SPA are same-origin, so connect-src stays 'self'. This admin-scoped CSP is rebuilt from scratch for the document response and always wins, even when an operator has set CSP_POLICY for the rest of the app (lib/shopify/admin-csp.ts’s applyShopifyAdminFrameHeaders) — App Bridge’s script-src need and the per-shop frame-ancestors are both non-negotiable for this one surface; CSP_POLICY still governs every non-/shopify-admin response unchanged. Static asset responses (JS/CSS bundles) aren’t documents, so only frame-ancestors is appended to whatever CSP is already present there.
  • Caching: the document response (any extensionless path — the one that carries the per-shop frame-ancestors CSP above) is served with Cache-Control: no-store. This is deliberate: that response is NOT safely cacheable behind a shared/CDN cache without a Vary on the shop, since a cached response for one shop could otherwise be served to a different shop with the wrong frame-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_id substituted with this tenant’s real Client ID, and all 5 edge-pending.kaireonai.com sentinel 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_url keeps its /shopify-admin suffix, the other 4 keep their own /api/shopify/* suffixes).
  • extensions/ — every storefront/checkout extension source, unmodified, bundled at image-build time by tools/scripts/bundle-shopify-kit.sh.
  • package.json / package-lock.json / .gitignore — the npm workspace root the extensions build under.
  • deploy.shnpm 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) documents bash deploy.sh as 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.
Response 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 503KIT_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:
  1. Lookup — extract an UNVERIFIED shop identifier from the request (the App Proxy’s shop query param, the webhook’s X-Shopify-Shop-Domain header, or a JWT’s dest/aud claim decoded without checking its signature) and use it purely as a key into ShopifyAppConfig (resolveCredentialsForShop / resolveCredentialsByClientId) to find which tenant’s secret to try.
  2. 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:
Response 200:
Response 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 UPDATE that 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 touches total_spent/orders_count/avg_order_value on the row-exists path, since those are owned exclusively by the authoritative customers/* upsert (Shopify’s own amountSpent/numberOfOrders already include every order) — incrementing them here would double-count. If no row exists yet (the order arrived before any customers/create webhook or backfill), it bootstraps a stub row from that one order’s total instead of dropping the signal; the next customers/* upsert overwrites it with the real absolute value.
  • syncCustomerFromOrder (orders/create, immediately after applyOrderAffinity) — fetches that SAME customer’s own authoritative amountSpent/numberOfOrders via a single-customer Admin GraphQL query and writes them through upsertShopifyCustomer’s absolute-overwrite path. Exists because Shopify does not reliably follow an order with a customers/update webhook carrying the recomputed lifetime spend (most notably orders placed from the Shopify admin) — without this fetch, total_spent/orders_count (and their derived spend_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 as applyOrderAffinity: 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 gets email/firstName/lastName/defaultAddress nulled on this query, but amountSpent/numberOfOrders are 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 by POST /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 at MAX_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.
Consumed by decisioning: 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:
Max 20 events per call. 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 its dest names a shop with no ShopifyShop row or whose tenant has no ShopifyAppConfig at 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’s status isn’t installed (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.
Query params: 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:
Reuses the exact {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:
Response: { "processed": 1, "succeeded": 1, "failed": 0, "errors": [] }. Both curation knobs drive real behavior, not just stored metadata:
  • surfaces decides 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 between active and draft (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.
  • capPerWeek maps to a per-offer frequency_cap contact policy (scope="offer", config.maxPerWeek) that the decisioning engine enforces per candidate. Clearing it to 0 removes the policy (no cap); it is never written as maxPerWeek: 0, which the engine would read as “block entirely.”
There is intentionally no 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):
The response is tenant-wide — every store under the caller’s tenant, not just the one whose session token authenticated the request. A store missing a channel row for some placement (e.g. an anomalous partial provision) reports 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:
clicks is a blended positive-engagement bucket (clicks, add-to-carts, and other positive interactions), not a pure click count — the same field the underlying dashboard aggregation returns under the name conversions. The embedded admin UI renders this under an “Engagements” column heading, never “Clicks”, to keep the label truthful.
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.