klaviyo-mcp

klaviyo-mcp

Enables Claude to access Klaviyo campaign and flow reporting, including email and SMS performance metrics, via MCP stdio or a Flask REST API.

Category
Visit Server

README

klaviyo-mcp

Klaviyo campaign and flow reporting tool for Claude. It exposes Klaviyo email and SMS performance data through two transports that share one service layer:

  • MCP stdio — Claude Desktop / Claude Code pick it up automatically via .mcp.json; no extra process to manage.
  • Flask REST — a local HTTP API for scripting, dashboards, or integration testing against a running server.

Both transports return identical data; they are thin adapters over the same KlaviyoService.

Multi-account support. Accounts are addressed by a short canonical name (e.g. acme). Raw API keys never appear in prompts, logs, or tool arguments — only the name does. The non-secret account manifest (accounts.toml) maps canonical names to the environment variable that holds each key.


Table of contents

  1. Architecture
  2. Install
  3. Configuration
  4. Running the transports
  5. Tools and API reference
  6. Dev workflow
  7. Work package status
  8. Troubleshooting

Architecture

Claude / REST client
        |
   MCP stdio (server.py)          Flask REST (api/)
        \                         /
         \                       /
          KlaviyoService  (klaviyo_analytics/service.py)
                 |
          KlaviyoClient  (klaviyo_analytics/client.py)
                 |
         Klaviyo REST API  (https://a.klaviyo.com)

Layer responsibilities:

Layer File(s) Owns
MCP adapter server.py JSON-RPC tool dispatch, TextContent rendering
REST adapter api/__init__.py, api/routes.py Flask app factory, bearer/X-API-Key auth, route handlers
Service klaviyo_analytics/service.py Account resolution, request building, metric math
Client klaviyo_analytics/client.py HTTP, auth headers, pagination, retry/backoff, response cache
Cache klaviyo_analytics/cache.py In-memory TTL cache of successful responses (NoOp when disabled)
Config klaviyo_analytics/config.py Env var loading, fail-fast validation
Registry klaviyo_analytics/registry.py accounts.toml parsing, canonical-name resolution

The service has no knowledge of HTTP transports. Both adapters import the same KlaviyoService and call the same methods, so the two transports are identical by construction.

KlaviyoClient sets the Authorization: Klaviyo-API-Key <key> and pinned revision headers on every request. It follows links.next cursor pagination, retries 429/5xx with exponential backoff and jitter (honouring Retry-After/RateLimit-Reset), and raises KlaviyoServiceError — not httpx types — so the service layer stays HTTP-free.

Logs are structured (structlog) and always go to stderr, keeping stdout a clean JSON-RPC channel for the MCP transport.


Install

Requires Python 3.11. The lock file (requirements.txt) is compiled and hash-pinned under Python 3.11; install it into a 3.11 virtual environment (see Dev workflow for how to regenerate it).

# Clone and enter the repo
cd klaviyo-mcp

# Create a virtual environment
python -m venv .venv

# Activate (PowerShell)
.\.venv\Scripts\Activate.ps1
# Activate (bash/zsh)
source .venv/bin/activate

# Install locked dependencies (hash-verified)
pip install --require-hashes -r requirements.txt

# Install pre-commit hooks
pre-commit install --hook-type commit-msg --hook-type pre-commit

Configuration

Configuration has two parts: secrets in .env and the account manifest in accounts.toml. Only .env is secret. accounts.toml is safe to commit.

Quick start: the installer

python install.py sets up and checks your configuration in one step:

  • scaffolds the per-user config directory with .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.
python install.py                 # scaffold + validate + print MCP config
python install.py --check-api     # also verify each key against Klaviyo
python install.py --config-dir ./somewhere --no-scaffold   # validate only, custom dir

Fill in the scaffolded .env and accounts.toml with your real values, then re-run to confirm. The two files are described below.

Secrets: .env

Copy .env.example to .env and fill in the values. The service reads .env via python-dotenv at startup. A real environment variable always wins over the .env file.

The service searches for .env in this order (highest priority first):

Platform Path
Windows %APPDATA%\klaviyo-mcp\.env
macOS ~/Library/Application Support/klaviyo-mcp/.env
Linux ~/.config/klaviyo-mcp/.env
Fallback <repo root>/.env

The per-user path is preferred so the file is written once and shared across every checkout. A .env in the repo root works fine for local development.

Account manifest: accounts.toml

The manifest maps each canonical account name to the environment variable that holds its API key, its Klaviyo conversion metric id, and a human label.

The service searches for accounts.toml in the same priority order as .env (user config directory first, then repo root). Set ACCOUNTS_FILE=/path/to/file to use an explicit path.

A sample file is provided at accounts.toml in this repo. Replace the example entries with your real accounts.

Required keys per account:

Key Type Description
api_key_env string Name of the env var holding the Klaviyo private API key
conversion_metric_id string Klaviyo metric id for conversion attribution
label string Human-readable display name (shown by list_accounts)

Example:

[acme]
api_key_env = "KLAVIYO_ACME_KEY"
conversion_metric_id = "ABC123"
label = "Acme Storefront"

Account names must be alphanumeric slugs ([a-z0-9][a-z0-9_-]{0,63}). When only one account is configured, the account argument to tools is optional and defaults to that account automatically.

Optional env vars

Variable Default Description
KLAVIYO_BASE_URL https://a.klaviyo.com Override the Klaviyo base URL (useful for testing proxies)
KLAVIYO_REVISION 2025-04-15 Pinned Klaviyo API revision header sent on every request
KLAVIYO_MAX_RETRIES 3 Retry budget for 429/5xx responses
CACHE_TTL_SECONDS 300 Response-cache time-to-live in seconds; 0 disables caching (always fetch fresh). See Response caching
REST_HOST 127.0.0.1 Interface for the Flask REST adapter
REST_PORT 8080 Port for the Flask REST adapter
ACCOUNTS_FILE (search path) Explicit path to accounts.toml

Response caching

Klaviyo's report endpoints are tightly rate-limited (the values/series reports allow only 1 request/sec, 2/min, 225/day), and the list tools issue several calls each, so the client keeps a small in-memory TTL cache of successful responses. A repeated identical request within the TTL is served from memory without touching Klaviyo — in practice a warm call returns in well under a millisecond versus ~700 ms cold.

  • On by default with a 300-second (5-minute) TTL. Report data is historical, so brief staleness is an acceptable trade for staying clear of the rate limits. A cache hit is visible as a near-zero metadata.latency_ms.
  • Disable with CACHE_TTL_SECONDS=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:

{
  "mcpServers": {
    "klaviyo-api": {
      "command": "python",
      "args": ["C:\\Users\\jodom\\projects\\klaviyo-mcp\\server.py"]
    }
  }
}

Claude picks this up automatically when the project is open. To start the server manually (for debugging):

python server.py

Log output goes to stderr. The server reads config and validates all account keys at startup; if an env var named in accounts.toml is missing the process exits immediately with a CONFIG_ERROR.

Flask REST

The REST adapter requires at least one credential — REST_API_KEY and/or REST_API_TOKENS — and refuses to start without one. Every request (except GET /health) must present a valid credential, either as a bearer token (preferred) or the legacy X-API-Key header:

Authorization: Bearer <token>
X-API-Key: <token>          # equivalent, accepted for backward compatibility

A request authenticates if its token matches REST_API_KEY or any entry in REST_API_TOKENS (a comma-separated list). Configuring several tokens lets you issue one per client and rotate or revoke individually — drop a token from the list and it stops working, without disturbing the others. Tokens are compared in constant time and never echoed back. Missing credential → 401; wrong credential → 403. This is token (bearer) auth, not a full OAuth2 authorization-server flow.

# Development server (Windows/dev)
flask --app api run --host 127.0.0.1 --port 8080

# Production (Linux/container)
gunicorn --workers 2 "api:create_app()"

Docker

The REST adapter ships a container (the MCP stdio transport runs locally and is not containerised). The image is built from requirements.prod.txt — the REST runtime deps only, no mcp and no dev tooling — runs as a non-root user, and serves api:create_app() via gunicorn with a GET /health healthcheck.

Secrets are never baked into the image: .env (the REST_API_KEY plus the Klaviyo per-account keys) is loaded at run time, and accounts.toml is mounted read-only.

# Fill in secrets first
cp .env.example .env   # set REST_API_KEY and your KLAVIYO_*_KEY values

# Build + run with compose (serves http://127.0.0.1:8080)
docker compose up --build

# Or plain docker
docker build -t klaviyo-mcp-rest .
docker run --rm -p 8080:8080 --env-file .env \
    -e ACCOUNTS_FILE=/app/accounts.toml \
    -v "$PWD/accounts.toml:/app/accounts.toml:ro" \
    klaviyo-mcp-rest

Example requests:

# Health check (no auth required)
curl http://127.0.0.1:8080/health

# List accounts
curl -H "X-API-Key: your-rest-secret" \
     http://127.0.0.1:8080/v1/accounts

# Campaign performance
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31"}' \
     http://127.0.0.1:8080/v1/campaigns/performance

# List flows (optional status + archived filters)
curl -H "X-API-Key: your-rest-secret" \
     "http://127.0.0.1:8080/v1/flows?account=acme&status=live"

# Flow performance
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31"}' \
     http://127.0.0.1:8080/v1/flows/performance

# Flow structure
curl -H "X-API-Key: your-rest-secret" \
     "http://127.0.0.1:8080/v1/flows/PPF7K3ABCD/structure?account=acme"

# Flow performance with resolved message names
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "start_date": "2025-01-01", "end_date": "2025-01-31", "resolve_message_names": true}' \
     http://127.0.0.1:8080/v1/flows/performance

