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 as two deployments of the same container image:
- Core — your normal KaireonAI deployment, serving
/api/v1/* and the Studio UI.
- Edge — a second deployment of the identical image with
SHOPIFY_EDGE=1 set. In edge mode, every path 404s except /api/shopify/* and the two health probes (/api/health, /api/ready) — the edge deployment’s entire job is to safely absorb public, unauthenticated Shopify traffic (webhooks, App Proxy calls, the embedded admin, the checkout web pixel) without exposing any other route. The edge deployment queries the same Postgres database directly (it needs to read/write ShopifyShop rows, webhook dedupe records, GDPR erasure state, etc.) and forwards every recommend/respond call to the core deployment over HTTP, authenticated as the tenant’s own scoped API key.
Required environment variables (edge deployment)
| Variable | Value | Notes |
|---|
SHOPIFY_EDGE | the exact string "1" | Checked with strict equality (=== "1") — "true", "yes", or any other truthy-looking string does NOT enable edge mode. |
SHOPIFY_API_KEY | your app’s Client ID | Same value the core deployment needs if it ever verifies a session token itself; must match the Partner Dashboard Client ID. |
SHOPIFY_API_SECRET | your app’s Client Secret | Used for webhook HMAC verification, App Proxy signature verification, and session-token (JWT) verification. |
KAIREON_CORE_URL | the core deployment’s base URL | Every recommend/respond call the edge makes on a merchant’s behalf goes to {KAIREON_CORE_URL}/api/v1/recommend or /api/v1/respond, authenticated with X-API-Key/X-Tenant-Id using that tenant’s own edge-scoped key. |
DATABASE_URL | same Postgres as the core deployment | The edge is not a pure HTTP proxy — it reads/writes ShopifyShop, webhook dedupe, and other tables directly. |
When SHOPIFY_EDGE=1, startup env validation additionally requires SHOPIFY_API_KEY, SHOPIFY_API_SECRET, and KAIREON_CORE_URL — an edge deployment cannot verify a single Shopify request or reach the core service without them, so it fails fast in production if any is missing. The edge’s own public URL is not a runtime environment variable — it lives only in shopify.app.toml’s application_url/webhook/App-Proxy URIs, which you fill in by hand once the service has a hostname.
The core deployment additionally needs SHOPIFY_API_KEY/SHOPIFY_API_SECRET set if you want session-token verification to work identically there (it won’t be reached in practice, since edge-only paths 404 on core, but the values should still match).
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 | 300 req / 60s per shop, and 3,000 req / 60s global | per claimed shop domain, plus one fixed global key |
POST /api/shopify/install | 20 req / hour | per client (fails closed) |
Every storefront-facing route (proxy recommend/outcome, ingest, thank-you offer) 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.
Never-5xx storefront contract
Every route a live storefront or checkout calls (proxy/recommend, proxy/outcome, ingest, ingest/thankyou-offer) 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 one exception is malformed JSON on POST /api/shopify/ingest, which 400s (there’s no meaningful degraded response to a request that didn’t parse at all). This guarantees a KaireonAI outage never breaks checkout or storefront rendering.
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 — self-authorizes by exchanging sessionToken with Shopify’s token-exchange endpoint. A forged, expired, or wrong-shop token is rejected by Shopify itself; a successful exchange IS the proof of authenticity.
Request body:
{
"shopDomain": "my-store.myshopify.com",
"sessionToken": "<App Bridge ID token>"
}
Response 200:
Idempotent: re-installing an already-installed shop 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 — self-authorizes via X-Shopify-Hmac-Sha256 verified against SHOPIFY_API_SECRET (computed over the exact raw request bytes).
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). |
refunds/create | Records one negative “refund” outcome per attributed refunded line. |
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 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.
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 — self-authorizes via verifyProxySignature against SHOPIFY_API_SECRET.
Query params:
| Param | Required | Notes |
|---|
shop | Yes | The *.myshopify.com shop domain — the route 404s 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:
{ "cards": [PublicOfferCard, ...] }
PublicOfferCard is the ONLY shape ever exposed to the storefront — every internal field (scores, raw offer metadata, personalization, trace data) is stripped:
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 — self-authorizes via verifyProxySignature.
Query params:
| Param | Required | Notes |
|---|
shop | Yes | The *.myshopify.com shop domain — the route 404s 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:
{
"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:
{
"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 above.
GET /api/shopify/ingest/thankyou-offer
Auth: session-token only — the checkout UI extension’s own useSessionToken() JWT (same verification scheme as the embedded-admin routes, verifySessionToken).
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.
Embedded admin
Every route below is authenticated the same way: Authorization: Bearer <App Bridge session token> (an HS256 JWT, verified against SHOPIFY_API_SECRET/SHOPIFY_API_KEY; the shop domain is read from the token’s dest claim and resolved to a tenant server-side — never trusted from a client-supplied value). A 401 is returned both for a missing/invalid token AND for a valid token whose shop has no ShopifyShop row (or isn’t installed) — the embedded admin’s install-gate treats either case as “needs POST /api/shopify/install.”
GET / PUT /api/shopify/admin/offers
GET lists Shopify-imported offers with their curation fields (paginated, cursor-based):
{
"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:
{ "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.)
GET /api/shopify/admin/surfaces
Returns the status of the four provisioned channels:
{ "data": [{ "key": "shopify_product_page", "label": "...", "status": "active", "enabled": true }, ...] }
Read-only — activating/deactivating a channel is done in the main KaireonAI Studio, not here.
Query param: days (1–365, default 7). Reuses the same aggregation as /api/v1/dashboard-data’s offer performance, reshaped:
{
"period": { "days": 7 },
"data": [{ "offerId": "...", "name": "...", "impressions": 100, "clicks": 12, "revenue": 240.50, "conversionRate": "12.0%" }]
}
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.
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:
{ "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.
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.