klaviyo-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@klaviyo-mcpshow campaign metrics for 'Summer Sale'"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Documentation. docs/TOOLSET_GUIDE.md is the
practical, per-tool user guide (what each tool does, MCP and REST, and the
metrics it returns). docs/BRD.md (business requirements) and
docs/TRD.md (technical design) document the complete, as-built
system — including the tool surface (TRD §7), error taxonomy, security
requirements (NFR-S), and coding standards (CS-xxx) referenced throughout the
code.
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
Related MCP server: Klaviyo API MCP Server
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 |
| JSON-RPC tool dispatch, |
REST adapter |
| Flask app factory, bearer/ |
Service |
| Account resolution, request building, metric math |
Client |
| HTTP, auth headers, pagination, retry/backoff, response cache |
Cache |
| In-memory TTL cache of successful responses (NoOp when disabled) |
Config |
| Env var loading, fail-fast validation |
Registry |
|
|
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-commitConfiguration
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 dirWindows one-step: install.bat bootstraps the Python 3.11 virtual environment
and dependencies (if .venv is missing) and then runs the configurator, passing
through any arguments:
install.bat :: set up venv + deps, then scaffold + validate
install.bat --check-api :: also verify each key against Klaviyo
install.bat --no-scaffold :: validate an existing config without writing templatesFill in the scaffolded .env and accounts.toml with your real values, then
re-run to confirm. The two files are described below.
Note: the bare
install.py/install.batscaffolds a template.envinto the per-user config directory if one is absent there. If your real keys already live in the repo-root.envinstead, run with--no-scaffoldso the per-user template doesn't shadow it; validation reads the same single.envthe server loads (highest-priority existing file), so it reflects exactly what the server will see.
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 |
|
macOS |
|
Linux |
|
Fallback |
|
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 |
| string | Name of the env var holding the Klaviyo private API key |
| string | Klaviyo metric id for conversion attribution |
| string | Human-readable display name (shown by |
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 |
|
| Override the Klaviyo base URL (useful for testing proxies) |
|
| Pinned Klaviyo API revision header sent on every request |
|
| Retry budget for |
|
| Response-cache time-to-live in seconds; |
|
| Interface for the Flask REST adapter |
|
| Port for the Flask REST adapter |
| (search path) | Explicit path to |
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.pyLog 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 compatibilityA 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-restExample 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/performanceTools and API reference
API scopes required
The Klaviyo private key configured for each account must have these scopes:
Scope | Used by |
| All tools (account resolution) |
| All report tools; |
|
|
|
|
|
|
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 |
| the current date only |
| the previous date only |
| the 7 complete days ending yesterday |
| the 30 complete days ending yesterday |
| the 90 complete days ending yesterday |
| the 365 complete days ending yesterday |
| the 1st of the current month through today |
| the full previous calendar month |
| 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
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 |
| string | No* | Canonical account name (e.g. |
| string | No† | Inclusive start date, |
| string | No† | Inclusive end date, |
| string | No† | Named relative window (see Timeframe presets) as an alternative to |
| string | No | Klaviyo campaign id — filters results to one campaign |
| boolean | No | When |
† 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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| string | No | Filter by flow status (e.g. |
| boolean | No | Filter to archived ( |
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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| string | No† | Inclusive start date, |
| string | No† | Inclusive end date, |
| string | No† | Named relative window (see Timeframe presets) as an alternative to |
| string | No | Klaviyo flow id — filters results to one flow |
| boolean | No | When |
| boolean | No | When |
† 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/performanceklaviyo_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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| 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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| string | Yes |
|
| string | No† | Inclusive start date, |
| string | No† | Inclusive end date, |
| string | No† | Named relative window (see Timeframe presets) as an alternative to |
| string | No | Bucket size: |
| string | No | Klaviyo flow/campaign id — narrows results to one entity |
| 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-timeA 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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| string | Yes |
|
| string | No† | Inclusive start of the current period, |
| string | No† | Inclusive end of the current period, |
| string | No† | Named relative window for the current period (see Timeframe presets). |
| string | No | Explicit prior-period start. Provide with |
| string | No | Explicit prior-period end. |
| 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/compareklaviyo_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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| 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 |
| string | No* | Canonical account name. Required when more than one account is configured. |
| string | No† | Inclusive start date, |
| string | No† | Inclusive end date, |
| string | No† | Named relative window (see Timeframe presets) as an alternative to |
† 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/growthklaviyo_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/breakdownDev 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-missingCoverage 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.inImportant: 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 acmeMakes 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_performanceREST equivalents:
GET /v1/accountsandPOST /v1/campaigns/performanceMulti-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_timeREST equivalents:
GET /v1/flows,POST /v1/flows/performance,POST /v1/performance/over-timeFlow 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-typesummaryresolve_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 presetsCI 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.11Lock 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_periodsresolve_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.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/ccwellness/klaviyo-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server