# Over-time series (weekly flow trend)
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "entity": "flow", "start_date": "2025-01-01", "end_date": "2025-03-31", "interval": "weekly"}' \
     http://127.0.0.1:8080/v1/performance/over-time

# Campaign performance using a timeframe preset instead of explicit dates
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "timeframe": "last_30_days"}' \
     http://127.0.0.1:8080/v1/campaigns/performance

Tools and API reference

API scopes required

The Klaviyo private key configured for each account must have these scopes:

Scope Used by
accounts:read All tools (account resolution)
metrics:read All report tools; klaviyo_get_list_growth, klaviyo_get_list_growth_by_list, klaviyo_get_list_breakdown (metric discovery + metric-aggregates)
campaigns:read klaviyo_get_campaign_performance, klaviyo_compare_periods (entity campaign)
flows:read klaviyo_get_flows, klaviyo_get_flow_performance, klaviyo_get_flow_structure, klaviyo_get_performance_over_time, klaviyo_compare_periods (entity flow)
lists:read klaviyo_get_list_health, klaviyo_get_list_growth_by_list, klaviyo_get_list_breakdown (list enumeration + sizes)

Report endpoints (/api/campaign-values-reports, /api/flow-values-reports, /api/flow-series-reports) are rate-limited by Klaviyo to 1 request/second, 2 requests/minute, and 225 requests/day. The client retries automatically with exponential backoff and jitter, honouring the Retry-After / RateLimit-Reset headers.

