Klaviyo’s API v3 has been the current version since 2023, but if you are maintaining an integration that was built before the REST API migration or has been incrementally updated, you are probably carrying at least one revision header assumption that will fail at the wrong time. High-volume senders — transactional event pipelines, CRM sync jobs enriching hundreds of thousands of profiles — hit failure modes that do not appear in development and are difficult to reproduce in staging because they are rate-tier-dependent.

This covers the failure modes that show up in production logs, not in the Klaviyo docs.

The Revision Header: What Goes Wrong

Every request to Klaviyo’s REST API requires a revision header with a date value in YYYY-MM-DD format. The value must be a valid revision date that Klaviyo supports for the specific endpoint you are calling. This sounds simple until you hit the edge cases.

Wrong date format. The revision value is an ISO 8601 date, not a datetime. 2024-10-15 is correct. 2024-10-15T00:00:00Z will return "No revisions found for method". The error message is not obvious about the formatting issue — it looks like the endpoint does not support the revision you specified.

Endpoint-specific revision support. Not every endpoint is available in every revision. If you pin a single revision date across your entire integration and Klaviyo adds a new endpoint that only exists in a later revision, your calls to that endpoint return a revision error, not a 404. Track the minimum revision required per endpoint, not one global revision for the integration.

Beta revision behavior. Endpoints in beta use a revision with the .pre suffix — for example, 2024-02-15.pre. Beta endpoints can change between revisions without notice. Do not use beta revisions in production pipelines unless you have monitoring that alerts on response schema changes, not just HTTP status codes.

Revision pinned too old. Klaviyo deprecates older revisions on a defined schedule. When a revision is deprecated, requests using it are rejected. The official deprecation policy provides advance notice, but integrations that were set up by engineers who are no longer at the company often have revisions pinned years in the past. Add the current revision date and the deprecation date to your integration’s configuration as documented values, not buried in code.

# Centralized revision management — avoid scattered hardcoded dates
KLAVIYO_REVISION = "2024-10-15"  # Update when Klaviyo releases new revisions
KLAVIYO_REVISION_DEPRECATES_AT = "2025-10-15"  # Check Klaviyo's deprecation schedule

def get_klaviyo_headers(api_key: str) -> dict:
    return {
        "Authorization": f"Klaviyo-API-Key {api_key}",
        "revision": KLAVIYO_REVISION,
        "accept": "application/json",
        "content-type": "application/json",
    }

Rate Tiers and the Headers That Disappear on 429

Klaviyo’s rate limits are endpoint-specific and organized into tiers. From the official documentation, the tier structure is:

TierBurst (per second)Steady (per minute)
S3/s60/m
M10/s150/m
L75/s700/m
XL350/s3,500/m

Profile enrichment endpoints (individual profile updates) are generally in the S or M tier. Bulk operations have their own separate limits. The Create Events endpoint, which is the high-frequency path for transactional event ingestion, is in the L tier.

Three response headers track rate limit state: RateLimit-Limit (requests allowed in the current window), RateLimit-Remaining (approximate remaining requests), and RateLimit-Reset (seconds until window reset).

Here is what the documentation buries: these headers are absent on 429 responses. When you hit the rate limit, the response contains a Retry-After header — the seconds you must wait — but not the RateLimit-* headers that would tell you the limit you exceeded. If your retry logic reads RateLimit-Reset to determine wait time, it will fail silently when the 429 arrives because the header it is looking for is not there.

import requests
import time

def klaviyo_request_with_retry(
    method: str,
    url: str,
    headers: dict,
    payload: dict = None,
    max_retries: int = 3
) -> requests.Response:
    attempt = 0
    
    while attempt < max_retries:
        response = requests.request(method, url, headers=headers, json=payload, timeout=30)
        
        if response.status_code == 429:
            # On 429, RateLimit-* headers are NOT present — use Retry-After
            retry_after = int(response.headers.get("Retry-After", 60))
            # Add jitter: avoid thundering herd on synchronized retry
            jitter = random.uniform(0, retry_after * 0.1)
            wait = retry_after + jitter
            time.sleep(wait)
            attempt += 1
            continue
        
        # On non-429, track rate limit state for proactive throttling
        limit = response.headers.get("RateLimit-Limit")
        remaining = response.headers.get("RateLimit-Remaining")
        reset = response.headers.get("RateLimit-Reset")
        
        if remaining and limit:
            utilization = 1 - (int(remaining) / int(limit.split(",")[0]))
            if utilization > 0.8:
                # Proactive throttle when consuming more than 80% of burst limit
                time.sleep(0.5)
        
        response.raise_for_status()
        return response
    
    raise Exception(f"Max retries exceeded for {url}")

The RateLimit-Limit header value is not always a simple integer. Community reports from early 2026 document observed values like '10, 10;w=1, 150;w=60' — a compound format where w=N indicates the window in seconds and the preceding number is the limit for that window. Parse defensively: split on comma, take the first value for burst-limit calculations.

Bulk Operation Limits and the Profile Enrichment Problem

Individual profile update calls at the S-tier limit — 3 per second, 60 per minute — are inadequate for any pipeline that needs to enrich more than a few thousand profiles. A 500,000-profile enrichment at S-tier steady rate would take over 138 hours.

Klaviyo’s Bulk Profile Import and Bulk Profile Update endpoints are the path for high-volume enrichment. These endpoints accept up to 1,000 profiles per job (for bulk import) or 100 profiles per request (for batch updates), with their own rate tier separate from individual profile endpoints.

