Klaviyo Sync

Klaviyo Sync

Feature F of the email-marketing epic (spec 05_tasks/specs/email-klaviyo-sync.md, issue #69): pushes consented shopper try-on affinity into the merchant’s own Klaviyo account as profile rollups + events, so they can build flows (abandoned-try-on follow-up, etc.) on top of Tryvio data without us hosting any email sending.

Authentication: OAuth + private key

Issue #69 follow-up: a one-click “Connect with Klaviyo” button, alongside the original private-key path. A store_integrations row is either:

auth_typeAuth headerWhat’s stored
'api_key' (original, default)Klaviyo-API-Key <key>the private key, encrypted, in api_key_encrypted
'oauth'Bearer <access token>current access token (encrypted) in api_key_encrypted, plus refresh_token_encrypted and token_expires_at

Added by migration 20260727_klaviyo_oauth.sql: auth_type (default 'api_key'), refresh_token_encrypted, token_expires_at; api_key_encrypted is made nullable (OAuth rows carry no private key).

All of this lives in lib/server/klaviyo-oauth.ts.

One Tryvio-level app

There’s a single, Tryvio-owned public OAuth app (not one per merchant) — env KLAVIYO_OAUTH_CLIENT_ID (public) + KLAVIYO_OAUTH_CLIENT_SECRET (server-only secret). hasKlaviyoOAuth() gates the UI: when both are set, the Integrations card shows the “Connect with Klaviyo” button; otherwise it falls back straight to the private-key input. The key path always stays available under an “Advanced” toggle, even when OAuth is configured.

⚠️

Prod rollout. Create ONE public OAuth app in a Tryvio-owned Klaviyo account (owner/admin/manager role, Manage apps page) to get a client_id/client_secret, allowlist the redirect URLs https://app.tryvio.ai/api/shopify/integrations/klaviyo/oauth/callback (prod) and the dev.app.tryvio.ai equivalent (dev), then set the two env vars per environment. Until they’re set, the button stays hidden and the private-key path is used — fully backward-compatible.

Flow (PKCE authorization code)

  • GET /api/shopify/integrations/klaviyo/oauth/install — opened in a popup, not the embedded iframe (Klaviyo’s authorize page can’t be framed). Mints a PKCE pair (verifier = base64url(randomBytes(64)), challenge = base64url(sha256(verifier))) and a signed state cookie (klaviyo_oauth_state, HMAC via MERCHANT_SESSION_SECRET, carrying shop:nonce:verifier, 10-minute httpOnly lax), then 302-redirects to https://www.klaviyo.com/oauth/authorize with response_type=code, client_id, redirect_uri, scope (accounts:read profiles:write events:write lists:write metrics:read flows:read flows:write templates:read templates:write), state, code_challenge_method=S256, code_challenge.
  • GET /api/shopify/integrations/klaviyo/oauth/callback — verifies the signed state cookie (CSRF; the shop + PKCE verifier are taken from the unforgeable cookie, not the query string), exchanges the code at POST https://a.klaviyo.com/oauth/token (grant_type=authorization_code, Basic client_id:client_secret auth, code_verifier, redirect_uri), persists the token trio as an 'oauth'-type integration, and returns a small HTML page that postMessages the opener ('klaviyo:connected') and closes itself. On error/denial it renders a friendly failure page and stores nothing.

Token lifecycle

Access tokens live ~1h; refresh tokens rotate on every use — the rotated pair must be persisted after each refresh (same discipline as the Shopify token machinery). getIntegrationAuthHeader(integration) is the single choke point that returns a ready-to-use Authorization header value:

  • api_key integrations → Klaviyo-API-Key <key> (static).
  • oauth integrations → Bearer <access token>, proactively refreshed ~5 minutes before expiry (REFRESH_SKEW_MS), single-flight per store (in-memory map) so concurrent syncs don’t race each other over the rotating refresh token. The refreshed pair is persisted via updateStoreIntegrationTokens.

Every call site (sync, realtime, flows, revenue) now calls getIntegrationAuthHeader(integration) rather than reading api_key_encrypted directly. The Klaviyo client’s klaviyoHeaders accepts either a full Authorization header value (from getIntegrationAuthHeader) or a bare private key (wrapped as Klaviyo-API-Key), so it works unchanged for both auth types.

Data model

store_integrations (migration 20260723_store_integrations.sql, extended by 20260723_store_integrations_v2.sql) — one row per (store_id, provider), extensible to future ESPs beyond Klaviyo:

ColumnPurpose
provider'klaviyo'
api_key_encryptedKlaviyo private API key, encrypted with the same AES-256-GCM helper (lib/server/token-crypto.ts) used for platform_connections tokens
statusidle | running | error — drives the merchant-facing sync indicator
last_synced_at / last_errorShown on the integration card; last_error persists until the next successful sync
sync_cursorMax customer_product_affinity.updated_at pushed so far. NULL = backfill still pending
settingsjsonb — merchant configuration (see Merchant settings)

Unique constraint (store_id, provider). Also added: index customer_product_affinity_updated_idx (store_id, updated_at) — the incremental cursor query needs it because updated_at (not last_tried_at) is the cursor: it’s bumped both by a repeat try-on and by a later consent/email stamping, so a last_tried_at-based cursor would miss consent flips.

The only data source for anything pushed to Klaviyo is listConsentedAffinity (lib/server/supabase-admin.ts), which enforces marketing_consent = true AND email IS NOT NULL in SQL. No code path in the sync, realtime-push, or flow-provisioning modules queries customer_product_affinity directly. Non-consented shoppers’ emails never reach Klaviyo — this is negative-tested.

What gets pushed

  • Profile rollups — one upsert per shopper via the Klaviyo single profile-import endpoint: tryvio_total_tryons, tryvio_tried_product_count, tryvio_first_tried_at/tryvio_last_tried_at, tryvio_last_tried_product. Built by buildProfileRollups (lib/server/klaviyo-client.ts).

    ⚠️

    Klaviyo’s bulk profile-import-jobs endpoint intermittently 500s. The single profile-import upsert is the primary path; bulk import is only used above ~1000 profiles, with a per-profile fallback on failure.

  • Events — a single metric, Tried On Product (KLAVIYO_TRIED_ON_METRIC), never per-product. Properties: product_handle, external_product_id, try_count, first_tried_at/last_tried_at, plus v2 enrichment (buildAffinityEnrichment, lib/server/klaviyo-enrich.ts): product_title, product_url, tryon_image_url (a watermarked share image via the existing share-token pipeline — camera/input photos never leave Tryvio), tryon_share_url.

    Pushed via pushTriedOnEvents → the Klaviyo single event endpoint (not bulk event-create-jobs — a repeated unique_id fails the whole batch there). Event unique_id = <email>:<external_product_id>:<last_tried_at>, so a repeat try-on on the same product produces a new event, and re-pushing an unchanged row dedupes naturally in Klaviyo.

    Backfill pushes (backfill: true on buildTriedOnEvents) set Klaviyo’s $do_not_trigger_flows flag so merchant flows don’t fire against historical try-ons.

Sync modes & cursor

runKlaviyoSync(storeId) (lib/server/klaviyo-sync.ts):

  1. mode = integration.sync_cursor ? "incremental" : "backfill".
  2. Loads the integration, decrypts the key, resolves settings, sets status='running'.
  3. listConsentedAffinity({ storeId, updatedSince: sync_cursor }) — full consented set on backfill, only rows changed since the cursor on incremental.
  4. Enriches, builds profile rollups + (if settings.sendTriedOn) events; profiles push before events so events attach to profiles that already carry the rollup properties.
  5. Advances sync_cursor to the max updated_at actually pushed — never past it, so a mid-sync write with an earlier updated_at than “now” isn’t skipped on the next run.
  6. On any failure: status='error' + last_error persisted on the row (the merchant sees this on the integration card; the cursor is not advanced, so the next run/manual retry safely replays the same window).

Job tracking reuses the existing sync_jobs table with job_type='klaviyo_sync' (createSyncJob/finishSyncJob).

Realtime push

lib/server/klaviyo-realtime.ts fires events immediately at the moment they happen, rather than waiting for the next 10-minute cron tick. Fire-and-forget, never awaited by the caller, never throws out to the caller, and never touches sync_cursor (the cron replays idempotently via the unique_id dedupe either way).

Why waitUntil. A bare floating promise (somePromise with no await) is frozen the moment a Vercel serverless route responds — the runtime can suspend the function before the promise resolves. scheduleBackground() wraps the work in @vercel/functionswaitUntil, which keeps the function alive until the push completes. Falls back to letting the already-started promise run when not inside a Vercel request context (tests, local dev).

Hook points:

TriggerFunctionMetricConsent check
Try-on success (api/eventsrecordTriedProductsFromEvent)queueShopperKlaviyoPushTried On Product (+ profile rollup)via listConsentedAffinity({ shopperPseudoId }) — no-op if not consent-linked yet
Email capture (api/storefront/capture-email)queueShopperKlaviyoPushsamesame
Add-to-cart — pixel product_added_to_cart and widget tryon_add_to_cartqueueKlaviyoAddedToCartTryvio Added To CartgetShopperConsentLink — no-op unless marketingConsent
Limit reached (api/storefront/try-on → 429)queueKlaviyoLimitReachedTryvio Try-on Limit Reachedsame — deduped per day per shopper (unique_id includes the date)

queueShopperKlaviyoPush splits the shopper’s consented rows by a 48h window (LIVE_EVENT_WINDOW_MS): rows updated within 48h push as live events (so the merchant’s flows fire); older rows push with the backfill flag (e.g. a shopper who consents weeks after trying) so a stale flow doesn’t fire against old data.

Each realtime hook independently respects settings.realtime and the specific event toggle (sendTriedOn/sendAddedToCart/sendLimitReached) via loadActiveIntegration, and is a no-op if KLAVIYO_SYNC_DISABLED is set or no integration is connected.

Cron

GET /api/cron/klaviyo-sync, scheduled every 10 minutes (vercel.json, */10 * * * *), bearer-authed with CRON_SECRET. Iterates listStoreIntegrations('klaviyo') and runs runKlaviyoSync per store with per-store failure isolation — one store’s error doesn’t block the others. Effectively inert with zero Klaviyo integrations connected (and on any environment where the store_integrations table doesn’t exist yet — see Rollout order).

⚠️

Kill switch. KLAVIYO_SYNC_DISABLED=true blocks the cron, manual “Sync now”, and every realtime push path in one flag — checked at the top of runKlaviyoSync and loadActiveIntegration.

Merchant settings

store_integrations.settings (jsonb), resolved via resolveKlaviyoSettingsdefaults all true, i.e. everything is on unless the merchant explicitly disables it:

KeyControls
sendTriedOnThe Tried On Product event (batch sync + realtime)
sendAddedToCartThe Tryvio Added To Cart realtime event
sendLimitReachedThe Tryvio Try-on Limit Reached realtime event
includeImageWhether buildAffinityEnrichment includes tryon_image_url/tryon_share_url
realtimeMaster toggle for all realtime pushes (batch sync still runs on cron regardless)

PATCH /api/shopify/integrations/klaviyo persists only these five known boolean keys (anything else in the request body is dropped), merged onto the existing settings object.

API routes

All routes resolve the merchant via resolveAuthorizedShopForShopifyRequest and return 401 on Unauthorized.

  • GET/POST/PATCH/DELETE /api/shopify/integrations/klaviyo

    • GET — status payload (connected, status, lastSyncedAt, lastError, backfillDone: sync_cursor !== null, settings). The encrypted key never leaves the server.
    • POST — connects: validates the key against Klaviyo GET /api/accounts (validateKlaviyoKey; a 403 there means “scoped but valid” — accepted), then encrypts + upserts the integration row.
    • PATCH — updates settings (see above).
    • DELETEhard-deletes the store_integrations row (deleteStoreIntegration) — sync stops immediately, no soft-disable state.
  • POST /api/shopify/integrations/klaviyo/sync — manual “Sync now”. mode is automatic: backfill if no cursor yet, else incremental. Same runKlaviyoSync the cron calls.

  • GET/POST /api/shopify/integrations/klaviyo/flows

    • GET — lists flows whose name starts with the Tryvio · prefix (KLAVIYO_TRYVIO_PREFIX, listTryvioFlows).
    • POST — one-click provisions “Tryvio · Try-on follow-up (<N> min)”: requires the Tried On Product metric to already exist in Klaviyo (409 if not — the merchant needs to run a sync first so at least one event lands), then creates a CODE email template referencing {{ event.tryon_image_url }}/{{ event.product_title }}/ {{ event.product_url }}, and a metric-triggered flow: trigger → time-delay (delayMinutes, clamped 1–1440) → send-email. Created as DRAFT unless the request sets live: true. The merchant edits the flow natively in Klaviyo afterwards (delay, content, live/paused — it’s fully theirs once created).
  • GET /api/shopify/integrations/klaviyo/revenue — “Tryvio + Klaviyo made you $X”: Klaviyo’s own last-touch attributed conversion value from its flow-values-reports API, conversion_metric_id = the merchant’s own Placed Order metric (only populated once they’ve connected Shopify inside Klaviyo), scoped to flows named with the Tryvio · prefix, group_by includes flow_message_id and results are re-aggregated per flow.

    • Cached 6h in settings.revenueCache (Klaviyo caps this report at 225 calls/day) — ?refresh=1 forces a re-fetch.
    • Degrades to { available: false, reason } (not an error) when Klaviyo has no Placed Order metric yet, or when that metric exists but has no order data (Klaviyo returns 400 on the values query in that case — caught via KlaviyoApiError.status === 400).

Email sources (v2.1)

How a consented email reaches customer_product_affinity in the first place:

  • rate_limit_gate — the widget’s existing email gate.

  • klaviyo_form — the merchant’s own Klaviyo signup form embedded on the storefront. The bootstrap scripts (tryvio-theme.js + tryvio-collection.js) listen for Klaviyo’s documented klaviyoForms submit DOM event and relay the submitted email via localStorage key "tryvio:klv" (30-day freshness window). The modal posts it on open (openStorefrontModalmaybeAutoCaptureEmail). Grants the merchant-configured email-gate bonus and marks captured_email_at — the shopper is never re-asked at the gate again.

  • shopify_customer — a logged-in customer whose Shopify accepts_marketing is true. Liquid (data-customer-accepts-marketing on tryvio-app-embed.liquid) → data-tryvio-customer-accepts-marketing attribute → read by the modal. No email-gate bonus, no Shopify write-back, applied once per email (localStorage guard).

    ⚠️

    Legal boundary. Auto-captured sources (klaviyo_form, shopify_customer) still require marketingConsent: true in the capture-email API call — the API rejects 400 otherwise. Being logged in with accepts_marketing is data about the customer, not proof of this shopper’s consent by itself; the app treats it as a consent signal it forwards, not something it can infer without the explicit flag.

GDPR

  • app/uninstalled webhook deletes the store’s store_integrations rows immediately — sync stops and the API key is gone the moment the merchant uninstalls.
  • shop/redact (deleteAllStoreData) explicitly clears both store_integrations and customer_product_affinity as part of the full data wipe.

Klaviyo API notes

  • Pinned revision: 2026-07-15 (KLAVIYO_REVISION, lib/server/klaviyo-client.ts).
  • Auth header: Authorization: Klaviyo-API-Key &lt;pk_...&gt;.
  • Retries with exponential backoff (RETRY_DELAYS_MS = [1000, 2000, 4000]) on 429/5xx, honoring Retry-After; no retry on 4xx auth errors.
  • Bulk profile-import-jobs 500s intermittently → single profile-import upsert is the primary write path (see above).
  • Bulk events endpoint fails the whole batch on a repeated unique_id → the single-event endpoint (rate-limited ~350/s) is used instead.
  • Only top-level event properties are segmentable in Klaviyo — nested objects in the properties payload are avoided for anything a merchant might filter/segment on.

Dev testing

Dev test Klaviyo workspace: account YpW7dk. Key stored at ~/.tryvio/klaviyo-dev-api-key.txt (operator machine, not in the repo).

Rollout order

⚠️

Apply both migrations (20260723_store_integrations.sql, 20260723_store_integrations_v2.sql) to prod Supabase before merging to main. Without the table, the Settings integrations tab errors and the cron 500s on every tick. Order:

  1. Migrations → prod Supabase.
  2. Merge to main (auto-deploy; the cron entry ships with it).
  3. Widget prod deploy (only needed for the klaviyo_form email-source capture — API-first per Releasing the storefront widget + API).
  4. Docs.