How to send targeted Klaviyo emails with Webflow Cloud

How to send targeted ecommerce emails with Klaviyo and Webflow Cloud

Learn how to track product views with Klaviyo's on-site script and send signed Webflow order webhooks to Klaviyo.

How to send targeted ecommerce emails with Klaviyo and Webflow Cloud

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Klaviyo's browser script handles the browsing and a Webflow Cloud route handles the orders; splitting the work that way keeps every event your flows fire on trustworthy.

A Klaviyo flow is only as effective as the data driving it, and in a Webflow store that data comes from two different places. Product views happen in the shopper's browser, where anyone can see and alter what gets sent, while orders are recorded on Webflow's servers, where they can be trusted. A flow that reacts to both needs each event captured where it actually happens.

Klaviyo's custom integration framework resolves this by splitting responsibilities. By using client-side JavaScript to track product views and a secure Webflow Cloud server route to validate order webhooks, you can safely trigger automated flows without risking forged requests or compromised customer data.

In this guide, we'll walk through setting up browse tracking, handling signed order webhooks, and creating automated flows for browse abandonment and post-purchase engagement.

What do you need to send targeted Klaviyo emails in Webflow?

You need five things:

  • A Webflow Ecommerce site: Published products and an Ecommerce plan, which produces order webhooks. Pasting Klaviyo's script into the site head requires a paid Site plan or a paid Workspace plan, and every Ecommerce plan qualifies.
  • Klaviyo public and private keys: The six-character public key for the on-site script, and a private key with the events:write scope for the server route.
  • Webflow site token: A Site API token with the sites:write scope, used once to create the order webhook.
  • Next.js project: Next.js 15 or higher on Node.js 22 or later, installed with npm, the only package manager Webflow Cloud supports.
  • Git repository: A repository for the app, owned by whoever owns the site long term, so the deploy pipeline survives a contractor handover.

With those in hand, the browser half takes three steps and needs no code outside the Designer, and the server half takes four.

7 steps to send targeted Klaviyo emails in Webflow Cloud

Steps 1 to 3 handle the browser half and steps 4 to 7 the server half, ending with the flows that use both. Work in that order, because the flows in step 7 need both kinds of events to exist before you can select them.

1. Add Klaviyo's onsite script to the site head

Klaviyo's onsite script makes a browser known to Klaviyo and records its activity. In Klaviyo, open Settings, then the API keys tab, and copy the six-character public key. It is designed to be public, so it is safe in page code.

In Webflow, open Site settings > Custom code and paste this into the head code, replacing PUBLIC_API_KEY with your key:

<script async type="text/javascript" src="https://static.klaviyo.com/onsite/js/PUBLIC_API_KEY/klaviyo.js"></script>
<script>
!function(){if(!window.klaviyo){window._klOnsite=window._klOnsite||[];try{window.klaviyo=new Proxy({},{get:function(n,i){return"push"===i?function(){var n;(n=window._klOnsite).push.apply(n,arguments)}:function(){for(var n=arguments.length,o=new Array(n),w=0;w<n;w++)o[w]=arguments[w];var t="function"==typeof o[o.length-1]?o.pop():void 0,e=new Promise((function(n){window._klOnsite.push([i].concat(o,[function(i){t&&t(i),n(i)}]))}));return e}}})}catch(n){window.klaviyo=window.klaviyo||[],window.klaviyo.push=function(){var n;(n=window._klOnsite).push.apply(n,arguments)}}}}();
</script>

The first tag loads the script. The second is Klaviyo's own loader for the klaviyo object, copied unchanged from Klaviyo's documentation.

It creates a stand-in that queues every klaviyo.track and klaviyo.identify call made before the script finishes loading, so a product page can record a view without waiting for it. Publish the site and open it in a browser: the Network tab should show klaviyo.js loading from static.klaviyo.com.

2. Track Viewed Product on the product template

A browse abandonment flow triggers on a metric called Viewed Product, and Klaviyo documents the properties it expects. The product template is where that call belongs, because every product page is rendered from it.

