Skip to Content
Widget guide

Embed the widget

The widget is the ready-made way to show our markets to your end users. You add one script tag to your page and call mount(). This guide covers the script tag, the session token that your server mints, the refresh of that token, the list of allowed origins, and the theme and locale options.

How it embeds

The script tag loads a versioned widget bundle from our CDN. The bundle registers one global, PredictionMarketWidget, whose one export is mount(). mount() renders the whole trading experience into a Shadow DOM root under a container element that you give it. The widget inherits your page’s layout, and no separate document sits in the way.

We use Shadow DOM instead of an iframe because the widget holds no secret that needs an iframe’s origin isolation. Its session token is short-lived and scoped to one user. Your brand theme must reach the widget, and CSS custom properties cross a shadow boundary but not into an iframe’s own document.

The isolation is about styles, not security. Your page’s JavaScript can still reach the widget’s DOM and its in-memory token. This is fine because it is your page, and the token covers only your own end user.

The bundle paths follow one scheme. https://test-cdn.predixum.net is the bundle host of stage, the sandbox you integrate against today. The Getting started table lists it with the API addresses. Production gets its own bundle host when it opens, and the paths do not change.

PathUse
/widget/v1/widget.iife.jsThe script tag embed. It moves when we ship a non-breaking release, so you get fixes without a change on your side.
/widget/v1/widget.esm.jsThe same bundle for a bundler. It also exports mount as a named export.
/widget/v1.2.3/widget.iife.jsOne exact version, shown here with an example number. It never changes. Pin it to roll back.
/widget/v1/locales/{locale}.jsThe text of one locale, loaded on demand. An exact-version path exists for it too.

A change that breaks the embed contract, including a change to the global name, ships under /widget/v2/ beside /widget/v1/. We never change an existing major path in a breaking way.

Get a session token

The widget never sees your tenant API key. The flow has three steps:

  1. When an end user loads your page, your backend calls POST /v1/widget/sessions. It authenticates with your tenant API key, which needs the write:positions scope, and it names the external_user_id of the user. The user must already be registered. An unregistered user gets 404 unknown_user.
  2. We mint a short-lived JWT and return it as { "token": "..." }. The token is valid for 15 minutes, and a 24-hour absolute cap carries through every refresh. A session cannot grow forever from the browser. We sign it with a secret that is only for widget sessions. It is separate from your API key and from your webhook secret.
  3. Your page passes the token to mount(). The widget uses it for every read, every position it opens and its realtime subscription.
POST /v1/widget/sessions Authorization: Bearer pmk_test_... Content-Type: application/json { "external_user_id": "u-10492" }

A session that you mint with a test key works on your test data, and one minted with a live key on your live data. Call this route from your server only. It has no CORS allowance, so a browser cannot call it. Mint a new token on every page load. The widget keeps the token in memory only and never writes it to a cookie or to storage.

Refresh and expiry

The widget refreshes its own token. When 5 minutes or less of the lifetime remain, it calls POST /v1/widget/sessions/refresh with the current token and no other credential. The response is a new token for the same user with the same cap. If a refresh fails, the widget tries again a few times with short waits before it gives up.

A refresh fails with 401 when the token is past its cap or already expired. The widget then enters its session-expired state and calls onSessionExpired. This is the only case that reaches your page. In the callback, mint a new token as in step 1 and pass it to widget.setSession(token): the widget opens again on the view the player was on. If it expires again later, the callback runs again.

We keep no server-side session list, so we cannot revoke a single token. The short lifetime limits how long a leaked token stays useful. When we suspend a tenant, we revoke its API keys, which stops new mints at once. A refresh then returns 401, and the tokens already issued end within 15 minutes.

Anonymous visitors

A visitor who has not logged in on your site can still see your markets and their prices. Your backend mints an anonymous session for them with the same call and the same key, with "anonymous": true in place of external_user_id:

POST /v1/widget/sessions Authorization: Bearer pmk_test_... Content-Type: application/json { "anonymous": true }

Send exactly one of the two fields. A body with both, or with neither, gets 400 validation_failed. The anonymous token has the same 15-minute lifetime, the same 24-hour cap and the same refresh as a player’s token, and it stays anonymous through every refresh.

