Integration Recipes
A production embed is more than an <iframe> tag. This guide covers the wiring patterns we use on our own properties and recommend to every partner: building the iframe URL from page context and user preferences, syncing dark/light mode, and - most importantly - never showing an empty or broken widget section.
The short version:
- Build the URL once, after preferences resolve - theme, language, odds format, geo.
- Scope to the page, not to a single market - over-scoping is the #1 cause of empty feeds.
- Listen for
WIDGET_EMPTYandWIDGET_ERROR- and collapse the whole section (heading included) or swap in fallback content.
Building the Iframe URL
Treat the URL as a merge of three layers, later layers winning:
- Deployment constants -
preset,productMode,widgetMode(usually fixed per site). - Page context - entity scoping (
eventIds,leagueKey,teamIds, ...) andlayoutMode(carousel band vs. vertical feed rail). - User preferences -
lang,oddsFormat,colorMode,country,region.
function buildWidgetUrl(origin, params) {
const url = new URL(origin);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
url.searchParams.set(key, String(value));
}
return url.toString();
}
const src = buildWidgetUrl("https://widget.example.com/", {
preset: "brand_dark_v2", // deployment constant
leagueKey: "nfl", // page context
layoutMode: "carousel",
lang: userPrefs.language, // user preferences
oddsFormat: userPrefs.oddsFormat,
colorMode: isDarkMode() ? "dark" : "light",
country: geo.country,
region: geo.region,
parentOrigin: window.location.origin,
});
Render once, not twiceChanging the
srcreloads the iframe. If your theme, geo, or preference values resolve asynchronously (cookies, a theme store, an IP-geo lookup), don't render the iframe until they're ready - otherwise the widget loads with defaults and immediately reloads with the real values. Gate the mount on areadyflag instead.
Always set parentOrigin to your own origin so widget events can't be read by other frames.
Dark / Light Mode Wiring
Pass your site's current theme as colorMode:
- Sites with a theme toggle - read your theme state (e.g. the
darkclass on<html>, or your theme store) at mount and put it in the URL. When the user toggles, update thesrc; the iframe reloads with the other mode. That reload is the accepted trade-off - it's a single navigation per toggle. - Static sites without a toggle - sample the OS preference once:
const colorMode = window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";- Distinct themes per mode - if your light and dark themes differ by more than colors, use mode-specific preset files (
{preset}_{layoutMode}_{colorMode}.json) so each mode loads its own full preset.
To avoid a background flash while the widget loads, give the iframe element a background matching your page (CSS background on the iframe, or the bg parameter).
Language, Odds Format & Geography
lang- drive it from your site's explicit language selection (a settings menu, a stored cookie) rather than silently fromnavigator.language. If you accept free-text locale input, validate it looks like a BCP-47 tag (/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/) before putting it in the URL. The widget canonicalizes casing and falls back per-language, soEN,es-419,pt-BR, anditall pass straight through. See Language.oddsFormat- default it by country when the user hasn't chosen:
function defaultOddsFormat(country) {
const c = (country || "").toUpperCase();
if (c === "US" || c === "CA") return "american";
if (c === "GB" || c === "IE") return "fractional";
return "decimal";
}country/region- resolve once server-side (edge IP-geo headers) into a cookie, and let the user override it. Never re-detect over a saved user choice. Geography drives operator eligibility in affiliate mode, so pass the same values on every embed.
Scoping to Avoid Empty Feeds
The most common cause of a WIDGET_EMPTY event is over-scoping. Rules of thumb from our own sites:
- Never scope an embedded feed to a single betting market position. A lone position frequently has no live flow, so a matchup page with plenty of live markets would show nothing. Scope to the event so all of its live markets can render.
- Event pages: scope to the event only if it's in the future; for past or unknown events, fall back to a league-wide feed.
- Team / player pages: scope to the entity; treat an upcoming event as an additional filter, not the primary scope.
- Homepages / general placements: leave the feed unscoped (or country-only) so it surfaces the most popular markets across everything - an unscoped feed is effectively never empty.
Narrow filters (minHitRateThreshold, tag filters, a single bettingMarketIds value) compound the risk - the tighter the query, the more important the empty-state handling below.
Handling Empty & Error States
The widget tells you when it has nothing to show:
WIDGET_EMPTY- the configured parameters returned zero flows.WIDGET_ERROR- the data fetch failed (payload.statusis the HTTP status ornullfor network failures;payload.phaseisinitial,retry, orloadMore). The widget only reports this when it has nothing to render, so it's safe to treat any error as fatal for the embed.
You have two good options - and one mistake to avoid:
Option A - collapse the whole section
Hide the entire section including its heading, not just the iframe. A leftover "Trending Bets" <h2> above a blank space looks just as broken as an empty carousel. Lift the hidden state up to the section wrapper:
function WidgetSection({ title, src, widgetOrigin, height = 360 }) {
const [hidden, setHidden] = useState(false);
useEffect(() => {
setHidden(false); // reset when the embed changes
function onMessage(e) {
if (e.origin !== widgetOrigin) return; // only trust the widget origin
if (e.data?.type === "WIDGET_EMPTY" || e.data?.type === "WIDGET_ERROR") {
setHidden(true);
}
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [widgetOrigin, src]);
if (hidden) return null; // section, heading, and iframe all disappear
return (
<section>
<h2>{title}</h2>
<iframe src={src} width="100%" height={height} loading="lazy"
title="Bet discovery widget" style={{ border: 0 }} />
</section>
);
}Two details that matter:
- Normalize the origin you compare against (
new URL(widgetSrc).origin) so a configured URL with a path or trailing slash still validates. - Reset the hidden state when the
srcchanges (new filters, new language, new page). Without the reset, one empty response permanently latches the section closed even after the user changes context.
Option B - swap in fallback content
On marketing or editorial pages where the section must never be empty, render static fallback content instead of hiding:
if (errored) return <StaticFallbackCards />;You can also make the reaction proportional: treat network failures (status == null) and server errors (status >= 500) as fatal, while ignoring transient loadMore failures on later pages - the feed already has content in that case (phase === "loadMore").
The mistake to avoid
Doing nothing. An embed with no listener leaves a blank block (or a spinner) sitting in your page whenever content is thin for a given scope, geography, or filter combination. If you take one thing from this guide: wire WIDGET_EMPTY and WIDGET_ERROR before you ship.
Optional belt-and-bracesIf your page can't tolerate even a briefly blank block, start the section hidden and reveal it on
WIDGET_READY, with a timeout (e.g. 10s) that gives up and leaves it hidden if no event arrives - this also covers the iframe failing to load at all (add anonErrorhandler on the iframe element for the same reason).
Loading & Layout
Recommended iframe attributes:
<iframe
src="https://widget.example.com/?preset=brand_dark_v2&parentOrigin=https://mysite.com"
width="100%"
height="360"
title="Bet discovery widget"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
scrolling="no"
style="border: 0; overflow: hidden;"
></iframe>- Fluid width, fixed height. Use
width="100%"and pick a height per placement. Typical values from our own properties: 320-360px for a horizontal carousel band, 520-600px for a vertical feed rail or modal, 620-680px for a tall vertical showcase. The widget does not emit resize messages - it lays itself out within the box you give it. loading="lazy"defers offscreen embeds for free.scrolling="no"+overflow: hiddensuppress a stray frame scrollbar on carousel layouts (feed layouts scroll internally by design).allow- addclipboard-writeif your integration copies bet text;fullscreenif you use expanded views. Neither is required for a basic embed.- CSP - if your site sets a Content-Security-Policy, add your widget origin to
frame-src(orchild-src). - Embed origins - your widget instance only renders inside domains registered with Odditt (see Access & Onboarding). A blank iframe with no events firing usually means the embedding origin isn't registered yet - send the origin to your Odditt contact. Remember to register staging and preview domains too.
Analytics Hook
The event stream is the natural place to wire engagement analytics - forward events to your tracker in the same listener that handles empty/error:
case "BET_CLICKED":
gtag("event", "widget_bet_clicked", { flow_id: payload.flowId, flow_type: payload.flowType });
break;
case "PAGE_LOADED":
gtag("event", "widget_page_loaded", { page: payload.page });
break;See Widget Events for every event and payload.
Next Steps
- Iframe Parameters - every URL parameter referenced above
- Widget Events - full event and payload reference
- Widget Modes - operator handoff, affiliate cart, clean mode
- Presets - theme presets and mode-specific preset files
Updated about 15 hours ago