Open the Products template page in the Designer. Select the product name heading and, in its element settings, add a custom attribute named data-kl-name. Do the same on the main product image with data-kl-image. These attributes are static markers, so the script finds the rendered values without any CMS binding inside the code.

Then open the template's page settings and add this before the closing body tag:

<script>
(function () {
  var nameEl = document.querySelector('[data-kl-name]');
  if (!nameEl) return;
  var imageEl = document.querySelector('[data-kl-image]');
  var slug = window.location.pathname.split('/').filter(Boolean).pop() || '';
  klaviyo.track('Viewed Product', {
    ProductName: nameEl.textContent.trim(),
    ProductID: slug,
    URL: window.location.origin + window.location.pathname,
    ImageURL: imageEl ? imageEl.currentSrc || imageEl.src : undefined
  });
})();
</script>

ProductID is the product slug on purpose. The order route in step 6 sends the same slug on each Ordered Product event, so Klaviyo can match a viewed product to a purchased one. Nothing is recorded yet for a first-time visitor, which step 3 addresses.

3. Identify shoppers so Klaviyo can email them

In Klaviyo's words, its onsite tracking "only tracks the browsing activity of 'known browsers'", meaning browsers it has already identified or cookied. An anonymous visitor's product views go nowhere, so the browse abandonment flow depends on shoppers becoming known first.

The simplest route is a signup form built in Klaviyo's form builder. Its popup-style forms publish through the script you installed in step 1 and identify the browser when someone submits. Klaviyo identifies a shopper who clicks a link in a Klaviyo email the same way.

If you would rather keep a newsletter form built in Webflow, give the form element a custom attribute named data-kl-signup and add this to that page:

<script>
(function () {
  var form = document.querySelector('[data-kl-signup]');
  if (!form) return;
  form.addEventListener('submit', function () {
    var input = form.querySelector('input[type="email"]');
    if (input && input.value) klaviyo.identify({ email: input.value.trim() });
  });
})();
</script>

Identifying a browser does not subscribe it to marketing. Consent is a separate decision you make in the flow in step 7. To confirm identification, submit the form with your own address, then open a product page and look for a Viewed Product event on your Klaviyo profile.

4. Scaffold the Webflow Cloud app and set its variables

The server half is a Next.js app with one Route Handler, mounted on your site by Webflow Cloud. Scaffold it with npm:

npx create-next-app@15 klaviyo-orders --typescript --app --no-src-dir --import-alias "@/*" --use-npm
cd klaviyo-orders

Leave next.config unchanged; Webflow's bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json." Do not add export const runtime = 'edge' to any route.

Webflow Cloud runs the app on Cloudflare Workers, an edge platform, but the Next.js edge runtime target is separate, and the OpenNext Cloudflare adapter Webflow Cloud builds does not support it.

In Klaviyo, create a private API key named for this app with only the events:write scope, and note the current API revision date shown in Klaviyo's API reference.

Create a Webflow Cloud project and an environment mounted at a path such as /klaviyo, and add three variables, the first and last marked as secrets:

  • KLAVIYO_PRIVATE_KEY: The private key, stored as a secret so Webflow Cloud redacts it from build logs.
  • KLAVIYO_REVISION: The revision date. Klaviyo retires a revision two years after release, so move this forward on schedule.
  • WEBFLOW_WEBHOOK_SECRET: Leave it empty for now. Step 7 creates the webhook and gives you its value.

For local runs, mirror them in a .env.local file that stays out of git, then run npm run dev and confirm the default page loads on localhost:3000.

5. Write the server-side Klaviyo client

Keeping the Klaviyo request in one server-only file means a revision change or a new header touches one place, and the Route Handler stays readable. The function sends one event to Klaviyo's Create Event endpoint and throws on any failure, so the caller decides what a failure means.

Create lib/klaviyo.ts:

// lib/klaviyo.ts
const KLAVIYO_EVENTS_URL = 'https://a.klaviyo.com/api/events';

export type ServerEvent = {
  metric: string;
  email: string;
  uniqueId: string;
  time: string;
  value?: number;
  currency?: string;
  properties: Record<string, unknown>;
};

