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 onWIDGET_EMPTYand the fatal error phases (initial,quote) only.
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- a data fetch failed (payload.statusis the HTTP status ornullfor network failures;payload.phaseisinitial,quote,retry, orloadMore). Fatal only when the feed is empty: collapse oninitialandquote. OnretryandloadMorethe widget still has cards on screen and shows its own notice - log those, never collapse.
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
const fatalError =
e.data?.type === "WIDGET_ERROR" &&
["initial", "quote"].includes(e.data.payload?.phase);
if (e.data?.type === "WIDGET_EMPTY" || fatalError) {
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 />;React to phase first - it is what tells you whether anything is on screen. status is for refining your logging and alerting (null = network failure, >= 500 = server error), not for escalating: a retry or loadMore failure leaves a working feed in place no matter what its status code says, and collapsing it punishes the user for a hiccup they'd otherwise never notice.
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).
The failure no event can report
There is one failure mode the events above can never surface: the iframe not booting at all. If the page embedding the widget isn't on your registered embed-origin list, the browser blocks the frame before any of our code runs - so no WIDGET_READY, no WIDGET_ERROR, nothing. No parameter or listener covers this case; the only signal is the absence of WIDGET_READY, which is exactly what the belt-and-braces timeout above exists to catch.
Because it can't be simulated from inside the widget, test it from your side: load your embed page from the real production origin (not localhost, not a staging domain) before launch, and re-test whenever the embedding domain changes. A blank iframe on an otherwise working page almost always means an unregistered origin - send us the origin and we'll add it.
Testing your integration
You don't have to wait for a real outage to find out whether the listener above works. Add a simulate* parameter to your embed URL and the widget will induce the state for you, emitting the same event a genuine failure would. The full guide - every parameter, the mechanics behind them, and the guardrails that keep a simulation out of your analytics - is Testing & Simulation.
Work down the table with your listener attached. For each row, the question is only ever did the section collapse:
| Embed URL parameters | What you should see | Section |
|---|---|---|
?simulateEmpty=true | WIDGET_EMPTY | Collapses, heading included |
?simulateError=500 | WIDGET_ERROR, phase: "initial", status: 500 | Collapses |
?simulateError=network | WIDGET_ERROR, phase: "initial", status: null | Collapses |
?country=US&simulateError=network&simulatePhase=quote | Cards render, then pricing invalidates them: phase: "quote", status: null | Collapses |
?simulateError=500&simulatePhase=loadMore | Page one renders; scroll to paginate. phase: "loadMore" fires with cards on screen | Stays visible |
?simulateError=503&simulatePhase=loadMore | Same, with a 5xx status | Stays visible |
?simulateError=500&simulatePhase=retry | Change a filter to trigger the refetch. phase: "retry" | Stays visible |
?simulateSilent=true | No events at all | Hidden, if you built the reveal timeout; otherwise a blank block - which is the point of building it |
?simulateDelay=4000 | WIDGET_READY arrives ~4s late | Visible, if your timeout is longer than 4s |
?simulateEvent=BET_CLICKED | One BET_CLICKED carrying simulated: true | Unchanged - and nothing should reach your analytics |
The two loadMore rows are the pair worth running twice. They differ only in status code, and both must leave your section visible. If the 503 one hides it, your handler is escalating on status instead of branching on phase - hiding a working feed because page three hiccuped is the classic mistake.
One thing this cannot cover: the iframe never booting because the embedding origin isn't registered. See The failure no event can report above - that one has to be tested from your side, on the real production origin.
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 2 months ago

