Install the PU ISV widget on your web app
A step-by-step guide for embedding the Polymarket US (ISV) prediction widget. There are two placements: a public page where anyone can browse markets (clicking Place Order sends them to your login), and an in-app "Prediction" tab where a logged-in user completes KYC once and then trades.
Assumes the widget is already configured for your business in the admin builder (theme, category menu, webhook endpoints + secret, default trade limit). This guide only covers the two embeds + the account handoff. For the full reference (all attributes, the JS bridge, framework recipes, webhook payloads + verification) see the Developer Guide.
The user journey
Public page ──click Place Order──▶ redirect to /login ──▶ your app (user logs in)
│
"Prediction" tab
▼
dashboard widget (PU) ── first time ──▶ KYC form ──▶ trade
└─ returning ────▶ trade directly
Replace YOURBIZ with your business code throughout. The widget host in the
snippets is filled in automatically.
Step 1 — Public page (browse + login redirect)
Use explore mode (explore.html) so there's no account. Set the
no-account action to redirect and point it at your login page.
<div class="prediction-widget"
data-src="https://widgets.example.com/explore.html"
data-settings='{"market-platform":"PU","business":"YOURBIZ","accountless-cta":"redirect","explore_redirect_to":"/login","explore_cta_label":"Log in to trade","explore_cta_message":"Log in or sign up to place this trade."}'></div>
<script src="https://widgets.example.com/loader.js"></script>
accountless-cta: "redirect"overrides the PU default (the in-widget KYC form) — on a public page we want a redirect instead.explore_redirect_tois a path on your site (or a full URL). When the user clicks Place Order, the widget asks your page to navigate there.explore_cta_label/explore_cta_messageset the button + copy.
That's the whole public page — no account handoff, no webhooks.
Step 2 — In-app "Prediction" tab (dashboard + KYC)
Inside your authenticated app, render the widget in dashboard mode
(widget.html). Pass your id for the logged-in user as pu-external-id
(the KYC correlation key) and the user's spendable balance as pu-trade-limit.
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"market-platform":"PU","business":"YOURBIZ","accountless-cta":"kyc","pu-external-id":"USER_123","pu-trade-limit":250}'></div>
<script src="https://widgets.example.com/loader.js"></script>
2a · Give the widget the account (returning users)
KYC produces an accountId for your user, for example PBR_00001
(your business code plus a number). Store it against your user after onboarding (2c). On loads where you
already have it, hand it to the widget so the user skips KYC:
// Look up the accountId you stored for this user (from your backend).
const accountId = MY_APP.getStoredAccountId(); // '' if not onboarded yet
if (accountId) PMWidgetFrame.setAccountId(accountId);
PMWidgetFrame.setAccountId() is the loader's API; it survives the widget's internal
reloads, so call it once after mount (see Step 3 for timing).
2b · First-time users → KYC (automatic)
If you don't call setAccountId (no stored accountId), the widget
knows the user isn't onboarded. It surfaces the KYC form two ways: a
"Register for Prediction Account" button in the widget's top toolbar (where the
Positions button sits once they have an account), and automatically when they try to
place an order. The toolbar button opens the form centered in the main area, so it works even
before they've opened any event. If they return while verification is still pending it shows
"verification in progress" instead of a second form — that gating uses the
pu-external-id you passed. There is no login/redirect CTA here (that's what
accountless-cta: "kyc" — the PU default — means).
2c · Capture the account when KYC completes
The moment onboarding succeeds, the widget posts a message to your page. Store the link (accountId ↔ your user) so future sessions skip KYC:
window.addEventListener('message', (event) => {
const d = event.data;
if (!d || d.source !== 'prediction-spa' || d.type !== 'puAccountOnboarded') return;
// d.detail = { accountId: 'YOURBIZ_00001', clientRef: 'USER_123', participantId, provisionedAccount }
MY_APP.saveAccountId(d.detail.clientRef, d.detail.accountId);
});
The widget already adopts the new accountId for the current session, so the
user can trade right away — persisting it is for their next visit. You also receive a
signed kyc.approved webhook server-side; treat that as the
authoritative record of the link (see Step 4).
Step 3 — Mounting inside a SPA tab
The loader auto-mounts every .prediction-widget on the page at load. For a tab that
renders later (React / Vue / …):
- Simplest: keep the widget's container in the DOM and show/hide the tab with CSS. It mounts once and preserves state.
- Mount on demand: when the Prediction tab first opens, inject the container +
the loader, then call
setAccountId. See the Developer Guide's React and Next.js recipes for a drop-in mount/unmount.
setAccountId is a no-op until the widget's iframe exists. Call it after mount — e.g.
on window load, or right after you inject the container — not at the top
of the page script.
Step 4 — Webhooks (your server)
You configured the webhook URL(s) + secret for YOURBIZ in the admin
builder. Your server receives signed events so you can link accounts and move balances:
| Event | Do |
|---|---|
kyc.approved | Persist { accountId, clientRef } to your user (the authoritative link). |
order.placed/accepted/filled/rejected | Record the trade. Without wallet funding, adjust the user's wallet balance here; with wallet funding on, money moves only through Deposit / Withdraw, so these are informational. |
position.updated | Reconcile the net position. |
Verify every delivery's HMAC signature before trusting it. Full payload shapes + a copy-paste verifier are in the Developer Guide's Webhooks and Verifying signatures sections.
Step 4b — Wallet Deposit / Withdraw (recommended)
Wallet funding is on by default, so the widget's balance card gets Deposit and Withdraw: users move money from their wallet in your app into their Polymarket account and back. You need two things:
- An account token. Your backend confirms the logged-in user owns the account, signs a
short-lived token, and your page hands it over with
PMWidgetFrame.setAccountToken(token)(plusonAccountTokenExpiringto re-mint it). Deposit / Withdraw never work without it. Copy the signer fromexamples/account-token/mint-account-token.mjs. - A wallet endpoint. We POST
wallet.debit_requestedand wait for your{"approved": true, "walletReference": "…"}(or{"approved": false, "message": "…"}) before funding the account; on a withdrawal we POSTwallet.creditonce Polymarket confirms it. Be idempotent ondata.movementId. Start fromexamples/pu-agency/wallet-endpoint.mjs.
Listen for the widget's walletFundsMoved message to refresh the wallet balance you display.
The full contract is in the Developer Guide's
Wallet Deposit / Withdraw section.
Step 5 — The trade limit
pu-trade-limit is the user's current available balance/allowance (USD) — you
own that number. The widget pre-checks it and the server enforces it (an over-limit order is
rejected). Refresh it whenever the balance changes: re-render the embed (or reload the tab) with
the new value.
With wallet funding (Step 4b) skip this step — pu-trade-limit is ignored, and every
order is checked against the user's own Polymarket balance by the widget, our server and Polymarket.
Step 6 — Gate trades on balance / status (optional)
Beyond the per-order limit, you can decide at click time whether the user may trade at all —
zero balance, an account hold, terms not accepted, whatever your rule is. Register a handler with
PMWidgetFrame.onTradeValidation(); when the user clicks Place Order, the widget
calls it (it can fetch) and waits for the verdict. Blocked → your message
(plus an optional button) shows under the button and no order is sent; allowed → the order proceeds.
With wallet funding (Step 4b) the widget doesn't call this handler. The money is in the user's Polymarket account, so checking your wallet here would block orders the account can afford. The widget shows its own "not enough balance" notice with a Deposit button instead. To stop an account from trading, set it read-only (admin or the partner API).
// 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 +', // optional
button_url: '/wallet/add' // optional (needs button_label)
};
});
- The handler is called on every click, so it always sees the current balance — no need to
re-push on change. It may be
async(return a Promise). - If it throws or doesn't answer within ~20 s, the widget fails closed (blocks with a "couldn't verify" notice) — a gate you registered must never be bypassed by an error.
- The redirect button uses the same host-navigation channel as the login CTA — the user is sent to
button_urlon your site. - Prefer to push proactively instead?
PMWidgetFrame.setTradeValidation(verdict)is the push alternative (pair it with the operator's Require parent trade validation option to fail closed). See the Developer Guide.
Full reference in the Developer Guide. This is a UX gate — keep your real authorization on the server.
Checklist
- Public page:
explore.html,accountless-cta:"redirect",explore_redirect_to→ your login. - In-app tab:
widget.html,market-platform:"PU",business,pu-external-id,pu-trade-limit. - Returning users:
PMWidgetFrame.setAccountId(accountId). - Listen for
puAccountOnboarded; persistaccountId. - Optional: gate trades at click time with
PMWidgetFrame.onTradeValidation(async (ctx) => verdict)(or push withsetTradeValidation). - Server: receive + verify webhooks; link account on
kyc.approved. - Wallet funding: mint account tokens (
setAccountToken+onAccountTokenExpiring), host an idempotent wallet endpoint forwallet.debit_requested/wallet.credit, refresh your UI onwalletFundsMoved. (Without wallet funding: move balances onorder.*.)