The failure mode in bulk jobs: Klaviyo processes bulk jobs asynchronously. You submit the job, receive a job ID, and poll for completion. Production pipelines that submit bulk jobs and then immediately attempt to read the updated profiles fail because the job is still processing. The polling pattern is mandatory, not optional.

import time
import requests

def submit_bulk_profile_update(profiles: list[dict], headers: dict) -> str:
    """Submit a bulk profile update job and return the job ID."""
    payload = {
        "data": {
            "type": "profile-bulk-import-job",
            "attributes": {
                "profiles": {
                    "data": [
                        {
                            "type": "profile",
                            "attributes": profile
                        }
                        for profile in profiles
                    ]
                }
            }
        }
    }
    
    response = requests.post(
        "https://a.klaviyo.com/api/profile-bulk-import-jobs/",
        headers=headers,
        json=payload,
        timeout=30
    )
    response.raise_for_status()
    return response.json()["data"]["id"]


def wait_for_bulk_job(job_id: str, headers: dict, poll_interval: int = 10, timeout: int = 3600) -> dict:
    """Poll bulk job status until complete, failed, or timeout."""
    deadline = time.time() + timeout
    
    while time.time() < deadline:
        response = requests.get(
            f"https://a.klaviyo.com/api/profile-bulk-import-jobs/{job_id}/",
            headers=headers,
            timeout=30
        )
        response.raise_for_status()
        
        job = response.json()["data"]
        status = job["attributes"]["status"]
        
        if status == "complete":
            return job
        elif status in ("cancelled", "failed"):
            raise Exception(f"Bulk job {job_id} failed with status: {status}")
        
        # Still processing — wait before next poll
        time.sleep(poll_interval)
    
    raise TimeoutError(f"Bulk job {job_id} did not complete within {timeout}s")

For the 500,000-profile enrichment scenario, chunk your profiles into batches of 1,000, submit jobs sequentially (or with controlled concurrency to stay within bulk job submission rate limits), and poll each job before submitting the next if you need to guarantee ordering.

Event Creation at High Volume

The Create Events endpoint (POST /api/events/) is the entry point for server-side event tracking — purchase completions, subscription changes, custom behavioral events. At L-tier (75/s burst, 700/m steady), it can handle meaningful throughput, but a batch event flush pattern is more reliable than per-event calls.

Klaviyo does not have a native batch events endpoint in the current REST API. Per-event calls at L-tier are the documented path. For sustained high-volume event ingestion, stay at or below the steady rate limit with token bucket rate limiting on the client side — see the adaptive rate limiter patterns for controlling throughput without reactive retry.

The other failure mode in event creation at scale: the unique_id field in the event payload is Klaviyo’s deduplication mechanism. If you set unique_id to the same value across multiple events that should be distinct (for example, using the order ID when a customer can place multiple orders), Klaviyo deduplicates them and you lose events silently. Generate unique_id from the combination of event type, profile identifier, and a timestamp at sufficient resolution that two legitimate events of the same type by the same profile in the same second get different IDs.

import hashlib
import time

def build_event_unique_id(event_type: str, profile_email: str, timestamp_ms: int = None) -> str:
    """
    Deterministic unique_id that prevents both duplicate events
    and incorrect deduplication of legitimate distinct events.
    """
    if timestamp_ms is None:
        timestamp_ms = int(time.time() * 1000)
    
    raw = f"{event_type}:{profile_email}:{timestamp_ms}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

The common pattern of using a database row ID as unique_id is only safe if each database row represents exactly one event that should be tracked exactly once. If your pipeline can insert the same row multiple times, or if the same logical event can appear in multiple rows, the unique_id scheme needs to be derived from the business event identity, not the storage identity.

Frequently Asked Questions

What happens if I forget to send a revision header on a Klaviyo API request?

Klaviyo requires the revision header on every REST API v3 call; omitting it or sending it in the wrong format (a datetime instead of a plain ISO 8601 date) returns an error like “No revisions found for method.” The message doesn’t clearly indicate a formatting problem — it reads as if the endpoint doesn’t support your revision, which sends teams down the wrong debugging path.

How often does Klaviyo release new API revisions, and do I need to update immediately?

Revisions release on a rolling schedule and are supported for roughly two years before deprecation. You don’t need to update immediately when a new revision ships, but you do need to track the deprecation date for whatever revision you’re pinned to — integrations set up by engineers no longer at the company often carry revisions pinned years in the past with no monitoring on the deprecation window.

Why did my Klaviyo integration break without any code changes on our side?

The most common cause is a pinned revision reaching its deprecation or retirement date, or Klaviyo changing the RateLimit-Limit header format between revisions (from a plain integer to a comma-delimited quota-policy string). If your parsing code assumes one format, a Klaviyo-side change alone — with zero commits on your end — is enough to break retry logic or trigger a ValueError.

What’s the practical difference between Klaviyo’s S, M, L, and XL rate tiers?

They cap different endpoint categories at different throughput: S allows 3 requests/second and 60/minute (individual profile updates), M is 10/s and 150/m, L is 75/s and 700/m (event creation), and XL is 350/s and 3,500/m. Bulk operations run on a separate limit entirely — mapping your endpoints to the correct tier before load-testing avoids surprise 429s in production.

How do I safely migrate a high-volume integration to a newer Klaviyo revision?

Centralize the revision value as a named constant in your codebase rather than a literal scattered across call sites, and never rely on an SDK’s default revision — SDK defaults track the latest revision automatically, which means a routine deployment can silently upgrade your revision with no corresponding code change. Test the new revision’s response schema against your parser in staging before pinning it in production.