Over-time statistics note

klaviyo_get_performance_over_time returns Klaviyo's statistic arrays verbatim (including rate statistics such as open_rate and click_rate). Each array is positionally aligned to date_times, exactly as Klaviyo provides them, so the numbers reconcile with the Klaviyo dashboard. By contrast, the values reports (klaviyo_get_campaign_performance and klaviyo_get_flow_performance) compute open, click, and bounce rates locally from the raw count statistics.

Timeframe presets

Every date-scoped tool (klaviyo_get_campaign_performance, klaviyo_get_flow_performance, klaviyo_get_performance_over_time) accepts the window as either an explicit start_date+end_date pair or a named timeframe preset — pass one or the other, never both. A preset resolves to absolute dates on the server (anchored to the current date), and the resolved window is echoed back in metadata.period so the exact dates queried are always visible.

Preset Resolves to
today the current date only
yesterday the previous date only
last_7_days the 7 complete days ending yesterday
last_30_days the 30 complete days ending yesterday
last_90_days the 90 complete days ending yesterday
last_365_days the 365 complete days ending yesterday
this_month the 1st of the current month through today
last_month the full previous calendar month
year_to_date January 1 of the current year through today

Trailing last_N_days windows end yesterday so a partial current day never skews the counts; the calendar windows (this_month, year_to_date) run through today. Omitting both the dates and a timeframe returns INVALID_ARGUMENT.

Long date ranges (auto-chunking)

Klaviyo rejects a single report request wider than one calendar year. Rather than erroring, every date-scoped tool auto-chunks a longer range into consecutive sub-windows (each strictly under a calendar year, so leap years never push a chunk over the limit), fetches each, and merges:

  • Performance totals (campaign_performance, flow_performance, compare_periods): each entity's counts are summed across chunks and its rates rederived from the sums.
  • Over-time series (get_performance_over_time, entity=flow): the chunks' date_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 makes repeats instant. The overall range is capped at ~5 years. When a range is chunked, the response warnings array says so.

Tool summary

Tool REST route Key inputs Key output fields
klaviyo_list_accounts GET /v1/accounts accounts[]{name, label}
klaviyo_get_campaign_performance POST /v1/campaigns/performance start_date+end_date or timeframe, campaign?, resolve_campaign_names? campaigns[]{campaign_id, campaign_name, sent, delivered, opens, open_rate, clicks, click_rate, bounces, bounce_rate, unsubscribes, conversions, conversion_value}, campaign_count
klaviyo_get_flows GET /v1/flows status?, archived? flows[]{flow_id, name, status, trigger_type, archived, created, updated}, flow_count
klaviyo_get_flow_performance POST /v1/flows/performance start_date+end_date or timeframe, flow?, resolve_message_names?, rollup? flows[]{flow_id, flow_message_id, flow_message_name, send_channel, sent, delivered, opens, open_rate, clicks, click_rate, bounces, bounce_rate, unsubscribes, conversions, conversion_value}, flow_count
klaviyo_get_flow_structure GET /v1/flows/<flow_id>/structure flow_id (required), account? flow_id, action_count, steps[]{action_id, action_type, message_id, message_name, channel}, summary{action_type: count}
klaviyo_get_performance_over_time POST /v1/performance/over-time entity (flow/campaign), start_date+end_date or timeframe, interval?, entity_id?, statistics? entity, interval, date_times[], series[]{groupings, statistics}
klaviyo_compare_periods POST /v1/performance/compare entity (campaign/flow), start_date+end_date or timeframe, prior_start_date?+prior_end_date?, entity_id? entity, current_period, prior_period, current_totals, prior_totals, deltas{metric:{absolute, pct_change}}, current_entity_count, prior_entity_count
klaviyo_get_list_health GET /v1/lists/health list_id? lists[]{list_id, name, opt_in_process, profile_count, created, updated}, list_count, total_profiles
klaviyo_get_list_growth POST /v1/lists/growth start_date+end_date or timeframe growth{list, email, sms}{subscribed, unsubscribed, net}
klaviyo_get_list_growth_by_list POST /v1/lists/growth-by-list start_date+end_date or timeframe lists[]{list_id, name, subscribed, unsubscribed, net}, list_count, totals
klaviyo_get_list_breakdown POST /v1/lists/breakdown start_date+end_date or timeframe lists[]{list_id, name, opt_in_process, profile_count, subscribed, unsubscribed, net}, list_count, totals