export async function sendEvent(event: ServerEvent): Promise<void> {
  const key = process.env.KLAVIYO_PRIVATE_KEY;
  const revision = process.env.KLAVIYO_REVISION;
  if (!key || !revision) {
    throw new Error('KLAVIYO_PRIVATE_KEY or KLAVIYO_REVISION is not set');
  }

  const res = await fetch(KLAVIYO_EVENTS_URL, {
    method: 'POST',
    headers: {
      Authorization: `Klaviyo-API-Key ${key}`,
      revision,
      'Content-Type': 'application/vnd.api+json',
      Accept: 'application/vnd.api+json',
    },
    body: JSON.stringify({
      data: {
        type: 'event',
        attributes: {
          properties: event.properties,
          time: event.time,
          value: event.value,
          value_currency: event.currency,
          unique_id: event.uniqueId,
          metric: { data: { type: 'metric', attributes: { name: event.metric } } },
          profile: { data: { type: 'profile', attributes: { email: event.email } } },
        },
      },
    }),
  });

  if (!res.ok) {
    throw new Error(`Klaviyo ${res.status}: ${await res.text()}`);
  }
}

unique_id is the field that makes this safe to retry. Klaviyo's reference says that "if the unique_id is repeated for the same profile and metric, only the first processed event will be recorded," so sending the same order twice records it once. That matters because Webflow retries a failed webhook, as step 6 relies on.

6. Add the signed order webhook Route Handler

The route accepts Webflow's ecomm_new_order webhook and nothing else. It proves the request came from Webflow before reading a byte of it: Webflow sends an x-webflow-signature header, an HMAC-SHA256 of the x-webflow-timestamp value, a colon and the raw request body, keyed with the webhook's secret.

Webflow's webhook documentation also advises rejecting any request more than five minutes old, which stops a captured request from being replayed later.

Create app/api/webflow-order/route.ts:

// app/api/webflow-order/route.ts
import { sendEvent } from '@/lib/klaviyo';

const MAX_AGE_MS = 5 * 60 * 1000; // Webflow's documented replay window

type Money = { unit: string; value: string };
type OrderPayload = {
  orderId: string;
  acceptedOn?: string;
  customerInfo?: { email?: string };
  customerPaid?: Money;
  purchasedItems?: Array<{
    count: number;
    rowTotal: Money;
    productName: string;
    productSlug: string;
    variantId: string;
    variantName?: string;
    variantSKU?: string;
  }>;
};

function hexToBytes(hex: string) {
  if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  return bytes;
}

// crypto.subtle.verify compares in constant time and runs on Workers and under next dev alike.
async function signatureIsValid(secret: string, timestamp: string, body: string, signature: string) {
  const expected = hexToBytes(signature);
  if (!expected) return false;
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  );
  return crypto.subtle.verify('HMAC', key, expected, encoder.encode(`${timestamp}:${body}`));
}

// Webflow sends amounts as strings in the currency's smallest unit, so "11873" USD is 118.73.
function toAmount(money: Money): number {
  const digits =
    new Intl.NumberFormat('en', { style: 'currency', currency: money.unit }).resolvedOptions()
      .maximumFractionDigits ?? 2;
  return Number(money.value) / 10 ** digits;
}

