Trigger Braze Canvas from real-time MailOdds intent

Forward MailOdds intent.hot_lead webhooks into Braze /users/track as a custom event, then trigger a multi-step Canvas with cross-device-aware Liquid personalisation and an optional SMS step.

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

Prerequisites

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

How the connection works

MailOdds emits a single intent.hot_lead webhook per visitor whose heat score crosses your store's hot threshold. A small middleware (Cloud Function, Lambda, Vercel Edge) verifies the HMAC signature, maps the payload into Braze's custom-event shape, and POSTs to /users/track. Braze recognises the event, Canvas Flow's entry trigger fires, and the visitor enters the Personalisation Path.

The cross-device fields MailOdds sends (cross_device, device_count, most_active_device, cluster_confidence) let your Canvas pick the right channel: push to mobile when the visitor has switched away from the desktop session, email otherwise.

Sample webhook payload

Note the cross-device block: this visitor was first seen on desktop, switched to iPhone, and is currently bouncing between both. most_active_device = mobile-ios drives the channel-pick branch in the Canvas below.

POST your-middleware-url

JSON
{
  "event": "intent.hot_lead",
  "request_id": "evt_b7f3e8a1c9d24f06",
  "timestamp": 1714670400,
  "data": {
    "base_id": "msg_a3f8c2",
    "heat_score": 0.87,
    "dwell_seconds": 142,
    "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,
      "page_type": "product",
      "utm_source": "braze",
      "utm_campaign": "spring_drop"
    },
    "sms_eligible": true,
    "cross_device": true,
    "device_count": 2,
    "device_types": ["desktop", "mobile-ios"],
    "most_active_device": "mobile-ios",
    "first_device_seen_at": "2026-04-29T10:14:02Z",
    "cluster_confidence": 0.84
  }
}

Middleware: verify + forward to /users/track

Verify the X-Signature-SHA256 with constant-time HMAC compare. Map every storefront field into event_properties so Liquid can read them downstream. _update_existing_only: true prevents creating Braze profiles for unknown visitors. The user must already have a Braze external_id.

braze-forwarder.ts

TYPESCRIPT
// Forward MailOdds intent.hot_lead webhooks into Braze /users/track.
// Verifies the X-Signature-SHA256 header before dispatch.

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

const SECRET = process.env.MAILODDS_WEBHOOK_SECRET!;
const BRAZE_REST_KEY = process.env.BRAZE_REST_KEY!;
const BRAZE_REST_ENDPOINT = process.env.BRAZE_REST_ENDPOINT!; // e.g. https://rest.iad-05.braze.com

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);
  const ctx = payload.data.storefront_context ?? {};
  const product = ctx.last_product_viewed ?? {};

  const brazeBody = {
    events: [{
      external_id: ctx.subscriber_email_or_external_id, // resolve in your system
      app_id: process.env.BRAZE_APP_ID,
      name: "mailodds_hot_lead",
      time: new Date(payload.timestamp * 1000).toISOString(),
      properties: {
        heat_score: payload.data.heat_score,
        product_id: product.id,
        product_title: product.title,
        product_price: product.price,
        currency: product.currency,
        product_image_url: product.image_url,
        cart_value: ctx.cart_value,
        cart_items: ctx.cart_items,
        page_type: ctx.page_type,
        utm_source: ctx.utm_source,
        utm_campaign: ctx.utm_campaign,
        cross_device: payload.data.cross_device,
        device_count: payload.data.device_count,
        most_active_device: payload.data.most_active_device,
        cluster_confidence: payload.data.cluster_confidence,
        sms_eligible: payload.data.sms_eligible
      },
      _update_existing_only: true
    }]
  };

  await fetch(`${BRAZE_REST_ENDPOINT}/users/track`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${BRAZE_REST_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(brazeBody)
  });

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

Liquid template inside the Canvas email step

Cross-device aware subject line and a low-friction CTA. The cluster_confidence gate keeps the explicit "we noticed you switched device" line off emails where the device-graph link is weak (probabilistic confidence below 0.8).

