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_type | Auth header | What’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 viaMERCHANT_SESSION_SECRET, carryingshop:nonce:verifier, 10-minute httpOnlylax), then 302-redirects tohttps://www.klaviyo.com/oauth/authorizewithresponse_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 atPOST https://a.klaviyo.com/oauth/token(grant_type=authorization_code, Basicclient_id:client_secretauth,code_verifier,redirect_uri), persists the token trio as an'oauth'-type integration, and returns a small HTML page thatpostMessages 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_keyintegrations →Klaviyo-API-Key <key>(static).oauthintegrations →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 viaupdateStoreIntegrationTokens.
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:
| Column | Purpose |
|---|---|
provider | 'klaviyo' |
api_key_encrypted | Klaviyo private API key, encrypted with the same AES-256-GCM helper (lib/server/token-crypto.ts) used for platform_connections tokens |
status | idle | running | error — drives the merchant-facing sync indicator |
last_synced_at / last_error | Shown on the integration card; last_error persists until the next successful sync |
sync_cursor | Max customer_product_affinity.updated_at pushed so far. NULL = backfill still pending |
settings | jsonb — 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.
Consent gate (law)
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 bybuildProfileRollups(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 repeatedunique_idfails the whole batch there). Eventunique_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: trueonbuildTriedOnEvents) set Klaviyo’s$do_not_trigger_flowsflag so merchant flows don’t fire against historical try-ons.
Sync modes & cursor
runKlaviyoSync(storeId) (lib/server/klaviyo-sync.ts):
mode = integration.sync_cursor ? "incremental" : "backfill".- Loads the integration, decrypts the key, resolves settings, sets
status='running'. listConsentedAffinity({ storeId, updatedSince: sync_cursor })— full consented set on backfill, only rows changed since the cursor on incremental.- Enriches, builds profile rollups + (if
settings.sendTriedOn) events; profiles push before events so events attach to profiles that already carry the rollup properties. - Advances
sync_cursorto the maxupdated_atactually pushed — never past it, so a mid-sync write with an earlierupdated_atthan “now” isn’t skipped on the next run. - On any failure:
status='error'+last_errorpersisted 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/functions’ waitUntil,
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:
| Trigger | Function | Metric | Consent check |
|---|---|---|---|
Try-on success (api/events → recordTriedProductsFromEvent) | queueShopperKlaviyoPush | Tried On Product (+ profile rollup) | via listConsentedAffinity({ shopperPseudoId }) — no-op if not consent-linked yet |
Email capture (api/storefront/capture-email) | queueShopperKlaviyoPush | same | same |
Add-to-cart — pixel product_added_to_cart and widget tryon_add_to_cart | queueKlaviyoAddedToCart | Tryvio Added To Cart | getShopperConsentLink — no-op unless marketingConsent |
Limit reached (api/storefront/try-on → 429) | queueKlaviyoLimitReached | Tryvio Try-on Limit Reached | same — 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 resolveKlaviyoSettings — defaults all
true, i.e. everything is on unless the merchant explicitly disables it:
| Key | Controls |
|---|---|
sendTriedOn | The Tried On Product event (batch sync + realtime) |
sendAddedToCart | The Tryvio Added To Cart realtime event |
sendLimitReached | The Tryvio Try-on Limit Reached realtime event |
includeImage | Whether buildAffinityEnrichment includes tryon_image_url/tryon_share_url |
realtime | Master 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/klaviyoGET— status payload (connected,status,lastSyncedAt,lastError,backfillDone: sync_cursor !== null,settings). The encrypted key never leaves the server.POST— connects: validates the key against KlaviyoGET /api/accounts(validateKlaviyoKey; a 403 there means “scoped but valid” — accepted), then encrypts + upserts the integration row.PATCH— updatessettings(see above).DELETE— hard-deletes thestore_integrationsrow (deleteStoreIntegration) — sync stops immediately, no soft-disable state.
-
POST /api/shopify/integrations/klaviyo/sync— manual “Sync now”.modeis automatic: backfill if no cursor yet, else incremental. SamerunKlaviyoSyncthe cron calls. -
GET/POST /api/shopify/integrations/klaviyo/flowsGET— lists flows whose name starts with theTryvio ·prefix (KLAVIYO_TRYVIO_PREFIX,listTryvioFlows).POST— one-click provisions “Tryvio · Try-on follow-up (<N> min)”: requires theTried On Productmetric 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 setslive: 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 theTryvio ·prefix,group_byincludesflow_message_idand results are re-aggregated per flow.- Cached 6h in
settings.revenueCache(Klaviyo caps this report at 225 calls/day) —?refresh=1forces a re-fetch. - Degrades to
{ available: false, reason }(not an error) when Klaviyo has noPlaced Ordermetric yet, or when that metric exists but has no order data (Klaviyo returns 400 on the values query in that case — caught viaKlaviyoApiError.status === 400).
- Cached 6h in
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 documentedklaviyoFormssubmit DOM event and relay the submitted email vialocalStoragekey"tryvio:klv"(30-day freshness window). The modal posts it on open (openStorefrontModal→maybeAutoCaptureEmail). Grants the merchant-configured email-gate bonus and markscaptured_email_at— the shopper is never re-asked at the gate again. -
shopify_customer— a logged-in customer whose Shopifyaccepts_marketingistrue. Liquid (data-customer-accepts-marketingontryvio-app-embed.liquid) →data-tryvio-customer-accepts-marketingattribute → 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 requiremarketingConsent: truein the capture-email API call — the API rejects400otherwise. Being logged in withaccepts_marketingis 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/uninstalledwebhook deletes the store’sstore_integrationsrows immediately — sync stops and the API key is gone the moment the merchant uninstalls.shop/redact(deleteAllStoreData) explicitly clears bothstore_integrationsandcustomer_product_affinityas 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 <pk_...>. - Retries with exponential backoff (
RETRY_DELAYS_MS = [1000, 2000, 4000]) on429/5xx, honoringRetry-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
propertiespayload 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:
- Migrations → prod Supabase.
- Merge to
main(auto-deploy; the cron entry ships with it). - Widget prod deploy (only needed for the
klaviyo_formemail-source capture — API-first per Releasing the storefront widget + API). - Docs.