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, with one method, 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. {{CDN_URL}} stands for the address of the widget CDN. We publish that address when the bundle goes live. 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_sandbox_... Content-Type: application/json { "external_user_id": "u-10492" }

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, at most once per mount. This is the only case that reaches your page. In the callback, mint a new token as in step 1, call unmount() on the old widget and call mount() 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.

Add the widget to your page

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

<script src="{{CDN_URL}}/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, call widget.unmount(), then mount() again }, // 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 address of the environment that you use, from the table on the Getting started page.

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, at most once per mount. Mint a new token and mount again.
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.

mount() returns a handle with one method, unmount(). 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.

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.

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 sends it 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.
radiusThe corner 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 seven 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.
  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.

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.