Klaviyo

Trigger Klaviyo Flows from real-time MailOdds intent

Send MailOdds intent.hot_lead webhooks straight into Klaviyo as Custom Events, so a Flow fires while the visitor is still on the page. HMAC-verified, ready in under an hour.

Setup time: 45 min
Difficulty: Intermediate
1,000 free validations included

Prerequisites

  • MailOdds account (free tier works)
  • API key from dashboard

How the connection works

MailOdds emits an intent.hot_lead webhook when a visitor's heat score crosses your store's hot threshold. The payload is HMAC-signed with the secret you set when you created the webhook in /dashboard/storefront. Your listener verifies the signature, then forwards the event to Klaviyo's POST /api/events/ endpoint as a Custom Event named MailOdds Hot Lead. From there, Klaviyo's Flow Builder treats it like any other metric trigger.

Two listener languages are shown below. Pick the stack you already deploy. The Klaviyo Flow at the bottom is the same regardless of language.

Sample webhook payload

This is what MailOdds POSTs to the URL you configure. Note the storefront_context sub-object: it carries the visitor's last product view, cart value, and UTM trail so your Flow can personalise the followup without a separate Klaviyo profile lookup.

POST your-webhook-url

JSON
{
  "event": "intent.hot_lead",
  "request_id": "evt_b7f3e8a1c9d24f06",
  "timestamp": 1714670400,
  "data": {
    "base_id": "msg_a3f8c2",
    "heat_score": 0.87,
    "dwell_seconds": 142,
    "environment": "production",
    "signal_integrity": {
      "score": 0.93,
      "probes_correlated": 3
    },
    "storefront_context": {
      "last_product_viewed": {
        "id": "sku-1042",
        "title": "Alpaca Runner",
        "price": "129.00",
        "currency": "EUR",
        "image_url": "https://shop.alpacanica.com/assets/runner-1.webp"
      },
      "cart_value": "129.00",
      "cart_items": 1,
      "session_dwell_seconds": 142,
      "page_type": "product",
      "utm_source": "klaviyo",
      "utm_campaign": "spring_drop"
    },
    "sms_eligible": true,
    "cross_device": false,
    "device_count": 1,
    "device_types": ["desktop"],
    "most_active_device": "desktop",
    "first_device_seen_at": "2026-04-29T10:14:02Z",
    "cluster_confidence": 1.0
  }
}

Verify the signature (TypeScript)

The X-Signature-SHA256 header is HMAC-SHA256 of the raw request body, keyed with the secret you set in the dashboard. Compare with constant time. Reject any mismatch with HTTP 401 so MailOdds retries the delivery on the next backoff window.

webhook-listener.ts

TYPESCRIPT
// Verify the X-Signature-SHA256 header on every inbound webhook.
// Reject any request whose computed HMAC does not match.
//
// Klaviyo's "Receive a Webhook" custom integration ingests this in
// a Cloud Function or Vercel Edge Function fronting your Klaviyo
// API endpoint.

import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.MAILODDS_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get("x-signature-sha256") ?? "";

  const expected = createHmac("sha256", SECRET)
    .update(rawBody)
    .digest("hex");

  const ok =
    signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!ok) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  // Forward into Klaviyo as a custom event
  await fetch("https://a.klaviyo.com/api/events/", {
    method: "POST",
    headers: {
      "Authorization": `Klaviyo-API-Key undefined`,
      "Content-Type": "application/json",
      "revision": "2026-01-15"
    },
    body: JSON.stringify({
      data: {
        type: "event",
        attributes: {
          metric: { data: { type: "metric", attributes: { name: "MailOdds Hot Lead" } } },
          properties: payload.data,
          unique_id: payload.request_id,
          time: new Date(payload.timestamp * 1000).toISOString(),
          profile: {
            data: {
              type: "profile",
              attributes: { _kx: payload.data.storefront_context?.utm_source }
            }
          }
        }
      }
    })
  });

  return new Response("ok", { status: 200 });
}

Verify the signature (PHP)

webhook-listener.php

PHP
<?php
// PHP verification snippet for the same Klaviyo webhook listener.
// Drop this in a Laravel route, WordPress plugin endpoint, or any
// PSR-7 controller.

$secret = getenv('MAILODDS_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE_SHA256'] ?? '';

$expected = hash_hmac('sha256', $rawBody, $secret);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    echo 'invalid signature';
    exit;
}

$payload = json_decode($rawBody, true);

$ch = curl_init('https://a.klaviyo.com/api/events/');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Klaviyo-API-Key ' . getenv('KLAVIYO_API_KEY'),
        'Content-Type: application/json',
        'revision: 2026-01-15',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'data' => [
            'type' => 'event',
            'attributes' => [
                'metric' => ['data' => ['type' => 'metric', 'attributes' => ['name' => 'MailOdds Hot Lead']]],
                'properties' => $payload['data'],
                'unique_id' => $payload['request_id'],
                'time' => date('c', $payload['timestamp']),
            ],
        ],
    ]),
]);
curl_exec($ch);
curl_close($ch);

http_response_code(200);
echo 'ok';

Klaviyo Flow that consumes the event

Build this in Klaviyo's Flow Builder under Flows -> Create Flow -> From Scratch. Pick the MailOdds Hot Lead metric as the trigger. Add a 15-minute delay, an email, and an optional SMS step gated on the sms_eligible field MailOdds emits.

flow-definition.json

JSON
{
  "name": "MailOdds Hot Lead - Personalized Followup",
  "trigger": {
    "type": "metric",
    "metric_name": "MailOdds Hot Lead"
  },
  "filter": {
    "and": [
      { "property": "data.heat_score", "op": ">=", "value": 0.8 },
      { "property": "data.storefront_context.cart_value", "op": ">", "value": 0 }
    ]
  },
  "delay_minutes": 15,
  "actions": [
    {
      "type": "email",
      "template_id": "TEMPLATE_HOT_LEAD_RECOVERY",
      "subject": "{{ event.data.storefront_context.last_product_viewed.title }} is still in your cart",
      "personalisation": {
        "product_image": "{{ event.data.storefront_context.last_product_viewed.image_url }}",
        "product_price": "{{ event.data.storefront_context.last_product_viewed.price }}",
        "currency": "{{ event.data.storefront_context.last_product_viewed.currency }}"
      }
    },
    {
      "type": "sms",
      "condition": "{{ event.data.sms_eligible == true }}",
      "delay_minutes": 60,
      "body": "Still thinking about {{ event.data.storefront_context.last_product_viewed.title }}? Free EU shipping today only."
    }
  ]
}

The JSON above is a description of the steps. Klaviyo does not import Flow JSON directly; recreate the steps in the Flow Builder UI. The trigger metric name (MailOdds Hot Lead) must match the metric.attributes.name your listener posts.

Operational notes

  • Idempotency: always pass payload.request_id as Klaviyo's unique_id. MailOdds retries on 5xx; Klaviyo dedupes.
  • Retries: MailOdds retries 3 times after the initial attempt (5s, 30s, 120s). After 4 total failures, the webhook is auto-disabled and a dashboard alert fires.
  • Cross-device: when cross_device=true, the visitor was first seen on one device and converted on another. Use most_active_device to pick the channel (push the SMS to the mobile, the email to the desktop).
  • Plan gating: Klaviyo Custom Events need the Klaviyo Pro plan. The MailOdds intent webhook is on Growth+.