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.
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
BASHBraze 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+.