Gareth Wilson Gareth Wilson

Guide to Klaviyo Webhooks: Features and Best Practices

Published


Klaviyo webhooks notify your systems about marketing activity: a subscriber opens an email, clicks a link, replies by SMS, or submits a review. If you're building on Klaviyo's marketing automation and customer data platform, webhooks are how you sync engagement data into your own stack without polling.

This guide covers how Klaviyo webhooks work, the HMAC-SHA256 verification scheme behind the Klaviyo-Signature header, the batched payload structure and topic catalog, the unsigned flow "Webhook" action, and the best practices for production.

What are Klaviyo webhooks?

Klaviyo pushes data out of your account in two ways. System webhooks, the recommended mechanism, are created via the Webhooks API: you subscribe to one or more topics (strings like event:klaviyo.opened_email), and Klaviyo batches matching events and delivers them to your endpoint in a predefined payload format, signed with an HMAC-SHA256 signature in the Klaviyo-Signature header. The older flow "Webhook" action is a step inside a flow that POSTs a custom JSON payload you define, and that delivery is not signed.

Klaviyo webhook features

FeatureDetails
ConfigurationPOST https://a.klaviyo.com/api/webhooks with a private API key (webhooks:write scope); you choose the secret_key (minimum 16 characters) when creating the webhook
VerificationHex HMAC-SHA256 over the raw request body with the Klaviyo-Timestamp header value appended, delivered in the Klaviyo-Signature header
PayloadJSON envelope with a data array of up to 1,000 events plus a meta block (webhook ID, account ID, timestamp, API version)
EventsTopics prefixed event:klaviyo. spanning email, SMS, push, and reviews; availability depends on your account and enabled channels
HeadersKlaviyo-Signature, Klaviyo-Timestamp, Klaviyo-Webhook-Id
IdempotencyEvery event carries a unique external_id to use as your idempotency key
Unsigned optionThe flow "Webhook" action posts a custom JSON payload with no signature; secure it with a secret token in the URL
SDKNo official verification helper; verify manually with your standard crypto library

Common events

Klaviyo event names are the topic values inside each delivery. The catalog spans email, SMS, push, and reviews; the topics most integrations start with:

TopicFires when
event:klaviyo.opened_emailA recipient opens an email
event:klaviyo.clicked_emailA recipient clicks a link in an email
event:klaviyo.bounced_emailAn email bounces
event:klaviyo.marked_email_as_spamA recipient marks an email as spam
event:klaviyo.unsubscribed_from_email_marketingA profile unsubscribes from email
event:klaviyo.sent_smsAn SMS is sent
event:klaviyo.received_smsAn inbound SMS arrives
event:klaviyo.submitted_reviewA review is submitted

Branch on the topic field of each element in the data array, not on anything at the top level: a single delivery can batch up to 1,000 events, each with its own topic, its own payload (the same shape as the Get Event API response), and a unique external_id. Topic availability depends on your account and enabled channels, so fetch the exact list for your account with the Get Webhook Topics endpoint.

See Klaviyo webhook payloads in action. Inspect and replay sample Klaviyo webhook payloads in the Hookdeck Console — no account or setup required.

Setting up Klaviyo webhooks

You'll need a private API key with the webhooks:write scope (plus events:read for event-based topics) and a publicly reachable endpoint URL.

First, choose your endpoint secret. Klaviyo signs system webhooks with a secret you provide when creating the webhook, at least 16 characters long. Generate a strong random value and store it in your app's environment, since your handler needs the same value to verify signatures:

openssl rand -hex 32
# Store as KLAVIYO_WEBHOOK_SECRET

Next, discover the topics enabled for your account:

curl https://a.klaviyo.com/api/webhook-topics \
  -H "Authorization: Klaviyo-API-Key YOUR_PRIVATE_KEY" \
  -H "revision: 2025-07-15"

Then register your endpoint and subscribe to topics. The secret_key you pass here is the HMAC key your handler must use:

curl -X POST https://a.klaviyo.com/api/webhooks \
  -H "Authorization: Klaviyo-API-Key YOUR_PRIVATE_KEY" \
  -H "revision: 2025-07-15" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "webhook",
      "attributes": {
        "name": "My app webhook",
        "endpoint_url": "https://your-app.com/webhooks/klaviyo",
        "secret_key": "your_endpoint_secret_min_16_chars",
        "enabled": true
      },
      "relationships": {
        "webhook-topics": {
          "data": [
            { "type": "webhook-topic", "id": "event:klaviyo.opened_email" },
            { "type": "webhook-topic", "id": "event:klaviyo.clicked_email" }
          ]
        }
      }
    }
  }'

Field names and the revision date may change as the Webhooks API evolves, so confirm against the Webhooks API reference when you build. Your endpoint then receives POSTs carrying three headers: Klaviyo-Signature (the hex HMAC-SHA256 signature), Klaviyo-Timestamp (part of the signed content), and Klaviyo-Webhook-Id.

For local development, the Hookdeck CLI (hookdeck listen 3000 klaviyo --path /webhooks/klaviyo) provides a publicly reachable URL plus an inspector for replaying deliveries. Trigger a subscribed event in Klaviyo (opening a test email, for example) to receive a real, signed delivery.

Securing Klaviyo webhooks

Verification is a manual HMAC check, since there is no official Klaviyo SDK helper. The signed content is the raw request body with the Klaviyo-Timestamp header value appended: compute HMAC-SHA256(secret, raw_body + timestamp), hex-encode it (lowercase), and compare it against the Klaviyo-Signature header with a timing-safe comparison.

The raw body matters. Compute the HMAC over the exact bytes received: if you JSON.parse and re-serialize, whitespace and key-order differences change the hash and verification fails. In Express use express.raw(), in Next.js use await request.text(), and in FastAPI use await request.body(). Order matters too, body first and timestamp appended after, and the encoding is hex, not base64.

const crypto = require("crypto");

function verifyKlaviyoWebhook(rawBody, timestamp, signature, secret) {
  const computed = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // raw body first (Buffer or string)
    .update(timestamp) // Klaviyo-Timestamp appended
    .digest("hex");
  try {
    return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
  } catch {
    return false; // different lengths, invalid
  }
}

app.post(
  "/webhooks/klaviyo",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get("Klaviyo-Signature");
    const timestamp = req.get("Klaviyo-Timestamp");
    if (
      !signature ||
      !timestamp ||
      !verifyKlaviyoWebhook(
        req.body,
        timestamp,
        signature,
        process.env.KLAVIYO_WEBHOOK_SECRET
      )
    ) {
      return res.sendStatus(401);
    }

    // The signature covers the whole body: verify once, then iterate
    const { data } = JSON.parse(req.body.toString());
    for (const event of data) {
      processQueue.add({
        id: event.external_id,
        topic: event.topic,
        payload: event.payload,
      });
    }
    res.sendStatus(200);
  }
);

The flow "Webhook" action carries no Klaviyo-Signature header. If signing is unavailable on your account, Klaviyo recommends embedding a hard-to-guess secret token in the endpoint URL (for example /webhooks/klaviyo?token=LONG_RANDOM_TOKEN) and rejecting any request whose token doesn't match, again with a timing-safe comparison. Prefer signed system webhooks whenever your account supports them.

Make Klaviyo webhooks production-ready. Hookdeck Event Gateway ingests every delivery, durably queues each batch, and retries your handler independently.

Klaviyo webhook limitations and pain points

Manual raw-body verification is easy to break

The Problem: With no official SDK helper, every handler implements the HMAC check by hand, and the scheme has several failure modes: a parsed-then-re-serialized body, a timestamp prepended instead of appended, base64 instead of hex, or a secret that doesn't match the secret_key set on the webhook all produce an always-invalid signature. Intermittent failures usually mean a proxy or middleware is mutating the body before your handler sees it.

Why It Happens: The signature covers the exact raw bytes plus the Klaviyo-Timestamp value, so anything that touches the body or gets the concatenation order wrong changes the digest.

Workarounds:

  • Read the raw body (express.raw(), await request.text(), await request.body()) before any JSON parsing.
  • Append the timestamp after the body, hex-encode, and compare timing-safe with crypto.timingSafeEqual or hmac.compare_digest.
  • Confirm the secret in your environment matches the secret_key on the webhook.