klaviyo_list_accounts

List configured Klaviyo accounts by canonical name and label. Returns no API keys or conversion ids.

Inputs: none

Output:

{
  "data": {
    "accounts": [
      {"name": "acme", "label": "Acme Storefront"}
    ]
  },
  "metadata": {
    "account": null,
    "period": null,
    "revision": "2025-04-15",
    "latency_ms": 0.0
  },
  "warnings": []
}

REST equivalent: GET /v1/accounts


klaviyo_get_campaign_performance

Per-campaign email performance for one account over an absolute date range.

Inputs:

Field Type Required Description
account string No* Canonical account name (e.g. acme). Required when more than one account is configured. Omit to use the only configured account.
start_date string No† Inclusive start date, YYYY-MM-DD
end_date string No† Inclusive end date, YYYY-MM-DD
timeframe string No† Named relative window (see Timeframe presets) as an alternative to start_date+end_date
campaign string No Klaviyo campaign id — filters results to one campaign
resolve_campaign_names boolean No When true, resolve each campaign_id to its human-readable campaign name (default false)

† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.

resolve_campaign_names details:

The Campaign Values Report groups results by campaign_id and send channel, not by name, so by default campaign_name falls back to the send channel (email / sms). When resolve_campaign_names is true, each distinct campaign_id is looked up once via GET /api/campaigns/{id} (deduped) and the real campaign name is attached. A failed or missing lookup leaves the channel fallback in place and never blocks the metrics. This mirrors resolve_message_names on klaviyo_get_flow_performance; the lookups use the campaigns:read scope this tool already requires.

Output:

{
  "data": {
    "campaigns": [
      {
        "campaign_id": "01ABCDEF...",
        "campaign_name": "March Newsletter",
        "sent": 12000.0,
        "delivered": 11800.0,
        "opens": 3540.0,
        "open_rate": 0.3,
        "clicks": 590.0,
        "click_rate": 0.05,
        "bounces": 200.0,
        "bounce_rate": 0.0167,
        "unsubscribes": 12.0,
        "conversions": 45.0,
        "conversion_value": 2250.0
      }
    ],
    "campaign_count": 1
  },
  "metadata": {
    "account": "acme",
    "period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
    "revision": "2025-04-15",
    "latency_ms": 412.3
  },
  "warnings": [
    "Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
  ]
}

Rates are null when the denominator is zero (e.g. open_rate is null when delivered is 0).

REST equivalent: POST /v1/campaigns/performance with a JSON body containing the same fields.


klaviyo_get_flows

List an account's flows with their lifecycle metadata. Does not return performance counts — use klaviyo_get_flow_performance for those.

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
status string No Filter by flow status (e.g. live, draft, manual). Must be alphanumeric.
archived boolean No Filter to archived (true) or active (false) flows.

Output:

{
  "data": {
    "flows": [
      {
        "flow_id": "XYZABC123",
        "name": "Welcome Series",
        "status": "live",
        "trigger_type": "list",
        "archived": false,
        "created": "2024-01-15T10:00:00+00:00",
        "updated": "2024-06-01T08:30:00+00:00"
      }
    ],
    "flow_count": 1
  },
  "metadata": {
    "account": "acme",
    "period": null,
    "revision": "2025-04-15",
    "latency_ms": null
  },
  "warnings": []
}

Follows Klaviyo cursor pagination automatically; returns all matching flows. Requires the flows:read scope on the account's private key.

REST equivalent: GET /v1/flows?account=acme&status=live&archived=false


klaviyo_get_flow_performance

Per-(flow, message, channel) performance for one account over an absolute date range. Returns one row per unique combination of flow, flow message, and send channel (email or SMS).

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
start_date string No† Inclusive start date, YYYY-MM-DD
end_date string No† Inclusive end date, YYYY-MM-DD
timeframe string No† Named relative window (see Timeframe presets) as an alternative to start_date+end_date
flow string No Klaviyo flow id — filters results to one flow
resolve_message_names boolean No When true, resolve each flow_message_id to its human-readable message name (default false)
rollup boolean No When true, collapse the per-message/channel rows into one summed row per flow (default false)

† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.

rollup details: By default each row is one (flow, message, channel) combination. With rollup: true, those rows are summed into one row per flow_id (counts added, rates rederived) with flow_message_id, flow_message_name, and send_channel set to null to mark a flow-level total. Rollup makes resolve_message_names moot (message identity is dropped), so its lookups are skipped when both are set.

Ranges longer than one year are auto-chunked (see Long date ranges (auto-chunking)). Engagement and conversion statistics are attributed by event time; sent is anchored to the message send date. See the warnings array in the response for the time-basis note.

resolve_message_names details:

By default (false) each row carries flow_message_id only, and no additional API calls are made. When true, each distinct flow_message_id is looked up once via GET /api/flow-messages/{id} and the resulting name is attached as flow_message_name on every matching row. Lookups are deduped — if ten rows share the same message id, Klaviyo is called exactly once for that id. A failed or missing name lookup leaves flow_message_name as null and never blocks the metrics from returning.