hot-lead-canvas-step.liquid

HTML
{# Braze Liquid template inside the Canvas email step.
   Personalises subject + body from the mailodds_hot_lead custom event
   payload that the middleware just inserted. #}

{% assign p = event_properties.${"product_title"} %}
{% assign price = event_properties.${"product_price"} %}
{% assign cur = event_properties.${"currency"} %}
{% assign device = event_properties.${"most_active_device"} %}

{# Subject: aware of cross-device, so we don't say "your laptop" if the
   user has switched to mobile mid-session #}
{% if device contains "mobile" %}
Subject: {{ p }} - one tap from checkout
{% else %}
Subject: {{ p }} is still in your cart
{% endif %}

<h1>Still thinking about {{ p }}?</h1>
<p>It's still in stock at {{ cur }} {{ price }}.</p>

<a href="{{ event_properties.${"product_image_url"} | default: '#' }}">
  <img src="{{ event_properties.${"product_image_url"} }}" alt="{{ p }}"
       width="320" />
</a>

{% if event_properties.${"cluster_confidence"} >= 0.8 %}
<p style="font-size: 12px; color: #666;">
  We noticed you're browsing from {{ device }}. Tap below to pick up where you left off.
</p>
{% endif %}

<a href="{{ ${"deep_link"} }}">Take me to checkout</a>

Canvas Flow configuration

Build this in Braze under Engagement -> Canvas -> Create Canvas. The decision splits use the custom event properties you forwarded from MailOdds.

canvas-flow-checklist.txt

BASH
Braze Canvas Flow setup
=======================
1.  Engagement -> Canvas -> Create Canvas
    Name: "MailOdds Hot Lead - Personalized Recovery"

2.  Entry Trigger:
    -> Custom Event = mailodds_hot_lead
    -> Re-eligibility: 24 hours
    -> Filters: heat_score >= 0.8 AND cart_value > 0

3.  Step 1 - Delay
    -> Wait: 15 minutes (avoid interrupting live session)

4.  Step 2 - Decision Split: Most Active Device
    -> property "most_active_device" contains "mobile"
       -> branch: Mobile Push then Email
    -> else
       -> branch: Email only

5.  Step 3a - Push Notification (mobile branch)
    -> Title: "{{ event_properties.${"product_title"} }} is waiting"
    -> Deep link: ${"product_id"}-resolves to PDP

6.  Step 3b - Email (both branches converge)
    -> Use the Liquid template above
    -> Send-time optimization: ON (Braze STO)

7.  Step 4 - Decision Split: SMS eligible
    -> sms_eligible == true
       -> Step 5: SMS via your provider integration
    -> else
       -> exit

8.  Conversion Event:
    -> Define "checkout_completed" as the conversion. Wire up via a
       second MailOdds webhook on storefront.checkout.completed,
       same forward-to-Braze pattern.

9.  Activate. Test by POSTing a sample mailodds_hot_lead via curl,
    confirm a test user lands in Step 1 within 30 seconds.

Operational notes

  • External ID resolution: Braze keys on external_id. The middleware is responsible for resolving the MailOdds visitor (cookie-id) to your application's user-id before posting. If you cannot resolve, drop the event silently and surface the count in your dashboard so you can size the gap.
  • Currents alternative: if you already use Braze Currents to consume Braze events into your warehouse, you can flip the direction and use Currents as a one-way audit log for MailOdds-triggered events. The webhook -> /users/track -> Canvas pattern is the read path; Currents is the write log.
  • Re-eligibility: set Canvas re-eligibility to 24h. Without it, every additional MailOdds event in the same day re-enters the visitor into the Canvas, multiplying messaging volume.
  • Send-time optimization: turn STO ON in the email step. MailOdds fires when intent peaks, but Braze STO will hold the email until the visitor's optimal open window.
  • Plan gating: Braze custom events and Canvas are on every Braze plan. The MailOdds intent webhook is on Growth+.