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
-
In Klaviyo, open Integrations → Manage apps (the page is
https://www.klaviyo.com/manage-apps) and choose Create app. -
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.
-
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.
-
Under Redirect URLs, add this URL exactly, then Save:
https://marketing-api.keyescode.com/integrations/klaviyo/callbackFor local development add
http://localhost:4000/integrations/klaviyo/callbackas a second one. -
Under Scopes → Edit scopes, pick these, which are exactly what the connect asks for:
Scope Why 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 Ordervalue via metric aggregateslists: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/listsanswers 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/audiencesthen read unavailable and say to reconnect.campaigns:writeandtemplates:writewere added on 2026-09-27 (MARK-I6K9N7) for sending email from the Terminal, andlists:readandprofiles:readlater 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, andemail_create_campaign_draftandemail_send_campaignanswer "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
| Variable | Value |
|---|---|
KLAVIYO_CLIENT_ID | the app's Client ID |
KLAVIYO_CLIENT_SECRET | the app's Client secret |
KLAVIYO_REDIRECT_URI | https://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
- Settings → Integrations → Klaviyo → Connect sends them to Klaviyo's consent screen for the account they are signed in to.
- 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.
- 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 Ordersum_valueby$attributed_message(campaigns) and$attributed_flow(flows) — the revenue.Received Email,Opened Email,Clicked Email,Received SMSandClicked SMScountby$messageand 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.