
Klaviyo Developer
- 58 installs
- 93 repo stars
- Updated May 14, 2026
- thatrebeccarae/claude-marketing
Helps with ai & agent building tasks.
About
klaviyo-developer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- klaviyo-developer
- AI & Agent Building
- AI-coding skill
Klaviyo Developer by the numbers
- 58 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #6,517 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thatrebeccarae/claude-marketing --skill klaviyo-developerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 93 |
| Last updated | May 14, 2026 |
| Repository | thatrebeccarae/claude-marketing ↗ |
What it does
Helps with ai & agent building tasks.
Files
Klaviyo Developer
Expert-level guidance for building with the Klaviyo API — custom event tracking, profile management, SDK integration, webhooks, catalog sync, and data pipeline architecture.
For marketing strategy, flow auditing, segmentation, deliverability, and campaign optimization, see the klaviyo-analyst skill.
Install
git clone https://github.com/thatrebeccarae/claude-marketing.git && cp -r claude-marketing/skills/klaviyo-developer ~/.claude/skills/MCP vs. SDK: When to Use Which
This skill is SDK-first by design — you're building production integrations against the Klaviyo API, not running ad-hoc queries. That said, Klaviyo's official MCP server is the right tool for parts of integration work, and you should know when to reach for it.
Use the SDK (klaviyo-api) when… | Use the MCP (https://mcp.klaviyo.com/mcp) when… |
|---|---|
| Writing production event-tracking code | Exploring an account's event schema before writing the integration |
| Building bulk import / sync pipelines | Sanity-checking that events landed with the right property shape |
| Implementing webhook handlers | Pulling a quick property inventory during integration design |
| Catalog sync jobs | Inspecting flow trigger conditions while debugging why an event isn't firing a flow |
| Anything in CI, cron, or a deployed service | Iterating on event schema design with the marketing analyst in the room |
The MCP wraps the same API this skill targets, so the schema rules, rate limits, and nesting constraints below apply equally to MCP-driven calls. The MCP is currently pinned to API revision 2026-04-15 — keep that in mind if you're versioning your own SDK code against an older revision.
For the full MCP tool inventory, OAuth setup, and read-only mode flag, see REFERENCE.md. For audit/analyst work, see the klaviyo-analyst skill — it's built around the MCP.
Core Capabilities
API Authentication & Versioning
- Private API key setup and key management best practices
- Public API key usage for client-side tracking (klaviyo.js)
- OAuth 2.0 authorization flow for third-party apps
- API revision headers and version lifecycle management
Custom Event Tracking
- Server-side event tracking via Events API
- Client-side tracking with klaviyo.js
- Event schema design and property naming conventions
- Idempotent event submission patterns
Profile Management
- Profile create, upsert, and bulk import patterns
- Custom property management and data types
- Subscription management (email, SMS consent)
- Profile merge and deduplication strategies
Webhooks
- Webhook subscription setup and event types
- Payload verification and signature validation
- Retry handling and idempotent webhook processing
SDK Usage & Libraries
- Python SDK (klaviyo-api)
- Node.js SDK (klaviyo-api-node)
- Ruby, PHP, and other community SDKs
- SDK initialization, error handling, and retry configuration
Catalog & Product Feed Sync
- Catalog item create/update/delete via API
- Category and variant management
- Product feed sync architecture for recommendations
- Handling large catalogs with bulk operations
Data Export & Warehouse Sync
- Metric aggregation API for reporting
- Profile and event export patterns
- Cursor-based pagination for large datasets
- ETL pipeline design for data warehouse integration
SDK Quick Reference
| Language | Package | Install |
|---|---|---|
| Python | klaviyo-api | pip install klaviyo-api |
| Node.js | klaviyo-api | npm install klaviyo-api |
| Ruby | klaviyo-api-sdk | gem install klaviyo-api-sdk |
| PHP | klaviyo/api | composer require klaviyo/api |
Rate Limits
| Endpoint Category | Limit | Window |
|---|---|---|
| Most endpoints | 75 requests | per second |
| Bulk imports | 10 requests | per second |
| Profile/Event create | 350 requests | per second |
| Campaign send | 10 requests | per second |
Headers returned: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
API Revision Timeline
| Revision | Key Changes |
|---|---|
| 2026-01-15 | Latest. Custom Objects Ingestion, Geofencing API (beta). |
| 2025-10-15 | Forms API, Flow Actions API, SMS ROI reporting. |
| 2025-07-15 | Mapped Metrics API, Custom Objects API (GA). |
| 2025-04-15 | Web Feeds API, Custom Metrics, Push Token registration. |
| 2025-01-15 | Reviews APIs, Flows Create API, Campaign image management. |
| 2024-10-15 | Universal Content API, Form/Segment Reporting, Reviews API. |
| 2024-07-15 | Forms API (retrieval), Webhooks API. |
| 2024-02-15 | Reporting API, Create or Update Profile (upsert). |
Always include the revision header in API requests.
Essential Developer Checklist
1. API key management — Store private keys in environment variables, never commit to source. Rotate keys periodically. 2. Revision header — Always include revision: YYYY-MM-DD header. Pin to a specific version. 3. Rate limit handling — Implement exponential backoff with jitter on 429 responses. 4. Idempotent events — Include a unique unique_id property to prevent duplicate event tracking. 5. Profile upserts — Use POST /profiles/ with existing identifier for upsert behavior (creates or updates). 6. Webhook verification — Validate webhook signatures before processing payloads. 7. Pagination — Use cursor-based pagination for list endpoints. Never assume result counts. 8. Error handling — Parse JSON:API error responses. Handle 4xx (client) and 5xx (server) differently. 9. SDK initialization — Configure SDK with API key at app startup, not per-request. 10. Testing — Use Klaviyo test/sandbox accounts. Mock API responses in unit tests.
Workflow: Custom Integration Setup
When building a custom Klaviyo integration:
1. Define requirements — What events to track, what profile data to sync, what triggers are needed 2. API key provisioning — Create a private API key with minimum required scopes 3. Event schema design — Map business events to Klaviyo metric names and properties 4. Profile sync strategy — Determine identifier (email vs phone vs external_id), upsert frequency 5. Implement tracking — Server-side event tracking with proper error handling and retries 6. Catalog sync (if applicable) — Product feed sync for recommendations and browse abandonment 7. Webhook setup — Subscribe to relevant events, implement handler with signature verification 8. Rate limit strategy — Queue and throttle API calls, implement backoff 9. Monitoring — Log API errors, track event delivery rates, alert on failures 10. Testing & validation — Verify events appear in Klaviyo, test flow triggers, validate profile data
Workflow: Integration Health Audit
When auditing an existing Klaviyo integration for health and data quality:
1. Inventory active integrations — List all configured integrations (built-in and custom). Identify active vs stale connections. 2. Map event sources — For each metric in the account, identify its source (built-in integration, custom API, Klaviyo-internal, form). Flag metrics with zero recent volume. 3. Audit event schemas — Pull property structures for key events (Placed Order, Started Checkout, Viewed Product, Added to Cart). Check for:
- Missing standard properties (e.g.,
$value,ItemNames, line items) - Duplicate/redundant metrics (e.g., "Placed Order" and "Order Placed" from different sources)
- Inconsistent property naming (camelCase vs snake_case across events)
4. Check profile data pipeline — Verify profile properties are being synced correctly. Look for:
- Properties set by API vs properties set by events
- Stale properties (set once, never updated)
- Properties used in segmentation vs properties sitting unused
5. Review catalog sync — Verify product catalog is synced and fresh. Check:
- Total catalog items vs expected product count
- Last sync timestamp
- Variant coverage (are variants synced or just parent products?)
- Category structure completeness
6. Assess flow trigger architecture — Map how flows are triggered:
- Direct metric triggers (robust) vs segment-entry triggers via API-synced properties (brittle)
- Single points of failure (if API sync breaks, do all flows stop?)
- Trigger redundancy and fallback patterns
7. Identify data accessibility gaps — Check for data that exists in event payloads but isn't usable:
- Nested objects in event properties (can use in templates, cannot use in segments/splits)
- Properties available in events but not synced to profiles (can't segment on them)
- Events tracked but not used in any flow or segment
8. Produce integration health report — Document findings with severity ratings:
- Critical: Integration failures, broken event tracking, data loss
- High: Missing standard events, duplicate metrics, flow trigger fragility
- Medium: Unused events, incomplete catalog, stale profile properties
- Low: Naming inconsistencies, optimization opportunities
Event Schema Best Practices
Property Naming Conventions
- Use PascalCase for standard Klaviyo properties:
ProductName,ItemPrice,OrderId - Use snake_case for custom properties:
business_type,account_id,reorder_count - Never mix conventions within a single event — pick one and be consistent
- Prefix custom properties to avoid collision with Klaviyo-reserved names
Required Properties by Event
| Event | Required Properties | Revenue Property |
|---|---|---|
| Placed Order | $value, OrderId, Items[] (line items) | $value |
| Started Checkout | $value, CheckoutURL, Items[] | $value |
| Viewed Product | ProductName, ProductID, URL, ImageURL | — |
| Added to Cart | $value, AddedItemProductName, AddedItemProductID, Items[] | $value |
| Fulfilled Order | $value, OrderId | — |
Nesting Rules and Limitations
Klaviyo handles nested objects differently depending on where you access them:
| Context | Access Level | Example |
|---|---|---|
| Email/SMS templates | Full access via Jinja — can loop over arrays, access nested properties | {% for item in event.Items %}{{ item.ProductName }}{% endfor %} |
| Flow conditional splits | Top-level properties ONLY — cannot access nested object fields | Can split on event.OrderId, cannot split on event.Items[0].ProductName |
| Segments | Top-level properties ONLY — cannot filter by nested object fields | Can segment on "has done Placed Order where $value > 100", cannot segment on "where Items contains ProductName = X" |
| Flow triggers | Top-level properties for trigger filters | Same as conditional splits |
Workaround for nested data: If you need to segment or split on nested data, flatten it to top-level properties:
# Instead of relying on Items[] array for segmentation:
properties = {
"$value": 149.99,
"OrderId": "ORD-123",
"Items": [{"ProductName": "Wireless Headphones", "Category": "Electronics"}],
# Flatten for segmentation:
"ItemCategories": "Electronics,Accessories", # Comma-joined for "contains" filter
"HasElectronics": True, # Boolean flag for split
"TopItemCategory": "Electronics" # Top category for split
}Custom Event Patterns (DTC / Subscription / Marketplace)
Additional events beyond the standard Shopify/e-commerce schema:
| Event Name | Trigger | Key Properties |
|---|---|---|
Account Created | New account registered | account_type, referral_source, signup_channel |
Subscription Started | Recurring order activated | $value, frequency, product_ids, plan_name |
Subscription Cancelled | Recurring order stopped | reason, plan_name, lifetime_charges |
Reorder Placed | Repeat purchase of consumable | $value, OrderId, days_since_last_order, reorder_items |
Wishlist Added | Item saved for later | ProductName, ProductID, Categories, Price |
Catalog Browsed | Category/search activity | category, search_term, results_count |
Sync key customer properties to profiles for segmentation:
profile_properties = {
"customer_type": "Subscriber",
"interests": ["Skincare", "Wellness"],
"subscription_plan": "Monthly Box",
"account_tier": "VIP",
"first_order_date": "2024-03-15",
"lifetime_order_count": 8,
"avg_order_value": 72.50,
"preferred_categories": ["Skincare", "Supplements"]
}Data Accessibility Diagnosis
When data exists in Klaviyo but isn't usable where expected:
Symptoms
- "We track [event] but can't segment on [property]"
- "Flow split doesn't see the property we're sending"
- "Profile has the data but segment doesn't pick it up"
Root Causes and Solutions
| Symptom | Root Cause | Solution |
|---|---|---|
| Can't segment on event property | Property is nested inside an array/object | Flatten to top-level property on the event |
| Can't split flow on event property | Property is nested | Flatten, or use profile property instead |
| Segment doesn't match profiles | Property is on events, not profiles | Sync property to profile via API or "Update Profile Property" flow action |
| Profile property exists but segment empty | Property value format mismatch (string "true" vs boolean true) | Standardize data types in API sync |
| Event tracked but no flow triggers | Metric name mismatch (case-sensitive) | Verify exact metric name in Klaviyo matches API call |
| Flow triggers but filter excludes everyone | Segment used as flow filter evaluates incorrectly | Check segment conditions — may reference stale or incorrectly-typed properties |
Diagnosis Workflow
1. Check metric exists: Use GET /metrics/ to verify the event name appears 2. Check event properties: Use GET /events/?filter=... to pull recent events and inspect property structure 3. Check profile properties: Use GET /profiles/{id}/ to verify expected properties are on the profile 4. Test segment conditions: Compare segment definition against actual profile data — look for type mismatches, case sensitivity issues 5. Test flow trigger: Send a test event and trace whether the flow fires, where it stops, and why
How to Use This Skill
Ask me questions like:
- "How do I track a custom event from my Node.js backend?"
- "Help me set up a bulk profile import script"
- "What are Klaviyo's rate limits and how should I handle them?"
- "How do I verify Klaviyo webhook signatures?"
- "Set up catalog sync for my custom e-commerce platform"
- "How do I implement OAuth for a Klaviyo app?"
- "Design a data pipeline to export Klaviyo data to BigQuery"
- "Help me migrate from Klaviyo v1/v2 API to the current API"
- "Audit my integration — are events structured correctly?"
- "Why can't I segment on a property I'm tracking in events?"
Integration Examples
For complete integration patterns, worked examples with sample output, and code snippets, see EXAMPLES.md.
Scripts
The skill includes utility scripts for API interaction and integration management:
Developer Client
# Track a custom event
python scripts/klaviyo_client.py --action track-event \
--email user@example.com --event "Placed Order" \
--properties '{"value": 99.99, "OrderId": "ORD-123"}'
# Upsert a profile
python scripts/klaviyo_client.py --action upsert-profile \
--email user@example.com \
--properties '{"first_name": "Jane", "loyalty_tier": "Gold"}'
# List catalog items
python scripts/klaviyo_client.py --action catalog-items --format table
# Export profiles to CSV
python scripts/klaviyo_client.py --action export-profiles \
--max-pages 10 --format csv --output profiles.csvDeveloper Tools
# Integration health check
python scripts/dev_tools.py --tool health-check
# Validate event tracking
python scripts/dev_tools.py --tool validate-events \
--events "Placed Order,Started Checkout,Viewed Product"
# Test webhook endpoint
python scripts/dev_tools.py --tool test-webhook \
--webhook-url https://example.com/webhooks/klaviyo
# Import profiles from CSV
python scripts/dev_tools.py --tool import-csv \
--file contacts.csv --list-id LIST_ID
# Export data with pagination
python scripts/dev_tools.py --tool export-data \
--resource profiles --max-records 5000 --output profiles.csvThe scripts handle API authentication, rate limiting, and JSON:API formatting. I'll help interpret results and provide implementation guidance.
Troubleshooting
Authentication Error: Verify that:
KLAVIYO_API_KEYis set as an environment variable or in a.envfile- The key starts with
pk_(private API key, not public) - The key has the required scopes for your operation (e.g.,
events:writefor tracking,profiles:writefor imports)
Rate Limit Errors (429): The SDK handles retries automatically (up to 3 retries with 60s max delay). If you still hit limits:
- Queue and throttle bulk operations (max 10 req/s for imports)
- Check
RateLimit-Remainingheader proactively - Implement exponential backoff with jitter for raw HTTP
Bulk Import Errors: Check that:
- Batch size does not exceed 10,000 profiles per job
- Email or phone is provided for each profile (at least one identifier)
- CSV column names match expected field names
Import Errors: Install required packages:
pip install klaviyo-api python-dotenv pandasSecurity Notes
- Never hardcode API keys in code or commit them to version control
- Store keys in environment variables or
.envfiles - Add
.envto.gitignore - Use minimum required scopes — only enable write access where needed
- Rotate API keys periodically in Klaviyo Settings
- For OAuth integrations, store client secrets securely and refresh tokens before expiry
Data Privacy
This skill interacts with the Klaviyo API for integration development. When using write operations:
- Validate data before sending to avoid corrupting profile records
- Never log or store API keys, webhook secrets, or PII in plain text
- Use idempotency keys to prevent duplicate events
- Implement webhook signature verification to prevent spoofing
- Follow GDPR/CCPA requirements when handling profile data
- Use the Data Privacy Deletion endpoint for right-to-erasure requests
All operations are performed via the official Klaviyo API with proper authentication.
For detailed API endpoint reference, code patterns, authentication, and architecture diagrams, see REFERENCE.md.
For marketing strategy, flow optimization, and campaign auditing, use the klaviyo-analyst skill.
# Klaviyo API Configuration
# Copy this file to .env and fill in your actual values
# Your Klaviyo Private API Key
# Find this in Klaviyo: Settings > Account > API Keys
# Format: pk_xxxxxxxxxxxxxxxx (starts with "pk_")
# Use a key with appropriate read/write scopes for developer tasks
KLAVIYO_API_KEY=pk_your-private-api-key-here
# How to set up:
# 1. Log in to Klaviyo (www.klaviyo.com)
# 2. Go to Settings > Account > API Keys
# 3. Click "Create Private API Key"
# 4. Name it (e.g., "Developer Integration")
# 5. Select scopes based on your needs:
# - Events: events:read, events:write (event tracking)
# - Profiles: profiles:read, profiles:write (profile management)
# - Catalogs: catalogs:read, catalogs:write (catalog sync)
# - Lists: lists:read, lists:write (list management)
# - Metrics: metrics:read (reporting)
# 6. Copy the key (starts with "pk_")
# 7. Paste it above and rename this file to .env
# 8. NEVER commit .env to version control!
Klaviyo Developer Examples
Practical examples of common Klaviyo integration tasks and developer patterns.
Example 1: Track Custom E-commerce Events
User Request: "How do I track a custom 'Placed Order' event from my backend?"
Analysis Steps: 1. Design the event payload with required fields 2. Include idempotency key to prevent duplicates 3. Show both SDK and raw HTTP approaches 4. Provide multi-language examples
Script Command:
python scripts/klaviyo_client.py --action track-event \
--email customer@example.com \
--event "Placed Order" \
--properties '{"OrderId": "ORD-12345", "value": 149.99, "ItemNames": ["Widget A", "Gadget B"], "ItemCount": 2}'Sample Output:
{
"status": "success",
"event": "Placed Order",
"email": "customer@example.com"
}Python SDK Example:
from klaviyo_api import KlaviyoAPI
klaviyo = KlaviyoAPI("pk_abc123...", max_delay=60, max_retries=3)
body = {
"data": {
"type": "event",
"attributes": {
"metric": {
"data": {"type": "metric", "attributes": {"name": "Placed Order"}}
},
"profile": {
"data": {"type": "profile", "attributes": {"email": "customer@example.com"}}
},
"properties": {
"OrderId": "ORD-12345",
"value": 149.99,
"ItemNames": ["Widget A", "Gadget B"],
"ItemCount": 2,
"Items": [
{"ProductID": "PROD-001", "ProductName": "Widget A", "Quantity": 1, "ItemPrice": 79.99},
{"ProductID": "PROD-002", "ProductName": "Gadget B", "Quantity": 1, "ItemPrice": 70.00}
]
},
"unique_id": "ORD-12345" # Idempotency key
}
}
}
klaviyo.Events.create_event(body)Node.js Example:
const { ApiClient, EventsApi } = require('klaviyo-api');
const defaultClient = ApiClient.instance;
defaultClient.authentications['ApiKeyAuth'].apiKey = 'pk_abc123...';
const eventsApi = new EventsApi();
await eventsApi.createEvent({
data: {
type: 'event',
attributes: {
metric: { data: { type: 'metric', attributes: { name: 'Placed Order' } } },
profile: { data: { type: 'profile', attributes: { email: 'customer@example.com' } } },
properties: {
OrderId: 'ORD-12345',
value: 149.99,
ItemNames: ['Widget A', 'Gadget B'],
ItemCount: 2
},
unique_id: 'ORD-12345'
}
}
});Key Points:
- Always include
unique_idfor idempotent event submission - The
valueproperty is used by Klaviyo for revenue attribution Itemsarray enables product-level reporting in flows- Profile is auto-created if it doesn't exist
Example 2: Bulk Profile Import
User Request: "I need to import 50,000 contacts from a CSV into Klaviyo"
Analysis Steps: 1. Read CSV file and validate structure 2. Batch into 10,000-profile chunks (API limit) 3. Submit each batch as a bulk import job 4. Track job status for completion
Script Command:
python scripts/dev_tools.py --tool import-csv \
--file contacts.csv \
--list-id YOUR_LIST_IDSample CSV (contacts.csv):
email,first_name,last_name,phone,loyalty_tier,source
jane@example.com,Jane,Doe,+15551234567,Gold,migration
john@example.com,John,Smith,+15559876543,Silver,migrationSample Output:
Batch 1/5: importing 10000 profiles...
Batch 2/5: importing 10000 profiles...
Batch 3/5: importing 10000 profiles...
Batch 4/5: importing 10000 profiles...
Batch 5/5: importing 10000 profiles...
{
"file": "contacts.csv",
"total_profiles": 50000,
"summary": {
"batches_submitted": 5,
"batches_failed": 0,
"profiles_submitted": 50000
},
"batches": [
{"batch": 1, "profiles": 10000, "status": "submitted", "job_id": "JOB-001"},
{"batch": 2, "profiles": 10000, "status": "submitted", "job_id": "JOB-002"},
{"batch": 3, "profiles": 10000, "status": "submitted", "job_id": "JOB-003"},
{"batch": 4, "profiles": 10000, "status": "submitted", "job_id": "JOB-004"},
{"batch": 5, "profiles": 10000, "status": "submitted", "job_id": "JOB-005"}
]
}Check Job Status:
python scripts/klaviyo_client.py --action import-status --job-id JOB-001Key Points:
- Maximum 10,000 profiles per bulk import job
- Jobs are async — use
import-statusto check completion - Include
--list-idto add profiles to a list during import - Custom properties in CSV columns are automatically mapped
Example 3: Webhook Handler Setup
User Request: "How do I set up a webhook to handle Klaviyo events?"
Analysis Steps: 1. Test webhook endpoint connectivity 2. Verify signature validation works 3. Provide handler code example 4. Cover retry and idempotency patterns
Script Command:
# Test webhook endpoint
python scripts/dev_tools.py --tool test-webhook \
--webhook-url https://example.com/webhooks/klaviyo
# Test with signature verification
python scripts/dev_tools.py --tool test-webhook \
--webhook-url https://example.com/webhooks/klaviyo \
--webhook-secret your-webhook-secretSample Output:
{
"url": "https://example.com/webhooks/klaviyo",
"payload_size": 198,
"signature_included": true,
"status": "pass",
"response_code": 200,
"response_body": "{\"received\": true}"
}Express.js Webhook Handler:
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.KLAVIYO_WEBHOOK_SECRET;
function verifySignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
const expected = hmac.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks/klaviyo', (req, res) => {
// 1. Verify signature
const signature = req.headers['x-klaviyo-webhook-signature'];
if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Parse event
const event = JSON.parse(req.body);
// 3. Idempotency check (use event ID to prevent duplicate processing)
const eventId = event.data?.id;
// Check if eventId was already processed (use Redis/DB)
// 4. Process by type
switch (event.type) {
case 'profile.subscribed':
handleNewSubscriber(event.data);
break;
case 'profile.unsubscribed':
handleUnsubscribe(event.data);
break;
case 'email.bounced':
handleBounce(event.data);
break;
default:
console.log(`Unhandled: ${event.type}`);
}
res.status(200).json({ received: true });
});Key Points:
- Always verify webhook signatures before processing
- Use
timingSafeEqualto prevent timing attacks - Implement idempotency using event IDs
- Return 200 quickly — process asynchronously for heavy work
- Klaviyo retries failed webhook deliveries (non-2xx responses)
Example 4: Catalog Sync Pipeline
User Request: "How do I sync my product catalog with Klaviyo for recommendations?"
Analysis Steps: 1. List existing catalog items to check current state 2. Define the catalog item schema 3. Create/update items via the API 4. Set up ongoing sync pattern
Script Command:
# List current catalog items
python scripts/klaviyo_client.py --action catalog-items --format table
# Create a catalog item
python scripts/klaviyo_client.py --action create-catalog-item \
--properties '{"external_id": "PROD-001", "title": "Widget A", "description": "Premium widget", "url": "https://shop.example.com/widget-a", "image_url": "https://shop.example.com/images/widget-a.jpg", "price": 79.99}'Sample Catalog Listing:
[
{
"id": "CATALOG-ITEM-001",
"type": "catalog-item",
"external_id": "PROD-001",
"title": "Widget A",
"description": "Premium widget",
"url": "https://shop.example.com/widget-a",
"price": 79.99
},
{
"id": "CATALOG-ITEM-002",
"type": "catalog-item",
"external_id": "PROD-002",
"title": "Gadget B",
"description": "Advanced gadget",
"url": "https://shop.example.com/gadget-b",
"price": 70.00
}
]Nightly Sync Pattern (Python):
from klaviyo_client import KlaviyoDevClient
import json
client = KlaviyoDevClient()
# Fetch products from your database/API
products = fetch_products_from_db()
for product in products:
try:
client.create_catalog_item({
"external_id": product["sku"],
"title": product["name"],
"description": product["description"],
"url": product["url"],
"image_url": product["image_url"],
"price": product["price"],
"category": product["category"],
"in_stock": product["inventory"] > 0,
})
except Exception as e:
if "conflict" in str(e).lower():
# Item already exists — update instead
pass
else:
print(f"Error syncing {product['sku']}: {e}")Key Points:
- Catalog items power product recommendations and Back in Stock flows
- Use
external_idto match your internal product IDs - For large catalogs (1000+), use bulk create/update endpoints
- Run sync nightly to keep prices and availability current
- Custom metadata fields can store any additional product attributes
Example 5: Data Export with Pagination
User Request: "I need to export all our Klaviyo profiles to a CSV for analysis"
Analysis Steps: 1. Use cursor-based pagination to fetch all profiles 2. Handle rate limits with built-in SDK retries 3. Write results to CSV incrementally 4. Report progress during export
Script Command:
# Export all profiles to CSV
python scripts/dev_tools.py --tool export-data \
--resource profiles \
--output profiles.csv
# Export first 5000 profiles
python scripts/dev_tools.py --tool export-data \
--resource profiles \
--max-records 5000 \
--output profiles.csv
# Export catalog to CSV
python scripts/dev_tools.py --tool export-data \
--resource catalog \
--output catalog.csvSample Output:
Page 1: fetched 100 profiles (total: 100)
Page 2: fetched 100 profiles (total: 200)
Page 3: fetched 100 profiles (total: 300)
...
Page 50: fetched 100 profiles (total: 5000)
Reached max pages limit (50)
Exported 5000 records to profiles.csv
{
"resource": "profiles",
"records_exported": 5000,
"output_file": "profiles.csv"
}Sample CSV Output:
id,email,phone_number,first_name,last_name,properties,created
PROF-001,jane@example.com,+15551234567,Jane,Doe,"{""loyalty_tier"": ""Gold""}",2025-01-15T10:30:00Z
PROF-002,john@example.com,+15559876543,John,Smith,"{""loyalty_tier"": ""Silver""}",2025-02-20T14:15:00ZKey Points:
- Cursor-based pagination handles large datasets efficiently
- Max 100 profiles per page (API limit)
- SDK handles rate limiting with automatic retries
- Use
--max-recordsto limit export size for testing - Properties column contains JSON for nested custom fields
- For data warehouse pipelines, schedule exports via cron or Airflow
Example 6: Integration Health Check
User Request: "Is my Klaviyo integration working correctly?"
Analysis Steps: 1. Verify API connectivity and authentication 2. Check API key scopes 3. Validate e-commerce event tracking 4. Test catalog access 5. Provide fix recommendations
Script Command:
python scripts/dev_tools.py --tool health-checkSample Output:
{
"timestamp": "2026-02-09T14:30:00",
"status": "degraded",
"checks": [
{
"check": "API Connectivity",
"status": "pass",
"detail": "Successfully connected to Klaviyo API"
},
{
"check": "Profile Read Scope",
"status": "pass",
"detail": "profiles:read scope verified"
},
{
"check": "Metrics Available",
"status": "pass",
"detail": "24 event types found"
},
{
"check": "E-commerce Events",
"status": "warning",
"detail": "Missing: started checkout"
},
{
"check": "Catalog Access",
"status": "pass",
"detail": "catalogs:read verified (156 items found)"
}
],
"recommendations": [
{
"priority": "HIGH",
"action": "Implement missing e-commerce event tracking",
"reason": "Missing: started checkout",
"expected_impact": "Enable automated flows for missing events"
}
]
}Validate Specific Events:
python scripts/dev_tools.py --tool validate-events \
--events "Placed Order,Started Checkout,Viewed Product,Added to Cart"Sample Validation Output:
{
"summary": {
"events_checked": 4,
"events_found": 3,
"events_missing": 1
},
"results": [
{"event": "Placed Order", "status": "found"},
{"event": "Started Checkout", "status": "missing"},
{"event": "Viewed Product", "status": "found"},
{"event": "Added to Cart", "status": "found"}
],
"recommendations": [
{
"priority": "HIGH",
"action": "Implement tracking for 'Started Checkout'",
"reason": "Event 'Started Checkout' not found in Klaviyo",
"expected_impact": "Enable flows and segments triggered by this event"
}
]
}Key Points:
- Run health checks after initial setup and periodically (weekly/monthly)
- Missing events mean flows that depend on them won't trigger
- "degraded" status means some features work but others need attention
- "unhealthy" status means core connectivity is broken
- Pair with event validation to verify specific tracking implementations
Common Developer Patterns
Event Tracking Template
# 1. Define event schema (name, required properties, unique_id source)
# 2. Implement server-side tracking with error handling
# 3. Include unique_id for idempotency
# 4. Log failures for monitoring
# 5. Verify events appear in Klaviyo metricsBulk Operation Template
# 1. Read data source (CSV, database, API)
# 2. Validate and clean data
# 3. Batch into chunks (max 10K for profiles)
# 4. Submit each batch with error handling
# 5. Track job IDs for status checking
# 6. Report results and errorsIntegration Testing Template
# 1. Health check: API connectivity + scopes
# 2. Event validation: all expected events tracked
# 3. Profile sync: test create/update/read cycle
# 4. Webhook test: endpoint reachable + signature valid
# 5. Export test: pagination works for your data volumeRate Limit Strategy Template
# 1. SDK: Use max_delay and max_retries for automatic handling
# 2. Raw HTTP: Implement exponential backoff with jitter
# 3. Bulk operations: Queue and throttle (10 req/s limit)
# 4. Monitor RateLimit-Remaining header for proactive throttling
# 5. Log 429 responses for capacity planningPro Tips
Ask Better Questions
Instead of: "How do I use the Klaviyo API?" Ask: "Help me implement server-side event tracking for my checkout flow"
Request Working Code
Instead of: "What's the event format?" Ask: "Give me a complete Python script to track Placed Order with line items"
Test Before Deploying
Instead of: "Deploy this integration" Ask: "Run a health check and validate my event tracking before I go live"
Focus on Error Handling
Instead of: "Track this event" Ask: "Track this event with proper retry logic, rate limit handling, and error logging"
Plan for Scale
Instead of: "Import these profiles" Ask: "Import 100K profiles with batching, progress reporting, and error recovery"
MIT License
Copyright (c) 2026 Rebecca Rae Barton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Klaviyo Developer Reference
API Endpoints
Base URL
https://a.klaviyo.com/api/Authentication
Authorization: Klaviyo-API-Key {private-api-key}
Content-Type: application/json
revision: 2025-10-15Profiles
GET /profiles/ # List profiles (paginated)
POST /profiles/ # Create or upsert profile
GET /profiles/{id}/ # Get profile by ID
PATCH /profiles/{id}/ # Update profile
POST /profile-subscription-bulk-create-jobs/ # Bulk subscribe profiles
POST /profile-suppression-bulk-create-jobs/ # Bulk suppress profiles
POST /profile-suppression-bulk-delete-jobs/ # Bulk unsuppress profiles
POST /profile-bulk-import-jobs/ # Bulk import profilesEvents
POST /events/ # Create event
GET /events/ # Query events (paginated)
GET /events/{id}/ # Get event by IDMetrics
GET /metrics/ # List available metrics
POST /metric-aggregates/ # Aggregate metric data (reporting)Lists
GET /lists/ # List all lists
POST /lists/ # Create list
GET /lists/{id}/ # Get list
PATCH /lists/{id}/ # Update list
DELETE /lists/{id}/ # Delete list
POST /lists/{id}/relationships/profiles/ # Add profiles to list
DELETE /lists/{id}/relationships/profiles/ # Remove profiles from list
GET /lists/{id}/profiles/ # Get list membersSegments
GET /segments/ # List all segments
GET /segments/{id}/ # Get segment
GET /segments/{id}/profiles/ # Get segment membersCampaigns
GET /campaigns/ # List campaigns
POST /campaigns/ # Create campaign
GET /campaigns/{id}/ # Get campaign
PATCH /campaigns/{id}/ # Update campaign
DELETE /campaigns/{id}/ # Delete campaign
POST /campaign-send-jobs/ # Send campaign
POST /campaign-recipient-estimation-jobs/ # Estimate recipientsFlows
GET /flows/ # List flows
GET /flows/{id}/ # Get flow details
PATCH /flows/{id}/ # Update flow status
GET /flows/{id}/flow-actions/ # Get flow actions
GET /flows/{id}/flow-messages/ # Get flow messagesCatalogs
POST /catalog-items/ # Create catalog item
GET /catalog-items/ # List catalog items
GET /catalog-items/{id}/ # Get catalog item
PATCH /catalog-items/{id}/ # Update catalog item
DELETE /catalog-items/{id}/ # Delete catalog item
POST /catalog-categories/ # Create category
GET /catalog-categories/ # List categories
POST /catalog-variants/ # Create variant
GET /catalog-variants/ # List variants
POST /catalog-item-bulk-create-jobs/ # Bulk create items
POST /catalog-item-bulk-update-jobs/ # Bulk update items
POST /catalog-item-bulk-delete-jobs/ # Bulk delete itemsTemplates
GET /templates/ # List templates
POST /templates/ # Create template
GET /templates/{id}/ # Get template
PATCH /templates/{id}/ # Update template
DELETE /templates/{id}/ # Delete template
POST /template-clone/ # Clone template
POST /template-render/ # Render templateTags
GET /tags/ # List tags
POST /tags/ # Create tag
GET /tag-groups/ # List tag groups
POST /tag-groups/ # Create tag groupImages
GET /images/ # List images
POST /images/ # Upload image
GET /images/{id}/ # Get image
PATCH /images/{id}/ # Update imageData Privacy
POST /data-privacy-deletion-jobs/ # Request profile deletion (GDPR)---
Authentication
Private API Key (Server-side)
# Python
from klaviyo_api import KlaviyoAPI
klaviyo = KlaviyoAPI("pk_abc123...", max_delay=60, max_retries=3)// Node.js
const { ApiClient } = require('klaviyo-api');
const klaviyo = new ApiClient('pk_abc123...');# Raw HTTP
curl -X GET "https://a.klaviyo.com/api/profiles/" \
-H "Authorization: Klaviyo-API-Key pk_abc123..." \
-H "revision: 2025-10-15"Public API Key (Client-side - klaviyo.js)
<script>
!function(){if(!window.klaviyo){window._klOnsite=window._klOnsite||[];try{window.klaviyo=new Proxy({},{get:function(n,i){return"push"===i?function(){var n;(n=window._klOnsite).push.apply(n,arguments)}:function(){for(var n=arguments.length,o=new Array(n),w=0;w<n;w++)o[w]=arguments[w];var t="function"==typeof o[o.length-1]?o.pop():void 0,e=new Promise((function(n){t&&n(t())}));return window._klOnsite.push([i].concat(o,[function(i){e=i}])),e}}})}catch(n){window.klaviyo=window.klaviyo||[],window.klaviyo.push=function(){var n;(n=window._klOnsite).push.apply(n,arguments)}}}}();
</script>
<script async src="https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=YOUR_PUBLIC_KEY"></script>OAuth 2.0 (Third-party Apps)
Authorization URL:
https://www.klaviyo.com/oauth/authorize?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&scope={SCOPES}&state={STATE}Token Exchange:
curl -X POST "https://a.klaviyo.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code={AUTH_CODE}&redirect_uri={REDIRECT_URI}" \
-u "{CLIENT_ID}:{CLIENT_SECRET}"Token Refresh:
curl -X POST "https://a.klaviyo.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token={REFRESH_TOKEN}" \
-u "{CLIENT_ID}:{CLIENT_SECRET}"OAuth Scopes:
| Scope | Description |
|---|---|
accounts:read | Read account info |
campaigns:read | Read campaigns |
campaigns:write | Create/update campaigns |
events:read | Read events |
events:write | Create events |
flows:read | Read flows |
flows:write | Update flows |
lists:read | Read lists |
lists:write | Create/update lists |
metrics:read | Read metrics |
profiles:read | Read profiles |
profiles:write | Create/update profiles |
segments:read | Read segments |
segments:write | Update segments |
subscriptions:read | Read subscriptions |
subscriptions:write | Manage subscriptions |
catalogs:read | Read catalog items |
catalogs:write | Create/update catalog items |
templates:read | Read templates |
templates:write | Create/update templates |
tags:read | Read tags |
tags:write | Create/update tags |
---
Code Patterns
Python SDK — Initialize
from klaviyo_api import KlaviyoAPI
klaviyo = KlaviyoAPI(
"pk_abc123...",
max_delay=60, # Max retry delay in seconds
max_retries=3 # Max retry attempts on 429/5xx
)Node.js SDK — Initialize
const { ApiClient, ProfilesApi, EventsApi } = require('klaviyo-api');
const defaultClient = ApiClient.instance;
const ApiKeyAuth = defaultClient.authentications['ApiKeyAuth'];
ApiKeyAuth.apiKey = 'pk_abc123...';
const profilesApi = new ProfilesApi();
const eventsApi = new EventsApi();Track Event (Python SDK)
from klaviyo_api import KlaviyoAPI
klaviyo = KlaviyoAPI("pk_abc123...")
body = {
"data": {
"type": "event",
"attributes": {
"metric": {
"data": {
"type": "metric",
"attributes": {"name": "Placed Order"}
}
},
"profile": {
"data": {
"type": "profile",
"attributes": {"email": "customer@example.com"}
}
},
"properties": {
"OrderId": "ORD-12345",
"value": 99.99,
"ItemNames": ["Widget A", "Gadget B"],
"ItemCount": 2
},
"unique_id": "ORD-12345" # Idempotency key
}
}
}
klaviyo.Events.create_event(body)Track Event (Raw HTTP)
import requests
response = requests.post(
"https://a.klaviyo.com/api/events/",
headers={
"Authorization": "Klaviyo-API-Key pk_abc123...",
"Content-Type": "application/json",
"revision": "2025-10-15"
},
json={
"data": {
"type": "event",
"attributes": {
"metric": {"data": {"type": "metric", "attributes": {"name": "Placed Order"}}},
"profile": {"data": {"type": "profile", "attributes": {"email": "customer@example.com"}}},
"properties": {"OrderId": "ORD-12345", "value": 99.99},
"unique_id": "ORD-12345"
}
}
}
)Profile Upsert
body = {
"data": {
"type": "profile",
"attributes": {
"email": "customer@example.com",
"phone_number": "+15551234567",
"first_name": "Jane",
"last_name": "Doe",
"properties": {
"loyalty_tier": "Gold",
"lifetime_orders": 12,
"preferred_category": "Electronics"
}
}
}
}
# POST creates if new, updates if email/phone matches existing profile
klaviyo.Profiles.create_profile(body)Bulk Profile Import
body = {
"data": {
"type": "profile-bulk-import-job",
"attributes": {
"profiles": {
"data": [
{
"type": "profile",
"attributes": {
"email": "user1@example.com",
"first_name": "Alice",
"properties": {"source": "migration"}
}
},
{
"type": "profile",
"attributes": {
"email": "user2@example.com",
"first_name": "Bob",
"properties": {"source": "migration"}
}
}
# Up to 10,000 profiles per job
]
}
},
"relationships": {
"lists": {
"data": [{"type": "list", "id": "LIST_ID"}] # Optional: add to list
}
}
}
}
klaviyo.Profiles.spawn_bulk_profile_import_job(body)Cursor-based Pagination
def get_all_profiles(klaviyo):
"""Paginate through all profiles."""
profiles = []
cursor = None
while True:
response = klaviyo.Profiles.get_profiles(
page_cursor=cursor,
page_size=100
)
profiles.extend(response.get("data", []))
# Get next page cursor from links
next_link = response.get("links", {}).get("next")
if not next_link:
break
# Extract cursor from next link URL
from urllib.parse import urlparse, parse_qs
parsed = urlparse(next_link)
cursor = parse_qs(parsed.query).get("page[cursor]", [None])[0]
return profilesWebhook Handler (Express.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.KLAVIYO_WEBHOOK_SECRET;
function verifyWebhookSignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
const expected = hmac.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks/klaviyo', (req, res) => {
const signature = req.headers['x-klaviyo-webhook-signature'];
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
// Process webhook event
switch (event.type) {
case 'profile.subscribed':
// Handle new subscriber
break;
case 'profile.unsubscribed':
// Handle unsubscribe
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
});Client-side Tracking (klaviyo.js)
// Identify a visitor
klaviyo.push(['identify', {
email: 'customer@example.com',
first_name: 'Jane',
last_name: 'Doe',
$consent: ['email']
}]);
// Track a custom event
klaviyo.push(['track', 'Viewed Product', {
ProductName: 'Widget A',
ProductID: 'PROD-123',
Price: 29.99,
ImageURL: 'https://example.com/widget-a.jpg',
URL: 'https://example.com/products/widget-a'
}]);
// Track Added to Cart
klaviyo.push(['track', 'Added to Cart', {
$value: 29.99,
AddedItemProductName: 'Widget A',
AddedItemProductID: 'PROD-123',
AddedItemPrice: 29.99,
AddedItemQuantity: 1,
ItemNames: ['Widget A'],
Items: [{
ProductID: 'PROD-123',
ProductName: 'Widget A',
Quantity: 1,
ItemPrice: 29.99,
ImageURL: 'https://example.com/widget-a.jpg',
URL: 'https://example.com/products/widget-a'
}]
}]);---
Rate Limits
Fixed-window Limits
| Endpoint Category | Burst Limit | Steady-state Limit |
|---|---|---|
| Most GET endpoints | 75/s | 700/min |
| Profile create/update | 350/s | 700/min |
| Event create | 350/s | 700/min |
| Bulk operations | 10/s | 150/min |
| Campaign send | 10/s | 100/min |
Rate Limit Headers
RateLimit-Limit: 75
RateLimit-Remaining: 42
RateLimit-Reset: 1700000000
Retry-After: 1 # Only on 429 responsesRetry Algorithm with Exponential Backoff
import time
import random
import requests
def klaviyo_request(method, url, **kwargs):
max_retries = 5
base_delay = 1.0
for attempt in range(max_retries):
response = requests.request(method, url, **kwargs)
if response.status_code == 429:
retry_after = float(response.headers.get("Retry-After", base_delay))
delay = retry_after + random.uniform(0, 1) # Jitter
time.sleep(delay)
continue
if response.status_code >= 500:
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
continue
return response
raise Exception(f"Max retries exceeded for {url}")---
Error Response Structure
All errors follow JSON:API format:
{
"errors": [
{
"id": "unique-error-id",
"status": 400,
"code": "invalid",
"title": "Invalid input.",
"detail": "The 'email' field is required.",
"source": {
"pointer": "/data/attributes/email"
}
}
]
}Common Error Codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid | Malformed request body or invalid parameters |
| 401 | not_authenticated | Missing or invalid API key |
| 403 | not_authorized | API key lacks required scope |
| 404 | not_found | Resource does not exist |
| 409 | conflict | Resource already exists (duplicate) |
| 429 | throttled | Rate limit exceeded — check Retry-After header |
| 500 | internal | Server error — retry with backoff |
| 503 | service_unavailable | Temporary outage — retry with backoff |
---
API Versioning
How It Works
- Include
revision: YYYY-MM-DDheader in every request - Omitting the header defaults to the oldest supported revision
- Each revision is supported for ~2 years after release
- Breaking changes only happen in new revisions, never within a revision
Revision History
| Revision | Status | Key Changes |
|---|---|---|
| 2026-01-15 | Latest | Custom Objects Ingestion, Geofencing API (beta) |
| 2025-10-15 | Supported | Forms API, Flow Actions API, SMS ROI reporting |
| 2025-07-15 | Supported | Mapped Metrics API, Custom Objects API (GA) |
| 2025-04-15 | Supported | Web Feeds API, Custom Metrics, Push Token registration |
| 2025-01-15 | Supported | Reviews APIs, Flows Create API, Campaign image management |
| 2024-10-15 | Supported | Universal Content API, Form/Segment Reporting, Reviews API |
| 2024-07-15 | Supported | Forms API (retrieval), Webhooks API |
| 2024-02-15 | Supported | Reporting API, Create or Update Profile (upsert) |
| 2023-10-15 | Deprecated | List suppression filtering, subscription status on profiles |
| 2023-06-15 | Deprecated | Accounts API, list/segment member counts, rate limit increases |
Version Lifecycle
1. Current — Latest revision, recommended for new integrations 2. Supported — Fully functional, receives bug fixes 3. Deprecated — 6-month sunset notice, then removed
---
Migration Guide: Legacy to Current API
Endpoint Mapping
| Legacy (v1/v2) | Current API | Notes |
|---|---|---|
POST /api/identify | POST /api/profiles/ | Use JSON:API body format |
POST /api/track | POST /api/events/ | Include metric + profile in body |
GET /api/v1/people | GET /api/profiles/ | Cursor pagination |
GET /api/v2/lists | GET /api/lists/ | JSON:API response format |
POST /api/v2/list/{id}/subscribe | POST /api/profile-subscription-bulk-create-jobs/ | Async job |
GET /api/v1/metrics/timeline | POST /api/metric-aggregates/ | New aggregation body |
POST /api/v1/catalog/items | POST /api/catalog-items/ | JSON:API format |
Key Migration Changes
- All endpoints now use JSON:API format (
{ "data": { "type": "...", "attributes": {...} } }) - Authentication changed from query param
?api_key=to headerAuthorization: Klaviyo-API-Key - Pagination changed from offset to cursor-based
revisionheader required- Async jobs for bulk operations (subscribe, import, suppress)
- Relationships use JSON:API linkage (
/relationships/)
---
Integration Architecture Patterns
E-commerce Sync
┌─────────────┐ Events API ┌─────────┐
│ Storefront │ ──────────────────→ │ Klaviyo │
│ (Custom) │ │ │
│ │ ←─────────────────── │ Flows │
│ │ Webhooks │ Sends │
└──────┬──────┘ └────┬────┘
│ │
│ Catalog API │ Reporting API
│ (nightly sync) │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Product DB │ │ Dashboard │
└─────────────┘ └─────────────┘Data Warehouse ETL
┌─────────┐ Profiles API ┌──────────┐ Transform ┌───────────┐
│ Klaviyo │ ─────────────────→ │ ETL Job │ ──────────────→ │ Warehouse │
│ │ Events API │ (Airflow │ │ (BigQuery │
│ │ ─────────────────→ │ / dbt) │ │ Snowflake│
│ │ Metrics API │ │ │ Redshift)│
│ │ ─────────────────→ └──────────┘ └───────────┘
└─────────┘Real-time Event Streaming
┌──────────┐ Track API ┌─────────┐ Webhook ┌──────────┐
│ App │ ──────────────→ │ Klaviyo │ ──────────────→ │ Queue │
│ Server │ │ │ │ (SQS / │
│ │ ←─────────────── │ Flows │ │ Kafka) │
│ │ Flow webhook │ │ │ │
└──────────┘ └─────────┘ └─────┬────┘
│
┌─────▼────┐
│ Worker │
│ (process │
│ events) │
└──────────┘---
Standard Event Property Schemas
Complete property schemas for standard e-commerce events. Use these as the reference when auditing event tracking or building custom integrations.
Placed Order
The primary revenue event. Must include line items for product-level analytics and flow personalization.
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Placed Order" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": {
"email": "customer@example.com",
"properties": { "first_name": "Jane", "last_name": "Doe" }
}
}
},
"properties": {
"$value": 149.99,
"OrderId": "ORD-12345",
"Categories": ["Electronics", "Accessories"],
"ItemNames": ["Wireless Headphones Pro", "Phone Case - Midnight"],
"Brands": ["AudioTech", "CaseCraft"],
"DiscountCode": "WELCOME10",
"DiscountValue": 15.00,
"Items": [
{
"ProductID": "PROD-001",
"SKU": "AT-WHP-001",
"ProductName": "Wireless Headphones Pro",
"Quantity": 1,
"ItemPrice": 89.99,
"RowTotal": 89.99,
"ProductURL": "https://example.com/products/wireless-headphones-pro",
"ImageURL": "https://example.com/images/headphones-pro.jpg",
"Categories": ["Electronics", "Audio"],
"Brand": "AudioTech"
},
{
"ProductID": "PROD-002",
"SKU": "CC-CASE-MID",
"ProductName": "Phone Case - Midnight",
"Quantity": 1,
"ItemPrice": 29.99,
"RowTotal": 29.99,
"ProductURL": "https://example.com/products/phone-case-midnight",
"ImageURL": "https://example.com/images/case-midnight.jpg",
"Categories": ["Accessories", "Cases"],
"Brand": "CaseCraft"
}
],
"ItemCount": 3,
"ShippingMethod": "Standard",
"ShippingCost": 0.00,
"Tax": 12.00,
"Subtotal": 149.97,
"BillingAddress": {
"FirstName": "Jane",
"LastName": "Doe",
"City": "Austin",
"Region": "TX",
"Country": "US",
"Zip": "60601"
}
},
"unique_id": "ORD-12345",
"time": "2026-02-09T14:30:00Z"
}
}
}Usage notes:
$valueis the revenue property Klaviyo uses for attribution and flow filtersItems[]array enables product-level recommendations in flow emailsunique_id=OrderIdfor idempotency (prevents duplicate order events)Categories(top-level) = flattened list for segmentation (sinceItems[].Categoriesis nested and not accessible in segments)BillingAddressis nested — accessible in templates but NOT in segments/splits
Started Checkout
Triggers abandoned cart flows. Must include cart contents for personalization.
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Started Checkout" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": { "email": "customer@example.com" }
}
},
"properties": {
"$value": 149.99,
"CheckoutURL": "https://example.com/checkout/abc123",
"ItemNames": ["Wireless Headphones Pro", "Phone Case - Midnight"],
"Categories": ["Electronics", "Accessories"],
"Items": [
{
"ProductID": "PROD-001",
"SKU": "AT-WHP-001",
"ProductName": "Wireless Headphones Pro",
"Quantity": 1,
"ItemPrice": 89.99,
"RowTotal": 89.99,
"ProductURL": "https://example.com/products/wireless-headphones-pro",
"ImageURL": "https://example.com/images/headphones-pro.jpg"
}
],
"ItemCount": 3
},
"unique_id": "CHECKOUT-abc123"
}
}
}Usage notes:
CheckoutURLis critical for the abandoned cart email CTA$valueenables "cart value > $X" flow filtersItems[]enables dynamic product blocks in cart recovery emails- Deduplicate with
unique_id= checkout session ID
Viewed Product
Triggers browse abandonment flows. Lightweight event — no line items needed.
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Viewed Product" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": { "email": "customer@example.com" }
}
},
"properties": {
"ProductName": "Wireless Headphones Pro",
"ProductID": "PROD-001",
"SKU": "AT-WHP-001",
"Categories": ["Electronics", "Audio"],
"Brand": "AudioTech",
"Price": 89.99,
"CompareAtPrice": 109.99,
"ImageURL": "https://example.com/images/headphones-pro.jpg",
"URL": "https://example.com/products/wireless-headphones-pro"
}
}
}
}Usage notes:
- No
$value— this isn't a revenue event URLandImageURLrequired for browse abandonment email personalizationCompareAtPriceenables "on sale" logic in flow templates- No
unique_id— multiple views of the same product are expected Categoriesat top level enables segment filters like "viewed product in Electronics category"
Added to Cart
Tracks cart additions. Useful for cart-based flows when Started Checkout isn't tracked.
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Added to Cart" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": { "email": "customer@example.com" }
}
},
"properties": {
"$value": 89.99,
"AddedItemProductName": "Wireless Headphones Pro",
"AddedItemProductID": "PROD-001",
"AddedItemSKU": "AT-WHP-001",
"AddedItemCategories": ["Electronics", "Audio"],
"AddedItemImageURL": "https://example.com/images/headphones-pro.jpg",
"AddedItemURL": "https://example.com/products/wireless-headphones-pro",
"AddedItemPrice": 89.99,
"AddedItemQuantity": 1,
"ItemNames": ["Wireless Headphones Pro", "Phone Case - Midnight"],
"Items": [
{
"ProductID": "PROD-001",
"ProductName": "Wireless Headphones Pro",
"Quantity": 1,
"ItemPrice": 89.99,
"ImageURL": "https://example.com/images/headphones-pro.jpg",
"URL": "https://example.com/products/wireless-headphones-pro"
},
{
"ProductID": "PROD-002",
"ProductName": "Phone Case - Midnight",
"Quantity": 1,
"ItemPrice": 29.99,
"ImageURL": "https://example.com/images/case-midnight.jpg",
"URL": "https://example.com/products/phone-case-midnight"
}
]
}
}
}
}Usage notes:
$value= price of the added item (not total cart value)AddedItem*properties describe the specific item added (top-level, segmentable)Items[]= full current cart contents (for template rendering)ItemNames= flattened array for segmentation
Order Completed / Fulfilled Order
Triggers post-purchase flows (review requests, replenishment reminders).
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Fulfilled Order" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": { "email": "customer@example.com" }
}
},
"properties": {
"$value": 149.99,
"OrderId": "ORD-12345",
"FulfillmentId": "SHIP-67890",
"TrackingNumber": "1Z999AA10123456784",
"TrackingURL": "https://tracking.example.com/1Z999AA10123456784",
"Carrier": "UPS",
"Items": [
{
"ProductID": "PROD-001",
"ProductName": "Wireless Headphones Pro",
"Quantity": 1,
"ItemPrice": 89.99
}
]
},
"unique_id": "SHIP-67890"
}
}
}Usage notes:
- Use as trigger for review request flows (with 7-14 day delay)
- Use as trigger for replenishment flows (with product-specific delay)
TrackingURLenables shipping confirmation emailsunique_id= fulfillment ID (an order can have multiple fulfillments/shipments)
Cancelled Order
Tracks order cancellations. Useful for re-engagement and save-the-sale flows.
{
"data": {
"type": "event",
"attributes": {
"metric": {
"data": { "type": "metric", "attributes": { "name": "Cancelled Order" } }
},
"profile": {
"data": {
"type": "profile",
"attributes": { "email": "customer@example.com" }
}
},
"properties": {
"$value": 149.99,
"OrderId": "ORD-12345",
"Reason": "Customer requested",
"Items": [
{
"ProductID": "PROD-001",
"ProductName": "Wireless Headphones Pro",
"Quantity": 1,
"ItemPrice": 89.99
}
]
},
"unique_id": "CANCEL-ORD-12345"
}
}
}Usage notes:
- Use to suppress cancelled orders from post-purchase and review flows
Reasonat top level enables flow splits by cancellation reason$value= cancelled order value (useful for segment: "cancelled $500+ order")
---
Nested Object Limitations & Workarounds
Klaviyo's data model handles nested objects (arrays, sub-objects) differently depending on where you access them. This is the #1 source of "I track the data but can't use it" complaints.
Access Matrix
| Data Location | Email/SMS Templates | Flow Conditional Splits | Flow Trigger Filters | Segments |
|---|---|---|---|---|
| Top-level string/number/boolean | Yes | Yes | Yes | Yes |
Top-level array (e.g., ItemNames) | Yes (loop) | Yes (contains) | Yes (contains) | Yes (contains) |
Nested object field (e.g., Items[0].ProductName) | Yes (loop) | No | No | No |
| Nested object in nested object | Yes (deep loop) | No | No | No |
| Profile property (string/number/boolean) | Yes | Yes | Yes | Yes |
| Profile property (array) | Yes | Yes (contains) | Yes (contains) | Yes (contains) |
| Profile property (object) | Yes (dot notation) | No | No | No |
Workaround Patterns
Pattern 1: Flatten arrays to comma-joined strings
# Instead of relying on Items[].Category for segmentation:
properties["ItemCategories"] = ",".join(set(item["Category"] for item in items))
# Now segment: "Placed Order where ItemCategories contains Electronics"Pattern 2: Promote key nested values to top-level
# Need to split flow by first item brand?
properties["TopBrand"] = items[0]["Brand"] if items else ""
properties["HasElectronics"] = any(
"Electronics" in item.get("Categories", []) for item in items
)Pattern 3: Sync to profile for persistent segmentation
# Need to segment by "ever purchased Electronics"?
# Track on profile, not just on event:
profile_properties["purchased_categories"] = list(set(
existing_categories + new_order_categories
))Pattern 4: Use "Update Profile Property" flow action
Flow: Placed Order trigger
→ Update Profile Property: "last_order_category" = {{ event.TopCategory }}
→ Conditional Split: Profile "last_order_category" = "Electronics"---
Custom Event Design Patterns (DTC / Subscription)
Beyond standard Shopify events, these patterns cover subscription, loyalty, and advanced DTC workflows.
Account & Subscription Lifecycle Events
# Account Created — new customer registration
{
"name": "Account Created",
"properties": {
"account_id": "ACCT-12345",
"signup_channel": "Website",
"referral_source": "Instagram Ad",
"interests": ["Skincare", "Wellness"],
"quiz_completed": True,
"skin_type": "Combination",
"state": "CA"
}
}
# Subscription Started — recurring order activated
{
"name": "Subscription Started",
"properties": {
"$value": 39.99,
"plan_name": "Monthly Essentials Box",
"frequency": "monthly",
"product_ids": ["PROD-100", "PROD-200"],
"payment_method": "Credit Card",
"account_id": "ACCT-12345"
}
}
# Subscription Cancelled — churn event
{
"name": "Subscription Cancelled",
"properties": {
"plan_name": "Monthly Essentials Box",
"reason": "Too expensive",
"lifetime_charges": 6,
"lifetime_revenue": 239.94,
"account_id": "ACCT-12345"
}
}Advanced Purchase Events
# Wishlist Added — high-intent browsing signal
{
"name": "Wishlist Added",
"properties": {
"ProductName": "Vitamin C Serum",
"ProductID": "PROD-300",
"Categories": ["Skincare", "Serums"],
"Price": 48.00,
"wishlist_count": 3,
"account_id": "ACCT-12345"
}
}
# Reorder Placed — repeat purchase (consumable)
{
"name": "Reorder Placed",
"properties": {
"$value": 72.50,
"OrderId": "ORD-99887",
"days_since_last_order": 28,
"is_auto_reorder": False,
"reorder_items": ["Daily Moisturizer", "SPF 50 Sunscreen", "Lip Balm"],
"reorder_category": "Skincare",
"account_id": "ACCT-12345"
}
}DTC Profile Properties for Segmentation
# Sync these to profiles for advanced segmentation:
profile_properties = {
# Customer identity
"customer_type": "Subscriber",
"interests": ["Skincare", "Wellness"],
"subscription_plan": "Monthly Essentials Box",
"account_tier": "VIP", # Standard, VIP, Founding Member
"state": "CA",
# Purchase behavior
"first_order_date": "2024-03-15",
"last_order_date": "2026-01-28",
"lifetime_order_count": 18,
"avg_order_value": 72.50,
"lifetime_revenue": 1305.00,
"avg_days_between_orders": 31,
# Product affinity
"preferred_categories": ["Skincare", "Supplements"],
"purchased_brands": ["GlowLab", "VitaWell", "PureBlend"],
# Personalization
"skin_type": "Combination",
"quiz_score": 85,
# Engagement
"last_email_click_date": "2026-02-05",
"email_engagement_tier": "Active"
}Segmentation Examples
# High-value customers for VIP flow
Segment: "VIP Customers"
Conditions:
- lifetime_revenue > 500 OR
- lifetime_order_count > 8 OR
- avg_order_value > 100
# Reorder candidates (consumable replenishment)
Segment: "Reorder - Skincare (30d)"
Conditions:
- Has done "Placed Order" where Categories contains "Skincare"
at least 1 time in the last 180 days
- Has NOT done "Placed Order" in the last 25 days
- preferred_categories contains "Skincare"
# Interest-based targeting
Segment: "Wellness Enthusiasts"
Conditions:
- interests contains "Wellness" OR
- preferred_categories contains "Supplements"
- Has done "Placed Order" at least 1 time (verified buyer)
# At-risk high-value customers
Segment: "At-Risk VIP"
Conditions:
- lifetime_revenue > 300
- Has NOT done "Placed Order" in the last 60 days
- Has NOT done "Opened Email" in the last 30 days---
MCP Server Reference
Klaviyo ships an official MCP server that wraps the same REST API documented above. For ad-hoc data exploration during integration work — schema inspection, event inventory, flow debugging — the MCP is faster than writing SDK code. The schema rules, rate limits, and nesting constraints described in this document apply equally to MCP-driven calls.
Connection
| Mode | Setup |
|---|---|
| Claude Chat / Cowork | Settings → Connectors → Browse Connectors → search "Klaviyo" → Connect. Available on Pro, Max, Team, Enterprise plans. Listed in the Claude Connector Directory as of the expanded Klaviyo + Anthropic integration announced 2026-05-07. |
| Claude Code (remote) | claude mcp add klaviyo --transport http https://mcp.klaviyo.com/mcp |
| Claude Code (local) | claude mcp add klaviyo -e PRIVATE_API_KEY=pk_... -- uvx klaviyo-mcp-server@latest |
| Read-only mode | Append ?read-only=true to the remote URL — disables all write tools |
| API revision | 2026-04-15 |
| Required Klaviyo role | Owner, Admin, or Manager |
| Transport | Streamable HTTP; OAuth (dynamic client registration) |
Tool Inventory
Read-only unless marked (write).
| Category | Tools |
|---|---|
| Accounts | get_account_details |
| Campaigns | get_campaigns, get_campaign, create_campaign (write), assign_template_to_campaign_message (write) |
| Catalogs | get_catalog_items |
| Events & Metrics | get_events, create_event (write), get_metrics, get_metric, query_metric_aggregates |
| Flows | get_flows, get_flow |
| Groups | get_lists, get_list, get_segments, get_segment |
| Images | upload_image_from_file (write), upload_image_from_url (write) |
| Profiles | get_profiles, get_profile, create_profile (write), update_profile (write), subscribe_profile_to_marketing (write), unsubscribe_profile_from_marketing (write) |
| Reporting | get_campaign_report, get_flow_report |
| Templates | get_email_template, create_email_template (write) |
| Translations (beta) | get_translations, get_translation, create_translation (write), update_translation (write), delete_translation (write) |
Integration debugging recipes
| Problem | MCP tool sequence |
|---|---|
| "Why doesn't this event fire my flow?" | get_metrics → get_metric (confirm name + property schema) → get_events (sample recent payloads) → get_flow (check trigger filter) |
| "Are my profile properties syncing?" | get_profile (by email or ID) → inspect attributes.properties |
| "Is my catalog up to date?" | get_catalog_items → check updated timestamp and item count |
| "What does the campaign report look like before I build it into a dashboard?" | get_campaigns → get_campaign_report for a sample ID → confirm property shape |
| "Are nested event properties making it through?" | get_events filtered to metric → inspect property tree → confirm query_metric_aggregates can roll up the top-level fields you flattened |
When MCP is wrong for the job
- CI / cron / deployed services — use the SDK with a scoped API key. The MCP is interactive and OAuth-bound.
- Bulk operations — for jobs above a few hundred records (bulk profile imports, suppression jobs, catalog bulk sync), use the dedicated bulk-job endpoints via SDK. The MCP exposes single-record write tools, not bulk job creators.
- Webhook handlers — webhooks are inbound and verified via the webhook signing secret, not an MCP concept.
- OAuth flows for third-party apps — if you're building a Klaviyo integration that uses Klaviyo's OAuth on behalf of merchants, you're implementing the OAuth provider integration directly, not consuming the MCP.
# Klaviyo Developer Skill Dependencies
# Install with: pip install -r requirements.txt
klaviyo-api>=9.0.0,<23.0.0
python-dotenv>=1.0.0,<2.0.0
#!/usr/bin/env python3
"""
Klaviyo Developer Tools
Higher-level developer utilities for Klaviyo integration management:
- Integration health check
- Event validation
- Webhook testing
- CSV import with progress
- Paginated data export
Usage:
python dev_tools.py --tool health-check
python dev_tools.py --tool validate-events --events "Placed Order,Started Checkout"
python dev_tools.py --tool test-webhook --webhook-url https://example.com/webhook
python dev_tools.py --tool import-csv --file contacts.csv --list-id LIST_ID
python dev_tools.py --tool export-data --resource profiles --max-records 1000
"""
import os
import sys
import json
import csv
import time
import argparse
import traceback
import hashlib
import hmac
from typing import Dict, List, Optional
from datetime import datetime
import ipaddress
from urllib.parse import urlparse
try:
from klaviyo_client import KlaviyoDevClient
except ImportError:
print("Error: klaviyo_client.py not found in the same directory", file=sys.stderr)
sys.exit(1)
def _safe_output_path(path: str) -> str:
"""Validate output path does not escape working directory."""
resolved = os.path.realpath(path)
cwd = os.path.realpath(os.getcwd())
if not resolved.startswith(cwd + os.sep) and resolved != cwd:
raise ValueError(f"Output path must be within working directory: {cwd}")
return resolved
def _safe_input_file(path: str) -> str:
"""Validate input file path: must be .csv and exist."""
resolved = os.path.realpath(path)
if not os.path.exists(resolved):
raise FileNotFoundError(f"File not found: {path}")
if not resolved.lower().endswith(".csv"):
raise ValueError("Input file must be a .csv file")
return resolved
def _validate_webhook_url(url: str) -> str:
"""Validate webhook URL is not targeting internal/private networks."""
parsed = urlparse(url)
if parsed.scheme not in ("https",):
raise ValueError("Webhook URL must use HTTPS")
hostname = parsed.hostname or ""
if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1", ""):
raise ValueError("Webhook URL cannot target localhost")
# Check for private/reserved IP ranges
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
raise ValueError("Webhook URL cannot target private/reserved IP addresses")
except ValueError as e:
if "private" in str(e).lower() or "reserved" in str(e).lower() or "localhost" in str(e).lower():
raise
# hostname is a domain name, not an IP — that's fine
# Block cloud metadata endpoints
if hostname == "169.254.169.254":
raise ValueError("Webhook URL cannot target cloud metadata endpoints")
return url
class KlaviyoDevTools:
"""Developer utilities for Klaviyo integration management."""
def __init__(self):
"""Initialize with Klaviyo developer client."""
self.client = KlaviyoDevClient()
def health_check(self) -> Dict:
"""
Check API connectivity, scopes, rate limits, and event health.
Returns:
Dictionary with health check results
"""
results = {
"timestamp": datetime.now().isoformat(),
"checks": [],
"status": "healthy",
}
# Check 1: API connectivity
try:
metrics = self.client.get_metrics()
results["checks"].append({
"check": "API Connectivity",
"status": "pass",
"detail": "Successfully connected to Klaviyo API",
})
except Exception as e:
results["checks"].append({
"check": "API Connectivity",
"status": "fail",
"detail": "Failed to connect. Check API key and network connection.",
"underlying_error": f"{type(e).__name__}: {e}",
})
results["status"] = "unhealthy"
return results
# Check 2: Read scopes (profiles)
try:
profiles = self.client.export_profiles(page_size=1, max_pages=1)
results["checks"].append({
"check": "Profile Read Scope",
"status": "pass",
"detail": "profiles:read scope verified",
})
except Exception as e:
results["checks"].append({
"check": "Profile Read Scope",
"status": "fail",
"detail": "profiles:read may not be enabled. Check API key scopes.",
"underlying_error": f"{type(e).__name__}: {e}",
})
# Check 3: Metrics availability
try:
metric_count = len(metrics) if isinstance(metrics, list) else 0
results["checks"].append({
"check": "Metrics Available",
"status": "pass",
"detail": f"{metric_count} event types found",
})
# Check for essential e-commerce events
metric_names = [
m.get("name", "").lower()
for m in metrics
if isinstance(m, dict)
]
essential_events = [
"placed order",
"started checkout",
"viewed product",
"added to cart",
]
found_events = [
e for e in essential_events if e in metric_names
]
missing_events = [
e for e in essential_events if e not in metric_names
]
if missing_events:
results["checks"].append({
"check": "E-commerce Events",
"status": "warning",
"detail": f"Missing: {', '.join(missing_events)}",
})
else:
results["checks"].append({
"check": "E-commerce Events",
"status": "pass",
"detail": f"All {len(essential_events)} essential events present",
})
except Exception as e:
results["checks"].append({
"check": "Metrics Available",
"status": "fail",
"detail": "Failed to retrieve metrics. Check API key scopes.",
"underlying_error": f"{type(e).__name__}: {e}",
})
# Check 4: Catalog access
try:
catalog = self.client.get_catalog_items()
item_count = len(catalog) if isinstance(catalog, list) else 0
results["checks"].append({
"check": "Catalog Access",
"status": "pass",
"detail": f"catalogs:read verified ({item_count} items found)",
})
except Exception as e:
results["checks"].append({
"check": "Catalog Access",
"status": "warning",
"detail": "catalogs:read may not be enabled. Check API key scopes.",
"underlying_error": f"{type(e).__name__}: {e}",
})
# Set overall status
statuses = [c["status"] for c in results["checks"]]
if "fail" in statuses:
results["status"] = "unhealthy"
elif "warning" in statuses:
results["status"] = "degraded"
results["recommendations"] = self._recommend_health_fixes(results["checks"])
return results
def validate_events(self, event_names: List[str]) -> Dict:
"""
Check that expected events exist and are being tracked.
Args:
event_names: List of event names to validate
Returns:
Dictionary with validation results
"""
metrics = self.client.get_metrics()
metric_names = [
m.get("name", "") for m in metrics if isinstance(m, dict)
]
metric_names_lower = [n.lower() for n in metric_names]
results = []
for event in event_names:
found = event.lower() in metric_names_lower
results.append({
"event": event,
"status": "found" if found else "missing",
})
found_count = sum(1 for r in results if r["status"] == "found")
return {
"summary": {
"events_checked": len(event_names),
"events_found": found_count,
"events_missing": len(event_names) - found_count,
},
"results": results,
"available_events": metric_names,
"recommendations": self._recommend_event_fixes(results),
}
def test_webhook(self, url: str, secret: Optional[str] = None) -> Dict:
"""
Send a test payload to a webhook URL and report results.
Args:
url: Webhook endpoint URL
secret: Optional webhook secret for signature generation
Returns:
Dictionary with test results
"""
url = _validate_webhook_url(url)
if not secret:
secret = os.environ.get("KLAVIYO_WEBHOOK_SECRET")
import urllib.request
import urllib.error
test_payload = json.dumps({
"type": "test",
"data": {
"type": "event",
"attributes": {
"metric_name": "webhook_test",
"timestamp": datetime.now().isoformat(),
"properties": {"source": "klaviyo-dev-tools"},
},
},
})
headers = {"Content-Type": "application/json"}
if secret:
signature = hmac.new(
secret.encode(), test_payload.encode(), hashlib.sha256
).hexdigest()
headers["X-Klaviyo-Webhook-Signature"] = signature
results = {
"url": url,
"payload_size": len(test_payload),
"signature_included": bool(secret),
}
try:
req = urllib.request.Request(
url,
data=test_payload.encode(),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as response:
results["status"] = "pass"
results["response_code"] = response.status
results["response_body"] = response.read().decode()[:500]
except urllib.error.HTTPError as e:
results["status"] = "fail"
results["response_code"] = e.code
results["error"] = "HTTP error received from webhook endpoint"
except urllib.error.URLError:
results["status"] = "fail"
results["error"] = "Connection failed. Check the webhook URL and network."
except Exception as e:
results["status"] = "fail"
results["error"] = "Webhook test failed. Check the URL and try again."
results["underlying_error"] = f"{type(e).__name__}: {e}"
return results
def import_csv(
self, filepath: str, list_id: Optional[str] = None
) -> Dict:
"""
Import profiles from CSV with batching and progress reporting.
Args:
filepath: Path to CSV file
list_id: Optional list ID to add profiles to
Returns:
Dictionary with import results
"""
# Read CSV
profiles = []
safe_file = _safe_input_file(filepath)
with open(safe_file, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
profiles.append(dict(row))
total = len(profiles)
batch_size = 10000
results = {
"file": filepath,
"total_profiles": total,
"batches": [],
"errors": [],
}
# Process in batches
for i in range(0, total, batch_size):
batch = profiles[i : i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (total + batch_size - 1) // batch_size
print(
f"Batch {batch_num}/{total_batches}: "
f"importing {len(batch)} profiles...",
file=sys.stderr,
)
try:
result = self.client.bulk_import(batch, list_id=list_id)
results["batches"].append({
"batch": batch_num,
"profiles": len(batch),
"status": "submitted",
"job_id": result.get("job_id"),
})
except Exception as e:
results["batches"].append({
"batch": batch_num,
"profiles": len(batch),
"status": "failed",
"error": "Batch import failed. Check API key and profile data.",
"underlying_error": f"{type(e).__name__}: {e}",
})
results["errors"].append(f"Batch import failed: {type(e).__name__}: {e}")
# Summary
successful = sum(
1 for b in results["batches"] if b["status"] == "submitted"
)
results["summary"] = {
"batches_submitted": successful,
"batches_failed": len(results["batches"]) - successful,
"profiles_submitted": sum(
b["profiles"]
for b in results["batches"]
if b["status"] == "submitted"
),
}
return results
def export_data(
self,
resource: str,
output_path: Optional[str] = None,
max_records: Optional[int] = None,
) -> Dict:
"""
Export data with pagination to CSV.
Args:
resource: Resource type (profiles, events, catalog)
output_path: Output CSV file path
max_records: Maximum records to export
Returns:
Dictionary with export summary
"""
max_pages = None
if max_records:
max_pages = (max_records + 99) // 100
if resource == "profiles":
data = self.client.export_profiles(
page_size=100, max_pages=max_pages
)
elif resource == "catalog":
data = self.client.get_catalog_items()
elif resource == "events":
# Events don't have a simple paginated export via SDK
data = []
print(
"Note: Event export requires metric aggregation API. "
"Use --resource profiles or catalog instead.",
file=sys.stderr,
)
else:
raise ValueError(f"Unsupported resource type: {resource}")
if max_records and len(data) > max_records:
data = data[:max_records]
# Write CSV if output specified
if output_path and data:
all_keys = []
for row in data:
for key in row.keys():
if key not in all_keys:
all_keys.append(key)
safe_path = _safe_output_path(output_path)
with open(safe_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f, fieldnames=all_keys, extrasaction="ignore"
)
writer.writeheader()
for row in data:
clean_row = {}
for k, v in row.items():
if isinstance(v, (dict, list)):
clean_row[k] = json.dumps(v)
else:
clean_row[k] = v
writer.writerow(clean_row)
print(f"Exported {len(data)} records to {output_path}", file=sys.stderr)
return {
"resource": resource,
"records_exported": len(data),
"output_file": output_path,
}
def _recommend_health_fixes(self, checks: List[Dict]) -> List[Dict]:
"""Generate recommendations from health check results."""
recommendations = []
for check in checks:
if check["status"] == "fail":
if "connectivity" in check["check"].lower():
recommendations.append({
"priority": "CRITICAL",
"action": "Fix API authentication",
"reason": check["detail"],
"expected_impact": "Restore all Klaviyo integrations",
})
elif "scope" in check["check"].lower():
recommendations.append({
"priority": "HIGH",
"action": f"Enable missing scope for {check['check']}",
"reason": check["detail"],
"expected_impact": "Restore access to this resource",
})
elif check["status"] == "warning":
if "events" in check["check"].lower():
recommendations.append({
"priority": "HIGH",
"action": "Implement missing e-commerce event tracking",
"reason": check["detail"],
"expected_impact": "Enable automated flows for missing events",
})
if not recommendations:
recommendations.append({
"priority": "INFO",
"action": "All health checks passed",
"reason": "Integration is healthy",
"expected_impact": "Continue monitoring",
})
return recommendations
def _recommend_event_fixes(self, results: List[Dict]) -> List[Dict]:
"""Generate recommendations from event validation results."""
recommendations = []
missing = [r for r in results if r["status"] == "missing"]
for event in missing:
recommendations.append({
"priority": "HIGH",
"action": f"Implement tracking for '{event['event']}'",
"reason": f"Event '{event['event']}' not found in Klaviyo",
"expected_impact": "Enable flows and segments triggered by this event",
})
if not missing:
recommendations.append({
"priority": "INFO",
"action": "All expected events are being tracked",
"reason": "Event validation passed",
"expected_impact": "Continue monitoring event freshness",
})
return recommendations
def main():
parser = argparse.ArgumentParser(
description="Klaviyo developer tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run integration health check
python dev_tools.py --tool health-check
# Validate expected events exist
python dev_tools.py --tool validate-events \\
--events "Placed Order,Started Checkout,Viewed Product"
# Test a webhook endpoint
python dev_tools.py --tool test-webhook \\
--webhook-url https://example.com/webhooks/klaviyo
# Test webhook with signature verification
python dev_tools.py --tool test-webhook \\
--webhook-url https://example.com/webhooks/klaviyo \\
--webhook-secret your-secret-here
# Import profiles from CSV
python dev_tools.py --tool import-csv \\
--file contacts.csv --list-id LIST_ID
# Export profiles to CSV
python dev_tools.py --tool export-data \\
--resource profiles --max-records 5000 --output profiles.csv
""",
)
parser.add_argument(
"--tool",
required=True,
choices=[
"health-check",
"validate-events",
"test-webhook",
"import-csv",
"export-data",
],
help="Tool to run",
)
parser.add_argument(
"--events",
help="Comma-separated event names (for validate-events)",
)
parser.add_argument(
"--webhook-url",
help="Webhook URL to test (for test-webhook)",
)
parser.add_argument(
"--webhook-secret",
help="Webhook secret for signature (for test-webhook)",
)
parser.add_argument(
"--file",
help="CSV file path (for import-csv)",
)
parser.add_argument(
"--list-id",
help="List ID (for import-csv)",
)
parser.add_argument(
"--resource",
choices=["profiles", "events", "catalog"],
help="Resource to export (for export-data)",
)
parser.add_argument(
"--max-records",
type=int,
help="Max records to export (for export-data)",
)
parser.add_argument(
"--format",
choices=["json", "table"],
default="json",
help="Output format (default: json)",
)
parser.add_argument(
"--output",
help="Output file path (default: stdout)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Re-raise exceptions with full traceback instead of friendly error",
)
args = parser.parse_args()
try:
tools = KlaviyoDevTools()
if args.tool == "health-check":
result = tools.health_check()
elif args.tool == "validate-events":
if not args.events:
parser.error("--events is required for validate-events")
event_list = [e.strip() for e in args.events.split(",")]
result = tools.validate_events(event_list)
elif args.tool == "test-webhook":
if not args.webhook_url:
parser.error("--webhook-url is required for test-webhook")
result = tools.test_webhook(
args.webhook_url, secret=args.webhook_secret or os.environ.get("KLAVIYO_WEBHOOK_SECRET")
)
elif args.tool == "import-csv":
if not args.file:
parser.error("--file is required for import-csv")
result = tools.import_csv(args.file, list_id=args.list_id)
elif args.tool == "export-data":
if not args.resource:
parser.error("--resource is required for export-data")
result = tools.export_data(
args.resource,
output_path=args.output,
max_records=args.max_records,
)
# Format output
output = json.dumps(result, indent=2, default=str)
# Write output
if args.output and args.tool != "export-data":
safe_path = _safe_output_path(args.output)
with open(safe_path, "w", encoding="utf-8") as f:
f.write(output)
print(f"Results saved to {args.output}", file=sys.stderr)
else:
print(output)
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception:
if args.debug:
traceback.print_exc()
else:
print("Error: Operation failed. Check your API key and network connection.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Klaviyo Developer API Client
Read-write SDK wrapper for Klaviyo event tracking, profile management,
bulk imports, catalog sync, and data export.
Usage:
python klaviyo_client.py --action track-event --email user@example.com --event "Placed Order" --properties '{"value": 99.99}'
python klaviyo_client.py --action upsert-profile --email user@example.com --properties '{"first_name": "Jane"}'
python klaviyo_client.py --action bulk-import --file contacts.csv --list-id LIST_ID
python klaviyo_client.py --action catalog-items --format table
python klaviyo_client.py --action export-profiles --max-pages 5 --output profiles.csv
Environment Variables:
KLAVIYO_API_KEY: Klaviyo private API key (required, starts with "pk_")
"""
import os
import sys
import json
import csv
import argparse
import traceback
from typing import Dict, List, Optional
try:
from klaviyo_api import KlaviyoAPI
from dotenv import load_dotenv
except ImportError:
print("Error: Required packages not installed.", file=sys.stderr)
print("Install with: pip install klaviyo-api python-dotenv", file=sys.stderr)
sys.exit(1)
def _safe_output_path(path: str) -> str:
"""Validate output path does not escape working directory."""
resolved = os.path.realpath(path)
cwd = os.path.realpath(os.getcwd())
if not resolved.startswith(cwd + os.sep) and resolved != cwd:
raise ValueError(f"Output path must be within working directory: {cwd}")
return resolved
def _safe_input_file(path: str) -> str:
"""Validate input file path: must be .csv and exist."""
resolved = os.path.realpath(path)
if not os.path.exists(resolved):
raise FileNotFoundError(f"File not found: {path}")
if not resolved.lower().endswith(".csv"):
raise ValueError("Input file must be a .csv file")
return resolved
class KlaviyoDevClient:
"""Read-write client for Klaviyo developer operations."""
API_REVISION = "2025-10-15"
def __init__(self):
"""Initialize the client with credentials from environment."""
load_dotenv()
api_key = os.environ.get("KLAVIYO_API_KEY")
if not api_key:
raise ValueError(
"KLAVIYO_API_KEY environment variable not set. "
"Find your API key in Klaviyo: Settings > Account > API Keys"
)
if not api_key.startswith("pk_"):
raise ValueError(
"KLAVIYO_API_KEY should start with 'pk_'. "
"Use a Private API Key, not a Public API Key."
)
try:
self.client = KlaviyoAPI(api_key, max_delay=60, max_retries=3)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to initialize Klaviyo client. Check your API key.") from e
def track_event(
self,
event_name: str,
email: str,
properties: Optional[Dict] = None,
unique_id: Optional[str] = None,
) -> Dict:
"""
Track a custom event.
Args:
event_name: Name of the event (e.g., "Placed Order")
email: Profile email address
properties: Event properties dict
unique_id: Idempotency key to prevent duplicates
Returns:
API response dictionary
"""
body = self._build_event_body(event_name, email, properties, unique_id)
try:
response = self.client.Events.create_event(body)
return {"status": "success", "event": event_name, "email": email}
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to track event. Check your API key and event data.") from e
def upsert_profile(
self,
email: Optional[str] = None,
phone: Optional[str] = None,
properties: Optional[Dict] = None,
) -> Dict:
"""
Create or update a profile.
Args:
email: Profile email address
phone: Profile phone number (with country code)
properties: Custom profile properties
Returns:
API response with profile data
"""
body = self._build_profile_body(email, phone, properties)
try:
response = self.client.Profiles.create_profile(body)
return self._parse_jsonapi_response(response)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to upsert profile. Check your API key and profile data.") from e
def bulk_import(
self, profiles: List[Dict], list_id: Optional[str] = None
) -> Dict:
"""
Submit a bulk profile import job (max 10,000 profiles per job).
Args:
profiles: List of profile dicts with email, phone, and/or properties
list_id: Optional list ID to add profiles to
Returns:
Dictionary with job ID and status
"""
if len(profiles) > 10000:
raise ValueError("Bulk import limited to 10,000 profiles per job")
profile_data = []
for p in profiles:
attrs = {}
if p.get("email"):
attrs["email"] = p["email"]
if p.get("phone"):
attrs["phone_number"] = p["phone"]
if p.get("first_name"):
attrs["first_name"] = p["first_name"]
if p.get("last_name"):
attrs["last_name"] = p["last_name"]
# Custom properties
custom_props = {
k: v
for k, v in p.items()
if k not in ("email", "phone", "first_name", "last_name")
}
if custom_props:
attrs["properties"] = custom_props
profile_data.append({"type": "profile", "attributes": attrs})
body = {
"data": {
"type": "profile-bulk-import-job",
"attributes": {"profiles": {"data": profile_data}},
}
}
if list_id:
body["data"]["relationships"] = {
"lists": {"data": [{"type": "list", "id": list_id}]}
}
try:
response = self.client.Profiles.spawn_bulk_profile_import_job(body)
result = self._parse_jsonapi_response(response)
return {
"status": "submitted",
"job_id": result.get("id") if isinstance(result, dict) else None,
"profiles_submitted": len(profiles),
}
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to submit bulk import. Check your API key and profile data.") from e
def get_import_job_status(self, job_id: str) -> Dict:
"""
Check status of a bulk import job.
Args:
job_id: The import job ID
Returns:
Dictionary with job status and progress
"""
try:
response = self.client.Profiles.get_bulk_profile_import_job(job_id)
return self._parse_jsonapi_response(response)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to get import job status. Check job ID and API key.") from e
def get_catalog_items(self, filter_str: Optional[str] = None) -> List[Dict]:
"""
List catalog items.
Args:
filter_str: Optional filter expression
Returns:
List of catalog item dictionaries
"""
try:
kwargs = {}
if filter_str:
kwargs["filter"] = filter_str
response = self.client.Catalogs.get_catalog_items(**kwargs)
return self._parse_jsonapi_response(response)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to fetch catalog items. Check your API key and catalog scopes.") from e
def create_catalog_item(self, item_data: Dict) -> Dict:
"""
Create a catalog item.
Args:
item_data: Dictionary with title, description, url, image_url, price, etc.
Returns:
Created catalog item data
"""
body = {
"data": {
"type": "catalog-item",
"attributes": {
"external_id": item_data.get("external_id", item_data.get("id")),
"title": item_data.get("title"),
"description": item_data.get("description", ""),
"url": item_data.get("url", ""),
"image_full_url": item_data.get("image_url", ""),
"custom_metadata": {
k: v
for k, v in item_data.items()
if k
not in (
"external_id",
"id",
"title",
"description",
"url",
"image_url",
"price",
)
},
},
}
}
if item_data.get("price"):
body["data"]["attributes"]["price"] = float(item_data["price"])
try:
response = self.client.Catalogs.create_catalog_item(body)
return self._parse_jsonapi_response(response)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to create catalog item. Check your API key and item data.") from e
def export_profiles(
self, page_size: int = 100, max_pages: Optional[int] = None
) -> List[Dict]:
"""
Export profiles with cursor-based pagination.
Args:
page_size: Number of profiles per page (max 100)
max_pages: Maximum pages to fetch (None for all)
Returns:
List of profile dictionaries
"""
profiles = []
cursor = None
page_count = 0
while True:
try:
kwargs = {"page_size": min(page_size, 100)}
if cursor:
kwargs["page_cursor"] = cursor
response = self.client.Profiles.get_profiles(**kwargs)
if hasattr(response, "to_dict"):
response = response.to_dict()
elif isinstance(response, str):
response = json.loads(response)
data = response.get("data", [])
for item in data:
flat = {"id": item.get("id")}
attrs = item.get("attributes", {})
flat.update(attrs)
profiles.append(flat)
page_count += 1
print(
f"Page {page_count}: fetched {len(data)} profiles "
f"(total: {len(profiles)})",
file=sys.stderr,
)
# Check pagination
next_link = response.get("links", {}).get("next")
if not next_link:
break
if max_pages and page_count >= max_pages:
print(
f"Reached max pages limit ({max_pages})", file=sys.stderr
)
break
# Extract cursor from next link
from urllib.parse import urlparse, parse_qs
parsed = urlparse(next_link)
cursor = parse_qs(parsed.query).get("page[cursor]", [None])[0]
if not cursor:
break
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError(
"Failed to export profiles. Check your API key and profile scopes."
) from e
return profiles
def get_metrics(self) -> List[Dict]:
"""
List all available event types (metrics).
Returns:
List of metric dictionaries with id and name
"""
try:
response = self.client.Metrics.get_metrics()
return self._parse_jsonapi_response(response)
except Exception as e:
print(f" caused by: {type(e).__name__}: {e}", file=sys.stderr)
raise RuntimeError("Failed to fetch metrics. Check your API key and metrics scopes.") from e
def _parse_jsonapi_response(self, response) -> List[Dict]:
"""Flatten JSON:API envelope into simple dictionaries."""
if hasattr(response, "to_dict"):
response = response.to_dict()
elif isinstance(response, str):
response = json.loads(response)
data = response.get("data", response)
if isinstance(data, list):
return [self._flatten_resource(item) for item in data]
elif isinstance(data, dict):
return self._flatten_resource(data)
else:
return data
def _flatten_resource(self, resource: Dict) -> Dict:
"""Flatten a single JSON:API resource object."""
flat = {
"id": resource.get("id"),
"type": resource.get("type"),
}
attributes = resource.get("attributes", {})
if attributes:
flat.update(attributes)
return flat
def _build_event_body(
self,
event_name: str,
email: str,
properties: Optional[Dict] = None,
unique_id: Optional[str] = None,
) -> Dict:
"""Build JSON:API event body."""
body = {
"data": {
"type": "event",
"attributes": {
"metric": {
"data": {
"type": "metric",
"attributes": {"name": event_name},
}
},
"profile": {
"data": {
"type": "profile",
"attributes": {"email": email},
}
},
"properties": properties or {},
},
}
}
if unique_id:
body["data"]["attributes"]["unique_id"] = unique_id
return body
def _build_profile_body(
self,
email: Optional[str] = None,
phone: Optional[str] = None,
properties: Optional[Dict] = None,
) -> Dict:
"""Build JSON:API profile body."""
attrs = {}
if email:
attrs["email"] = email
if phone:
attrs["phone_number"] = phone
if properties:
# Separate standard fields from custom properties
standard_fields = {
"first_name",
"last_name",
"organization",
"title",
"image",
"location",
}
for key in list(properties.keys()):
if key in standard_fields:
attrs[key] = properties.pop(key)
if properties:
attrs["properties"] = properties
return {"data": {"type": "profile", "attributes": attrs}}
def main():
parser = argparse.ArgumentParser(
description="Klaviyo developer operations client",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Track a custom event
python klaviyo_client.py --action track-event \\
--email user@example.com --event "Placed Order" \\
--properties '{"value": 99.99, "OrderId": "ORD-123"}'
# Upsert a profile
python klaviyo_client.py --action upsert-profile \\
--email user@example.com \\
--properties '{"first_name": "Jane", "loyalty_tier": "Gold"}'
# Check import job status
python klaviyo_client.py --action import-status --job-id JOB_ID
# List catalog items
python klaviyo_client.py --action catalog-items --format table
# Export profiles to CSV
python klaviyo_client.py --action export-profiles \\
--max-pages 10 --format csv --output profiles.csv
# List available metrics
python klaviyo_client.py --action metrics
""",
)
parser.add_argument(
"--action",
required=True,
choices=[
"track-event",
"upsert-profile",
"bulk-import",
"import-status",
"catalog-items",
"create-catalog-item",
"export-profiles",
"metrics",
],
help="Action to perform",
)
parser.add_argument("--email", help="Profile email address")
parser.add_argument("--event", help="Event name (for track-event)")
parser.add_argument(
"--properties", help="JSON properties string (for events or profiles)"
)
parser.add_argument("--file", help="CSV file path (for bulk-import)")
parser.add_argument("--list-id", help="List ID (for bulk-import)")
parser.add_argument("--job-id", help="Import job ID (for import-status)")
parser.add_argument(
"--max-pages",
type=int,
help="Max pages to fetch (for export-profiles)",
)
parser.add_argument(
"--format",
choices=["json", "table", "csv"],
default="json",
help="Output format (default: json)",
)
parser.add_argument("--output", help="Output file path (default: stdout)")
parser.add_argument(
"--debug",
action="store_true",
help="Re-raise exceptions with full traceback instead of friendly error",
)
args = parser.parse_args()
try:
client = KlaviyoDevClient()
# Parse properties
properties = None
if args.properties:
properties = json.loads(args.properties)
# Execute action
if args.action == "track-event":
if not args.email:
parser.error("--email is required for track-event")
if not args.event:
parser.error("--event is required for track-event")
result = client.track_event(
event_name=args.event,
email=args.email,
properties=properties,
)
elif args.action == "upsert-profile":
if not args.email:
parser.error("--email is required for upsert-profile")
result = client.upsert_profile(
email=args.email, properties=properties
)
elif args.action == "bulk-import":
if not args.file:
parser.error("--file is required for bulk-import")
# Read CSV
profiles = []
safe_file = _safe_input_file(args.file)
with open(safe_file, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
profiles.append(dict(row))
result = client.bulk_import(profiles, list_id=args.list_id)
elif args.action == "import-status":
if not args.job_id:
parser.error("--job-id is required for import-status")
result = client.get_import_job_status(args.job_id)
elif args.action == "catalog-items":
result = client.get_catalog_items()
elif args.action == "create-catalog-item":
if not properties:
parser.error(
"--properties is required for create-catalog-item (JSON with title, etc.)"
)
result = client.create_catalog_item(properties)
elif args.action == "export-profiles":
result = client.export_profiles(max_pages=args.max_pages)
elif args.action == "metrics":
result = client.get_metrics()
# Format output
if args.format == "csv" and isinstance(result, list):
output = format_as_csv(result)
elif args.format == "json":
output = json.dumps(result, indent=2, default=str)
else:
output = json.dumps(result, indent=2, default=str)
# Write output
if args.output:
safe_path = _safe_output_path(args.output)
with open(safe_path, "w", encoding="utf-8") as f:
f.write(output)
print(f"Data saved to {args.output}", file=sys.stderr)
else:
print(output)
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception:
if args.debug:
traceback.print_exc()
else:
print("Error: Operation failed. Check your API key and network connection.", file=sys.stderr)
sys.exit(1)
def format_as_csv(data: List[Dict]) -> str:
"""Format list of dicts as CSV string."""
if not data:
return ""
import io
output = io.StringIO()
# Collect all keys across all rows
all_keys = []
for row in data:
for key in row.keys():
if key not in all_keys:
all_keys.append(key)
writer = csv.DictWriter(output, fieldnames=all_keys, extrasaction="ignore")
writer.writeheader()
for row in data:
# Stringify nested dicts/lists
clean_row = {}
for k, v in row.items():
if isinstance(v, (dict, list)):
clean_row[k] = json.dumps(v)
else:
clean_row[k] = v
writer.writerow(clean_row)
return output.getvalue()
if __name__ == "__main__":
main()