The flow-messages endpoint is on a lighter rate-limit tier (3 requests/second, 60 requests/minute) compared to the report endpoints, so name resolution is suitable for interactive queries but should be avoided in tight polling loops. The flows:read scope already required by this tool covers the name lookups.

Output (with resolve_message_names: true):

{
  "data": {
    "flows": [
      {
        "flow_id": "XYZABC123",
        "flow_message_id": "MSGDEF456",
        "flow_message_name": "Post-Purchase Day 1 Email",
        "send_channel": "email",
        "sent": 5200.0,
        "delivered": 5100.0,
        "opens": 1530.0,
        "open_rate": 0.3,
        "clicks": 255.0,
        "click_rate": 0.05,
        "bounces": 100.0,
        "bounce_rate": 0.0192,
        "unsubscribes": 8.0,
        "conversions": 22.0,
        "conversion_value": 1100.0
      }
    ],
    "flow_count": 1
  },
  "metadata": {
    "account": "acme",
    "period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
    "revision": "2025-04-15",
    "latency_ms": 520.1
  },
  "warnings": [
    "Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
  ]
}

flow_message_name is always present in the output; it is null when resolve_message_names is false or when a lookup fails. Rates are null when the denominator is zero. Requires the flows:read scope.

REST equivalent: POST /v1/flows/performance with a JSON body containing the same fields.

# With name resolution enabled
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "start_date": "2025-03-01", "end_date": "2025-03-31", "resolve_message_names": true}' \
     http://127.0.0.1:8080/v1/flows/performance

klaviyo_get_flow_structure

Return the ordered list of actions in a flow, with send steps enriched with their resolved message name and channel. Useful for auditing flow logic, cross-referencing message ids from klaviyo_get_flow_performance, and understanding a flow's shape before diving into its metrics.

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
flow_id string Yes The Klaviyo flow id whose structure to return

flow_id must be an alphanumeric Klaviyo id. It is validated before being interpolated into the request path. Requires the flows:read scope.

Output:

{
  "data": {
    "flow_id": "PPF7K3ABCD",
    "action_count": 20,
    "steps": [
      {
        "action_id": "ACT001",
        "action_type": "SEND_EMAIL",
        "message_id": "MSG001",
        "message_name": "Post-Purchase: Thank You",
        "channel": "email"
      },
      {
        "action_id": "ACT002",
        "action_type": "TIME_DELAY",
        "message_id": null,
        "message_name": null,
        "channel": null
      },
      {
        "action_id": "ACT003",
        "action_type": "BOOLEAN_BRANCH",
        "message_id": null,
        "message_name": null,
        "channel": null
      },
      {
        "action_id": "ACT004",
        "action_type": "SEND_EMAIL",
        "message_id": "MSG002",
        "message_name": "Post-Purchase: Day 3 Cross-Sell",
        "channel": "email"
      },
      {
        "action_id": "ACT005",
        "action_type": "TIME_DELAY",
        "message_id": null,
        "message_name": null,
        "channel": null
      },
      {
        "action_id": "ACT006",
        "action_type": "SEND_EMAIL",
        "message_id": "MSG003",
        "message_name": "Post-Purchase: Day 7 Review Request",
        "channel": "email"
      }
    ],
    "summary": {
      "SEND_EMAIL": 9,
      "TIME_DELAY": 8,
      "BOOLEAN_BRANCH": 3
    }
  },
  "metadata": {
    "account": "acme",
    "period": null,
    "revision": "2025-04-15",
    "latency_ms": null
  },
  "warnings": []
}

Steps are returned in flow order as Klaviyo provides them. For SEND_EMAIL and SEND_SMS actions the service fetches the first related flow-message via GET /api/flow-actions/{id}/flow-messages and attaches its message_id, message_name, and channel. A failed lookup leaves those three fields as null without blocking the rest of the steps. Non-send actions (TIME_DELAY, BOOLEAN_BRANCH, and similar) always have null for the message fields.

summary is a count of steps keyed by action_type. Types not known at write-time are keyed as-is (Klaviyo may add new action types); an action with an unparseable type is counted under "UNKNOWN".

REST equivalent: GET /v1/flows/<flow_id>/structure

curl -H "X-API-Key: your-rest-secret" \
     "http://127.0.0.1:8080/v1/flows/PPF7K3ABCD/structure?account=acme"

klaviyo_get_performance_over_time

Bucketed over-time series for flows or campaigns over a date range. Returns a date_times array and one series row per flow/campaign, each with statistics arrays positionally aligned to date_times.

The two entities are served differently:

  • 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.
  • 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) as an alternative to start_date+end_date
interval string No Bucket size: hourly, daily, weekly (default), or monthly. Campaigns support daily/weekly/monthly only (no hourly).
entity_id string No Klaviyo flow/campaign id — narrows results to one entity
statistics array of strings No Statistic names to request; defaults to a volume + engagement + conversion subset

† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.

Ranges longer than one year are auto-chunked (see Long date ranges (auto-chunking)); for flow the chunks' series are concatenated, and for campaign the bucket cap still applies. Passing an invalid interval or unsupported entity returns an INVALID_ARGUMENT error.

