klaviyo-mcp
Enables Claude to access Klaviyo campaign and flow reporting, including email and SMS performance metrics, via MCP stdio or a Flask REST API.
README
klaviyo-mcp
Klaviyo campaign and flow reporting tool for Claude. It exposes Klaviyo email and SMS performance data through two transports that share one service layer:
- MCP stdio — Claude Desktop / Claude Code pick it up automatically via
.mcp.json; no extra process to manage. - Flask REST — a local HTTP API for scripting, dashboards, or integration testing against a running server.
Both transports return identical data; they are thin adapters over the same
KlaviyoService.
Multi-account support. Accounts are addressed by a short canonical name
(e.g. acme). Raw API keys never appear in prompts, logs, or tool arguments —
only the name does. The non-secret account manifest (accounts.toml) maps
canonical names to the environment variable that holds each key.
Table of contents
- Architecture
- Install
- Configuration
- Running the transports
- Tools and API reference
- Dev workflow
- Work package status
- Troubleshooting
Architecture
Claude / REST client
|
MCP stdio (server.py) Flask REST (api/)
\ /
\ /
KlaviyoService (klaviyo_analytics/service.py)
|
KlaviyoClient (klaviyo_analytics/client.py)
|
Klaviyo REST API (https://a.klaviyo.com)
Layer responsibilities:
| Layer | File(s) | Owns |
|---|---|---|
| MCP adapter | server.py |
JSON-RPC tool dispatch, TextContent rendering |
| REST adapter | api/__init__.py, api/routes.py |
Flask app factory, bearer/X-API-Key auth, route handlers |
| Service | klaviyo_analytics/service.py |
Account resolution, request building, metric math |
| Client | klaviyo_analytics/client.py |
HTTP, auth headers, pagination, retry/backoff, response cache |
| Cache | klaviyo_analytics/cache.py |
In-memory TTL cache of successful responses (NoOp when disabled) |
| Config | klaviyo_analytics/config.py |
Env var loading, fail-fast validation |
| Registry | klaviyo_analytics/registry.py |
accounts.toml parsing, canonical-name resolution |
The service has no knowledge of HTTP transports. Both adapters import the same
KlaviyoService and call the same methods, so the two transports are identical
by construction.
KlaviyoClient sets the Authorization: Klaviyo-API-Key <key> and pinned
revision headers on every request. It follows links.next cursor pagination,
retries 429/5xx with exponential backoff and jitter (honouring
Retry-After/RateLimit-Reset), and raises KlaviyoServiceError — not httpx
types — so the service layer stays HTTP-free.
Logs are structured (structlog) and always go to stderr, keeping stdout a clean JSON-RPC channel for the MCP transport.
Install
Requires Python 3.11. The lock file (requirements.txt) is compiled and
hash-pinned under Python 3.11; install it into a 3.11 virtual environment (see
Dev workflow for how to regenerate it).
# Clone and enter the repo
cd klaviyo-mcp
# Create a virtual environment
python -m venv .venv
# Activate (PowerShell)
.\.venv\Scripts\Activate.ps1
# Activate (bash/zsh)
source .venv/bin/activate
# Install locked dependencies (hash-verified)
pip install --require-hashes -r requirements.txt
# Install pre-commit hooks
pre-commit install --hook-type commit-msg --hook-type pre-commit
Configuration
Configuration has two parts: secrets in .env and the account manifest
in accounts.toml. Only .env is secret. accounts.toml is safe to commit.
Quick start: the installer
python install.py sets up and checks your configuration in one step:
- scaffolds the per-user config directory with
.envandaccounts.tomltemplates (existing files are never overwritten — use--forceto replace them); - validates that the account manifest parses and every referenced API-key environment variable resolves (the same fail-fast the server does at startup);
- with
--check-api, pings Klaviyo once per account to confirm each key works; - prints the MCP server entry to drop into
.mcp.json/claude_desktop_config.json.
python install.py # scaffold + validate + print MCP config
python install.py --check-api # also verify each key against Klaviyo
python install.py --config-dir ./somewhere --no-scaffold # validate only, custom dir
Fill in the scaffolded .env and accounts.toml with your real values, then
re-run to confirm. The two files are described below.
Secrets: .env
Copy .env.example to .env and fill in the values. The service reads .env
via python-dotenv at startup. A real environment variable always wins over the
.env file.
The service searches for .env in this order (highest priority first):
| Platform | Path |
|---|---|
| Windows | %APPDATA%\klaviyo-mcp\.env |
| macOS | ~/Library/Application Support/klaviyo-mcp/.env |
| Linux | ~/.config/klaviyo-mcp/.env |
| Fallback | <repo root>/.env |
The per-user path is preferred so the file is written once and shared across
every checkout. A .env in the repo root works fine for local development.
Account manifest: accounts.toml
The manifest maps each canonical account name to the environment variable that holds its API key, its Klaviyo conversion metric id, and a human label.
The service searches for accounts.toml in the same priority order as .env
(user config directory first, then repo root). Set ACCOUNTS_FILE=/path/to/file
to use an explicit path.
A sample file is provided at accounts.toml in this repo. Replace the example
entries with your real accounts.
Required keys per account:
| Key | Type | Description |
|---|---|---|
api_key_env |
string | Name of the env var holding the Klaviyo private API key |
conversion_metric_id |
string | Klaviyo metric id for conversion attribution |
label |
string | Human-readable display name (shown by list_accounts) |
Example:
[acme]
api_key_env = "KLAVIYO_ACME_KEY"
conversion_metric_id = "ABC123"
label = "Acme Storefront"
Account names must be alphanumeric slugs ([a-z0-9][a-z0-9_-]{0,63}). When
only one account is configured, the account argument to tools is optional and
defaults to that account automatically.
Optional env vars
| Variable | Default | Description |
|---|---|---|
KLAVIYO_BASE_URL |
https://a.klaviyo.com |
Override the Klaviyo base URL (useful for testing proxies) |
KLAVIYO_REVISION |
2025-04-15 |
Pinned Klaviyo API revision header sent on every request |
KLAVIYO_MAX_RETRIES |
3 |
Retry budget for 429/5xx responses |
CACHE_TTL_SECONDS |
300 |
Response-cache time-to-live in seconds; 0 disables caching (always fetch fresh). See Response caching |
REST_HOST |
127.0.0.1 |
Interface for the Flask REST adapter |
REST_PORT |
8080 |
Port for the Flask REST adapter |
ACCOUNTS_FILE |
(search path) | Explicit path to accounts.toml |
Response caching
Klaviyo's report endpoints are tightly rate-limited (the values/series reports allow only 1 request/sec, 2/min, 225/day), and the list tools issue several calls each, so the client keeps a small in-memory TTL cache of successful responses. A repeated identical request within the TTL is served from memory without touching Klaviyo — in practice a warm call returns in well under a millisecond versus ~700 ms cold.
- On by default with a 300-second (5-minute) TTL. Report data is historical,
so brief staleness is an acceptable trade for staying clear of the rate limits.
A cache hit is visible as a near-zero
metadata.latency_ms. - Disable with
CACHE_TTL_SECONDS=0to always fetch fresh, or raise/lower the TTL to taste. - Entries are keyed by account + method + path + request body (so accounts never share data and distinct queries never collide), bounded with LRU eviction, and isolated by deep copy so a cached body can't be mutated. Only successful responses are cached — errors are never stored. The cache is process-local; it resets when the MCP server or REST process restarts.
Running the transports
MCP (Claude Desktop / Claude Code)
The .mcp.json at the repo root registers the server:
{
"mcpServers": {
"klaviyo-api": {
"command": "python",
"args": ["C:\\Users\\jodom\\projects\\klaviyo-mcp\\server.py"]
}
}
}
Claude picks this up automatically when the project is open. To start the server manually (for debugging):
python server.py
Log output goes to stderr. The server reads config and validates all account
keys at startup; if an env var named in accounts.toml is missing the process
exits immediately with a CONFIG_ERROR.
Flask REST
The REST adapter requires at least one credential — REST_API_KEY and/or
REST_API_TOKENS — and refuses to start without one. Every request (except
GET /health) must present a valid credential, either as a bearer token
(preferred) or the legacy X-API-Key header:
Authorization: Bearer <token>
X-API-Key: <token> # equivalent, accepted for backward compatibility
A request authenticates if its token matches REST_API_KEY or any entry in
REST_API_TOKENS (a comma-separated list). Configuring several tokens lets you
issue one per client and rotate or revoke individually — drop a token from the
list and it stops working, without disturbing the others. Tokens are compared in
constant time and never echoed back. Missing credential → 401; wrong credential
→ 403. This is token (bearer) auth, not a full OAuth2 authorization-server flow.
# Development server (Windows/dev)
flask --app api run --host 127.0.0.1 --port 8080
# Production (Linux/container)
gunicorn --workers 2 "api:create_app()"
Docker
The REST adapter ships a container (the MCP stdio transport runs locally and is
not containerised). The image is built from requirements.prod.txt — the REST
runtime deps only, no mcp and no dev tooling — runs as a non-root user, and
serves api:create_app() via gunicorn with a GET /health healthcheck.
Secrets are never baked into the image: .env (the REST_API_KEY plus the
Klaviyo per-account keys) is loaded at run time, and accounts.toml is mounted
read-only.
# Fill in secrets first
cp .env.example .env # set REST_API_KEY and your KLAVIYO_*_KEY values
# Build + run with compose (serves http://127.0.0.1:8080)
docker compose up --build
# Or plain docker
docker build -t klaviyo-mcp-rest .
docker run --rm -p 8080:8080 --env-file .env \
-e ACCOUNTS_FILE=/app/accounts.toml \
-v "$PWD/accounts.toml:/app/accounts.toml:ro" \
klaviyo-mcp-rest
Example requests:
# Health check (no auth required)
curl http://127.0.0.1:8080/health
# List accounts
curl -H "X-API-Key: your-rest-secret" \
http://127.0.0.1:8080/v1/accounts
# Campaign performance
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31"}' \
http://127.0.0.1:8080/v1/campaigns/performance
# List flows (optional status + archived filters)
curl -H "X-API-Key: your-rest-secret" \
"http://127.0.0.1:8080/v1/flows?account=acme&status=live"
# Flow performance
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31"}' \
http://127.0.0.1:8080/v1/flows/performance
# Flow structure
curl -H "X-API-Key: your-rest-secret" \
"http://127.0.0.1:8080/v1/flows/PPF7K3ABCD/structure?account=acme"
# Flow performance with resolved message names
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31", "resolve_message_names": true}' \
http://127.0.0.1:8080/v1/flows/performance
# Over-time series (weekly flow trend)
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "entity": "flow", "start_date": "2025-01-01", "end_date": "2025-03-31", "interval": "weekly"}' \
http://127.0.0.1:8080/v1/performance/over-time
# Campaign performance using a timeframe preset instead of explicit dates
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "timeframe": "last_30_days"}' \
http://127.0.0.1:8080/v1/campaigns/performance
Tools and API reference
API scopes required
The Klaviyo private key configured for each account must have these scopes:
| Scope | Used by |
|---|---|
accounts:read |
All tools (account resolution) |
metrics:read |
All report tools; klaviyo_get_list_growth, klaviyo_get_list_growth_by_list, klaviyo_get_list_breakdown (metric discovery + metric-aggregates) |
campaigns:read |
klaviyo_get_campaign_performance, klaviyo_compare_periods (entity campaign) |
flows:read |
klaviyo_get_flows, klaviyo_get_flow_performance, klaviyo_get_flow_structure, klaviyo_get_performance_over_time, klaviyo_compare_periods (entity flow) |
lists:read |
klaviyo_get_list_health, klaviyo_get_list_growth_by_list, klaviyo_get_list_breakdown (list enumeration + sizes) |
Report endpoints (/api/campaign-values-reports, /api/flow-values-reports,
/api/flow-series-reports) are rate-limited by
Klaviyo to 1 request/second, 2 requests/minute, and 225 requests/day. The
client retries automatically with exponential backoff and jitter, honouring the
Retry-After / RateLimit-Reset headers.
Over-time statistics note
klaviyo_get_performance_over_time returns Klaviyo's statistic arrays verbatim
(including rate statistics such as open_rate and click_rate). Each array is
positionally aligned to date_times, exactly as Klaviyo provides them, so the
numbers reconcile with the Klaviyo dashboard. By contrast, the values reports
(klaviyo_get_campaign_performance and klaviyo_get_flow_performance) compute
open, click, and bounce rates locally from the raw count statistics.
Timeframe presets
Every date-scoped tool (klaviyo_get_campaign_performance,
klaviyo_get_flow_performance, klaviyo_get_performance_over_time) accepts the
window as either an explicit start_date+end_date pair or a named
timeframe preset — pass one or the other, never both. A preset resolves to
absolute dates on the server (anchored to the current date), and the resolved
window is echoed back in metadata.period so the exact dates queried are always
visible.
| Preset | Resolves to |
|---|---|
today |
the current date only |
yesterday |
the previous date only |
last_7_days |
the 7 complete days ending yesterday |
last_30_days |
the 30 complete days ending yesterday |
last_90_days |
the 90 complete days ending yesterday |
last_365_days |
the 365 complete days ending yesterday |
this_month |
the 1st of the current month through today |
last_month |
the full previous calendar month |
year_to_date |
January 1 of the current year through today |
Trailing last_N_days windows end yesterday so a partial current day never
skews the counts; the calendar windows (this_month, year_to_date) run through
today. Omitting both the dates and a timeframe returns INVALID_ARGUMENT.
Long date ranges (auto-chunking)
Klaviyo rejects a single report request wider than one calendar year. Rather than erroring, every date-scoped tool auto-chunks a longer range into consecutive sub-windows (each strictly under a calendar year, so leap years never push a chunk over the limit), fetches each, and merges:
- Performance totals (
campaign_performance,flow_performance,compare_periods): each entity's counts are summed across chunks and its rates rederived from the sums. - Over-time series (
get_performance_over_time,entity=flow): the chunks'date_timesand per-flow statistic arrays are concatenated into one continuous series. (entity=campaignis already bucket-stitched and bounded by its 53-bucket cap.) - List growth (
get_list_growth,…_by_list,…_breakdown): the subscribe/unsubscribe event counts are summed across chunks.
Each chunk is a separate rate-limited report call (paced ~1.1 s apart), so a long
range is slower; the response cache makes repeats instant. The
overall range is capped at ~5 years. When a range is chunked, the response
warnings array says so.
Tool summary
| Tool | REST route | Key inputs | Key output fields |
|---|---|---|---|
klaviyo_list_accounts |
GET /v1/accounts |
— | accounts[]{name, label} |
klaviyo_get_campaign_performance |
POST /v1/campaigns/performance |
start_date+end_date or timeframe, campaign?, resolve_campaign_names? |
campaigns[]{campaign_id, campaign_name, sent, delivered, opens, open_rate, clicks, click_rate, bounces, bounce_rate, unsubscribes, conversions, conversion_value}, campaign_count |
klaviyo_get_flows |
GET /v1/flows |
status?, archived? |
flows[]{flow_id, name, status, trigger_type, archived, created, updated}, flow_count |
klaviyo_get_flow_performance |
POST /v1/flows/performance |
start_date+end_date or timeframe, flow?, resolve_message_names?, rollup? |
flows[]{flow_id, flow_message_id, flow_message_name, send_channel, sent, delivered, opens, open_rate, clicks, click_rate, bounces, bounce_rate, unsubscribes, conversions, conversion_value}, flow_count |
klaviyo_get_flow_structure |
GET /v1/flows/<flow_id>/structure |
flow_id (required), account? |
flow_id, action_count, steps[]{action_id, action_type, message_id, message_name, channel}, summary{action_type: count} |
klaviyo_get_performance_over_time |
POST /v1/performance/over-time |
entity (flow/campaign), start_date+end_date or timeframe, interval?, entity_id?, statistics? |
entity, interval, date_times[], series[]{groupings, statistics} |
klaviyo_compare_periods |
POST /v1/performance/compare |
entity (campaign/flow), start_date+end_date or timeframe, prior_start_date?+prior_end_date?, entity_id? |
entity, current_period, prior_period, current_totals, prior_totals, deltas{metric:{absolute, pct_change}}, current_entity_count, prior_entity_count |
klaviyo_get_list_health |
GET /v1/lists/health |
list_id? |
lists[]{list_id, name, opt_in_process, profile_count, created, updated}, list_count, total_profiles |
klaviyo_get_list_growth |
POST /v1/lists/growth |
start_date+end_date or timeframe |
growth{list, email, sms}{subscribed, unsubscribed, net} |
klaviyo_get_list_growth_by_list |
POST /v1/lists/growth-by-list |
start_date+end_date or timeframe |
lists[]{list_id, name, subscribed, unsubscribed, net}, list_count, totals |
klaviyo_get_list_breakdown |
POST /v1/lists/breakdown |
start_date+end_date or timeframe |
lists[]{list_id, name, opt_in_process, profile_count, subscribed, unsubscribed, net}, list_count, totals |
klaviyo_list_accounts
List configured Klaviyo accounts by canonical name and label. Returns no API keys or conversion ids.
Inputs: none
Output:
{
"data": {
"accounts": [
{"name": "acme", "label": "Acme Storefront"}
]
},
"metadata": {
"account": null,
"period": null,
"revision": "2025-04-15",
"latency_ms": 0.0
},
"warnings": []
}
REST equivalent: GET /v1/accounts
klaviyo_get_campaign_performance
Per-campaign email performance for one account over an absolute date range.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name (e.g. acme). Required when more than one account is configured. Omit to use the only configured account. |
start_date |
string | No† | Inclusive start date, YYYY-MM-DD |
end_date |
string | No† | Inclusive end date, YYYY-MM-DD |
timeframe |
string | No† | Named relative window (see Timeframe presets) as an alternative to start_date+end_date |
campaign |
string | No | Klaviyo campaign id — filters results to one campaign |
resolve_campaign_names |
boolean | No | When true, resolve each campaign_id to its human-readable campaign name (default false) |
† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.
resolve_campaign_names details:
The Campaign Values Report groups results by campaign_id and send channel, not
by name, so by default campaign_name falls back to the send channel (email /
sms). When resolve_campaign_names is true, each distinct campaign_id is
looked up once via GET /api/campaigns/{id} (deduped) and the real campaign name
is attached. A failed or missing lookup leaves the channel fallback in place and
never blocks the metrics. This mirrors resolve_message_names on
klaviyo_get_flow_performance; the lookups use the campaigns:read scope this
tool already requires.
Output:
{
"data": {
"campaigns": [
{
"campaign_id": "01ABCDEF...",
"campaign_name": "March Newsletter",
"sent": 12000.0,
"delivered": 11800.0,
"opens": 3540.0,
"open_rate": 0.3,
"clicks": 590.0,
"click_rate": 0.05,
"bounces": 200.0,
"bounce_rate": 0.0167,
"unsubscribes": 12.0,
"conversions": 45.0,
"conversion_value": 2250.0
}
],
"campaign_count": 1
},
"metadata": {
"account": "acme",
"period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
"revision": "2025-04-15",
"latency_ms": 412.3
},
"warnings": [
"Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
]
}
Rates are null when the denominator is zero (e.g. open_rate is null
when delivered is 0).
REST equivalent: POST /v1/campaigns/performance with a JSON body
containing the same fields.
klaviyo_get_flows
List an account's flows with their lifecycle metadata. Does not return
performance counts — use klaviyo_get_flow_performance for those.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
status |
string | No | Filter by flow status (e.g. live, draft, manual). Must be alphanumeric. |
archived |
boolean | No | Filter to archived (true) or active (false) flows. |
Output:
{
"data": {
"flows": [
{
"flow_id": "XYZABC123",
"name": "Welcome Series",
"status": "live",
"trigger_type": "list",
"archived": false,
"created": "2024-01-15T10:00:00+00:00",
"updated": "2024-06-01T08:30:00+00:00"
}
],
"flow_count": 1
},
"metadata": {
"account": "acme",
"period": null,
"revision": "2025-04-15",
"latency_ms": null
},
"warnings": []
}
Follows Klaviyo cursor pagination automatically; returns all matching flows.
Requires the flows:read scope on the account's private key.
REST equivalent: GET /v1/flows?account=acme&status=live&archived=false
klaviyo_get_flow_performance
Per-(flow, message, channel) performance for one account over an absolute date range. Returns one row per unique combination of flow, flow message, and send channel (email or SMS).
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
start_date |
string | No† | Inclusive start date, YYYY-MM-DD |
end_date |
string | No† | Inclusive end date, YYYY-MM-DD |
timeframe |
string | No† | Named relative window (see Timeframe presets) as an alternative to start_date+end_date |
flow |
string | No | Klaviyo flow id — filters results to one flow |
resolve_message_names |
boolean | No | When true, resolve each flow_message_id to its human-readable message name (default false) |
rollup |
boolean | No | When true, collapse the per-message/channel rows into one summed row per flow (default false) |
† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.
rollup details: By default each row is one (flow, message, channel)
combination. With rollup: true, those rows are summed into one row per flow_id
(counts added, rates rederived) with flow_message_id, flow_message_name, and
send_channel set to null to mark a flow-level total. Rollup makes
resolve_message_names moot (message identity is dropped), so its lookups are
skipped when both are set.
Ranges longer than one year are auto-chunked (see Long date ranges
(auto-chunking)). Engagement and conversion
statistics are attributed by event time; sent is anchored to the message send
date. See the warnings array in the response for the time-basis note.
resolve_message_names details:
By default (false) each row carries flow_message_id only, and no additional
API calls are made. When true, each distinct flow_message_id is looked up
once via GET /api/flow-messages/{id} and the resulting name is attached as
flow_message_name on every matching row. Lookups are deduped — if ten rows
share the same message id, Klaviyo is called exactly once for that id. A failed
or missing name lookup leaves flow_message_name as null and never blocks the
metrics from returning.
The flow-messages endpoint is on a lighter rate-limit tier (3 requests/second,
60 requests/minute) compared to the report endpoints, so name resolution is
suitable for interactive queries but should be avoided in tight polling loops.
The flows:read scope already required by this tool covers the name lookups.
Output (with resolve_message_names: true):
{
"data": {
"flows": [
{
"flow_id": "XYZABC123",
"flow_message_id": "MSGDEF456",
"flow_message_name": "Post-Purchase Day 1 Email",
"send_channel": "email",
"sent": 5200.0,
"delivered": 5100.0,
"opens": 1530.0,
"open_rate": 0.3,
"clicks": 255.0,
"click_rate": 0.05,
"bounces": 100.0,
"bounce_rate": 0.0192,
"unsubscribes": 8.0,
"conversions": 22.0,
"conversion_value": 1100.0
}
],
"flow_count": 1
},
"metadata": {
"account": "acme",
"period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
"revision": "2025-04-15",
"latency_ms": 520.1
},
"warnings": [
"Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
]
}
flow_message_name is always present in the output; it is null when
resolve_message_names is false or when a lookup fails. Rates are null
when the denominator is zero. Requires the flows:read scope.
REST equivalent: POST /v1/flows/performance with a JSON body containing
the same fields.
# With name resolution enabled
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "start_date": "2025-03-01", "end_date": "2025-03-31", "resolve_message_names": true}' \
http://127.0.0.1:8080/v1/flows/performance
klaviyo_get_flow_structure
Return the ordered list of actions in a flow, with send steps enriched with
their resolved message name and channel. Useful for auditing flow logic,
cross-referencing message ids from klaviyo_get_flow_performance, and
understanding a flow's shape before diving into its metrics.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
flow_id |
string | Yes | The Klaviyo flow id whose structure to return |
flow_id must be an alphanumeric Klaviyo id. It is validated before being
interpolated into the request path. Requires the flows:read scope.
Output:
{
"data": {
"flow_id": "PPF7K3ABCD",
"action_count": 20,
"steps": [
{
"action_id": "ACT001",
"action_type": "SEND_EMAIL",
"message_id": "MSG001",
"message_name": "Post-Purchase: Thank You",
"channel": "email"
},
{
"action_id": "ACT002",
"action_type": "TIME_DELAY",
"message_id": null,
"message_name": null,
"channel": null
},
{
"action_id": "ACT003",
"action_type": "BOOLEAN_BRANCH",
"message_id": null,
"message_name": null,
"channel": null
},
{
"action_id": "ACT004",
"action_type": "SEND_EMAIL",
"message_id": "MSG002",
"message_name": "Post-Purchase: Day 3 Cross-Sell",
"channel": "email"
},
{
"action_id": "ACT005",
"action_type": "TIME_DELAY",
"message_id": null,
"message_name": null,
"channel": null
},
{
"action_id": "ACT006",
"action_type": "SEND_EMAIL",
"message_id": "MSG003",
"message_name": "Post-Purchase: Day 7 Review Request",
"channel": "email"
}
],
"summary": {
"SEND_EMAIL": 9,
"TIME_DELAY": 8,
"BOOLEAN_BRANCH": 3
}
},
"metadata": {
"account": "acme",
"period": null,
"revision": "2025-04-15",
"latency_ms": null
},
"warnings": []
}
Steps are returned in flow order as Klaviyo provides them. For SEND_EMAIL and
SEND_SMS actions the service fetches the first related flow-message via
GET /api/flow-actions/{id}/flow-messages and attaches its message_id,
message_name, and channel. A failed lookup leaves those three fields as
null without blocking the rest of the steps. Non-send actions (TIME_DELAY,
BOOLEAN_BRANCH, and similar) always have null for the message fields.
summary is a count of steps keyed by action_type. Types not known at
write-time are keyed as-is (Klaviyo may add new action types); an action with
an unparseable type is counted under "UNKNOWN".
REST equivalent: GET /v1/flows/<flow_id>/structure
curl -H "X-API-Key: your-rest-secret" \
"http://127.0.0.1:8080/v1/flows/PPF7K3ABCD/structure?account=acme"
klaviyo_get_performance_over_time
Bucketed over-time series for flows or campaigns over a date range. Returns a
date_times array and one series row per flow/campaign, each with statistics
arrays positionally aligned to date_times.
The two entities are served differently:
flowuses Klaviyo's native flow-series report; its statistic arrays are passed through verbatim (including rate statistics likeopen_rate), so they reconcile with the Klaviyo UI. See the Over-time statistics note.campaignis stitched: Klaviyo has no campaign-series endpoint (/api/campaign-series-reports404s at every revision), so the service issues onecampaign-valuesreport per bucket and assembles the series. This meansdaily/weekly/monthlyonly (nohourly), and one rate-limited report call per bucket — so the bucket count is capped (53) and weekly/monthly are strongly preferred for campaigns. A campaign is a one-time send, so it appears as a spike in the bucket(s) its send and engagement fall in, not a continuous line. Campaign statistics use the same names as the flow series (recipients,delivered,opens_unique,clicks_unique,conversions,conversion_value); rate statistics are not available for campaign trends.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
entity |
string | Yes | flow (native series) or campaign (stitched from campaign-values). |
start_date |
string | No† | Inclusive start date, YYYY-MM-DD |
end_date |
string | No† | Inclusive end date, YYYY-MM-DD |
timeframe |
string | No† | Named relative window (see Timeframe presets) as an alternative to start_date+end_date |
interval |
string | No | Bucket size: hourly, daily, weekly (default), or monthly. Campaigns support daily/weekly/monthly only (no hourly). |
entity_id |
string | No | Klaviyo flow/campaign id — narrows results to one entity |
statistics |
array of strings | No | Statistic names to request; defaults to a volume + engagement + conversion subset |
† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.
Ranges longer than one year are auto-chunked (see Long date ranges
(auto-chunking)); for flow the chunks' series
are concatenated, and for campaign the bucket cap still applies. Passing an
invalid interval or unsupported entity returns an INVALID_ARGUMENT error.
Campaign trend rate-limit cost. Each campaign bucket is a separate
campaign-values report call, and those endpoints allow only ~1/sec and 2/min.
The service paces the calls (~1.1 s apart) to stay under the burst limit and caps
the bucket count at 53, but a wide range is still slow — prefer monthly or
weekly for campaigns (e.g. 4 monthly buckets ≈ 15 s; a full quarter of weekly
buckets can take a minute or more under throttling). The response
cache makes a repeated trend instant, and a retried call after
a partial throttle reuses the buckets already cached.
Output:
{
"data": {
"entity": "flow",
"interval": "weekly",
"date_times": ["2025-03-03T00:00:00", "2025-03-10T00:00:00", "2025-03-17T00:00:00"],
"series": [
{
"groupings": {
"flow_id": "XYZABC123",
"flow_message_id": "MSGDEF456",
"send_channel": "email"
},
"statistics": {
"recipients": [1200.0, 0.0, 3400.0],
"delivered": [1180.0, 0.0, 3340.0],
"opens_unique": [354.0, 0.0, 1002.0],
"open_rate": [0.3, null, 0.3],
"clicks_unique": [59.0, 0.0, 167.0],
"conversions": [4.0, 0.0, 18.0],
"conversion_value": [200.0, 0.0, 900.0]
}
}
]
},
"metadata": {
"account": "acme",
"period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
"revision": "2025-04-15",
"latency_ms": 387.6
},
"warnings": []
}
REST equivalent: POST /v1/performance/over-time with a JSON body
containing the same fields.
# Campaign trend, monthly (one campaign-values call per month — keep it coarse)
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "entity": "campaign", "timeframe": "last_90_days", "interval": "monthly"}' \
http://127.0.0.1:8080/v1/performance/over-time
A campaign trend returns one series per campaign with statistics named like the
flow series (recipients, delivered, opens_unique, clicks_unique,
conversions, conversion_value); a campaign shows nonzero values only in the
bucket(s) its send and engagement fall in. The warnings array carries the
time_basis note and a note explaining the stitched shape.
klaviyo_compare_periods
Compare aggregate campaign or flow performance between a current period and
a prior period, returning per-metric absolute and percent-change deltas. Because
campaigns are one-shot (a campaign sent in one period does not recur in another),
the comparison is done on period totals — the summed counts across all rows,
with rates rederived from those sums — rather than per-entity. Flows aggregate
the same way and additionally accept an entity_id to trend a single flow over
time.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
entity |
string | Yes | campaign or flow. |
start_date |
string | No† | Inclusive start of the current period, YYYY-MM-DD. |
end_date |
string | No† | Inclusive end of the current period, YYYY-MM-DD. |
timeframe |
string | No† | Named relative window for the current period (see Timeframe presets). |
prior_start_date |
string | No | Explicit prior-period start. Provide with prior_end_date. |
prior_end_date |
string | No | Explicit prior-period end. |
entity_id |
string | No | Campaign/flow id to narrow both periods to one entity before aggregating. |
† Set the current window with either start_date+end_date or timeframe.
When prior_start_date/prior_end_date are omitted, the prior period defaults to
the equal-length window ending the day before the current period starts (e.g. a
30-day current window compares against the preceding 30 days). Explicit prior
dates must be supplied as a pair.
Output:
{
"data": {
"entity": "campaign",
"current_period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
"prior_period": {"start_date": "2025-01-29", "end_date": "2025-02-28"},
"current_totals": {
"sent": 106147.0, "delivered": 104900.0, "opens": 36280.0, "open_rate": 0.3459,
"clicks": 5120.0, "click_rate": 0.0488, "bounces": 1247.0, "bounce_rate": 0.0117,
"unsubscribes": 210.0, "conversions": 125.0, "conversion_value": 45171.43
},
"prior_totals": {
"sent": 50284.0, "delivered": 49600.0, "opens": 18060.0, "open_rate": 0.3641,
"clicks": 2480.0, "click_rate": 0.05, "bounces": 620.0, "bounce_rate": 0.0123,
"unsubscribes": 95.0, "conversions": 68.0, "conversion_value": 29825.59
},
"deltas": {
"sent": {"absolute": 55863.0, "pct_change": 1.1109},
"conversions": {"absolute": 57.0, "pct_change": 0.8382},
"conversion_value": {"absolute": 15345.84, "pct_change": 0.5145},
"open_rate": {"absolute": -0.0182, "pct_change": -0.05}
},
"current_entity_count": 6,
"prior_entity_count": 3
},
"metadata": {
"account": "acme",
"period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
"revision": "2025-04-15",
"latency_ms": 980.2
},
"warnings": [
"Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
]
}
deltas carries every metric in the totals (only a few are shown above).
absolute is current - prior; pct_change is the fraction relative to the
prior value (e.g. 1.1109 = +111%) and is null when the prior value is 0.
metadata.period echoes the current period. This tool makes two report
calls (current + prior), so the report rate limit applies to each; the
time_basis caveat is the same as the underlying performance reports.
REST equivalent: POST /v1/performance/compare with a JSON body containing
the same fields.
# Campaigns: this month vs. the preceding equal-length window
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "entity": "campaign", "timeframe": "this_month"}' \
http://127.0.0.1:8080/v1/performance/compare
klaviyo_get_list_health
Per-list membership health for an account: each list's current profile_count,
opt_in_process (single vs double opt-in), name, and created/updated
timestamps, plus list_count and total_profiles. This is current-state
inventory only — subscribe/unsubscribe trends over time are out of scope for
this tool.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
list_id |
string | No | Klaviyo list id — return just this one list instead of all. |
Output:
{
"data": {
"lists": [
{
"list_id": "SrEULb",
"name": "New Email Subs",
"opt_in_process": "single_opt_in",
"profile_count": 24296,
"created": "2025-01-05T01:21:29+00:00",
"updated": "2026-06-18T00:00:00+00:00"
}
],
"list_count": 13,
"total_profiles": 43730
},
"metadata": {
"account": "acme",
"period": null,
"revision": "2025-04-15",
"latency_ms": null
},
"warnings": [
"total_profiles is the sum of per-list profile_count values; a profile in several lists is counted once per list, so this is not a deduplicated audience size."
]
}
profile_count is only available on Klaviyo's single-list endpoint (the
/api/lists collection rejects additional-fields[list]=profile_count), so the
all-lists path enumerates lists and then fetches each list's count
individually — one extra request per list. A per-list lookup failure leaves that
list's profile_count as null rather than dropping the list or failing the
call. profile_count is also null if Klaviyo omits it. Requires the
lists:read scope.
REST equivalent: GET /v1/lists/health (optional list_id query param).
# All lists with sizes
curl -H "X-API-Key: your-rest-secret" \
"http://127.0.0.1:8080/v1/lists/health?account=acme"
# A single list
curl -H "X-API-Key: your-rest-secret" \
"http://127.0.0.1:8080/v1/lists/health?account=acme&list_id=SrEULb"
klaviyo_get_list_growth
Subscribe/unsubscribe totals and net growth over a period, per channel
(list, email, sms). This is the growth counterpart to
klaviyo_get_list_health (which is current-state sizes). For each channel it
sums the subscribed and unsubscribed Klaviyo system-metric event counts over the
window and returns net = subscribed - unsubscribed.
Inputs:
| Field | Type | Required | Description |
|---|---|---|---|
account |
string | No* | Canonical account name. Required when more than one account is configured. |
start_date |
string | No† | Inclusive start date, YYYY-MM-DD |
end_date |
string | No† | Inclusive end date, YYYY-MM-DD |
timeframe |
string | No† | Named relative window (see Timeframe presets) as an alternative to start_date+end_date |
† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.
Output:
{
"data": {
"growth": {
"list": {"subscribed": 4630, "unsubscribed": 51, "net": 4579},
"email": {"subscribed": 2952, "unsubscribed": 327, "net": 2625},
"sms": {"subscribed": 1395, "unsubscribed": 538, "net": 857}
}
},
"metadata": {
"account": "acme",
"period": {"start_date": "2025-05-19", "end_date": "2025-06-17"},
"revision": "2025-04-15",
"latency_ms": 642.0
},
"warnings": []
}
Each channel maps to two Klaviyo system metrics, resolved to ids by name at call
time (Subscribed to List / Unsubscribed from List, the … Email Marketing
pair, and the … SMS Marketing pair). Counts are event totals, not
deduplicated profiles — a profile that subscribes twice counts twice, and these
do not reconcile to profile_count deltas. A metric name absent on the account
(e.g. an account with no SMS) yields null for that side, net null, and a
warning naming the unresolved metrics; a failed aggregate call degrades the same
way rather than failing the response. The tool issues one metric-aggregates call
per metric (up to six), all under the metrics:read scope.
REST equivalent: POST /v1/lists/growth with a JSON body containing the same
fields.
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "timeframe": "last_30_days"}' \
http://127.0.0.1:8080/v1/lists/growth
klaviyo_get_list_growth_by_list
The per-list view of klaviyo_get_list_growth: one row per list with subscribed,
unsubscribed, and net over the window, plus account-wide totals. The
Subscribed to List / Unsubscribed from List metrics are each summed grouped
by Klaviyo's List dimension (one metric-aggregates call apiece) and joined to
list ids by name. Only lists with activity in the window appear.
Inputs: account?, and the window as start_date+end_date or
timeframe (see Timeframe presets).
Output:
{
"data": {
"lists": [
{"list_id": "SrEULb", "name": "New Email Subs", "subscribed": 2554, "unsubscribed": 48, "net": 2506}
],
"list_count": 5,
"totals": {"subscribed": 4630, "unsubscribed": 51, "net": 4579}
},
"metadata": {"account": "acme", "period": {"start_date": "2025-05-19", "end_date": "2025-06-17"}, "revision": "2025-04-15", "latency_ms": null},
"warnings": ["Counts are subscribe/unsubscribe events over the window, not deduplicated profiles; per-list rows are keyed by Klaviyo's list name and joined to the list id, so lists sharing a name may not be distinguishable."]
}
Per-list rows are keyed by Klaviyo's list name (the List dimension value)
and joined back to /api/lists to recover list_id — so a growth row for a
deleted list, or one sharing a name with another list, may have a null or
ambiguous list_id. net is null when a metric is absent on the account (with
a warning). Requires metrics:read and lists:read.
REST equivalent: POST /v1/lists/growth-by-list.
klaviyo_get_list_breakdown
The combined per-list view: each list's current size (profile_count,
opt_in_process) and its growth (subscribed/unsubscribed/net) over
the window, with account-wide totals. Every list is included — a list with no
subscribe/unsubscribe activity in the window shows 0 (when the metric
resolved). This merges klaviyo_get_list_health with the per-list growth above.
Inputs: account?, and the window as start_date+end_date or
timeframe.
Output:
{
"data": {
"lists": [
{"list_id": "SrEULb", "name": "New Email Subs", "opt_in_process": "single_opt_in", "profile_count": 24307, "subscribed": 2554, "unsubscribed": 48, "net": 2506}
],
"list_count": 13,
"totals": {"profile_count": 43748, "subscribed": 4630, "unsubscribed": 51, "net": 4579}
},
"metadata": {"account": "acme", "period": {"start_date": "2025-05-19", "end_date": "2025-06-17"}, "revision": "2025-04-15", "latency_ms": null},
"warnings": ["Counts are subscribe/unsubscribe events over the window, not deduplicated profiles; ..."]
}
Sizes come from the single-list endpoint (one request per list, as in
klaviyo_get_list_health) and growth from two grouped metric-aggregates calls.
Requires metrics:read and lists:read.
REST equivalent: POST /v1/lists/breakdown.
curl -X POST \
-H "X-API-Key: your-rest-secret" \
-H "Content-Type: application/json" \
-d '{"account": "acme", "timeframe": "last_30_days"}' \
http://127.0.0.1:8080/v1/lists/breakdown
Dev workflow
Linting and type checking
# Lint and auto-fix
ruff check --fix .
# Format
ruff format .
# Type check
mypy .
Tests
# Unit tests only (no live Klaviyo calls)
pytest -m "not integration"
# All tests including live integration (requires real credentials)
pytest
# With coverage report
pytest --cov --cov-report=term-missing
Coverage gate: 80% line coverage on klaviyo_analytics/.
Continuous integration
.github/workflows/ci.yml runs on every push and pull request to main. It
installs the hash-pinned dependencies on Python 3.11 and runs the same gates as
local dev: ruff check, ruff format --check, mypy on the business modules
(klaviyo_analytics server.py api), and pytest -m "not integration" with the
80% coverage gate. Live integration tests are excluded — they need real
credentials.
Updating dependencies
Two lock files are managed with pip-tools: requirements.txt (everything —
runtime, MCP, and dev/test) for local development, and requirements.prod.txt
(REST runtime only, no mcp, no dev tooling) for the Docker image. Regenerate
both when changing pins:
pip-compile --allow-unsafe --generate-hashes --output-file=requirements.txt requirements.in
pip-compile --allow-unsafe --generate-hashes --output-file=requirements.prod.txt requirements.prod.in
Important: Always regenerate the lock files on Python 3.11 (the project's
target). Compiling on a different minor version can pin version-specific or
platform-specific wheels and produce hashes that fail --require-hashes on 3.11.
Live connectivity check
python live_smoke.py --account acme
Makes real Klaviyo calls against five checks: account listing, campaign
performance (last 30 days), flow listing, flow structure (using the first flow
returned by the listing step), and an over-time weekly series (last 90 days).
Requires a valid .env and accounts.toml. The flow checks require the
flows:read scope; if the key lacks it the script prints a warning and
continues rather than aborting. See live_smoke.py for details.
Work package status
WP-0 — done:
- Two MCP tools:
klaviyo_list_accountsandklaviyo_get_campaign_performance - REST equivalents:
GET /v1/accountsandPOST /v1/campaigns/performance - Multi-account canonical-name registry (
accounts.toml) - Klaviyo Campaign Values Report integration with derived open/click/bounce rates
- Retry/backoff, pagination, pinned API revision, structured logging
WP-1 — done:
- Three new MCP tools:
klaviyo_get_flows,klaviyo_get_flow_performance,klaviyo_get_performance_over_time - REST equivalents:
GET /v1/flows,POST /v1/flows/performance,POST /v1/performance/over-time - Flow Values Report integration with per-(flow, message, channel) rows and derived rates
- Over-time series (flow only — Klaviyo has no campaign-series endpoint) with Klaviyo-native statistic arrays passed through verbatim
- 366-day date range cap enforced up front across all report tools
flows:readscope requirement documented
WP-2 — done:
- New MCP tool
klaviyo_get_flow_structureand REST equivalentGET /v1/flows/<flow_id>/structure— returns ordered flow actions withaction_id,action_type, and (for send steps) resolvedmessage_id,message_name, andchannel; plusaction_countand a per-typesummary resolve_message_namesoption onklaviyo_get_flow_performance(defaultfalse): whentrue, each distinctflow_message_idis resolved once viaGET /api/flow-messages/{id}(deduped) and attached asflow_message_nameper row — delivers the previously-deferred flow-message label resolution
WP-3 — done:
- Timeframe presets on all three date-scoped tools (
klaviyo_get_campaign_performance,klaviyo_get_flow_performance,klaviyo_get_performance_over_time): pass a namedtimeframe(today,yesterday,last_7_days,last_30_days,last_90_days,last_365_days,this_month,last_month,year_to_date) instead of explicitstart_date+end_date. Presets resolve to absolute dates on the server and the resolved window is echoed inmetadata.period. Trailing windows end yesterday to exclude the partial current day; the service rejects supplying both forms or neither — see Timeframe presets - CI workflow (
.github/workflows/ci.yml): ruff lint + format check, mypy on the business modules, and the unit suite with the coverage gate, all on Python 3.11 - Lock file recompiled and hash-pinned under Python 3.11 (the project target)
WP-4 — done:
- New MCP tool
klaviyo_compare_periodsand REST equivalentPOST /v1/performance/compare— period-over-period aggregate comparison for campaigns or flows. Returns summed totals for a current and a prior period (rates rederived from the sums) plus per-metric absolute and percent-change deltas. The current window takes atimeframepreset or explicit dates; the prior window defaults to the equal-length window immediately before it (overridable viaprior_start_date/prior_end_date). An optionalentity_idtrends a single campaign/flow. Seeklaviyo_compare_periods resolve_campaign_namesoption onklaviyo_get_campaign_performance(defaultfalse): resolve eachcampaign_idto its real campaign name viaGET /api/campaigns/{id}(deduped), the campaign-side counterpart toresolve_message_names— without itcampaign_namefalls back to the send channel because the values report groups by id and channel, not name
WP-5 — done:
- New MCP tool
klaviyo_get_list_healthand REST equivalentGET /v1/lists/health— per-list currentprofile_count,opt_in_process, name, and timestamps, pluslist_countandtotal_profiles. Because Klaviyo exposesprofile_countonly on the single-list endpoint, the all-lists path enumerates lists then fetches each count individually; a per-list failure leaves that countnullwithout dropping the list. Seeklaviyo_get_list_health
WP-6 — done:
- New MCP tool
klaviyo_get_list_growthand REST equivalentPOST /v1/lists/growth— per-channel (list, email, sms) subscribed/unsubscribed totals and net growth over atimeframeor explicit date range. Klaviyo system metrics are resolved to ids by name and summed viaPOST /api/metric-aggregates; an absent metric yields a null count with a warning. This also lands the previously-deferred metric-aggregates integration. The growth counterpart toklaviyo_get_list_health. Seeklaviyo_get_list_growth
WP-7 — done:
- Two per-list tools:
klaviyo_get_list_growth_by_list(POST /v1/lists/growth-by-list) — subscribed/unsubscribed/net per list via theSubscribed/Unsubscribed to Listmetrics grouped by Klaviyo'sListdimension, joined to list ids by name — andklaviyo_get_list_breakdown(POST /v1/lists/breakdown) — every list's current size and its window growth in one row. Seeklaviyo_get_list_growth_by_listandklaviyo_get_list_breakdown
WP-8 — done:
- In-memory TTL response cache (
klaviyo_analytics/cache.py) wired into the client: successful GET/POST/paginated responses are cached, keyed by account + method + path + body, with LRU eviction and deep-copy isolation. On by default at a 300 s TTL (CACHE_TTL_SECONDS,0to disable). Eases Klaviyo's report rate limits and makes repeated queries near-instant. See Response caching
WP-9 — done:
- Campaign trends:
klaviyo_get_performance_over_timenow acceptsentity="campaign"and builds the series by stitching onecampaign-valuesreport per bucket (daily/weekly/monthly), since Klaviyo has no campaign-series endpoint. Output matches the flow series shape (one series per campaign, flow-series statistic names). Calls are paced (~1.1 s) to respect the report burst limit and capped at 53 buckets; the response cache makes repeats instant. Seeklaviyo_get_performance_over_time
WP-10 — done:
- Auto-chunking for date ranges over one year, across all date-scoped tools. A long range is split into consecutive sub-windows (calendar-accurate, each strictly under a year), fetched per chunk (paced), and merged: performance totals are summed (rates rederived), flow over-time series are concatenated, and list-growth event counts are summed. Calls are paced and the overall range capped at ~5 years; chunked responses carry a warning. See Long date ranges (auto-chunking)
WP-11 — done:
rollupoption onklaviyo_get_flow_performance(defaultfalse): collapse the per-(flow, message, channel) rows into one summed row per flow — counts added, rates rederived,flow_message_id/flow_message_name/send_channelnulled. Reuses the chunk-merge summing and skips message-name resolution (moot under rollup). Seeklaviyo_get_flow_performance
WP-12 — done:
- Containerisation of the REST adapter:
Dockerfile(slimpython:3.11-slim, non-root, gunicorn,/healthhealthcheck) built from a new REST-runtime-only lockrequirements.prod.txt(nomcp, no dev tooling),docker-compose.yml(env-based secrets, mountedaccounts.toml), and.dockerignore. Secrets are never baked into the image. See Docker
WP-13 — done:
- Installer / configurator CLI (
install.py): scaffolds the per-user config directory with.envandaccounts.tomltemplates (never overwriting without--force), validates that every account's API-key env var resolves, optionally pings Klaviyo per account (--check-api), and prints the MCP server config entry. Reusespaths.pyand the registry loader; helpers are unit tested. See Quick start: the installer
WP-14 — done:
- Token (bearer) auth for the REST adapter: requests authenticate via
Authorization: Bearer <token>(preferred) or the legacyX-API-Keyheader, matched constant-time againstREST_API_KEYplus a comma-separatedREST_API_TOKENSlist — so several clients can be issued tokens and rotated/revoked individually. The adapter now starts with either credential source. Full OAuth2 (an authorization-server / external IdP flow) remains out of scope. See Flask REST
All planned work packages are complete; no items remain deferred.
Troubleshooting
CONFIG_ERROR: environment variable KLAVIYO_ACME_KEY for account 'acme' is not set
The env var named in accounts.toml under api_key_env is not in the
environment. Check that .env contains the variable and is being found (see
Configuration).
CONFIG_ERROR: no REST credential configured
The Flask REST adapter requires REST_API_KEY and/or REST_API_TOKENS in .env.
Set at least one to a random string (e.g. output of
python -c "import secrets; print(secrets.token_hex(32))").
UNKNOWN_ACCOUNT: unknown account 'foo'
The name passed to account does not match any entry in accounts.toml. The
error response includes available_accounts listing the valid names.
INVALID_ARGUMENT: account is required when multiple accounts are configured
More than one account is in accounts.toml and no account was passed. Pass
the canonical name explicitly.
MCP server not appearing in Claude
Verify that .mcp.json points to the correct absolute path for server.py and
that the .venv has all dependencies installed. Run python server.py manually
to check for startup errors on stderr.
401 Unauthorized / 403 Forbidden from the REST adapter
Every request except GET /health must present a valid credential, as
Authorization: Bearer <token> or X-API-Key: <token>. 401 means no credential
was sent; 403 means it didn't match REST_API_KEY or any entry in
REST_API_TOKENS.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.