Klaviyo setup

Create the Klaviyo OAuth app behind the email channel, set the API environment, and know exactly what Marketing OS reads from Klaviyo and writes to it.

How to create the Klaviyo OAuth app that backs the email channel: campaign and flow revenue in the client roll-up, the daily report, the client PDF and the budget forecast, and the four customer segments kept current as Klaviyo lists.

The app exists: Marketing OS, client id 723e5cae-96a4-49b7-974d-9f6f2fd2fbfc, in the Keyescode Klaviyo account, created on 2026-09-23 (MARK-2QF7VO). The first real connect worked end to end: the consent screen, the PKCE token exchange and the account read. The first sync then hit Klaviyo's rate limit, and the retry below was added for it. The other request and response shapes are still read from Klaviyo's API reference (revision 2025-07-15, pinned in apps/api/src/modules/klaviyo/klaviyo-client.ts) until a sync completes against a real account. The specs stub every call.


1. Create the app

  1. In Klaviyo, open Integrations → Manage apps (the page is https://www.klaviyo.com/manage-apps) and choose Create app.

  2. Klaviyo asks how the app is distributed, and this cannot be changed later. Ours is Publish to the marketplace, so any merchant can install it. Until Klaviyo's partner team approves the listing, the app stays a Draft. It still works: the consent screen first shows an "App has not been reviewed" warning with a Continue button. The other choice, Keep it unpublished, only works in your own account and accounts you pick.

  3. Name it Marketing OS. The Client ID and Client secret are shown on this same form before you press Create, and the secret is never shown again. Copy both first. Later, Generate new client secret is the only way to get another one.

  4. Under Redirect URLs, add this URL exactly, then Save:

    https://marketing-api.keyescode.com/integrations/klaviyo/callback

    For local development add http://localhost:4000/integrations/klaviyo/callback as a second one.

  5. Under Scopes → Edit scopes, pick these, which are exactly what the connect asks for:

    ScopeWhy
    accounts:readthe account's currency and timezone, so revenue converts and buckets
    campaigns:readcampaign names and their message ids
    flows:readflow names
    metrics:readsends, opens, clicks and Placed Order value via metric aggregates
    lists:readfind a list by name or id, its member count and its members
    lists:writecreate one list per customer segment, add and remove members
    profiles:readthe email → profile lookup before removing members
    profiles:writethe bulk profile import that adds members
    campaigns:writecreate a draft email campaign, schedule it and create its send job
    templates:writethe HTML template the draft's email uses

    Klaviyo's write scopes do not include read access. With only lists:write, GET /api/lists answers 403 "missing required scopes: lists:read" (seen live on 2026-09-27), so each resource needs both. A connection granted without the list and profile scopes still syncs revenue; the segment rows on /audiences then read unavailable and say to reconnect.

    campaigns:write and templates:write were added on 2026-09-27 (MARK-I6K9N7) for sending email from the Terminal, and lists:read and profiles:read later the same day, when the first live call showed that write does not imply read. Add them to the app's scopes before deploying, or the connect asks for scopes the app does not have. Every connection made before then must reconnect before sending works. Its card reads Reconnect to enable sending, and email_create_campaign_draft and email_send_campaign answer "Reconnect Klaviyo under Settings › Integrations to grant campaign sending." Reading and syncing keep working meanwhile. Klaviyo's marketplace listing may need re-review for the new scopes.

Klaviyo requires PKCE on every authorization. The code verifier is sealed with the app's encryption key inside the signed state, so nothing extra has to be configured for it.

2. Set the API environment

VariableValue
KLAVIYO_CLIENT_IDthe app's Client ID
KLAVIYO_CLIENT_SECRETthe app's Client secret
KLAVIYO_REDIRECT_URIhttps://marketing-api.keyescode.com/integrations/klaviyo/callback

All three are set on @marketing-os/api in production as of 2026-09-23. KLAVIYO_REDIRECT_URI has to be set: its default is the localhost URL.

Without the first two the Klaviyo card on Settings → Integrations reads "not configured" and the connect route answers 503; nothing else changes. KLAVIYO_BASE_URL exists only so a test can point the client at a fake host.

3. What a merchant sees

  1. Settings → Integrations → Klaviyo → Connect sends them to Klaviyo's consent screen for the account they are signed in to.
  2. On return the first sync is queued at once and reads 90 days; the daily 05:00 tick then re-reads the last 7 days, the window in which Klaviyo restates attribution.
  3. Disconnect deletes the grant and the mirrored metrics and revokes the refresh token at Klaviyo. Lists the segment sync created stay in Klaviyo — they are the merchant's lists now, and a flow may be triggered by one.

Access tokens live an hour; the API renews them with the refresh token as it needs them.

Klaviyo rate-limits each endpoint in short bursts. The metric-aggregates endpoint allows about three calls a second, and one sync makes up to twelve. A 429 is waited out inside the client: it honours Retry-After, otherwise it waits a growing number of seconds, up to five times. Only then does the job fail and fall back to BullMQ's five-minute backoff. Without this wait, the first real sync failed on a throttle that said "Expected available in 1 second". A refresh Klaviyo refuses marks the connection revoked, and the card asks for a reconnect.

What is read

Per sync: the account, the metric list, every email and SMS campaign (with its message ids), every flow, then one metric aggregate per (metric, grouping) over the window, bucketed by day in the account's timezone:

  • Placed Order sum_value by $attributed_message (campaigns) and $attributed_flow (flows) — the revenue.
  • Received Email, Opened Email, Clicked Email, Received SMS and Clicked SMS count by $message and by $flow — the activity.

SMS is folded into the email channel with a medium of email, sms or mixed. The result is email_campaign_metrics: one row per campaign or flow per day, in the account's currency. Every reader converts it through the organization's reporting currency, and none adds it to a paid channel's revenue or ROAS — see ADR 0060.

What is written

Only list membership, and only for segments a person switched on for Klaviyo on /audiences:

  • one list per segment, named <business> · <segment> (Marketing OS), found by name or created;
  • members added by a bulk profile import keyed by email only — the emails are the ones Shopify already gave us for the store's own customers, and customers who opted out of marketing or were erased are excluded exactly as for the ad platforms (ADR 0047);
  • members who left the segment removed by profile id, looked up by email.

An import never subscribes anyone to marketing. Klaviyo keeps its own consent record, and nothing here sends a consent field — a list member who never consented to email marketing is still not emailed by a campaign that respects consent. Marketing OS never sends an email or an SMS.


Something wrong or missing on this page? Tell us and we will fix it.