Campaign trend rate-limit cost. Each campaign bucket is a separate campaign-values report call, and those endpoints allow only ~1/sec and 2/min. The service paces the calls (~1.1 s apart) to stay under the burst limit and caps the bucket count at 53, but a wide range is still slow — prefer monthly or weekly for campaigns (e.g. 4 monthly buckets ≈ 15 s; a full quarter of weekly buckets can take a minute or more under throttling). The response cache makes a repeated trend instant, and a retried call after a partial throttle reuses the buckets already cached.

Output:

{
  "data": {
    "entity": "flow",
    "interval": "weekly",
    "date_times": ["2025-03-03T00:00:00", "2025-03-10T00:00:00", "2025-03-17T00:00:00"],
    "series": [
      {
        "groupings": {
          "flow_id": "XYZABC123",
          "flow_message_id": "MSGDEF456",
          "send_channel": "email"
        },
        "statistics": {
          "recipients": [1200.0, 0.0, 3400.0],
          "delivered": [1180.0, 0.0, 3340.0],
          "opens_unique": [354.0, 0.0, 1002.0],
          "open_rate": [0.3, null, 0.3],
          "clicks_unique": [59.0, 0.0, 167.0],
          "conversions": [4.0, 0.0, 18.0],
          "conversion_value": [200.0, 0.0, 900.0]
        }
      }
    ]
  },
  "metadata": {
    "account": "acme",
    "period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
    "revision": "2025-04-15",
    "latency_ms": 387.6
  },
  "warnings": []
}

REST equivalent: POST /v1/performance/over-time with a JSON body containing the same fields.

# Campaign trend, monthly (one campaign-values call per month — keep it coarse)
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "entity": "campaign", "timeframe": "last_90_days", "interval": "monthly"}' \
     http://127.0.0.1:8080/v1/performance/over-time

A campaign trend returns one series per campaign with statistics named like the flow series (recipients, delivered, opens_unique, clicks_unique, conversions, conversion_value); a campaign shows nonzero values only in the bucket(s) its send and engagement fall in. The warnings array carries the time_basis note and a note explaining the stitched shape.


klaviyo_compare_periods

Compare aggregate campaign or flow performance between a current period and a prior period, returning per-metric absolute and percent-change deltas. Because campaigns are one-shot (a campaign sent in one period does not recur in another), the comparison is done on period totals — the summed counts across all rows, with rates rederived from those sums — rather than per-entity. Flows aggregate the same way and additionally accept an entity_id to trend a single flow over time.

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
entity string Yes campaign or flow.
start_date string No† Inclusive start of the current period, YYYY-MM-DD.
end_date string No† Inclusive end of the current period, YYYY-MM-DD.
timeframe string No† Named relative window for the current period (see Timeframe presets).
prior_start_date string No Explicit prior-period start. Provide with prior_end_date.
prior_end_date string No Explicit prior-period end.
entity_id string No Campaign/flow id to narrow both periods to one entity before aggregating.

† Set the current window with either start_date+end_date or timeframe. When prior_start_date/prior_end_date are omitted, the prior period defaults to the equal-length window ending the day before the current period starts (e.g. a 30-day current window compares against the preceding 30 days). Explicit prior dates must be supplied as a pair.

Output:

{
  "data": {
    "entity": "campaign",
    "current_period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
    "prior_period": {"start_date": "2025-01-29", "end_date": "2025-02-28"},
    "current_totals": {
      "sent": 106147.0, "delivered": 104900.0, "opens": 36280.0, "open_rate": 0.3459,
      "clicks": 5120.0, "click_rate": 0.0488, "bounces": 1247.0, "bounce_rate": 0.0117,
      "unsubscribes": 210.0, "conversions": 125.0, "conversion_value": 45171.43
    },
    "prior_totals": {
      "sent": 50284.0, "delivered": 49600.0, "opens": 18060.0, "open_rate": 0.3641,
      "clicks": 2480.0, "click_rate": 0.05, "bounces": 620.0, "bounce_rate": 0.0123,
      "unsubscribes": 95.0, "conversions": 68.0, "conversion_value": 29825.59
    },
    "deltas": {
      "sent": {"absolute": 55863.0, "pct_change": 1.1109},
      "conversions": {"absolute": 57.0, "pct_change": 0.8382},
      "conversion_value": {"absolute": 15345.84, "pct_change": 0.5145},
      "open_rate": {"absolute": -0.0182, "pct_change": -0.05}
    },
    "current_entity_count": 6,
    "prior_entity_count": 3
  },
  "metadata": {
    "account": "acme",
    "period": {"start_date": "2025-03-01", "end_date": "2025-03-31"},
    "revision": "2025-04-15",
    "latency_ms": 980.2
  },
  "warnings": [
    "Engagement and conversion statistics are attributed by event time, while 'sent' is anchored to the campaign send date; counts in a short window may not align."
  ]
}

deltas carries every metric in the totals (only a few are shown above). absolute is current - prior; pct_change is the fraction relative to the prior value (e.g. 1.1109 = +111%) and is null when the prior value is 0. metadata.period echoes the current period. This tool makes two report calls (current + prior), so the report rate limit applies to each; the time_basis caveat is the same as the underlying performance reports.

