Widget Events
The widget communicates with the parent page via window.postMessage(). This allows your site to react to user interactions inside the iframe - for example opening a betslip when a user taps a bet, or tracking engagement analytics.
Communication is outbound only: the widget emits events to your page, but there is no JS API for sending messages or method calls into the iframe - all configuration happens via iframe parameters and presets. In native app embeds, the Mobile SDKs deliver the same events as typed callbacks through an injected native bridge instead of postMessage.
Listening for Events
Add a message listener on the page that embeds the widget:
window.addEventListener('message', (event) => {
// In production, validate the origin
// if (event.origin !== 'https://widget.example.com') return;
const { type, payload, timestamp } = event.data;
switch (type) {
case 'WIDGET_READY':
console.log('Widget loaded at', timestamp);
break;
case 'WIDGET_EMPTY':
// Hide the whole section (heading included), not just the iframe
document.getElementById('odditt-widget-section').style.display = 'none';
break;
case 'WIDGET_ERROR':
console.warn('Widget error:', payload.message);
break;
case 'BET_CLICKED':
openBetslip(payload.data);
break;
case 'GRAPH_EXPANDED':
trackEngagement('expand', payload.flowId);
break;
case 'GRAPH_COLLAPSED':
trackEngagement('collapse', payload.flowId);
break;
case 'PAGE_LOADED':
console.log(`Page ${payload.page}: ${payload.newItemCount} new items`);
break;
case 'FILTER_CHANGED':
console.log('Active filters:', payload.filters);
break;
}
});
CautionAlways validate
event.originin production to ensure messages come from your widget domain and not from unrelated iframes on the same page.
Message Format
Every message has the same envelope:
{
"type": "BET_CLICKED",
"payload": { ... },
"timestamp": 1711800000000
}| Field | Type | Description |
|---|---|---|
type | string | Event name (see table below) |
payload | object | Event-specific data |
timestamp | number | Unix milliseconds when the event was emitted |
Event Reference
WIDGET_READY
WIDGET_READYFired once when the widget has mounted and is ready to display content.
| Payload field | Type | Description |
|---|---|---|
| (empty object) | No payload |
{ "type": "WIDGET_READY", "payload": {}, "timestamp": 1711800000000 }WIDGET_EMPTY
WIDGET_EMPTYFired on initial load when the configured parameters return zero flows (the widget also renders nothing, so it takes up no space). Use this to collapse or hide the whole widget section (heading included) - see Integration Recipes for the recommended pattern and how to avoid triggering it through over-scoping. It is an initial-load signal only: a user filtering the feed down to zero results does not re-fire it - the widget shows its own "No Results Found" message in place of the cards, keeps the filter bar usable, and tells you nothing.
Test your handler with ?simulateEmpty=true on your embed URL (Simulation Parameters).
| Payload field | Type | Description |
|---|---|---|
| (empty object) | No payload |
{ "type": "WIDGET_EMPTY", "payload": {}, "timestamp": 1711800000000 }WIDGET_ERROR
WIDGET_ERRORFired when a data fetch fails. Whether it's fatal depends on phase. The widget keeps showing whatever it already has and surfaces its own notice for transient failures, so only the phases that leave it with nothing on screen should collapse your section:
phase | What failed | Feed state after | Your reaction |
|---|---|---|---|
initial | The first load | Empty - nothing was ever rendered | Collapse the section or swap in fallback content |
quote | Pricing invalidated every card after a first load that had flows (status is always null) | Empty | Collapse or fallback - same treatment as WIDGET_EMPTY |
retry | A reload after a filter change | Previous cards stay on screen (or the widget shows its own inline error-and-retry state if it had none) | Log it; don't collapse |
loadMore | A later page during infinite scroll | Existing cards stay on screen | Log it; don't collapse - hiding a working feed because page three hiccuped is the classic mistake |
See Integration Recipes for the recommended listener.
Each phase is reachable on demand, so you can watch your handler run against a real event: add ?simulateError=500&simulatePhase=initial (or quote, retry, loadMore) to your embed URL. Status and phase are set independently, so you can also produce the case that catches most integrations out - a 5xx on a non-fatal phase. See Simulation Parameters.
| Payload field | Type | Description |
|---|---|---|
message | string | Human-readable error description |
status | number | null | HTTP status code of the failed request; null for network failures and for phase: "quote" |
phase | string | initial, quote, retry, or loadMore (see table above) |
{ "type": "WIDGET_ERROR", "payload": { "message": "Failed to load flows", "status": 500, "phase": "initial" }, "timestamp": 1711800000000 }BET_CLICKED
BET_CLICKEDFired when the user clicks the betslip button on any flow card.
| Payload field | Type | Description |
|---|---|---|
flowId | string | number | Unique identifier of the flow or parlay |
flowType | string | Widget type (see Flow Types) |
buttonPayload | string | null | Whatever token, ID, or deeplink your bet slip needs to add this bet - supplied by your backend in its live-quote response and passed through unchanged. null when live quoting isn't enabled for your deployment or no value was returned for this flow. |
data | object | Full flow data object - contents vary by flow type |
The data field contains the complete data for the flow, including player name, odds in all formats, payout multiplier, countdown, and type-specific fields. This avoids the need to request additional data from your backend after a click.
Example - single flow:
{
"type": "BET_CLICKED",
"payload": {
"flowId": "abc-123",
"flowType": "funflow",
"data": {
"id": "abc-123",
"playerName": "Patrick Mahomes",
"betDescription": "Over 274.5 passing yards",
"oddsAmerican": 150,
"oddsDecimal": 2.5,
"oddsFractional": "3/2",
"payoutMultiplier": 2.5,
"gameTime": "Sun 4:25 PM",
"eventShort": "KC @ BUF"
}
},
"timestamp": 1711800000000
}Example - parlay:
{
"type": "BET_CLICKED",
"payload": {
"flowId": "parlay-456",
"flowType": "parlay",
"data": {
"title": "Sunday Parlay",
"subtitle": "3-Leg Player Props",
"oddsAmerican": 600,
"oddsDecimal": 7.0,
"oddsFractional": "6/1",
"payoutMultiplier": 7.0,
"legs": [
{ "id": "leg-1", "playerName": "Patrick Mahomes", "betDescription": "Over 274.5 passing yards" },
{ "id": "leg-2", "playerName": "Travis Kelce", "betDescription": "Over 5.5 receptions" },
{ "id": "leg-3", "playerName": "Isiah Pacheco", "betDescription": "Over 64.5 rushing yards" }
]
}
},
"timestamp": 1711800000000
}
TipThe
dataobject is the same object the widget renders from. Its shape depends onflowType- see the Flow Types table for the variants.
GRAPH_EXPANDED
GRAPH_EXPANDEDFired when the user expands a graph, stats section, or parlay leg.
| Payload field | Type | Description |
|---|---|---|
flowId | string | number | ID of the flow whose graph was expanded |
flowType | string | Widget type |
{ "type": "GRAPH_EXPANDED", "payload": { "flowId": "abc-123", "flowType": "factflow-base" }, "timestamp": 1711800000000 }GRAPH_COLLAPSED
GRAPH_COLLAPSEDFired when the user collapses a previously expanded graph or parlay leg.
| Payload field | Type | Description |
|---|---|---|
flowId | string | number | ID of the flow whose graph was collapsed |
flowType | string | Widget type |
{ "type": "GRAPH_COLLAPSED", "payload": { "flowId": "abc-123", "flowType": "factflow-base" }, "timestamp": 1711800000000 }PAGE_LOADED
PAGE_LOADEDFired when the widget loads a new page of content via infinite scroll or pagination.
| Payload field | Type | Description |
|---|---|---|
page | number | Page number that was loaded |
itemCount | number | Total items now in the feed |
newItemCount | number | Items added in this page |
{ "type": "PAGE_LOADED", "payload": { "page": 2, "itemCount": 40, "newItemCount": 20 }, "timestamp": 1711800000000 }
InfoThe initial server-rendered page does not emit a
PAGE_LOADEDevent. Only subsequent client-side page fetches emit this event. Pages that return only already-displayed items are skipped silently, sopagevalues are monotonically increasing but not necessarily consecutive - treat this event as "new content arrived", not as a backend page counter.
FILTER_CHANGED
FILTER_CHANGEDFired when the user changes a filter in the carousel filter bar.
| Payload field | Type | Description |
|---|---|---|
filters | object | Current filter selections keyed by category (context, wager_type, market_type, probability, bet_type) |
factFlowDisplayType | string | Current fact flow display variant (base, expanded) |
useCartoonImages | boolean | Whether cartoon-jersey images are active |
productMode | string | Current product mode (sportsbook, dfs, prediction_market) |
widgetMode | string | Current widget mode (operator, affiliate, clean) |
{
"type": "FILTER_CHANGED",
"payload": {
"filters": {
"context": ["fact"],
"wager_type": ["singles"],
"market_type": [],
"probability": [],
"bet_type": ["overs"]
},
"factFlowDisplayType": "base",
"useCartoonImages": false,
"productMode": "sportsbook",
"widgetMode": "operator"
},
"timestamp": 1711800000000
}
InfoFilters set via iframe parameters do not emit a
FILTER_CHANGEDevent on initial load. The event only fires on subsequent user-initiated changes.
Target Origin
By default the widget posts messages with target origin "*" (any parent). For production, restrict this to your site's origin using the ?parentOrigin= query param in the iframe URL, or ask the Odditt team to configure a default origin for your deployment.
<!-- Restrict events to your domain -->
<iframe
src="https://widget.example.com/?preset=brand_dark_v2&parentOrigin=https://mysite.com"
width="100%"
height="480"
frameborder="0"
style="border: none;"
></iframe>
CautionUsing
"*"as the target origin means any page can receive your widget's events if they embed it. Always set theparentOriginquery param in production.
Complete Example
A full integration that embeds the widget and handles all events:
<!DOCTYPE html>
<html>
<head>
<title>Odditt Widget Integration</title>
</head>
<body>
<section id="odditt-widget-section">
<h2>Trending Bets</h2>
<iframe
id="odditt-widget"
src="https://widget.example.com/?preset=brand_dark_v2&parentOrigin=https://mysite.com&leagueKey=nfl"
width="100%"
height="480"
frameborder="0"
style="border: none;"
></iframe>
</section>
<script>
const WIDGET_ORIGIN = 'https://widget.example.com';
window.addEventListener('message', (event) => {
if (event.origin !== WIDGET_ORIGIN) return;
const { type, payload } = event.data;
switch (type) {
case 'WIDGET_READY':
console.log('Widget is ready');
break;
case 'WIDGET_EMPTY':
// Nothing to show - collapse the whole section, heading included
document.getElementById('odditt-widget-section').style.display = 'none';
break;
case 'WIDGET_ERROR':
// Collapse only when the feed is actually empty (initial/quote).
// retry and loadMore failures leave cards on screen - never hide those.
if (payload.phase === 'initial' || payload.phase === 'quote') {
document.getElementById('odditt-widget-section').style.display = 'none';
}
break;
case 'BET_CLICKED':
// Open your betslip with the flow data
openBetslip({
id: payload.flowId,
type: payload.flowType,
odds: payload.data.oddsDecimal,
description: payload.data.betDescription,
});
break;
case 'PAGE_LOADED':
// Track scroll depth
analytics.track('widget_page_loaded', {
page: payload.page,
totalItems: payload.itemCount,
});
break;
case 'FILTER_CHANGED':
// Sync filters with your own UI
updateFilterDisplay(payload.filters);
break;
case 'GRAPH_EXPANDED':
case 'GRAPH_COLLAPSED':
// Track engagement
analytics.track('widget_graph_toggle', {
action: type === 'GRAPH_EXPANDED' ? 'expand' : 'collapse',
flowId: payload.flowId,
});
break;
}
});
</script>
</body>
</html>Next Steps
- Integration Recipes - production patterns for empty/error handling, theming, and analytics
- Iframe Parameters - configure filters, language, and entity scoping
- Widget Overview - introduction and flow types
- Presets - theme management and deployment
Updated about 2 months ago