How Hookdeck Can Help: Hookdeck captures the exact body and headers of every request, so you can see precisely what Klaviyo sent, replay it against your handler while debugging, and get alerted through Issues when deliveries start failing.

The flow "Webhook" action is unsigned

The Problem: Flow "Webhook" action deliveries carry no signature at all, so anyone who discovers the URL can POST forged payloads to it.

Why It Happens: The flow action predates system webhooks and sends a custom JSON payload you define, with no signing mechanism attached.

Workarounds:

  • Put a long random token in the endpoint URL and reject requests that don't match, using a timing-safe comparison.
  • Move to signed system webhooks wherever your account supports them.

How Hookdeck Can Help: Filters can reject requests whose token doesn't match before they reach your handler, and every rejected request stays visible in the dashboard for auditing.

Batches of up to 1,000 events per request

The Problem: A single POST can carry up to 1,000 events in its data array. A handler that processes the whole batch synchronously responds slowly, and a slow or failed response causes Klaviyo to retry, redelivering the entire batch.

Why It Happens: Klaviyo batches matching events into one delivery, with one signature covering the whole body.

Workarounds:

  • Verify the signature once, return a 2xx as soon as it checks out, and enqueue each element of data for asynchronous processing.
  • Key processing on each event's external_id so a redelivered batch doesn't double-process.

How Hookdeck Can Help: Hookdeck acknowledges Klaviyo in milliseconds and retries delivery to your handler on its own schedule, and a max delivery rate smooths bursts to a pace your infrastructure can absorb.

An evolving API with account-dependent topics

The Problem: The topics available to you depend on your account and enabled channels, signing may be unavailable on some accounts, and the Webhooks API's field names and revision date may change as it evolves.

Why It Happens: The Webhooks API is a newer surface than the flow action and is still developing, and channel-specific topics only exist where the channel is enabled.

Workarounds:

  • Fetch the exact topic list for your account from the Get Webhook Topics endpoint instead of assuming from documentation.
  • Confirm request field names and the revision header against the Webhooks API reference at build time.
  • Keep the URL-token fallback ready for accounts where signing is unavailable.

How Hookdeck Can Help: The Hookdeck dashboard shows exactly which topics are actually arriving, and Filters let you subscribe broadly upstream while routing only the topics each handler cares about.

Best practices

Verify against the raw body

Read the raw bytes before any parsing, append the Klaviyo-Timestamp value after the body, hex-encode, and compare timing-safe. A 401 on mismatch keeps forged payloads out.

Acknowledge quickly, process asynchronously

Return a 2xx as soon as the signature checks out and do heavy processing off the response path, so Klaviyo doesn't retry a batch you already received. See why to process webhooks asynchronously.

Use external_id as your idempotency key

Retried deliveries redeliver whole batches. Each event's unique external_id is the natural dedupe key: record it, and skip events you've already processed. See our guide to webhook idempotency.

Always iterate the data array

One delivery may batch multiple events. Loop over data and dispatch on each event's topic rather than assuming one event per request.

Prefer signed system webhooks

The flow "Webhook" action has no signature. Use system webhooks created via the Webhooks API where your account supports them, and fall back to a URL token only when signing is unavailable.

Subscribe to the topics you need

Fetch your account's topic list from the Get Webhook Topics endpoint and subscribe deliberately; there is no value in handling topics your channels never emit.

Conclusion

Klaviyo system webhooks sign each delivery with a hex HMAC-SHA256 over the raw body plus the Klaviyo-Timestamp value, keyed with an endpoint secret you choose at creation, and batch up to 1,000 events per request. Verify the raw body timing-safely, acknowledge fast, iterate the data array, and dedupe on external_id; for the unsigned flow action, protect the URL with a secret token.

Hookdeck Event Gateway ingests Klaviyo's batched deliveries, acknowledges instantly, queues durably, and retries your handler independently, so batch size and redelivery stop being constraints your code has to satisfy.

Get started with Hookdeck for free and handle Klaviyo webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.