REST equivalent: POST /v1/performance/compare with a JSON body containing the same fields.

# Campaigns: this month vs. the preceding equal-length window
curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "entity": "campaign", "timeframe": "this_month"}' \
     http://127.0.0.1:8080/v1/performance/compare

klaviyo_get_list_health

Per-list membership health for an account: each list's current profile_count, opt_in_process (single vs double opt-in), name, and created/updated timestamps, plus list_count and total_profiles. This is current-state inventory only — subscribe/unsubscribe trends over time are out of scope for this tool.

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
list_id string No Klaviyo list id — return just this one list instead of all.

Output:

{
  "data": {
    "lists": [
      {
        "list_id": "SrEULb",
        "name": "New Email Subs",
        "opt_in_process": "single_opt_in",
        "profile_count": 24296,
        "created": "2025-01-05T01:21:29+00:00",
        "updated": "2026-06-18T00:00:00+00:00"
      }
    ],
    "list_count": 13,
    "total_profiles": 43730
  },
  "metadata": {
    "account": "acme",
    "period": null,
    "revision": "2025-04-15",
    "latency_ms": null
  },
  "warnings": [
    "total_profiles is the sum of per-list profile_count values; a profile in several lists is counted once per list, so this is not a deduplicated audience size."
  ]
}

profile_count is only available on Klaviyo's single-list endpoint (the /api/lists collection rejects additional-fields[list]=profile_count), so the all-lists path enumerates lists and then fetches each list's count individually — one extra request per list. A per-list lookup failure leaves that list's profile_count as null rather than dropping the list or failing the call. profile_count is also null if Klaviyo omits it. Requires the lists:read scope.

REST equivalent: GET /v1/lists/health (optional list_id query param).

# All lists with sizes
curl -H "X-API-Key: your-rest-secret" \
     "http://127.0.0.1:8080/v1/lists/health?account=acme"

# A single list
curl -H "X-API-Key: your-rest-secret" \
     "http://127.0.0.1:8080/v1/lists/health?account=acme&list_id=SrEULb"

klaviyo_get_list_growth

Subscribe/unsubscribe totals and net growth over a period, per channel (list, email, sms). This is the growth counterpart to klaviyo_get_list_health (which is current-state sizes). For each channel it sums the subscribed and unsubscribed Klaviyo system-metric event counts over the window and returns net = subscribed - unsubscribed.

Inputs:

Field Type Required Description
account string No* Canonical account name. Required when more than one account is configured.
start_date string No† Inclusive start date, YYYY-MM-DD
end_date string No† Inclusive end date, YYYY-MM-DD
timeframe string No† Named relative window (see Timeframe presets) as an alternative to start_date+end_date

† Provide either start_date+end_date or timeframe, not both. Omitting all three is an error.

Output:

{
  "data": {
    "growth": {
      "list":  {"subscribed": 4630, "unsubscribed": 51,  "net": 4579},
      "email": {"subscribed": 2952, "unsubscribed": 327, "net": 2625},
      "sms":   {"subscribed": 1395, "unsubscribed": 538, "net": 857}
    }
  },
  "metadata": {
    "account": "acme",
    "period": {"start_date": "2025-05-19", "end_date": "2025-06-17"},
    "revision": "2025-04-15",
    "latency_ms": 642.0
  },
  "warnings": []
}

Each channel maps to two Klaviyo system metrics, resolved to ids by name at call time (Subscribed to List / Unsubscribed from List, the … Email Marketing pair, and the … SMS Marketing pair). Counts are event totals, not deduplicated profiles — a profile that subscribes twice counts twice, and these do not reconcile to profile_count deltas. A metric name absent on the account (e.g. an account with no SMS) yields null for that side, net null, and a warning naming the unresolved metrics; a failed aggregate call degrades the same way rather than failing the response. The tool issues one metric-aggregates call per metric (up to six), all under the metrics:read scope.

REST equivalent: POST /v1/lists/growth with a JSON body containing the same fields.

curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "timeframe": "last_30_days"}' \
     http://127.0.0.1:8080/v1/lists/growth

klaviyo_get_list_growth_by_list

The per-list view of klaviyo_get_list_growth: one row per list with subscribed, unsubscribed, and net over the window, plus account-wide totals. The Subscribed to List / Unsubscribed from List metrics are each summed grouped by Klaviyo's List dimension (one metric-aggregates call apiece) and joined to list ids by name. Only lists with activity in the window appear.

Inputs: account?, and the window as start_date+end_date or timeframe (see Timeframe presets).

Output:

{
  "data": {
    "lists": [
      {"list_id": "SrEULb", "name": "New Email Subs", "subscribed": 2554, "unsubscribed": 48, "net": 2506}
    ],
    "list_count": 5,
    "totals": {"subscribed": 4630, "unsubscribed": 51, "net": 4579}
  },
  "metadata": {"account": "acme", "period": {"start_date": "2025-05-19", "end_date": "2025-06-17"}, "revision": "2025-04-15", "latency_ms": null},
  "warnings": ["Counts are subscribe/unsubscribe events over the window, not deduplicated profiles; per-list rows are keyed by Klaviyo's list name and joined to the list id, so lists sharing a name may not be distinguishable."]
}

