TL;DR: A WooCommerce Klaviyo integration on the API comes down to three endpoints and one header most tutorials leave out.
- Every server call needs a private key (
Authorization: Klaviyo-API-Key pk_…) and arevisionheader naming an API version date. The current stable revision is2026-07-15. POST /api/profile-importcreates or updates a profile: 201 when new, 200 when updated. PlainPOST /api/profilesanswers 409 for a customer who already exists.POST /api/eventsrecords an order and upserts its profile in the same call. Aunique_idmakes retries safe.- Neither call subscribes anyone. Email marketing consent is a separate, asynchronous endpoint.
/ Official
Is there an official Klaviyo plugin for WooCommerce?
Yes, and for a standard store it should be your first stop. The Klaviyo plugin is published by Klaviyo, has 100,000+ active installs, and sends the events Klaviyo's e-commerce flows are built on:¹ Started Checkout, Added to Cart, Viewed Product, Placed Order (when the order reaches processing), Fulfilled Order (at completed), Ordered Product for each line item, Cancelled Order and Refunded Order, as the WooCommerce integration reference lists them.
The API route is for what that list does not cover. A payment that failed, a subscription renewal, a booking, a membership level, a quote request, or a WooCommerce order you want to carry properties the plugin does not send. In the plugin's current source the filters cover cart and checkout data; none reaches the Placed Order payload. The API is also the route when the events do not come from WooCommerce at all. A custom event sits beside the official ones without replacing them, so the two approaches combine well.
/ Auth
How do you authenticate to the Klaviyo API from WordPress?
With a private API key in a custom Authorization scheme: Authorization: Klaviyo-API-Key pk_…, per the authentication guide. Note the scheme name: it is Klaviyo-API-Key, not the Bearer most APIs use. Create the key with a custom scope rather than full access: events:write and profiles:write cover everything below, plus lists:write and subscriptions:write for consent. Scopes cannot be edited after creation, so a key missing one is replaced, not fixed, and until then every call it makes returns 403.
The six-character public key, also called the site ID, is for the browser-side /client/ endpoints only. Sent to /api/events it returns 401, "Missing or invalid private key".
The second required header is revision, an ISO date naming the API version you wrote against. The versioning policy supports each revision for two years. That makes the header a dated dependency: code pinned to 2026-07-15 works unchanged until mid-2028, and then has to be moved to a newer revision. Put the date on a calendar, not in a code comment.
/ Profiles
Which endpoint creates or updates a Klaviyo profile?
POST /api/profile-import. It returns 201 when it creates a profile and 200 when it updates one, so a repeat customer is never an error. The plain POST /api/profiles is create-only and answers 409 the second time a customer orders, which is why copied snippets fail on returning buyers.
The body follows JSON:API: a data object with a type and attributes. A field you leave out keeps its current value; a field you send as null is cleared. Custom data goes under properties.
JSON — POST https://a.klaviyo.com/api/profile-import
{ "data": { "type": "profile", "attributes": { "email": "[email protected]", "first_name": "Anna", "last_name": "Kowalska", "properties": { "woocommerce_customer_id": 318, "lifetime_orders": 4 } } } }
Identify by email. Klaviyo's integration FAQ is explicit that external_id is not used for merging: a profile created with only an external id and a later one created with only an email stay two profiles, permanently. Send the WooCommerce customer id as a property, as above, not as the identity.
/ Events
How do you send a WooCommerce order to Klaviyo as an event?
POST /api/events. An event names a metric, carries properties and a value, and identifies a profile, which Klaviyo creates or updates as part of the same call. For most order integrations this is the only call you need: there is no separate profile step.
PHP — record a paid order as a Klaviyo event
add_action( 'woocommerce_order_status_processing', function ( $order_id, $order ) { $email = $order->get_billing_email(); if ( ! $email ) { return; } // In production: hand this to a queue instead of calling inline. $res = wp_remote_post( 'https://a.klaviyo.com/api/events', [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Klaviyo-API-Key ' . KLAVIYO_PRIVATE_KEY, 'revision' => '2026-07-15', 'Content-Type' => 'application/vnd.api+json', 'Accept' => 'application/vnd.api+json', ], 'body' => wp_json_encode( [ 'data' => [ 'type' => 'event', 'attributes' => [ 'metric' => [ 'data' => [ 'type' => 'metric', 'attributes' => [ 'name' => 'Order Paid' ] ] ], 'profile' => [ 'data' => [ 'type' => 'profile', 'attributes' => [ 'email' => $email, 'first_name' => $order->get_billing_first_name(), ] ] ], 'properties' => [ 'order_id' => $order_id, 'items' => $order->get_item_count() ], 'value' => (float) $order->get_total(), 'value_currency' => $order->get_currency(), 'time' => ( $order->get_date_paid() ?: $order->get_date_created() )->format( DATE_ATOM ), // Same order + same metric = same id, so a retry cannot double-count. 'unique_id' => 'order-' . $order_id . '-paid', ], ] ] ), ] ); return $res; // 202 = accepted for processing, not yet processed }, 10, 2 );
Three details decide whether this works in production.
The 202. The endpoint validates the request and answers 202, "submitted for processing", which is not a promise that processing succeeded. A 4xx is still returned synchronously for a malformed body, so check the status code. Just do not read a 202 as proof the event is on the timeline.
The unique_id. Per the Events API overview, Klaviyo keeps the first event for each combination of profile, metric and unique_id, and silently drops later ones. Build it from the order id and the transition, as above, and a retried request after a timeout lands on the same event. Without it, Klaviyo falls back to the timestamp truncated to the second, and two different orders from one customer in the same second overwrite each other.
The metric name is a flow trigger. Flows start on metrics, so a name like Order Paid becomes something your marketing team builds automations on. Pick it once. For a historical import, the backfill flag added in revision 2026-07-15 records events without starting flows, so an import of last year's orders does not email every past customer.
/ Consent
Does creating a Klaviyo profile subscribe the customer to email?
No. Neither profile-import nor events has a consent field. A profile created this way has email consent NEVER_SUBSCRIBED. Klaviyo's consent guide notes such profiles can technically receive email, and recommends sending marketing only to people who gave explicit consent. SMS always requires it.
Subscribing is its own endpoint, POST /api/profile-subscription-bulk-create-jobs. It takes up to 1,000 profiles, returns 202 and runs as a background job. If you name a list, that list's opt-in setting applies. The account default is double opt-in, in which case the customer's consent changes only after they click the confirmation email.
JSON — subscribe one customer who ticked the checkout box
{ "data": { "type": "profile-subscription-bulk-create-job", "attributes": { "custom_source": "WooCommerce checkout", "profiles": { "data": [{ "type": "profile", "attributes": { "email": "[email protected]", "subscriptions": { "email": { "marketing": { "consent": "SUBSCRIBED" } } } } }] } }, "relationships": { "list": { "data": { "type": "list", "id": "YOUR_LIST_ID" } } } } }
Send this only for customers whose checkout recorded a marketing opt-in. The call also clears unsubscribe and spam-report suppressions, so sending it for everyone who orders resubscribes people who asked to leave.
/ Limits
What are the Klaviyo API rate limits?
Per account, in two fixed windows: a burst limit per second and a steady limit per minute. The rate limit guide groups endpoints into tiers from XS (1/s, 15/m) to XL (350/s, 3,500/m). The ones in this article:²
/api/events: 350/s and 3,500/m./api/profile-importand the bulk subscribe job: 75/s and 750/m.
Work it through at store volume. One event per paid order, plus a subscribe job for the ones who opted in: 3,500 events a minute is 3,500 ÷ 60 = 58 orders a second before the first 429. No live store gets near that. A backfill does: importing 50,000 existing customers one profile-import at a time takes at least 50,000 ÷ 750 = 67 minutes, and during that hour the quota is shared with every private-key integration on the account, including the official plugin. Send history through the bulk endpoints, and schedule it for a quiet night.
On a 429, Klaviyo replaces its RateLimit-* headers with Retry-After in seconds. Retrying immediately always meets another 429.
| Concern | Hand-rolled wp_remote_post | Webhook Actions |
|---|---|---|
| Cart tracking, product catalogue, abandoned checkout | Not this. Use the official plugin | Not this either. It delivers events; it does not track carts or sync a catalogue |
| Where the call runs | Inside the order status change unless you build a queue | Queued in its own table, delivered in the background |
| A 429 or 503 from Klaviyo | Lost unless you wrote retry logic | Retried with exponential backoff, five attempts by default. Retry-After is not read |
| A 400 from a malformed body | Invisible | Marked permanently failed, the JSON:API errors array in the delivery log |
| The private key | A constant in wp-config.php | A Credentials Vault header named Authorization, value Klaviyo-API-Key pk_…, redacted in every log |
| The revision header | Written in your array | A static custom request header |
| The JSON:API envelope with its constant type fields | Written in your array | A short pre-dispatch snippet; field mapping moves values but cannot invent a constant |
| Subscribe after the event | A second request in the same function | A chained webhook that fires on the event call's 2xx, with its own condition on the opt-in field |
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Queue
Should the Klaviyo call run inside the order request?
No. woocommerce_order_status_processing fires in whatever request moved the order: the customer's checkout, or the payment gateway's callback. A slow third party inside either one either keeps a customer waiting or risks the gateway timing out and resending its notification. Record the event when the hook fires and let a background worker make the call.
Klaviyo is unusually forgiving of retries, which makes that queue simple. Profile-import is an upsert. An event with a stable unique_id is deduplicated on Klaviyo's side, so a retry after a timeout cannot double-count revenue. Retry 429 and 503, which are the two codes the guide names as retryable, with exponential backoff and jitter. Stop on the rest: a 400 is a body you built wrong, a 401 a revoked key, a 403 a key without the scope. Retrying them only burns quota. The retry policy article covers the schedule, and the checkout hook reference covers why order hooks can fire more than once for one order.
/ Exposure
What does the Klaviyo API not protect you from?
Duplicate profiles you create yourself. Klaviyo does not merge on external_id. Identify one call by external id and the next by email, and you have two customers with half a history each, which no later call will merge.
Your consent logic. The subscribe endpoint accepts SUBSCRIBED for any address and removes existing suppressions. Whether the customer agreed is a fact only your checkout knows.
A shared quota. Every private-key integration on the account draws from the same per-account limits. When a sync starts collecting 429s at modest volume, look for another integration running an import, before you look at your own code.
Profile growth. Every event creates a profile if one does not exist. Sending every guest checkout fills the account with people who never opted in, so check how your plan counts profiles before you ship.
The expiry date in your headers. The revision you pin today stops being supported two years after its release. Nothing breaks until the day it does.