Embed the Prediction Widget
The Prediction Widget is an iframe-based component that lets you drop the full PropAccount prediction-markets dashboard (live charts, order panel, positions, leaderboard) into any web app — vanilla HTML, React, Next.js, Vue, anything that can render an HTML element. You theme it from the outside, you push an accountId in via postMessage, and it does the rest.
This guide covers the minimum embed, all configurable attributes, the JS bridge, and copy-paste examples for the common frameworks.
Overview
Everything you embed boils down to three things:
- A
<div class="prediction-widget">placeholder withdata-*attributes that describe what to load and how it should look. - A single
<script>tag that loads the widget loader — it scans the page, replaces every placeholder with an<iframe>, and wires up the postMessage bridge. - Optionally, a small bit of glue code that pushes the user's
accountIdinto the widget so it can render account-specific data (balance, equity, positions, PnL).
The widget itself runs inside the iframe so its CSS, JS, and dependencies never collide with your host page. Your host page only ever talks to the iframe through the loader's bridge.
How it works
At DOMContentLoaded the loader script runs:
- Finds every
.prediction-widgetcontainer on the page. - Reads its
data-src,data-settings, anddata-style-overridesattributes. - Creates an
<iframe>inside the container, points it atdata-src. - Stores a reference to the iframe on the container as
container._predictionIframeso your code can find it later. - Once the iframe fires its
loadevent, posts the parsed settings and style overrides into it viawindow.postMessage. - Listens for return messages from the iframe (
prediction:resize,prediction:confirm,prediction:alert,prediction:stateChange) and reacts in the parent page.
Because the loader hangs the iframe off container._predictionIframe, you can address it any time after init — useful for SPAs that mount the widget after navigation.
The widget remembers where the user was. As they navigate (open a market, pick a market/outcome, switch the Buy/Sell and Market/Limit/Modify tabs, filter the list, switch to Positions and its Active/Closed/Pending tab), it (a) saves the route to its own sessionStorage so a host-page refresh returns to the same view, and (b) mirrors a single opaque ?pmw=… query param onto your page URL so the route is directly addressable and shareable — paste that link into a new tab and the widget boots straight into that market. This is automatic; no configuration needed. The loader only ever touches the pmw param and leaves the rest of your URL untouched. Only public navigation (market / category / tab) is encoded — never an account or positions data, which always arrive separately via setAccountId. With multiple widgets on one page, only the first drives the shared URL; every widget still restores on refresh.
Back / Forward. Structural navigations (opening a market, changing category/outcome/tab, switching to Positions) push a browser history entry via history.pushState, so the user can step back and forward through the widget just like page navigation; the loader listens for popstate and re-syncs the widget to match. Search-as-you-type and the sort/frequency filters instead replaceState in place so they don't flood history. Because this shares the browser history with your page, be aware of it if your host app also drives history — the loader only reacts to popstate events that actually change the pmw param, and never disturbs your other params.
Requirements
- A page that can serve over HTTPS (the iframe streams WebSocket data over wss; mixed-content rules block plain HTTP hosts).
- No dependencies on your page — the widget runs entirely inside the iframe. The loader and the account-injection snippet are plain JS; you don't need jQuery or any framework.
- One CSP rule: allow the iframe's host as a frame source (and a script source, if you point at a non-default loader). See CSP / iframe permissions.
Quick start
The minimum embed is two lines — a container and a script:
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT"}'></div>
<script src="https://widgets.example.com/loader.js"></script>
Drop that into any page and you'll see the widget render. With no accountId pushed in, account-specific surfaces (balance, positions, PnL) stay empty — see Setting accountId for the next step.
Replace widgets.example.com with your deployed host. Both the page (/widget.html or /explore.html) and the loader (/loader.js) are served from the same origin, so only the host changes between environments.
The two modes
The widget ships two iframe entry points. Pick one by changing data-src.
1. Dashboard mode — for logged-in users
data-src="…/widget.html" renders the full trading dashboard: balance/equity strip, order panel, live charts, positions table, leaderboard. Requires an accountId to be pushed in (see Setting accountId) — otherwise account-specific rows render empty.
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT","customizer":false}'></div>
2. Explore mode — public, no account required
data-src="…/explore.html" renders the public markets browser — live events, charts, prices — with no account-aware features (balance, positions). The order panel stays interactive so visitors can explore outcomes, pricing, and payouts; the moment they click Place Order, it swaps to a Purchase Challenge call-to-action that navigates the host page to your sign-up / funding page (defaults to /challenges, override with explore_redirect_to — see Settings JSON). Perfect for landing pages, marketing sites, or any context where the user isn't logged in yet.
<div class="prediction-widget"
data-src="https://widgets.example.com/explore.html"
data-settings='{"business":"PMKT"}'></div>
You can render both on the same page — say, an explore widget on the homepage and a dashboard widget on the trading page. Each is independent.
Container attributes
Everything you configure lives on the <div class="prediction-widget"> as a data-* attribute. All JSON-valued attributes must be valid JSON — single quotes around the attribute, double quotes inside is the easiest pattern (and what the examples here use).
| Attribute | Required | Type | Purpose |
|---|---|---|---|
class="prediction-widget" |
Yes | Class | The loader looks for this selector. Add other classes freely. |
data-src |
Yes | URL | The iframe target — pick dashboard or explore mode (see above). |
data-settings |
Yes | JSON | Per-widget configuration. At minimum business. See Settings JSON. |
data-style-overrides |
No | JSON | Raw --pm-* CSS variable overrides — the way to theme the widget. See Style overrides. |
data-color-scheme |
No | light | dark | auto |
Pins the iframe's color scheme. Defaults to light (omit the attribute) so a transparent --pm-bg stays transparent even on host pages that set color-scheme: dark. Use dark for a dark-themed widget, or auto to inherit the host page. |
Invalid JSON in any of the JSON-valued attributes is logged as a warning in the browser console ([Prediction Widget] Invalid JSON in data-… attribute) and the widget proceeds with empty defaults for that attribute — it won't crash the page.
Settings JSON (data-settings)
Per-widget configuration. Posted into the iframe at startup.
| Key | Type | Default | Description |
|---|---|---|---|
business |
string | — | Your prop firm's business identifier issued by PropAccount (e.g. "PMKT"). Routes the widget to your tenant's data and branding. Required. |
customizer |
boolean | false |
Shows the in-iframe runtime Customizer panel — a floating launcher that lets a designer tweak every --pm-* variable live and copy the result back as JSON. Use during theming, leave off in production. |
explore_redirect_to |
string | /challenges |
The path the Purchase Challenge call-to-action sends the visitor to. The loader navigates the host page (the page embedding the widget) to this path, so it's a route on your own site — e.g. "/funding". The CTA appears whenever there's no active account: explore mode, and dashboard mode before an accountId is pushed. |
Example
{
"business": "PMKT",
"customizer": false
}
Theming
The widget is themed entirely through data-style-overrides — a flat map of --pm-* CSS variables. There is no separate high-level palette attribute; every surface (backgrounds, text, buttons, borders, hover states) is a variable you can set directly.
The easiest way to build a theme is the admin builder: it lists every --pm-* variable grouped by surface, gives you live preview, optional AI suggestions, and generates the exact data-style-overrides JSON to paste here.
Style overrides (data-style-overrides)
Raw CSS-variable overrides. The widget exposes a wide catalog of --pm-* variables — backgrounds, borders, text colors, padding, border-radius — for every surface. Any variable you pass here is applied as the highest-priority override inside the iframe, winning over the plugin's stylesheet defaults without rebuilding any CSS.
Shape
A flat JSON object — keys are CSS variable names ("--pm-…"), values are CSS values:
{
"--pm-positions-pager-bg": "#FFFFFF00",
"--pm-positions-pager-text": "#48FFCE",
"--pm-positions-pager-text-active": "#000000",
"--pm-positions-pager-text-hover": "#000000",
"--pm-crypto-sidebar-border-color": "#131d19",
"--pm-live-chart-toggle-border-color": "#131d19",
"--pm-live-chart-toggle-border-width": "2px",
"--pm-live-chart-toggle-btn-bg-active": "#112725",
"--pm-live-chart-toggle-btn-text": "#7f8583",
"--pm-live-go-btn-bg": "#1a1e19",
"--pm-live-go-btn-bg-hover": "#0e231f",
"--pm-live-go-btn-text": "#ffffff",
"--pm-live-price-box-border-color": "#131d19",
"--pm-live-price-box-border-width": "2px"
}
Variables catalog
Variables follow a predictable --pm-{surface}-{property} pattern. The categories you'll touch most:
| Prefix | Surface | Common properties |
|---|---|---|
--pm-card-* | Event cards & generic cards | bg, border-color, border-width, border-radius |
--pm-text / --pm-text-secondary | Body / muted text | (value only) |
--pm-surface | Subtle elevated surface (drawer, tooltip) | (value only) |
--pm-border | Default border color | (value only) |
--pm-money-card-* | Balance / Equity strip | bg, border-*, label-color, value-color |
--pm-active-positions-btn-* | Top "Positions" button | bg, text, border-*, *-hover |
--pm-positions-tabs-* | Positions tab bar container (Active / Closed) | bg, border-*, padding, border-radius |
--pm-positions-tab-* | Positions tab buttons | bg, text, bg-hover, text-hover, bg-active, text-active, padding-x, padding-y, border-radius, font-size, font-weight |
--pm-positions-pager-* | Positions table pagination | bg, text, text-active, text-hover |
--pm-live-price-box-* | Live crypto price tiles | bg, border-*, border-radius |
--pm-live-chart-toggle-* | Chances ↔ Price chart toggle | bg, border-*, btn-bg, btn-bg-active, btn-text, btn-text-active |
--pm-live-go-btn-* | "Go to live market" call-to-action | bg, bg-hover, text, border-*, border-radius |
--pm-crypto-sidebar-* | Crypto vertical sidebar (desktop) | bg, border-*, item-bg, item-bg-hover, item-bg-active, item-text, item-text-hover, item-text-active, icon-color, icon-color-active |
--pm-trade-btn-* | Buy / Sell trade button | bg, text, border-*, radius |
--pm-filter-btn-* | Top filter pills | bg, text, bg-hover, text-hover, bg-active, text-active |
--pm-order-* | Order panel surfaces | bg, border-*, label-color, value-color |
Set "customizer": true in data-settings and reload — a floating launcher opens an in-iframe panel that lists every discovered --pm-* variable grouped by surface. Edit live, then click "Copy as JSON" and paste the result into data-style-overrides. This is the fastest way to find the variable that controls the surface you want to retheme without grepping CSS.
PMWidgetFrame API
The loader exposes a small global, PMWidgetFrame. Use its methods any time after the loader script has run.
PMWidgetFrame.setAccountId(accountId)
Pushes the user's account login into every widget on the page. The dashboard mode renders nothing account-specific until this is called.
PMWidgetFrame.setAccountId('1234567');
Safe to call multiple times — re-issues the message to every widget. Safe to call before iframe load (the message will queue once the iframe is ready); but the conservative pattern is to also call it from the iframe's load handler. See Direct injection for the full pattern.
PMWidgetFrame.setStyleOverrides(overrides)
Runtime equivalent of data-style-overrides. Apply or change --pm-* variables live without remounting:
PMWidgetFrame.setStyleOverrides({
'--pm-card-bg': '#0b0f17',
'--pm-card-border-color': '#1f2937'
});
Useful for dark/light mode toggles, A/B branding tests, or any scenario where the host page's color scheme changes after first paint.
PMWidgetFrame.setMarketPlatform(platform)
Switches the widget's market-data platform at runtime. Accepts 'PM' (Polymarket) or 'KL' (Kalshi); any other value is ignored.
PMWidgetFrame.setMarketPlatform('KL'); // or 'PM'
Unlike setAccountId and setStyleOverrides — which update the running widget in place — the platform is wired at boot (menu, live-data WebSocket, events list, request routing). So this method reloads the iframe with the new platform to re-run that boot path cleanly. That means it drops in-iframe UI state (selected event, order-panel input) and reconnects the live feed. It no-ops when the widget is already on the requested platform, so calling setMarketPlatform('PM') while already on Polymarket does nothing.
PMWidgetFrame.onTradeValidation(handler) — Polymarket US
Decide, on each Place Order click, whether the current user may trade — balance, a block flag,
KYC state, any rule of yours. Register a handler; when the user clicks, the widget asks it and waits
(the button shows "Checking…"), then places the order only if your verdict allows it. The handler can be
async — do a fetch for a fresh balance and return the result. This is a Polymarket US
(ISV) feature; it's ignored on Polymarket and Kalshi.
PMWidgetFrame.onTradeValidation(async (ctx) => {
// ctx = { side, amount, accountId, business, externalId, eventSlug, marketId, outcome }
const { balance } = await fetch('/api/me/wallet').then(r => r.json());
if (balance > 0) {
return { allow_to_trade: true }; // → order proceeds
}
return { // → order blocked, notice shown
allow_to_trade: false,
message: 'Your balance is $0. Please add credits.',
button_label: 'Add credit +', // optional
button_url: '/wallet/add' // optional — needs button_label too
};
});
The handler returns a verdict object (or a Promise of one). Returning true is shorthand for
{ allow_to_trade: true }. Fields:
| Field | Type | Meaning |
|---|---|---|
allow_to_trade | boolean | Required. true lets the order through. Anything else blocks it. |
message | string | Shown under Place Order when blocked. Falls back to a generic "not able to trade right now" line when empty. |
button_label | string | Optional. Label for a redirect button under the message. |
button_url | string | Optional. Where the button sends the user (via the same host-navigation channel as the explore CTA). Both label and url must be set for the button to appear. |
camelCase keys (allowToTrade, buttonLabel, buttonUrl) are accepted too.
Pass null to unregister.
If your handler throws or doesn't answer within ~20 s, the widget blocks the order with a
"couldn't verify — try again" message (overridable via pu-trade-validation-error-message). The intent to
gate is explicit, so a non-answer must never let a trade through. Keep the handler fast; do the heavy check in
your own backend if needed.
PMWidgetFrame.setTradeValidation(validation) — proactive alternative
Prefer onTradeValidation above. Use setTradeValidation instead when you'd rather
push the verdict ahead of time (no per-click round-trip) — the widget reads the latest pushed value on
the next click. Same verdict shape; it's a live push like setAccountId, so re-push whenever the user's
state changes. Pass null/{} to clear. If you register an onTradeValidation
handler, it takes precedence and this pushed value is only the fallback.
PMWidgetFrame.setTradeValidation({ allow_to_trade: true });
PMWidgetFrame.setTradeValidation({
allow_to_trade: false,
message: 'Your balance is $0. Please add credits.',
button_label: 'Add credit +',
button_url: '/wallet/add'
});
A business can be configured (admin builder → ISV integration → Require parent trade validation)
to fail closed when you use the push model: with it on, a Place Order made before you've pushed
any verdict is blocked rather than allowed. (With an onTradeValidation handler registered every
click already gets a verdict, so this is moot.) Leave it off and an un-pushed verdict simply lets trading proceed — the
gate then only acts on an explicit allow_to_trade: false. Either way this is a client-side UX gate:
keep your real authorization on the server (the per-order pu-trade-limit is enforced there).
postMessage protocol
Internally, host ↔ iframe uses window.postMessage. You normally don't touch this directly — PMWidgetFrame wraps it — but knowing the surface is useful for debugging and for advanced integrations.
Host → Widget
| Message | Payload | Effect |
|---|---|---|
prediction:setAccountId | { accountId: string } | Switches the active account; refetches balance, positions, etc. |
prediction:settings | { settings: {…} } | Sent once on init from data-settings. Carries business, customizer, etc. |
prediction:styleOverrides | { overrides: { "--pm-…": "…" } } | Applies a batch of CSS variable overrides. Replays on every call. |
prediction:setTradeValidation | { validation: {…} | null } | Polymarket US only. The parent's proactively-pushed "can this user trade?" verdict. Wrapped by setTradeValidation. |
prediction:tradeValidatorReady | { ready: boolean } | Polymarket US only. Announces that an onTradeValidation handler is (un)registered, so Place Order (does not) route through the request/response flow. |
prediction:tradeValidationResult | { requestId, validation, error } | Polymarket US only. The host's answer to a requestTradeValidation (below), keyed by requestId. Posted by the loader after running onTradeValidation. |
prediction:applyRoute | { pmw: string } | Loader-internal. Sent on host Back/Forward (popstate) so the widget navigates to the URL's route. You never send this yourself. |
There is no prediction:setMarketPlatform message — setMarketPlatform reloads the iframe with a market_platform query param rather than posting a message, because the platform is resolved at boot. See the method above.
Widget → Host
| Message | Payload | Handled by |
|---|---|---|
prediction:resize | { height: number } | Loader resizes the container + iframe. |
prediction:platform | { platform: 'PM' | 'KL' } | Loader records the frame's resolved platform so setMarketPlatform can no-op when unchanged. |
prediction:confirm | { title, message, details, requestId, … } | Loader opens a centered modal in the parent viewport. Reply via prediction:confirmResult. |
prediction:alert | { title, message, confirmLabel } | Loader opens a single-OK alert modal in the parent viewport. |
prediction:requestTradeValidation | { requestId, context: {…} } | Polymarket US only. Sent on a Place Order click when an onTradeValidation handler is registered. Loader runs the handler and replies with prediction:tradeValidationResult. |
prediction:stateChange | { pmw: string, replace: boolean } | The user navigated. Loader mirrors the opaque pmw token into a single ?pmw= param on the parent URL — pushState for a navigation (Back/Forward steps through it), or replaceState when replace is set (search/filter refinements). Empty string clears it. |
Outgoing messages are origin-checked against the iframe's data-src. If you load the widget from widgets.example.com but receive a message claiming to come from somewhere else, the loader drops it. This is enforced inside the loader and doesn't need any configuration on your side.
Setting accountId
In dashboard mode, the widget needs to know which trading account it belongs to. You push it in with PMWidgetFrame.setAccountId() (defined by the loader). It works with any host stack — vanilla, React, Next, your own auth — because you supply the id; there are no required globals or cookies.
Direct injection
Once you know the account (from your auth, your API, wherever), push it. The call is safe any time after the loader <script> tag has run — the loader queues the message and delivers it as soon as the iframe is ready.
<script>
// Push the account once the loader has parsed the page.
document.addEventListener('DOMContentLoaded', () => {
const accountId = getMyAccountId(); // from your auth
if (accountId) PMWidgetFrame.setAccountId(accountId);
});
</script>
Call it again whenever the active account changes — it re-issues the message to every widget on the page. For belt-and-suspenders coverage (the iframe could reload independently — back/forward cache, anchor change), also re-push from the iframe's load event:
const accountId = '1234567';
document.querySelectorAll('.prediction-widget').forEach((container) => {
const iframe = container._predictionIframe;
if (!iframe) return;
PMWidgetFrame.setAccountId(accountId);
iframe.addEventListener('load', () => PMWidgetFrame.setAccountId(accountId));
});
Polymarket US (ISV) — overview & model
The widget can source markets from more than one platform. Setting
market-platform: "PU" switches it to Polymarket US, which
runs on an Independent Software Vendor (ISV) model: you (the agency)
hold a Polymarket US partner account, your end-users trade through the widget under that
account, and you keep your own wallet/balance ledger. To your users the whole thing is
transparent — they never see Polymarket, only your branded widget.
The pieces you own vs. what the widget handles:
- You onboard each user (they complete KYC once, in the widget), store the resulting Polymarket account id against your own user record, and run their wallet balance.
- The widget renders navigation + charts, collects the order, sends it to Polymarket under your partner account, shows the user the result, and relays every KYC and trade event to your server via webhook so you can record the trade and move the user's balance.
On PU the widget does not display balance/equity (your app owns that), and
each order is capped by a per-order limit you pass in
data-settings. Instead of the "Purchase Challenge" call-to-action, an
account-less user sees the onboarding (KYC) flow.
Building the two agency placements (a public page with a login-redirect CTA + an in-app "Prediction" tab with KYC and the account handoff)? Follow the copy-paste Agency Install guide → — the sections below are the underlying reference.
Onboarding (KYC)
A PU user can only trade once they have a Polymarket account (a participantId).
The widget drives that onboarding for you:
- Embed in dashboard mode with
market-platform: "PU"and noaccountId(the user isn't onboarded yet). Pass your own user id aspu-external-id— it's the correlation key. - When the user goes to trade, the widget shows a KYC form (name, address, SSN, DOB, …). On submit the widget calls our backend, which starts verification with Polymarket.
- On approval the widget adopts the returned
participantIdas the active account and posts apuAccountOnboardedmessage to your page. It also fires akyc.approvedwebhook to your server. - You store
participantIdagainst your user. On the user's next visit, embed withaccountId: "<participantId>"so they skip onboarding and trade straight away.
Verification can be instant or take a moment (document review / manual). The widget polls
status and completes when the participantId lands; the authoritative signal for
your server is always the kyc.approved webhook
(it carries both participantId and your externalId).
Listen for puAccountOnboarded
The widget emits this to the host page the moment onboarding completes, so a single-page app can store the id and re-point the embed without a reload:
window.addEventListener('message', (event) => {
const d = event.data;
if (!d || d.source !== 'prediction-spa' || d.type !== 'puAccountOnboarded') return;
// { externalId: your user id, participantId: the Polymarket account id }
saveParticipantId(d.detail.externalId, d.detail.participantId);
});
This client message is convenient for updating the UI, but treat the
kyc.approved webhook as the source of truth for persisting the
link (it's signed and can't be spoofed by the browser).
Embed settings for PU
PU-specific keys on data-settings (kebab or snake accepted):
| Key | Type | Description |
|---|---|---|
market-platform | string | "PU" selects Polymarket US. |
business | string | Your agency identifier (drives webhook config + limits). |
pu-external-id | string | Your own user id — becomes our KYC externalId and the correlation key across webhooks. |
accountId | string | The onboarded participantId. Omit to trigger onboarding; set it once you've stored it. |
pu-trade-limit | number | Max USD a single order may cost — the user's current available balance/allowance as you compute it. Enforced server-side. Refresh it (re-embed) as the balance moves. |
accountless-cta | string | "kyc" (in-widget onboarding) or "redirect" (a button to a Sign-up / Login / funding page). Default on PU is "kyc". |
pu-onboard-message | string | Overrides the intro text above the KYC form. |
pu-trade-blocked-message | string | Overrides the default message shown when a trade is blocked by trade validation and the verdict carried no message. |
pu-trade-validation-error-message | string | Overrides the message shown when an onTradeValidation handler times out or throws (the widget fails closed). |
explore_redirect_to / explore_cta_label / explore_cta_message | string | When accountless-cta:"redirect", the target path, button label, and supporting line of the redirect CTA. |
Example — PU onboarding embed
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"market-platform":"PU","business":"ACME","pu-external-id":"user_8821","pu-trade-limit":250}'></div>
<script src="https://widgets.example.com/loader.js"></script>
Gating a trade on the user's balance (or any rule)
The per-order pu-trade-limit caps a single order and is enforced on the server. For a richer
check at click time — "does this user have any balance?", "are they blocked?", "did they accept the
latest terms?" — register PMWidgetFrame.onTradeValidation(). When the
user clicks Place Order, the widget calls your handler (which may fetch) and waits
for the verdict before doing anything: allowed → the order is placed; blocked → your message (and an optional
button) is shown right under the button and no order is sent.
// Register once, after the loader script has run.
PMWidgetFrame.onTradeValidation(async (ctx) => {
const { balance } = await fetch('/api/me/wallet').then(r => r.json());
if (balance > 0) return { allow_to_trade: true };
return {
allow_to_trade: false,
message: 'Your balance is $0. Add credits to place a trade.',
button_label: 'Add credit +',
button_url: '/wallet/add'
};
});
Full field reference, the request/response timing, and the fail-closed Require parent trade validation option
(for the proactive setTradeValidation push alternative) are in the
onTradeValidation section above.
Webhooks
So your app can record trades and move balances, we POST every KYC and trade event to an HTTPS endpoint you host. The endpoint URL and a signing secret are configured per business by your operator in the admin builder (the ISV integration panel) — you don't set them in the embed.
Configure a base URL plus optional per-category overrides —
kyc, order, position (the category is the event
type prefix). A category with no URL of its own inherits the base; likewise its
secret. So you can send KYC events to one server and trade events to another, or point
everything at one base URL. Either way, branch on the type field (or the
X-PredictionSpa-Event header) within each endpoint.
Failed deliveries are retried automatically with backoff (default 4 attempts
over ~1h) before being marked exhausted; an operator can then re-drive them. So a
brief outage on your side won't drop events — but you must dedupe on the
event id, since a delivery can repeat. Each retry is re-signed with a
fresh X-PredictionSpa-Timestamp — verify against that header, not
the envelope created.
Request envelope
Every delivery is a JSON POST with this shape:
{
"id": "3f1c…", // unique event id (idempotency key)
"type": "order.accepted", // what happened — switch on this
"created": "2026-08-11T12:01:00Z",
"business": "ACME",
"data": { /* event-specific — see below */ }
}
Headers
| Header | Purpose |
|---|---|
X-PredictionSpa-Event | Event type (same as type). |
X-PredictionSpa-Id | Event id (same as id) — dedupe on this. |
X-PredictionSpa-Timestamp | Delivery time in ms — part of the signed string. |
X-PredictionSpa-Signature | sha256=<hex> HMAC of the payload — see Verifying signatures. |
Deliveries are best-effort and don't block the user's trade — respond 2xx quickly
and do heavy work async. Events carry a unique id; dedupe on it
(a delivery may repeat). Order the effects by created, not arrival. During
integration you can turn on demo mode (ask your operator) to receive realistic
fake-data events with no real trading.
Webhook events & payloads
| Type | Fires when | Key data fields |
|---|---|---|
kyc.started | Onboarding submitted | externalId, status |
kyc.status_changed | Verification moved to a non-terminal state | externalId, status |
kyc.approved | Account provisioned | externalId, participantId, status |
kyc.rejected | Verification declined | externalId, status |
order.placed | Order intent received (before upstream) | account, symbol, side, notional, price |
order.accepted | Accepted by Polymarket | + orderId |
order.filled | Filled | account, symbol, side, qty, price, orderId |
order.rejected | Rejected (incl. over-limit) | account, symbol, side, error |
position.updated | Net position changed after a fill | account, symbol, netPosition |
account is the user's participantId; join it to your user via the
externalId you received in kyc.approved.
Sample — kyc.approved
{
"type": "kyc.approved", "business": "ACME",
"data": { "externalId": "user_8821", "participantId": "acct_123", "status": "APPROVED" }
}
Sample — order.filled + position.updated
{ "type": "order.filled", "business": "ACME",
"data": { "account": "acct_123", "symbol": "usse-midterms-2026-11-03-dem",
"side": "SIDE_BUY", "qty": 52, "price": 0.48, "orderId": "ord_789" } }
{ "type": "position.updated", "business": "ACME",
"data": { "account": "acct_123", "symbol": "usse-midterms-2026-11-03-dem", "netPosition": 52 } }
Verifying webhook signatures
Verify every delivery before trusting it. We sign the raw request body with your
business's shared secret using HMAC-SHA256 over the string
<timestamp>.<rawBody>, where <timestamp> is the value in
X-PredictionSpa-Timestamp. The result is sent as
X-PredictionSpa-Signature: sha256=<hex>. (The timestamp is inside the signed
string to prevent replay — reject deliveries whose timestamp is too old.)
Compute the HMAC over the exact bytes we sent — verify before JSON-parsing (or from a preserved raw copy). Re-serializing the parsed object can change bytes and break the signature.
const crypto = require('crypto');
// Mount with the raw body preserved, e.g. express.raw({ type: 'application/json' })
app.post('/hooks/prediction', (req, res) => {
const raw = req.body; // Buffer (raw bytes)
const ts = req.get('X-PredictionSpa-Timestamp');
const sig = req.get('X-PredictionSpa-Signature') || '';
// Freshness — reject stale/replayed deliveries (5 min window).
if (Math.abs(Date.now() - Number(ts)) > 300000) return res.sendStatus(400);
const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(ts + '.' + raw).digest('hex');
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const evt = JSON.parse(raw.toString('utf8'));
res.sendStatus(200); // ack fast…
handleEvent(evt); // …then process by evt.type
});
Vanilla JS / HTML
End-to-end working example you can paste into a standalone .html file:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>My App</title>
</head>
<body>
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT","customizer":false}'
data-style-overrides='{"--pm-card-bg":"#0b0f17","--pm-text":"#e6edf3"}'></div>
<script src="https://widgets.example.com/loader.js"></script>
<script>
// Push the account once the loader has parsed the page.
document.addEventListener('DOMContentLoaded', () => {
const accountId = getMyAccountId(); // from your auth
if (accountId) PMWidgetFrame.setAccountId(accountId);
});
</script>
</body>
</html>
React
Two patterns: (1) a script tag in index.html + a thin React component that renders the placeholder, or (2) dynamic injection from inside the component. Pattern 1 is simpler and works for ~all cases. Pattern 2 is for SPAs that need to mount/unmount the widget on route changes.
Pattern 1 — script tag in index.html
<!-- public/index.html (CRA / Vite) -->
<body>
<div id="root"></div>
<script src="https://widgets.example.com/loader.js"></script>
</body>
import { useEffect, useRef } from 'react';
export function PredictionWidget({
src = 'https://widgets.example.com/widget.html',
settings = { business: 'PMKT' },
styleOverrides,
accountId,
}) {
const ref = useRef(null);
// Push accountId whenever it changes. PMWidgetFrame is a global
// exposed by the loader script in index.html.
useEffect(() => {
if (!accountId || typeof window.PMWidgetFrame === 'undefined') return;
window.PMWidgetFrame.setAccountId(accountId);
// Also re-push on iframe load — covers full reloads of the iframe
// and React StrictMode double-mounting.
const iframe = ref.current?._predictionIframe;
if (!iframe) return;
const push = () => window.PMWidgetFrame.setAccountId(accountId);
iframe.addEventListener('load', push);
return () => iframe.removeEventListener('load', push);
}, [accountId]);
// Push style overrides whenever they change.
useEffect(() => {
if (!styleOverrides || typeof window.PMWidgetFrame === 'undefined') return;
window.PMWidgetFrame.setStyleOverrides(styleOverrides);
}, [styleOverrides]);
return (
<div
ref={ref}
className="prediction-widget"
data-src={src}
data-settings={JSON.stringify(settings)}
data-style-overrides={styleOverrides && JSON.stringify(styleOverrides)}
/>
);
}
<PredictionWidget
accountId={user.accountLogin}
settings={{ business: 'PMKT', customizer: false }}
styleOverrides={{ '--pm-card-bg': '#0b0f17' }}
/>
Pattern 2 — inject the loader from inside the component
Useful if you don't control index.html (libraries, embeds, multi-tenant apps where the loader URL varies):
import { useEffect } from 'react';
const LOADER_SRC =
'https://widgets.example.com/loader.js';
// Cached promise so the script is only injected once per page.
let loaderPromise = null;
function loadLoader() {
if (window.PMWidgetFrame) return Promise.resolve();
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = LOADER_SRC;
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
return loaderPromise;
}
export function PredictionWidget(props) {
useEffect(() => { loadLoader(); }, []);
// …same JSX + effects as Pattern 1…
}
Next.js
The widget renders client-side (it's an iframe + a postMessage bridge). In Next this means: (1) load the loader script with next/script in strategy="afterInteractive", and (2) mark the component 'use client'. Works the same in the App Router and Pages Router.
App Router (Next 13+)
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://widgets.example.com/loader.js"
strategy="afterInteractive"
/>
</body>
</html>
);
}
'use client';
import { useEffect, useRef } from 'react';
declare global {
interface Window {
PMWidgetFrame?: {
setAccountId(id: string): void;
setStyleOverrides(o: Record<string, string>): void;
setMarketPlatform(platform: 'PM' | 'KL' | 'PU'): void;
// TradeVerdict = { allow_to_trade: boolean; message?: string; button_label?: string; button_url?: string }
onTradeValidation(h: ((ctx: any) => TradeVerdict | boolean | Promise<TradeVerdict | boolean>) | null): void;
setTradeValidation(v: TradeVerdict | null): void;
};
}
}
type Props = {
accountId?: string;
src?: string;
settings?: Record<string, unknown>;
styleOverrides?: Record<string, string>;
};
export function PredictionWidget({
accountId,
src = 'https://widgets.example.com/widget.html',
settings = { business: 'PMKT' },
styleOverrides,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!accountId) return;
const push = () => window.PMWidgetFrame?.setAccountId(accountId);
// Wait for the loader to be ready (Next loads it afterInteractive).
const iv = setInterval(() => {
if (window.PMWidgetFrame) {
push();
clearInterval(iv);
const iframe = (ref.current as any)?._predictionIframe;
if (iframe) iframe.addEventListener('load', push);
}
}, 50);
return () => clearInterval(iv);
}, [accountId]);
return (
<div
ref={ref}
className="prediction-widget"
data-src={src}
data-settings={JSON.stringify(settings)}
data-style-overrides={styleOverrides && JSON.stringify(styleOverrides)}
/>
);
}
Pages Router
Same component file. Mount it inside a page; use next/script with the same afterInteractive strategy in pages/_app.tsx:
import Script from 'next/script';
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<Script
src="https://widgets.example.com/loader.js"
strategy="afterInteractive"
/>
</>
);
}
The widget never renders during SSR — it's an iframe initialized from a client-side script. Don't try to useEffect against window.PMWidgetFrame until after hydration. The setInterval polling pattern in the example above is the safest portable approach across Next versions.
Auto-resize
The iframe measures its own content height and posts prediction:resize to the parent whenever the layout changes — switching between list and event detail, opening positions, mobile breakpoint flips. The loader resizes both the container and the iframe to match, so the widget never scrolls inside itself.
You don't need to do anything to opt in. If you need the widget to occupy a specific minimum height (e.g. above-the-fold layout), set CSS on the container:
.prediction-widget {
min-height: 600px;
}
The loader's resize messages will then grow the container beyond min-height as the inner content demands.
Modal forwarding
Some flows (e.g. close-position confirmation, hard error alerts) need a modal centered in the user's viewport — but a modal rendered inside the iframe can be clipped by the host page if the iframe is short. To avoid this, the widget asks the host to render the modal on its behalf via prediction:confirm and prediction:alert messages. The loader ships a self-contained, dependency-free modal that:
- Renders centered in the parent viewport.
- Uses inline styles so it's never broken by the host page's CSS.
- Supports keyboard (Enter confirms, Esc cancels).
- Routes the user's choice back to the iframe via
prediction:confirmResult.
No configuration on your side — it just works. If you want to override the modal with your own design system, you can capture the messages in your own window.addEventListener('message', …) handler and call event.stopImmediatePropagation() before the loader's handler runs (rare; mention it here for completeness).
Runtime theme updates
Use PMWidgetFrame.setStyleOverrides(…) to change --pm-* variables live. The iframe applies them as an injected <style> block — no reload, no remount.
// Example: flip to a light variant when the host app's theme toggle fires
document.querySelector('#theme-toggle').addEventListener('change', (e) => {
const isLight = e.target.checked;
PMWidgetFrame.setStyleOverrides(isLight
? { '--pm-card-bg': '#ffffff', '--pm-text': '#0b0f17' }
: { '--pm-card-bg': '#0b0f17', '--pm-text': '#e6edf3' }
);
});
Multiple widgets on one page
The loader scans every .prediction-widget container, so you can drop as many as you want on the same page. PMWidgetFrame.setAccountId(…) and .setStyleOverrides(…) address all of them at once. If you need to target a single instance, post directly to its iframe:
const container = document.getElementById('my-widget');
const iframe = container._predictionIframe;
iframe.contentWindow.postMessage({
type: 'prediction:setAccountId',
accountId: '1234567'
}, '*');
CSP / iframe permissions
If your host page sets a Content Security Policy, you need to allow the widget host as a frame source and a script source. Replace the host with whatever environment you've been issued.
Content-Security-Policy:
default-src 'self';
script-src 'self' https://widgets.example.com;
frame-src https://widgets.example.com;
connect-src 'self' https://widgets.example.com wss://widgets.example.com;
frame-src: allows the iframe to load.script-src: allows the loader script to execute.connect-srcwithwss:: the widget streams live prices over WebSocket. Without this, charts won't tick.
If your host page sets X-Frame-Options or Content-Security-Policy: frame-ancestors, those only constrain what can frame your page — they don't affect what your page can embed.
Common pitfalls
-
JSON in
data-*attributes — use single quotes around the attribute value and double quotes inside the JSON, exactly as in every example here. Don't escape inner double quotes; if the JSON renders in the browser inspector as readable JSON, it's right. -
data-style-overridesdoesn't seem to do anything — open the iframe's devtools (right-click → inspect inside the widget) and check the<style>block injected at the top of<head>. If your variables are there but nothing changed visually, the rule consuming the variable might already be more-specific elsewhere; switch the Customizer panel on ("customizer": trueindata-settings) to confirm which variable controls the surface. -
Dashboard mode looks like explore mode (no balance, no positions) — you haven't pushed an
accountIdyet. Until one arrives, dashboard mode intentionally mirrors explore: it hides the Balance/Equity toolbar and Positions tab, shows the full public markets list, and routes Place Order to the Purchase Challenge CTA. Push anaccountId(PMWidgetFrame.setAccountId(...)) and the account-aware UI and real trading appear automatically. -
PMWidgetFrame is not defined— the loader script hasn't executed yet. In Next.js this means it ran before your component mounted (the polling pattern in the recipe handles it). In plain HTML, make sure the loader's<script>tag is before any code that usesPMWidgetFrame. -
Theme changes do nothing — theming is via
data-style-overrides(the--pm-*variables), not a high-level palette attribute. A straydata-themeis ignored. Use the style overrides (or the admin builder) to restyle surfaces.
FAQ
Can I host the loader script myself instead of pointing at the widget host?
Yes — the loader is self-contained and has no runtime dependencies. Copy loader.js to your CDN and point the <script src> at it. The iframe itself (the data-src page) still has to come from the widget host, since that's where the widget actually runs.
Can I render multiple businesses on one page?
Yes — each widget's data-settings carries its own business. Two widgets on the same page can target two different tenants.
How do I get a list of all --pm-* variables?
Set "customizer": true in data-settings. A floating launcher appears in the bottom corner of the iframe; opening it discovers every variable declared in the widget's stylesheet, grouped by surface. Click "Copy as JSON" to grab your edits in the right shape for data-style-overrides.
Does the widget work on mobile?
Yes — the layout switches to a mobile-optimized view below ~1024px, including a full-screen order panel on small screens. No configuration needed.
What about TypeScript types?
The loader exposes a global PMWidgetFrame. Declare it in your project once:
// global.d.ts
declare global {
interface Window {
PMWidgetFrame?: {
setAccountId(id: string): void;
setStyleOverrides(o: Record<string, string>): void;
setMarketPlatform(platform: 'PM' | 'KL' | 'PU'): void;
// TradeVerdict = { allow_to_trade: boolean; message?: string; button_label?: string; button_url?: string }
onTradeValidation(h: ((ctx: any) => TradeVerdict | boolean | Promise<TradeVerdict | boolean>) | null): void;
setTradeValidation(v: TradeVerdict | null): void;
};
}
}
export {};
How do I unmount the widget cleanly in a SPA?
Removing the container from the DOM is sufficient — the loader doesn't attach any global listeners that survive container removal (other than the page-level message listener, which is harmless and idempotent). React's unmount is enough; no manual cleanup required.