Per-list rows are keyed by Klaviyo's list name (the List dimension value) and joined back to /api/lists to recover list_id — so a growth row for a deleted list, or one sharing a name with another list, may have a null or ambiguous list_id. net is null when a metric is absent on the account (with a warning). Requires metrics:read and lists:read.

REST equivalent: POST /v1/lists/growth-by-list.


klaviyo_get_list_breakdown

The combined per-list view: each list's current size (profile_count, opt_in_process) and its growth (subscribed/unsubscribed/net) over the window, with account-wide totals. Every list is included — a list with no subscribe/unsubscribe activity in the window shows 0 (when the metric resolved). This merges klaviyo_get_list_health with the per-list growth above.

Inputs: account?, and the window as start_date+end_date or timeframe.

Output:

{
  "data": {
    "lists": [
      {"list_id": "SrEULb", "name": "New Email Subs", "opt_in_process": "single_opt_in", "profile_count": 24307, "subscribed": 2554, "unsubscribed": 48, "net": 2506}
    ],
    "list_count": 13,
    "totals": {"profile_count": 43748, "subscribed": 4630, "unsubscribed": 51, "net": 4579}
  },
  "metadata": {"account": "acme", "period": {"start_date": "2025-05-19", "end_date": "2025-06-17"}, "revision": "2025-04-15", "latency_ms": null},
  "warnings": ["Counts are subscribe/unsubscribe events over the window, not deduplicated profiles; ..."]
}

Sizes come from the single-list endpoint (one request per list, as in klaviyo_get_list_health) and growth from two grouped metric-aggregates calls. Requires metrics:read and lists:read.

REST equivalent: POST /v1/lists/breakdown.

curl -X POST \
     -H "X-API-Key: your-rest-secret" \
     -H "Content-Type: application/json" \
     -d '{"account": "acme", "timeframe": "last_30_days"}' \
     http://127.0.0.1:8080/v1/lists/breakdown

Dev workflow

Linting and type checking

# Lint and auto-fix
ruff check --fix .

# Format
ruff format .

# Type check
mypy .

Tests

# Unit tests only (no live Klaviyo calls)
pytest -m "not integration"

# All tests including live integration (requires real credentials)
pytest

# With coverage report
pytest --cov --cov-report=term-missing

Coverage gate: 80% line coverage on klaviyo_analytics/.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request to main. It installs the hash-pinned dependencies on Python 3.11 and runs the same gates as local dev: ruff check, ruff format --check, mypy on the business modules (klaviyo_analytics server.py api), and pytest -m "not integration" with the 80% coverage gate. Live integration tests are excluded — they need real credentials.

Updating dependencies

Two lock files are managed with pip-tools: requirements.txt (everything — runtime, MCP, and dev/test) for local development, and requirements.prod.txt (REST runtime only, no mcp, no dev tooling) for the Docker image. Regenerate both when changing pins:

pip-compile --allow-unsafe --generate-hashes --output-file=requirements.txt requirements.in
pip-compile --allow-unsafe --generate-hashes --output-file=requirements.prod.txt requirements.prod.in

Important: Always regenerate the lock files on Python 3.11 (the project's target). Compiling on a different minor version can pin version-specific or platform-specific wheels and produce hashes that fail --require-hashes on 3.11.

Live connectivity check

python live_smoke.py --account acme

Makes real Klaviyo calls against five checks: account listing, campaign performance (last 30 days), flow listing, flow structure (using the first flow returned by the listing step), and an over-time weekly series (last 90 days). Requires a valid .env and accounts.toml. The flow checks require the flows:read scope; if the key lacks it the script prints a warning and continues rather than aborting. See live_smoke.py for details.


Work package status

WP-0 — done:

  • Two MCP tools: klaviyo_list_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
  • 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
  • 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

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

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 and 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

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

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:

  • 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

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

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

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

All planned work packages are complete; no items remain deferred.


Troubleshooting

CONFIG_ERROR: environment variable KLAVIYO_ACME_KEY for account 'acme' is not set The env var named in accounts.toml under api_key_env is not in the environment. Check that .env contains the variable and is being found (see Configuration).

CONFIG_ERROR: no REST credential configured The Flask REST adapter requires REST_API_KEY and/or REST_API_TOKENS in .env. Set at least one to a random string (e.g. output of python -c "import secrets; print(secrets.token_hex(32))").

UNKNOWN_ACCOUNT: unknown account 'foo' The name passed to account does not match any entry in accounts.toml. The error response includes available_accounts listing the valid names.

INVALID_ARGUMENT: account is required when multiple accounts are configured More than one account is in accounts.toml and no account was passed. Pass the canonical name explicitly.

MCP server not appearing in Claude Verify that .mcp.json points to the correct absolute path for server.py and that the .venv has all dependencies installed. Run python server.py manually to check for startup errors on stderr.

401 Unauthorized / 403 Forbidden from the REST adapter Every request except GET /health must present a valid credential, as Authorization: Bearer <token> or X-API-Key: <token>. 401 means no credential was sent; 403 means it didn't match REST_API_KEY or any entry in REST_API_TOKENS.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured