> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Shopify App

> Proxy, webhook, ingest, and embedded-admin surface for self-hosting the KaireonAI Personalization Shopify app.

<Info>
  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](/integrations/shopify) instead.
</Info>

## 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](#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](/integrations/shopify#setup)), 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](#auth-lookup-then-verify) below.

### Required environment variables

| Variable                | Required on                                                                                                                                                                                                 | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SHOPIFY_EDGE`          | the dedicated edge deployment                                                                                                                                                                               | the exact string `"1"` — checked with strict equality (`=== "1"`); `"true"`, `"yes"`, or any other truthy-looking string does NOT enable edge mode.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `SHOPIFY_SERVE_ON_CORE` | a single-service core deployment opting in                                                                                                                                                                  | the exact string `"1"`, same strict-equality rule.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `KAIREON_CORE_URL`      | either flag set                                                                                                                                                                                             | the base URL every recommend/respond call is forwarded to (`{KAIREON_CORE_URL}/api/v1/recommend` / `/api/v1/respond`), authenticated with the calling tenant's own `X-API-Key`/`X-Tenant-Id`. Required even under `SHOPIFY_SERVE_ON_CORE=1` — the Shopify code path always makes an outbound HTTP call to this URL, even when it points back at the same service. Startup env validation throws in production if it's missing on a deployment that serves Shopify traffic.                                                                                                                             |
| `DATABASE_URL`          | always                                                                                                                                                                                                      | the same Postgres the core deployment uses — the Shopify surface is not a pure HTTP proxy; it reads/writes `ShopifyShop`, `ShopifyAppConfig`, webhook dedupe, and other tables directly.                                                                                                                                                                                                                                                                                                                                                                                                               |
| `KAIREON_PUBLIC_URL`    | recommended in production on whichever deployment serves `GET /api/v1/settings/integrations/shopify/deploy-kit` (always the CORE deployment — see [below](#get-apiv1settingsintegrationsshopifydeploy-kit)) | this deployment's own public origin (bare host or full `https://` URL — either is accepted). Used to fill in the deploy kit's 5 URL fields: `application_url` (which resolves to `{origin}/shopify-admin` — the embedded admin page Shopify actually iframes, not the bare origin), `auth.redirect_urls`, both webhook `uri`s, and `app_proxy.url` (these 4 keep their own `/api/shopify/*` path suffixes). **Production must set this explicitly** rather than rely on the fallback chain below — see the note in [the deploy-kit endpoint](#get-apiv1settingsintegrationsshopifydeploy-kit) for why. |

`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](#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.

| Scope            | Powers                                                                                                                                                                             |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read_products`  | Catalog import (`import.ts`) and the `products/update`/`products/delete` webhook topics.                                                                                           |
| `read_orders`    | The `orders/create` webhook, the refund attribution path (`refunds/create`), and the reconciliation sweep's orders query.                                                          |
| `read_inventory` | `inventoryItem.unitCost`, read alongside the products query for margin calculation.                                                                                                |
| `read_customers` | Customer sync (`customer-sync.ts`): the `customers/create`/`customers/update` webhook topics and the install-time `customers(first:...)` GraphQL backfill of the existing catalog. |

<Warning>
  `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](/integrations/shopify#1-create-a-custom-app) for the merchant-facing version of this.
</Warning>

<Info>
  **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](/integrations/shopify#1-create-a-custom-app) for the merchant-facing setup step.
</Info>

### 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](https://shopify.dev/docs/apps/build/security/set-up-iframe-protection), 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](https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/content-security-policy.md), 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](/integrations/shopify#3-download-the-deploy-kit)).

**Auth:** `requireRole(req, "admin")` + `requireTenant(req)` — same admin-only pattern as the sibling [Settings API](#settings-api-per-tenant-credentials--store-list) 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.sh` — `npm install && npx shopify app deploy`, plus a printed reminder about the one-time network-access approval (see the [integration guide](/integrations/shopify#3-download-the-deploy-kit)). 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 `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.

<Note>
  **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.
</Note>

## 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.

| Surface                               | Lookup key (unverified)                                  | Verified against                                                                            |
| ------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Webhooks (`X-Shopify-Hmac-Sha256`)    | `X-Shopify-Shop-Domain` header                           | the resolved shop's tenant's `ShopifyAppConfig` secret                                      |
| App Proxy (`verifyProxySignature`)    | `shop` query param                                       | the resolved shop's tenant's secret                                                         |
| Session tokens (`verifySessionToken`) | the JWT's `dest` claim                                   | the resolved shop's tenant's secret + `clientId` (as the JWT `audience`)                    |
| Install (`exchangeSessionToken`)      | the JWT's `aud` claim (→ `resolveCredentialsByClientId`) | Shopify's own token-exchange endpoint (a forged/expired/wrong-shop token is rejected there) |

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

| Endpoint                                    | Limit                                                  | Key                                                |
| ------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- |
| `GET /api/shopify/proxy/recommend`          | 120 req / 60s                                          | per shop                                           |
| `POST /api/shopify/proxy/outcome`           | 300 req / 60s                                          | per shop                                           |
| `GET /api/shopify/ingest/thankyou-offer`    | 120 req / 60s                                          | per shop                                           |
| `POST /api/shopify/ingest/thankyou-outcome` | 300 req / 60s                                          | per shop                                           |
| `POST /api/shopify/ingest`                  | 300 req / 60s per shop, **and** 3,000 req / 60s global | per claimed shop domain, plus one fixed global key |
| `GET /api/shopify/app-config`               | 60 req / 60s                                           | per claimed shop domain                            |
| `POST /api/shopify/install`                 | 20 req / hour                                          | per client (fails closed)                          |

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:**

```json theme={null}
{
  "shopDomain": "my-store.myshopify.com",
  "sessionToken": "<App Bridge ID token>"
}
```

**Response `200`:**

```json theme={null}
{ "tenantId": "..." }
```

**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](#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:**

| Topic                                      | Effect                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `products/update`                          | Syncs price/image onto the matching offer (unless the merchant has edited that field in KaireonAI).                                                                                                                                                                                                                                                                                                                             |
| `products/delete`                          | Archives the matching offer unconditionally.                                                                                                                                                                                                                                                                                                                                                                                    |
| `orders/create`                            | Records one "purchase" outcome per attributed line item (see [Attribution](#attribution)); refreshes that customer's recency + product/variant affinity, then re-fetches that customer's own authoritative `amountSpent`/`numberOfOrders` via Admin GraphQL and writes them absolutely (see [Customer sync](#customer-sync)) — closes the gap where Shopify doesn't reliably follow an order with a `customers/update` webhook. |
| `refunds/create`                           | Records one negative "refund" outcome per attributed refunded line.                                                                                                                                                                                                                                                                                                                                                             |
| `customers/create`                         | Upserts the customer's profile, purchase aggregates, and derived tiers into `ds_shopify_customers` (see [Customer sync](#customer-sync)).                                                                                                                                                                                                                                                                                       |
| `customers/update`                         | Same upsert as `customers/create` — Shopify sends this topic for essentially any profile/aggregate change (name, marketing consent, `total_spent`/`orders_count` refresh, etc.), and the payload shape is identical.                                                                                                                                                                                                            |
| `app/uninstalled`                          | Marks the shop `uninstalled` and revokes its edge API key. Dispatches even if the shop is already marked uninstalled.                                                                                                                                                                                                                                                                                                           |
| `customers/redact` (GDPR compliance)       | Erases all KaireonAI data for that customer id.                                                                                                                                                                                                                                                                                                                                                                                 |
| `customers/data_request` (GDPR compliance) | Exports all KaireonAI data for that customer id, downloadable via the standard DSAR flow.                                                                                                                                                                                                                                                                                                                                       |
| `shop/redact` (GDPR compliance)            | Erases all customer data ever recorded for the shop, and scrubs the shop's stored credentials.                                                                                                                                                                                                                                                                                                                                  |

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](#reconciliation) 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).

| Column group  | Columns                                                                                                                                                                                                                                                 |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identity      | `email`, `first_name`, `last_name`, `tags`, `city`, `country`, `accepts_marketing`, `verified_email`, `shopify_created_at`, `shopify_shop_domain`                                                                                                       |
| Aggregates    | `total_spent`, `orders_count`, `avg_order_value`, `first_order_at`, `last_order_at`, `days_since_last_order`                                                                                                                                            |
| Derived tiers | `spend_tier` (`new`/`low`/`mid`/`high`/`vip`, banded on `total_spent`), `recency_bucket` (`active`/`lapsing`/`dormant`/`unknown`, banded on `days_since_last_order`), `frequency_bucket` (`none`/`one_time`/`repeat`/`loyal`, banded on `orders_count`) |
| Affinity      | `top_categories`, `recent_product_ids`, `recent_variant_ids` (JSON arrays, id arrays capped to the most recent 20)                                                                                                                                      |

**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](/integrations/shopify#1-create-a-custom-app)).
* **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](/integrations/shopify#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](#auth-lookup-then-verify) above).

**Query params:**

| Param                   | Required    | Notes                                                                                                                                                                                                                     |
| ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shop`                  | Yes         | The `*.myshopify.com` shop domain — the route `404`s without it (or for an unknown/uninstalled shop). Shopify's App Proxy appends it automatically; only relevant to supply yourself when testing the raw route directly. |
| `placementKey`          | Yes         | One of `shopify_product_page`, `shopify_cart`, `shopify_home`, `shopify_thank_you`.                                                                                                                                       |
| `logged_in_customer_id` | Conditional | Shopify-supplied for a logged-in shopper.                                                                                                                                                                                 |
| `anon`                  | Conditional | Required if `logged_in_customer_id` is absent — must match `^anon_[A-Za-z0-9_-]{8,64}$`.                                                                                                                                  |
| `limit`                 | No          | Max cards to return.                                                                                                                                                                                                      |

**Response `200`:**

```json theme={null}
{ "cards": [PublicOfferCard, ...] }
```

`PublicOfferCard` is the ONLY shape ever exposed to the storefront — every internal field (scores, raw offer metadata, personalization, trace data) is stripped:

```ts theme={null}
interface PublicOfferCard {
  rid: string;          // the recommended Creative's id — stamp this back via _kaireon_rid
  offerId: string;
  name: string;
  imageUrl: string | null;
  price: string | null;     // Shopify's raw display string, e.g. "24.99"
  ctaText: string;          // defaults to "Shop Now"
  variantId: string | null; // numeric Shopify variant id for /cart/add.js, if synced
}
```

### `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:**

| Param                   | Required    | Notes                                                                                                                                                                                                                     |
| ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shop`                  | Yes         | The `*.myshopify.com` shop domain — the route `404`s without it (or for an unknown/uninstalled shop). Shopify's App Proxy appends it automatically; only relevant to supply yourself when testing the raw route directly. |
| `logged_in_customer_id` | Conditional | Shopify-supplied for a logged-in shopper; used instead of the body's `anon` when present.                                                                                                                                 |

**Body:**

```json theme={null}
{
  "rid": "<Creative id from the recommend response>",
  "outcome": "impression" | "click" | "add_to_cart" | "dismiss",
  "anon": "anon_..."
}
```

`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:**

```json theme={null}
{
  "shopDomain": "my-store.myshopify.com",
  "events": [
    { "name": "checkout_started" | "checkout_completed", "anon": "anon_...", "checkoutToken": "..." }
  ]
}
```

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](#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:**

```json theme={null}
{
  "rid": "<Creative id from the thankyou-offer response>",
  "outcome": "impression" | "click"
}
```

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](#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](#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](#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):

```json theme={null}
{
  "data": [
    {
      "id": "...", "name": "...", "status": "active" | "draft" | "archived",
      "price": 24.99, "shopifyProductId": "gid://shopify/Product/123",
      "imageUrl": "...", "merchantTouched": true,
      "surfaces": ["shopify_product_page"], "capPerWeek": 5
    }
  ],
  "pagination": { "limit": 50, "cursor": "...", "hasMore": true }
}
```

`PUT` bulk-updates curation fields (`activate`, `surfaces`, `capPerWeek`) for up to 200 offers per call:

```json theme={null}
{ "updates": [{ "offerId": "...", "activate": true, "surfaces": ["shopify_product_page"], "capPerWeek": 5 }] }
```

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.)

<Note>
  **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.
</Note>

### `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):

```json theme={null}
{
  "data": [
    {
      "shopDomain": "my-store.myshopify.com",
      "surfaces": [{ "key": "shopify_product_page", "label": "Product page", "status": "active", "enabled": true }, ...]
    }
  ]
}
```

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:

```json theme={null}
{
  "period": { "days": 7 },
  "data": [{ "offerId": "...", "name": "...", "impressions": 100, "clicks": 12, "revenue": 240.50, "conversionRate": "12.0%" }]
}
```

<Warning>
  `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.
</Warning>

<Note>
  **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.
</Note>

### `GET` / `PUT /api/shopify/admin/settings`

```json theme={null}
{ "autoDraftNew": true }
```

`PUT` body: `{ "autoDraftNew": boolean }`. See [Curation and defaults](/integrations/shopify#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:

```json theme={null}
{ "created": 3, "updated": 12, "skipped": 1 }
```

`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`:**

```json theme={null}
{
  "configured": true,
  "clientId": "0efcc19c536d2166de1730349d9f16a4",
  "secretSet": true,
  "lastVerifiedAt": "2026-07-06T12:00:00.000Z",
  "stores": [
    {
      "shopDomain": "my-store.myshopify.com",
      "status": "installed",
      "installedAt": "2026-07-01T00:00:00.000Z",
      "offersImported": 42,
      "lastReconciledAt": "2026-07-06T06:00:00.000Z"
    }
  ]
}
```

`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](#reconciliation) 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.

## 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

| Extension                                                 | Type                  | Target                                                                    |
| --------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------- |
| `kaireon-blocks` (product-offer, cart-offer, home-banner) | Theme app extension   | `section` app block, added via the theme editor                           |
| `kaireon-thankyou`                                        | Checkout UI extension | `purchase.thank-you.block.render`                                         |
| `kaireon-pixel`                                           | Web pixel extension   | Subscribes to the standard `checkout_started`/`checkout_completed` events |

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.