export async function POST(request: Request) {
  const secret = process.env.WEBFLOW_WEBHOOK_SECRET;
  if (!secret) return new Response('Not configured', { status: 500 });

  const timestamp = request.headers.get('x-webflow-timestamp') ?? '';
  const signature = request.headers.get('x-webflow-signature') ?? '';
  const body = await request.text(); // verify the raw bytes, never a re-serialized copy

  const age = Date.now() - Number(timestamp);
  if (
    !timestamp ||
    !Number.isFinite(age) ||
    Math.abs(age) > MAX_AGE_MS ||
    !(await signatureIsValid(secret, timestamp, body, signature))
  ) {
    return new Response('Invalid signature', { status: 401 });
  }

  let event: { triggerType?: string; payload?: OrderPayload };
  try {
    event = JSON.parse(body);
  } catch {
    return new Response('Invalid JSON', { status: 400 });
  }

  // Acknowledge anything else with 200 so Webflow does not retry it.
  const order = event.payload;
  const email = order?.customerInfo?.email;
  if (event.triggerType !== 'ecomm_new_order' || !order || !email || !order.customerPaid) {
    return new Response('Ignored', { status: 200 });
  }

  const time = order.acceptedOn ?? new Date().toISOString();
  const items = order.purchasedItems ?? [];

  try {
    await sendEvent({
      metric: 'Placed Order',
      email,
      uniqueId: order.orderId,
      time,
      value: toAmount(order.customerPaid),
      currency: order.customerPaid.unit,
      properties: {
        OrderId: order.orderId,
        ItemNames: items.map((item) => item.productName),
        Items: items.map((item) => ({
          ProductName: item.productName,
          ProductID: item.productSlug,
          SKU: item.variantSKU,
          VariantName: item.variantName,
          Quantity: item.count,
          RowTotal: toAmount(item.rowTotal),
        })),
      },
    });

    for (const item of items) {
      await sendEvent({
        metric: 'Ordered Product',
        email,
        uniqueId: `${order.orderId}:${item.variantId}`,
        time,
        value: toAmount(item.rowTotal),
        currency: item.rowTotal.unit,
        properties: {
          OrderId: order.orderId,
          ProductName: item.productName,
          ProductID: item.productSlug,
          SKU: item.variantSKU,
          VariantName: item.variantName,
          Quantity: item.count,
        },
      });
    }
  } catch (err) {
    console.error('klaviyo event failed', err);
    // A 500 makes Webflow retry; unique_id keeps a retry from recording anything twice.
    return new Response('Upstream error', { status: 500 });
  }

  return new Response('OK', { status: 200 });
}

Three details carry the design. The email comes from customerInfo, which Webflow wrote when the order was placed, so nothing a visitor types reaches the private key. A Klaviyo failure returns 500 on purpose: Webflow's documentation says a failed webhook is retried up to three more times, 10 minutes apart, and unique_id makes those retries harmless.

And amounts are converted by the currency's own decimal places rather than divided by 100, so a yen order is not off by a factor of a hundred.

7. Register the webhook and build the flows

Push the app to its repository and deploy the environment. The route answers at your mount path, for example, https://yourstore.com/klaviyo/api/webflow-order.

Create the webhook through the API rather than the dashboard: Webflow notes that "webhooks created through the dashboard will not include the request headers needed to validate request signatures," and this route rejects anything unsigned.

Run this once with your site token and site ID:

curl -X POST "https://api.webflow.com/v2/sites/$SITE_ID/webhooks" \
  -H "Authorization: Bearer $WEBFLOW_SITE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"triggerType":"ecomm_new_order","url":"https://yourstore.com/klaviyo/api/webflow-order"}'

The response includes a secretKey. For webhooks created with a site token, Webflow gives each webhook its own secret, so paste that value into WEBFLOW_WEBHOOK_SECRET and redeploy, because a changed variable reaches the app only after a deploy.

Place a test order and check your profile in Klaviyo for one Placed Order event and an Ordered Product event per line item.

Then build the two flows in Klaviyo. For browse abandonment, trigger on Viewed Product, add a delay, and filter out anyone who has placed an order since starting the flow, which the Placed Order events now make possible.

For post-purchase, trigger on Placed Order and use ItemNames or the Ordered Product events to tailor what you recommend. Before setting either live, decide who may receive it: per Klaviyo's consent guide, profiles that never explicitly subscribed "are eligible for campaign and flow emails", so filter the flows on consent if your market requires it.

What causes Klaviyo events from Webflow to fail?

Browser events fail on the page or in Klaviyo's view of the shopper; order events fail at the signature check or at Klaviyo.

Match the symptom to the half of the build it belongs to.

Product views never appear on the shopper's Klaviyo profile

