---
title: "WooCommerce Klaviyo Integration: API, Events and Consent"
description: "A WooCommerce Klaviyo integration on the API: the revision header, profile-import upserts, events with a unique_id, and why a profile is not a subscriber."
url: "https://wpwebhooks.org/blog/woocommerce-klaviyo-integration/"
date: "2026-09-28"
---

# WooCommerce Klaviyo Integration: API, Events and Consent

**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** a `revision` header naming an API version date. The current stable revision is `2026-07-15`.
-   `POST /api/profile-import` creates or updates a profile: **201** when new, **200** when updated. Plain `POST /api/profiles` answers 409 for a customer who already exists.
-   `POST /api/events` records an order and upserts its profile in the same call. A `unique_id` makes 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](https://wordpress.org/plugins/klaviyo/) 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](https://help.klaviyo.com/hc/en-us/articles/360030732832) 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](https://developers.klaviyo.com/en/docs/authenticate_). 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](https://developers.klaviyo.com/en/docs/api_versioning_and_deprecation_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](https://developers.klaviyo.com/en/reference/create_or_update_profile). 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": "anna@example.com",
      "first_name": "Anna",
      "last_name": "Kowalska",
      "properties": {
        "woocommerce_customer_id": 318,
        "lifetime_orders": 4
      }
    }
  }
}
```

Identify by email. Klaviyo's [integration FAQ](https://developers.klaviyo.com/en/docs/custom_integration_faqs) 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](https://developers.klaviyo.com/en/reference/create_event). 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.

FIG 01 — One order, one event: the profile upsert rides along, the unique\_id absorbs retries, consent is a separate job

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](https://developers.klaviyo.com/en/reference/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.

![Cyberpunk illustration of a woman with a chrome arm on a rain-soaked rooftop, a lit key turned home in one half of a two-key lock while the second keyhole beside it stays dark and empty, and the armoured gate behind her stays shut.](https://wpwebhooks.org/blog/woocommerce-klaviyo-integration/og_image.jpg)

/ 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](https://developers.klaviyo.com/en/docs/collect_email_and_sms_consent_via_api) 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](https://developers.klaviyo.com/en/reference/bulk_subscribe_profiles). 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": "anna@example.com",
            "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](https://developers.klaviyo.com/en/docs/rate_limits_and_error_handling) 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-import` and 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 |

try\_it

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.

[Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip)

/ 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](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) covers the schedule, and [the checkout hook reference](https://wpwebhooks.org/blog/woocommerce-checkout-order-created/) 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.

/Footnotes

¹ Install count, version 3.8.3 and author read from the [WordPress.org plugin page](https://wordpress.org/plugins/klaviyo/) on 2026-09-28. The note on filters comes from reading the 3.8.3 source; it is not a Klaviyo statement.

² Rate tiers, the retryable status codes and Retry-After behaviour: Klaviyo [rate limits and error handling](https://developers.klaviyo.com/en/docs/rate_limits_and_error_handling). Per-endpoint limits as listed on each endpoint's reference page, read 2026-09-28.

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WooCommerce Klaviyo Integration: API, Events and Consent","description":"A WooCommerce Klaviyo integration on the API: the revision header, profile-import upserts, events with a unique_id, and why a profile is not a subscriber.","datePublished":"2026-09-28","dateModified":"2026-09-28","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-klaviyo-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/woocommerce-klaviyo-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a woman with a chrome arm on a rain-soaked rooftop, a lit key turned home in one half of a two-key lock while the second keyhole beside it stays dark and empty, and the armoured gate behind her stays shut."},"keywords":["woocommerce klaviyo integration","klaviyo woocommerce integration","woocommerce klaviyo","klaviyo wordpress","klaviyo api woocommerce","klaviyo events api"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WooCommerce Klaviyo Integration: API, Events and Consent","item":"https://wpwebhooks.org/blog/woocommerce-klaviyo-integration/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is there an official Klaviyo plugin for WooCommerce?","acceptedAnswer":{"@type":"Answer","text":"Yes. The Klaviyo plugin on WordPress.org is published by Klaviyo and has more than 100,000 active installs. It sends Started Checkout, Added to Cart, Viewed Product, Placed Order, Fulfilled Order, Ordered Product, Cancelled Order and Refunded Order events. The API route suits events it does not send and data from outside WooCommerce."}},{"@type":"Question","name":"How do I authenticate to the Klaviyo API?","acceptedAnswer":{"@type":"Answer","text":"Send a private API key in the header Authorization: Klaviyo-API-Key followed by the key, which starts with pk_. Every request also needs a revision header naming an API version date; the current stable revision is 2026-07-15, and each revision is supported for two years. The six-character public key only works on client endpoints."}},{"@type":"Question","name":"Which Klaviyo endpoint creates or updates a profile?","acceptedAnswer":{"@type":"Answer","text":"POST /api/profile-import. It returns 201 when it creates a profile and 200 when it updates an existing one, so repeat customers never cause an error. The create-only POST /api/profiles returns 409 when the profile already exists. Identify profiles by email, because Klaviyo does not merge profiles on external_id."}},{"@type":"Question","name":"How do I stop duplicate Klaviyo events when a request is retried?","acceptedAnswer":{"@type":"Answer","text":"Send a unique_id with each event, for example the order id plus the transition. Klaviyo keeps the first event for each combination of profile, metric and unique_id and silently drops later duplicates. Without a unique_id it falls back to the timestamp truncated to the second, so two events in the same second can collide."}},{"@type":"Question","name":"Does adding a profile subscribe the customer to Klaviyo emails?","acceptedAnswer":{"@type":"Answer","text":"No. Profile-import and events have no consent field, so the profile stays NEVER_SUBSCRIBED. Subscribing uses POST /api/profile-subscription-bulk-create-jobs, which runs as a background job and follows the list's or account's opt-in setting, double opt-in by default. Send it only for customers who opted in at checkout."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/woocommerce-klaviyo-integration.png","caption":"FIG 01 — One order, one event: the profile upsert rides along, the unique_id absorbs retries, consent is a separate job","description":"An order reaches the processing status and the delivery is queued. The worker sends POST /api/events with a private key in the Klaviyo-API-Key authorization scheme and a revision header. The event carries a metric name, a profile identified by email, value and currency, and a unique_id built from the order id. Klaviyo answers 202 and creates or updates the profile with the event. A retried request with the same unique_id is dropped as a duplicate. A 429 or 503 is retried after the Retry-After interval; 400, 401 and 403 are not retried. Only if the customer opted in at checkout, a second call starts a bulk subscribe job, which follows the list opt-in setting, double opt-in by default.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
