# Guide to Klaviyo Webhooks: Features and Best Practices

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](https://www.klaviyo.com)'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

| Feature | Details |
| --- | --- |
| Configuration | `POST 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 |
| Verification | Hex HMAC-SHA256 over the raw request body with the `Klaviyo-Timestamp` header value appended, delivered in the `Klaviyo-Signature` header |
| Payload | JSON envelope with a `data` array of up to 1,000 events plus a `meta` block (webhook ID, account ID, timestamp, API version) |
| Events | Topics prefixed `event:klaviyo.` spanning email, SMS, push, and reviews; availability depends on your account and enabled channels |
| Headers | `Klaviyo-Signature`, `Klaviyo-Timestamp`, `Klaviyo-Webhook-Id` |
| Idempotency | Every event carries a unique `external_id` to use as your idempotency key |
| Unsigned option | The flow "Webhook" action posts a custom JSON payload with no signature; secure it with a secret token in the URL |
| SDK | No 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:

| Topic | Fires when |
| --- | --- |
| `event:klaviyo.opened_email` | A recipient opens an email |
| `event:klaviyo.clicked_email` | A recipient clicks a link in an email |
| `event:klaviyo.bounced_email` | An email bounces |
| `event:klaviyo.marked_email_as_spam` | A recipient marks an email as spam |
| `event:klaviyo.unsubscribed_from_email_marketing` | A profile unsubscribes from email |
| `event:klaviyo.sent_sms` | An SMS is sent |
| `event:klaviyo.received_sms` | An inbound SMS arrives |
| `event:klaviyo.submitted_review` | A 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](https://developers.klaviyo.com/en/reference/get_webhook_topics) endpoint.

> See Klaviyo webhook payloads in action. Inspect and replay sample Klaviyo webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — 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:

```bash
openssl rand -hex 32
# Store as KLAVIYO_WEBHOOK_SECRET

```

Next, discover the topics enabled for your account:

```bash
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:

```bash
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](https://developers.klaviyo.com/en/reference/webhooks_api_overview) 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](/docs/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.

```javascript
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](/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](/docs/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](/docs/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](/docs/retries) delivery to your handler on its own schedule, and a [max delivery rate](/docs/destinations#set-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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Klaviyo webhooks reliably in minutes.