The token is bound to your tenant and can only read. The widget shows the market list, a market with its price chart, and the leaderboard. It does not show the Portfolio button or the save buttons on the cards. Where the stake form would be, the trade panel shows a log-in prompt. Every route that acts for a user, such as opening a position, placing an order, selling or reading a balance, answers 403 insufficient_scope to this token, whatever the page sends.

When the visitor presses the log-in button, the widget calls your onLoginRequest callback. A mount with an anonymous token requires this callback, and mount() throws without it. Your page then does this:

  1. Open your own login screen.
  2. After the login, ask your backend to mint a token for that player, as in Get a session token.
  3. Pass the new token to widget.setSession(token).

The same widget then trades. It keeps the markets it already shows and draws the view again with the stake form, the Portfolio button and the save buttons. The other direction is a logout: setSession() with an anonymous token after a player’s token throws. Call unmount() and then mount() with the anonymous token.

const widget = PredictionMarketWidget.mount({ target: document.getElementById('prediction-market'), sessionToken: '{{ANONYMOUS_SESSION_TOKEN}}', onSessionExpired: () => location.reload(), onLoginRequest: () => openLogin({ onDone: async () => widget.setSession(await mintPlayerToken()) }), });

openLogin and mintPlayerToken stand for your own page’s code. mintPlayerToken asks your backend for a player token.

Site attribution

If you run more than one site or brand on one account, you can tag each position with the site it came from. Your reports can then be filtered by site, and each report row shows the site.

For the widget, your backend names the site when it mints the session. Add site to the body of POST /v1/widget/sessions, for a player or for an anonymous visitor:

POST /v1/widget/sessions Authorization: Bearer pmk_test_... Content-Type: application/json { "external_user_id": "u-10492", "site": "casino-north" }

We sign the site into the token, and every refresh keeps it. The widget sends no site of its own. Every position and order it makes takes the site from the token, so the page cannot change it. We do not read the site from the Origin header, because a client can forge that header.

If your backend opens positions with its API key, send site in the body of POST /v1/positions or POST /v1/orders:

POST /v1/positions Authorization: Bearer pmk_test_... Idempotency-Key: 7f3c9a1e-2b4d-4c8a-9e61-0d5b7a3f2c10 Content-Type: application/json { "external_user_id": "u-10492", "market_slug": "example-market", "outcome_id": "<outcome id from the feed>", "stake": "25.00", "site": "casino-north" }

A site is 1 to 32 characters: lowercase letters, digits and -. Any other value gets 400 validation_failed on the field site. A request made with a widget token can carry site only if it is the same as the site in the token. A different site, or a site when the token has none, gets the same 400.

The field is optional. A request without it works as before, and a retry of an older request with the same Idempotency-Key still replays. Positions opened without a site show an empty site in reports.

For EveryMatrix and other partners: site is a general field of the API, and it is not specific to one partner. Any integration can send it. To split one EveryMatrix account across its casino brands, send the brand’s site value in each mint or position request.

Add the widget to your page

Once you have a token, this is the whole embed:

<script src="https://test-cdn.predixum.net/widget/v1/widget.iife.js"></script> <div id="prediction-market"></div> <script> const widget = PredictionMarketWidget.mount({ target: document.getElementById('prediction-market'), sessionToken: '{{SESSION_TOKEN}}', apiBaseUrl: '{{API_BASE_URL}}', onSessionExpired: () => { // ask your backend for a new session token, then call widget.setSession(token) }, // optional locale: 'en', scheme: 'dark', theme: { accent: '#7c5cff', }, }); </script>

{{SESSION_TOKEN}} is the token that your server minted for this page load. {{API_BASE_URL}} is the API address from the table on the Getting started page. Test keys and live keys use the same address.

FieldRequiredNotes
targetyesThe element that the widget mounts into.
sessionTokenyesThe per-user token from above. Never your tenant API key.
onSessionExpiredyesCalled when the session can no longer be refreshed. Mint a new token and pass it to setSession().
apiBaseUrlnoThe address of our API: http or https, the host and an optional port, with no path, query or hash. The widget sends every request there. If you leave it out, the widget calls your page’s own origin, which suits a page that proxies /v1 on its own origin. For a direct call, the allowed origins apply.
localenoA BCP 47 tag. See Locale.
schemenolight or dark. It wins over the scheme of your tenant setting. See Theme.
themenoValues for the theme tokens. See Theme.
onLoginRequestwith an anonymous tokenCalled when a visitor who has not logged in presses the log-in button in the trade panel. See Anonymous visitors.
onReady, onDepositRequest, onPositionOpened, onPositionClosednoFunctions the widget calls when something happens inside it. See Callbacks.
routenoThe view the widget opens on: { view: 'list' }, { view: 'market', slug }, { view: 'portfolio' } or { view: 'leaderboard' }. The list when you leave it out. See Routes.
onRouteChangenoCalled with the new route after the player moves between views inside the widget. See Routes.
hostNavigationnotrue when your page offers its own way to the portfolio and the leaderboard. The widget then leaves out its own Portfolio and Leaderboard buttons in the list header. false when you leave it out.

mount() returns a handle with these methods:

MethodWhat it does
unmount()Removes what the widget added to your element, stops its requests and timers, and drops the token from memory. A second call does nothing.
navigate(route)Switches the widget to route, for your page’s back and forward buttons. It does not read the config or the session again, and it does not call onRouteChange. A malformed route throws.
refreshBalance()Reads the player’s balance again. See Callbacks.
refreshConfig()Reads GET /v1/widget/config now instead of at the next timed read, and applies a theme change. It returns a promise that settles when the read is done. See Theme.
applyTheme({ theme, card, scheme })Applies theme values, card options and a scheme on top of your tenant setting, without a new mount. Each call replaces the previous one, so applyTheme({}) returns to the tenant setting. A value the widget cannot accept is skipped.
setTheme(theme)Replaces only the theme values you set at run time. The card options and the scheme from earlier calls stay.
setScheme(scheme)Replaces only the scheme you set at run time, for example when your page switches between light and dark. The theme values and card options from earlier calls stay.
setLocale(locale)Switches the widget to another locale, for example when the player changes the language of your site. It resolves the locale as the locale option does and redraws the view in it. It does not read the config again. While a stake or a sale is being sent, the view stays as it is and the next view uses the new locale.
setSession(token)Gives the widget a new token for the same player, for example from onSessionExpired. A request already on its way finishes with the old token; every later request and the live updates use the new one. A widget on its session-expired screen opens again. On a widget mounted with an anonymous token, a player’s token is the login, and the same widget trades from then on. See Anonymous visitors. It throws on an empty token or a tenant API key, as mount() does, and on an anonymous token after a player’s token.

When the player logs out of your site, call unmount(). When another player logs in, call unmount() and then mount() with that player’s token; setSession() is not for a different player. The one exception is the first login on a widget mounted with an anonymous token. After unmount(), every method does nothing, but navigate() and setSession() still throw on a malformed argument.

Do not paste your tenant API key (the pmk_ key) into sessionToken. The widget checks for that prefix on every mount. If it matches, or if any other option is malformed, mount() writes the reason to the browser console and throws a synchronous Error, and nothing renders. Code after the call stops running unless you wrap the call in a try/catch. This way your key does not reach every visitor of the page.

Callbacks

Five optional callbacks tell your page what the player did in the widget. Leave one out and nothing changes, except onLoginRequest, which a mount with an anonymous token requires. A value that is not a function makes mount() throw, like any other malformed option.

CallbackWhen the widget calls itArgument
onReadyOnce per mount, when the widget first shows loaded data: the market list, a market, the portfolio or the leaderboard. An error screen does not count.none
onLoginRequestA visitor on an anonymous session pressed the log-in button in the trade panel. See Anonymous visitors.none
onDepositRequestThe player pressed Deposit in the widget, under the stake field or in the “Insufficient balance” dialog.{ marketSlug, stake }
onPositionOpenedA position the player confirmed in the widget is open.{ positionId, marketSlug, outcomeId, stake, price }
onPositionClosedA position the player sold from the widget is closed.{ positionId, marketSlug, outcomeId, payout }

Amounts and prices are decimal strings, such as "25.5" and "0.440000", as in the API. payout is what the sale paid.

While the stake the player typed is above their balance, the widget says “Insufficient balance” under the stake field. If the player presses Buy with that stake, or our server refuses the position because the balance moved, the widget opens a dialog over the stake form. The dialog shows the stake and the balance, and its Edit stake button takes the player back to the stake field.

Deposit exists only when you pass onDepositRequest. Then the dialog offers Deposit, and a Deposit button also shows under the “Insufficient balance” line. Without the callback there is no Deposit, and the dialog asks the player to lower the stake. Deposit closes the dialog and calls onDepositRequest. Your page then opens its own deposit screen; no money passes through the widget or through us. Once the player has paid in, report the new balance through balance sync as you always do.

How the widget then shows the new balance depends on where your deposit screen runs:

  • On another page of your site: the widget mounts again when the player comes back, and reads the balance then. Nothing to do.
  • In another tab or window: after Deposit was pressed, the widget reads the balance again whenever your page becomes visible or takes focus.
  • Inside the same page (a modal or an overlay): call widget.refreshBalance() once the deposit is done. The stake form shows the new balance in place and keeps the stake the player typed. With no stake form open, the call does nothing; the next one reads the balance anyway.

Call refreshBalance() after balance sync has accepted the new balance, or the widget reads the old one.

const widget = PredictionMarketWidget.mount({ target: document.getElementById('prediction-market'), sessionToken: '{{SESSION_TOKEN}}', onSessionExpired: () => location.reload(), onReady: () => document.getElementById('widget-spinner').remove(), onDepositRequest: ({ stake }) => openCashier({ suggestedAmount: stake, onDone: () => widget.refreshBalance() }), onPositionOpened: () => refreshHeaderBalance(), onPositionClosed: () => refreshHeaderBalance(), });

openCashier and refreshHeaderBalance stand for your own page’s code. Here openCashier opens a deposit overlay on the same page and calls onDone once the deposit is synced.

The widget calls a callback after it has drawn what the callback reports. If your callback throws, the widget carries on, and it reports only that the callback threw, never your error message. The arguments carry no session token, no user id and no balance.

Use these callbacks for your page, not for your books. The position.opened webhook is the record of a position: we write it in the same transaction as the position and deliver it at least once to your webhook address. If your tenant has no webhook address, we make no delivery, so set one before you rely on it. A callback can be missed, for example when the player closes the tab.

Routes

Your page owns the URL. The widget never reads or writes window.location or the browser history. To keep a widget view in your URL:

  1. Pass the view from your URL as route when you call mount().
  2. In onRouteChange, write the new route into your URL. A market route can also carry title, the market question as the widget drew it, for your document title.
  3. When the player presses back or forward in the browser, call widget.navigate(route) with the route from your URL.

The widget does not call onRouteChange for a route that your page set, for a retry, or for a filter or a search inside the list. A market slug that the feed does not know shows a “market not found” state with a way back to the list. A leaderboard route shows the list when your tenant has the leaderboard turned off.

Allowed origins

The widget calls our API from the browser of your end user. We answer such a call only for the origins on your allowed list. Our team registers the list in the admin panel during onboarding. Tell us every page origin that you embed from, and open a support ticket when the list changes.

An origin is a scheme and a host, with a port when it is not the default. For example: https://shop.example.com or http://localhost:3000.

  • The scheme is http or https.
  • The host is in lower case.
  • There is no path and no trailing slash.
  • Each origin is on the list once.

For a page on an origin that is not on the list, our API sends no CORS headers, and the browser blocks the call. A page that the browser loads from a local file, or from an app-specific scheme such as capacitor://localhost, cannot be registered.

For a native app, load the widget in a WebView that opens a page of yours over https. Register the origin of that page. The widget has no native bridge. If your app must know about an event, your page hears it through a callback and sends it on through your own WebView bridge. The session token follows the same rules as in a browser: when the WebView reloads, the page mints a new token.

Theme

The widget uses a fixed set of theme tokens. You can set a value for each of them. You cannot add a token, and you cannot inject your own CSS.

TokenWhat it sets
background, surface, textThe page, card and text colors.
accentThe accent color of buttons and highlights. The widget derives the text color on an accent button, so the text stays readable.
positive, negativeThe colors for gains and losses.
mutedThe color of secondary text. If you do not set it, the widget uses text at 60%.
card-borderThe border color of cards. If you do not set it, the widget uses text at 14%.
border-widthThe border width, a px length from 0 to 8px. The default is 1px.
radiusThe corner radius of cards and panels.
button-radiusThe corner radius of every button and control. If you do not set it, each control keeps its own default corner, most of them taken from radius.
font-familyA comma-separated list of font family names. Each name may contain letters, digits, spaces, underscores and hyphens, and may be quoted. The whole value has at most 200 characters. Any other punctuation is refused.
font-sizeThe base text size. The default is 14px. Every other text size scales with it. A form field keeps its font at 16 px or more.
spacingThe unit that every gap and padding is a multiple of. The default is 4px. A control keeps its 44 px touch target.

font-size and spacing take a px or rem length, or 0. The widget ships no web font. It uses the system fonts by default, and a font-family value names a font that your page already loads.

Two places set the tokens, and the second wins:

  1. The tenant default. Our team sets it in the admin panel, and you can edit all of the tokens in the card theme editor of the tenant portal. The widget reads it from GET /v1/widget/config before its first paint, and again every 15 minutes while its tab is visible. Call refreshConfig() to read it at once.
  2. The theme option of mount(). It sets the values for one embed only, for example on a dark section of your page. A token that you leave out keeps the tenant default, and then the widget’s own default.

The scheme option follows the same rule. It is not a token, so it has its own option. Two embeds on one page can use two schemes.

Sticky header offset

On a wide market page, the trade panel stays in view while the player scrolls. If your page has its own sticky header, set --pm-sticky-offset in your CSS so the panel stops below that header. Use the header height plus the gap you want:

:root { --pm-sticky-offset: calc(72px + 1rem); }

You can set it on :root or on the element you mount the widget into. It is not a theme token, so the theme option and the tenant default do not carry it. If you do not set it, the offset is 16px at the default spacing.

Bottom bar offset

When the widget is narrower than 992 px, a market page shows the market in one column, and a market with two outcomes shows a trade bar at the bottom of the screen instead of the trade panel. On a phone screen (767 px wide or less) the bar is fixed there, edge to edge, all the way to the end of your page. If your page has a fixed bar of its own along the bottom, such as a tab bar, set --pm-sticky-bottom-offset to its height so the trade bar sits above it:

:root { --pm-sticky-bottom-offset: calc(4rem + env(safe-area-inset-bottom)); }

The same rules apply as for --pm-sticky-offset: set it on :root or on the mount element, and the theme option does not carry it. If you do not set it, the offset is 0. The bar keeps clear of the iPhone home indicator by itself, less what your offset already covers, so include env(safe-area-inset-bottom) in the value when your bar sits over the indicator. If your bar hides on scroll and you change the offset as it goes, the trade bar follows it over 260ms, which you can match to your own animation with --pm-sticky-bottom-transition-duration (for example 200ms), set the same way.

Because the bar stays on the screen past the end of the widget, it covers whatever your page has under the widget, such as a footer. On a market page, at 767 px wide or less, reserve 76px at the bottom of the page for it, on top of your own bottom bar. The widget reserves the same height under its own content. If your offset does not include env(safe-area-inset-bottom), reserve that inset as well.

Locale

The locale option is a BCP 47 tag. The widget resolves the locale in this order: the locale that you request, if your tenant enables it, then the default locale of your tenant, then en. We do not guess a locale from the browser or from the location of the user.

The widget loads the text of the locale from locales/{locale}.js, next to the script, before the first paint. The same locale drives the formatting of dates and numbers. If the text fails to load, the widget shows English text. A locale that is written from right to left, such as ar or he, lays the widget out from right to left.

Our team sets the enabled locales and the default locale of your tenant. The widget reads them from GET /v1/widget/config.

Width and phones

The widget takes the full width of the element you mount it into, and it picks its layout from that width, not from the screen. At 480 px or narrower it switches to its phone layout: 44 px touch targets and a stake field that iOS doesn’t zoom into. So a narrow sidebar on a desktop page gets the phone layout too. It’s checked at 360, 390 and 430 px.

Give the target element a width. Inside something that sizes itself to its content, like an inline-block, a float, or a flex item with no width set, the widget has no width to take and collapses. A plain block <div>, as in the snippet above, is fine.

Troubleshooting

Each entry gives a symptom, its cause and the fix. Open the browser console first. The widget writes the reason for a failed mount there, and the browser reports CORS and Content-Security-Policy blocks there too.

A CORS error in the console, and the widget stays empty

Cause: the origin of your page is not on your allowed list. Our API then sends no CORS headers for that origin, and the browser blocks every response.

Fix: open a support ticket with the exact origin, with its scheme and any port that is not the default. https://shop.example.com and http://shop.example.com are two origins, and so are http://localhost:3000 and http://localhost:5173. See Allowed origins.

If you leave out apiBaseUrl, the widget calls your own origin, and no CORS applies. Your page must then proxy /v1 and the realtime path /connection/websocket to our API.

The widget stays empty in a native app

Cause: the WebView loads the page from a local file or from an app-specific scheme such as capacitor://localhost. We register only http and https origins, so the browser blocks the calls.

Fix: let the WebView open a page of yours over https, and register the origin of that page.

A Content-Security-Policy violation in the console

Cause: your page sends a Content-Security-Policy that does not allow a source the widget uses. A page without a policy needs none of the changes below.

Fix: add these sources to your policy. The widget needs no other source.

DirectiveSource to allow
script-srcThe origin of https://test-cdn.predixum.net. The locale files load from the same path prefix, and this source covers them too.
connect-srcThe origin of apiBaseUrl, for every feed, session, position and config call. Also the WebSocket on the same host with the wss scheme, or ws for an http address: the widget connects to wss://{host}/connection/websocket. Without apiBaseUrl, both are your own origin.
style-src'unsafe-inline'. The widget puts one <style> element in its shadow root, and a policy checks it as it checks any inline style. Without the keyword, the widget draws with no styles. There is no nonce option.
img-srcThe hosts of the market images and of your brand assets. For an image that we host, this is the host of its URL. A blocked image does not break the widget: the card draws without it.

The widget needs no 'unsafe-eval', no blob: or data: source, no frame-src and no font-src. It runs no eval, creates no frame and loads no font file. The widget is not an iframe, so your frame-ancestors and X-Frame-Options settings do not affect it.

The widget shows English text instead of your locale

Cause: the locale file did not load. The widget loads it with import(), which your script-src must allow, and which needs a CORS header from our CDN. The widget then falls back to English and writes one error to the console.

Fix: check that script-src allows the origin of https://test-cdn.predixum.net. If the console shows a CORS error for a file under /widget/v1/locales/, open a support ticket, because the fix is on our CDN.

mount() throws, and nothing renders

Cause: an option failed the check at boot. Common cases are a tenant API key (a pmk_ value) in sessionToken, a callback that is not a function, and an apiBaseUrl with a path, a query or a hash. The widget shows nothing to the end user. This state is widget_config_invalid.

Fix: read the reason in the console, and correct the option. Pass the token that your server minted for this page load.

The widget draws as a narrow strip

Cause: the target element has no width of its own, for example an inline-block, a float, or a flex item with no width set.

Fix: give the target element a width, or use a plain block <div>. See Width and phones.

The player sees “Your session has expired” after some time

Cause: the token expired, and the automatic refresh failed. A refresh answers 401 invalid_session_token when the session is past its 24-hour cap, when the token already expired, or when we suspended your tenant. This state is widget_session_expired.

Fix: in onSessionExpired, mint a new token for the same player on your server and pass it to widget.setSession(token). The widget opens again where the player was. If a different player logs in, call unmount() and then mount() with that player’s token instead. See Refresh and expiry.

Error codes the widget shows

These codes are states of the widget in the browser. Four of them never appear in an HTTP response. The error-code registry lists every code.

CodeWhat the player seesWhat to do
widget_config_invalidNothing. The reason is in the console only.Fix the embed option that the console names.
widget_session_expired“Your session has expired. Please refresh the page.”Mint a new token in onSessionExpired and mount again.
widget_paused“The widget is temporarily unavailable. Please check back shortly.”Nothing. We paused session mint, refresh and the config read. The mint route answers 503 widget_paused to your server meanwhile. Retry after we clear the pause.
realtime_unavailable“Live updates are unavailable right now. Prices may be out of date.”Nothing. The widget used all its reconnect attempts, reads by polling, and keeps trying to reconnect. If it stays, check connect-src for the WebSocket.
widget_runtime_errorDepends on the error.Nothing. The widget reports the error to us.