klaviyo-mcp
by ccwellness
README.md
# 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`](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`](docs/BRD.md) (business requirements) and
[`docs/TRD.md`](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
1. [Architecture](#architecture)
2. [Install](#install)
3. [Configuration](#configuration)
4. [Running the transports](#running-the-transports)
5. [Tools and API reference](#tools-and-api-reference)
6. [Dev workflow](#dev-workflow)
7. [Work package status](#work-package-status)
8. [Troubleshooting](#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](#dev-workflow) for how to regenerate it).
```bash
# 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 `.env` and `accounts.toml`
templates (existing files are never overwritten — use `--force` to 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`.
```bash
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
```
**Windows 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:
```bat
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 templates
```
Fill 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.bat` scaffolds a template `.env` into the
> per-user config directory if one is absent there. If your real keys already live
> in the **repo-root** `.env` instead, run with `--no-scaffold` so the per-user
> template doesn't shadow it; validation reads the same single `.env` the 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 | `%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:
```toml
[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](#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=0` to 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:
```json
{
"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):
```bash
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.
```bash
# 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.
```bash
# 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:**
```bash
# 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_times` and per-flow statistic arrays are **concatenated** into one
continuous series. (`entity=campaign` is 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](#response-caching) 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:**
```json
{
"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](#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:**
```json
{
"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:**
```json
{
"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](#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)](#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`):**
```json
{
"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.
```bash
# 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:**
```json
{
"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`
```bash
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:
- **`flow`** uses Klaviyo's native flow-series report; its statistic arrays are
passed through verbatim (including rate statistics like `open_rate`), so they
reconcile with the Klaviyo UI. See the [Over-time statistics
note](#over-time-statistics-note).
- **`campaign`** is **stitched**: Klaviyo has no campaign-series endpoint
(`/api/campaign-series-reports` 404s at every revision), so the service issues
one `campaign-values` report **per bucket** and assembles the series. This means
`daily`/`weekly`/`monthly` only (no `hourly`), 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](#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)](#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](#response-caching) makes a repeated trend instant, and a retried call after
a partial throttle reuses the buckets already cached.
**Output:**
```json
{
"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.
```bash
# 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](#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:**
```json
{
"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.
```bash
# 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:**
```json
{
"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).
```bash
# 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](#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:**
```json
{
"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.
```bash
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](#timeframe-presets)).
**Output:**
```json
{
"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:**
```json
{
"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`.
```bash
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
```bash
# Lint and auto-fix
ruff check --fix .
# Format
ruff format .
# Type check
mypy .
```
### Tests
```bash
# 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:
```bash
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
```bash
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_accounts` and `klaviyo_get_campaign_performance`
- REST equivalents: `GET /v1/accounts` and `POST /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:read` scope requirement documented
**WP-2 — done:**
- New MCP tool `klaviyo_get_flow_structure` and REST equivalent `GET /v1/flows/<flow_id>/structure`
— returns ordered flow actions with `action_id`, `action_type`, and (for send steps) resolved
`message_id`, `message_name`, and `channel`; plus `action_count` and a per-type `summary`
- `resolve_message_names` option on `klaviyo_get_flow_performance` (default `false`): when `true`,
each distinct `flow_message_id` is resolved once via `GET /api/flow-messages/{id}` (deduped)
and attached as `flow_message_name` per 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 named
`timeframe` (`today`, `yesterday`, `last_7_days`, `last_30_days`, `last_90_days`,
`last_365_days`, `this_month`, `last_month`, `year_to_date`) instead of explicit
`start_date`+`end_date`. Presets resolve to absolute dates on the server and the resolved
window is echoed in `metadata.period`. Trailing windows end yesterday to exclude the partial
current day; the service rejects supplying both forms or neither — see
[Timeframe presets](#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_periods` and REST equivalent `POST /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 a `timeframe` preset or explicit dates; the
prior window defaults to the equal-length window immediately before it (overridable via
`prior_start_date`/`prior_end_date`). An optional `entity_id` trends a single campaign/flow.
See [`klaviyo_compare_periods`](#klaviyo_compare_periods)
- `resolve_campaign_names` option on `klaviyo_get_campaign_performance` (default `false`): resolve
each `campaign_id` to its real campaign name via `GET /api/campaigns/{id}` (deduped), the
campaign-side counterpart to `resolve_message_names` — without it `campaign_name` falls 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_health` and REST equivalent `GET /v1/lists/health` — per-list
current `profile_count`, `opt_in_process`, name, and timestamps, plus `list_count` and
`total_profiles`. Because Klaviyo exposes `profile_count` only on the single-list endpoint, the
all-lists path enumerates lists then fetches each count individually; a per-list failure leaves
that count `null` without dropping the list. See [`klaviyo_get_list_health`](#klaviyo_get_list_health)
**WP-6 — done:**
- New MCP tool `klaviyo_get_list_growth` and REST equivalent `POST /v1/lists/growth` — per-channel
(list, email, sms) subscribed/unsubscribed totals and net growth over a `timeframe` or explicit
date range. Klaviyo system metrics are resolved to ids by name and summed via
`POST /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 to
`klaviyo_get_list_health`. See [`klaviyo_get_list_growth`](#klaviyo_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 the `Subscribed/Unsubscribed to List` metrics grouped
by Klaviyo's `List` dimension, joined to list ids by name — and `klaviyo_get_list_breakdown`
(`POST /v1/lists/breakdown`) — every list's current size *and* its window growth in one row.
See [`klaviyo_get_list_growth_by_list`](#klaviyo_get_list_growth_by_list) and
[`klaviyo_get_list_breakdown`](#klaviyo_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`, `0` to
disable). Eases Klaviyo's report rate limits and makes repeated queries near-instant. See
[Response caching](#response-caching)
**WP-9 — done:**
- Campaign trends: `klaviyo_get_performance_over_time` now accepts `entity="campaign"` and builds
the series by stitching one `campaign-values` report 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. See
[`klaviyo_get_performance_over_time`](#klaviyo_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)](#long-date-ranges-auto-chunking)
**WP-11 — done:**
- `rollup` option on `klaviyo_get_flow_performance` (default `false`): collapse the
per-(flow, message, channel) rows into one summed row per flow — counts added, rates rederived,
`flow_message_id`/`flow_message_name`/`send_channel` nulled. Reuses the chunk-merge summing and
skips message-name resolution (moot under rollup). See
[`klaviyo_get_flow_performance`](#klaviyo_get_flow_performance)
**WP-12 — done:**
- Containerisation of the REST adapter: `Dockerfile` (slim `python:3.11-slim`, non-root, gunicorn,
`/health` healthcheck) built from a new REST-runtime-only lock `requirements.prod.txt`
(no `mcp`, no dev tooling), `docker-compose.yml` (env-based secrets, mounted `accounts.toml`),
and `.dockerignore`. Secrets are never baked into the image. See [Docker](#docker)
**WP-13 — done:**
- Installer / configurator CLI (`install.py`): scaffolds the per-user config directory with `.env`
and `accounts.toml` templates (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. Reuses `paths.py` and the registry loader; helpers are unit
tested. See [Quick start: the installer](#quick-start-the-installer)
**WP-14 — done:**
- Token (bearer) auth for the REST adapter: requests authenticate via
`Authorization: Bearer <token>` (preferred) or the legacy `X-API-Key` header, matched
constant-time against `REST_API_KEY` plus a comma-separated `REST_API_TOKENS` list — 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](#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](#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 deployed
Maintenance
ActivityInactive
ResponsivenessNo issues