Cause: The browser is not known to Klaviyo yet. Klaviyo's onsite tracking only records activity for browsers it has identified or cookied, so it drops an anonymous shopper's views rather than storing them. Less often, the script never loaded, usually because the head code was not republished or a content blocker stopped klaviyo.js.

Fix: Identify the test browser first through a Klaviyo form, the Webflow form from step 3, or a click from a Klaviyo email, then open a product page. In the Network tab, confirm klaviyo.js loaded from static.klaviyo.com and that a request went to Klaviyo after the page rendered.

If the script is missing, republish the site after pasting the head code, and check the six-character key against the one in Klaviyo's API keys settings.

The order route returns 401 for every Webflow delivery

Cause: The signature never matches. The usual reason is a webhook created in the Webflow dashboard, which sends no signature headers at all. Other causes include a WEBFLOW_WEBHOOK_SECRET that belongs to a different webhook or was saved without a redeploy, and code that parsed and re-serialized the body before verifying it, which changes the bytes the signature covers.

Fix: Delete any dashboard-created webhook for ecomm_new_order and create it with the API call from step 7. Paste the secretKey from that response into the environment and redeploy. Keep request.text() as the first read of the body, and verify before calling JSON.parse. A signed test order should then return 200 and appear in Klaviyo within a minute.

Orders reach the route, but no Placed Order shows up in Klaviyo

Cause: The route verified the webhook, but Klaviyo rejected the event, so the route returned 500 and logged the reason. A revoked key, or one without events:write, produces this. So does a retired KLAVIYO_REVISION. Webflow retries the delivery three times, 10 minutes apart, and then stops.

Fix: Open the Webflow Cloud logs for the route and read the line that starts with klaviyo event failed; Klaviyo's status and message follow. Regenerate the key with events:write, or move the revision to a current date, then redeploy. Pending retries will now succeed, and unique_id prevents any order from being counted twice.

The Webflow Cloud build breaks after adding the route

Cause: A route contains export const runtime = 'edge'. Webflow's bring-your-own-app page still suggests that directive for API routes, but the OpenNext Cloudflare adapter that Webflow Cloud builds with does not support the Next.js edge runtime target, so the build fails. The line sometimes arrives in code copied from other projects.

Fix: Search the repository for runtime = 'edge', delete every occurrence, and redeploy. Nothing replaces it: the default runtime is the supported one, and the route still runs on Cloudflare Workers.

Run npm run dev first to confirm the app starts locally, and if the build still fails, read the build log for the file path that triggers the runtime error.

What you can build next with Klaviyo and Webflow

Explore the Klaviyo integration for the no-code options, such as Klaviyo's own signup forms.

Keep order receipts and password resets out of Klaviyo; those are transactional, and Postmark on Webflow Cloud covers them. For deeper customization beyond one webhook route, Webflow's developer docs cover Webflow Cloud, webhooks and the Ecommerce APIs.

Frequently asked questions

Why not send product views through the Webflow Cloud route too?

Because the route would have to trust the browser. Any route that accepts a product view also accepts a forged one, with any email and any product link, and behind a private key that lets anyone make your account email strangers. Klaviyo's onsite script is built for browser events and uses a public key that can do far less.

Should I hash shopper emails before sending them to Klaviyo?

No. Klaviyo identifies the profile by email and has to be able to mail it, so send the address as Webflow recorded it. The protection in this build comes from where the email originates: the route only ever reads it from a signed Webflow order, never from a request a visitor can shape.

How do I rotate the webhook secret or the Klaviyo key?

For the webhook, create a second ecomm_new_order webhook with the API call from step 7, put its new secretKey in the environment, redeploy, then delete the old webhook. For Klaviyo, create a new private key with events:write, update KLAVIYO_PRIVATE_KEY, redeploy, and revoke the old key once a test order lands.

Does this need Webflow Cloud at all?

Only for orders. Product views and signups need nothing but Klaviyo's script in the site head. Orders need a server because the Klaviyo private key cannot live in page code, and Webflow's order webhook has to be received and verified somewhere; a Webflow Cloud route does that on your own domain.


Last Updated
October 2, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.