WP Webhooks / Blog / Integrations

Sending WooCommerce Orders and Customers to Klaviyo Through the API

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.

8 min 2026-09-28
#klaviyo#woocommerce#api

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

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, 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.

/ 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-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.

ConcernHand-rolled wp_remote_postWebhook Actions
Cart tracking, product catalogue, abandoned checkoutNot this. Use the official pluginNot this either. It delivers events; it does not track carts or sync a catalogue
Where the call runsInside the order status change unless you build a queueQueued in its own table, delivered in the background
A 429 or 503 from KlaviyoLost unless you wrote retry logicRetried with exponential backoff, five attempts by default. Retry-After is not read
A 400 from a malformed bodyInvisibleMarked permanently failed, the JSON:API errors array in the delivery log
The private keyA constant in wp-config.phpA Credentials Vault header named Authorization, value Klaviyo-API-Key pk_…, redacted in every log
The revision headerWritten in your arrayA static custom request header
The JSON:API envelope with its constant type fieldsWritten in your arrayA short pre-dispatch snippet; field mapping moves values but cannot invent a constant
Subscribe after the eventA second request in the same functionA 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.

/ 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.

/Footnotes
¹ Install count, version 3.8.3 and author read from the WordPress.org plugin page 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. Per-endpoint limits as listed on each endpoint's reference page, read 2026-09-28.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Is there an official Klaviyo plugin for WooCommerce? +
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.
How do I authenticate to the Klaviyo API? +
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.
Which Klaviyo endpoint creates or updates a profile? +
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.
How do I stop duplicate Klaviyo events when a request is retried? +
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.
Does adding a profile subscribe the customer to Klaviyo emails? +
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.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate