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:

  1. Build the URL once, after preferences resolve - theme, language, odds format, geo.
  2. Scope to the page, not to a single market - over-scoping is the #1 cause of empty feeds.
  3. Listen for WIDGET_EMPTY and WIDGET_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:

  1. Deployment constants - preset, productMode, widgetMode (usually fixed per site).
  2. Page context - entity scoping (eventIds, leagueKey, teamIds, ...) and layoutMode (carousel band vs. vertical feed rail).
  3. 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 twice

Changing the src reloads 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 a ready flag 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 dark class on <html>, or your theme store) at mount and put it in the URL. When the user toggles, update the src; 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 from navigator.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, so EN, es-419, pt-BR, and it all 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.status is the HTTP status or null for network failures; payload.phase is initial, retry, or loadMore). 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 src changes (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-braces

If 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 an onError handler 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: hidden suppress a stray frame scrollbar on carousel layouts (feed layouts scroll internally by design).
  • allow - add clipboard-write if your integration copies bet text; fullscreen if 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 (or child-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


Did this page help you?