Widget integration guide
Embed conviction voting in any web app in under five minutes. The unovote-widget web component handles rendering, coin allocation, and participant verification out of the box.
Add a conviction-voting board to any web page with two lines of HTML. Participants allocate a fixed pool of coins across your competing ideas. Spending on one means skipping another, revealing intensity of preference.
Quick start
Add the script tag and the custom element anywhere in your HTML. No build step required.
<script src="https://cdn.unovote.com/v1/widget.js" defer></script> <unovote-widget board="your-board-key" public-api-key="pk_your_key"> </unovote-widget>
Find your board key and public API key in your board embed settings.
Widget attributes
| Attribute | Type | Required | Default | Description |
|---|---|---|---|---|
board | string | Yes | - | Board key from your dashboard embed settings |
public-api-key | string | Yes | - | Public API key (safe to commit and ship) |
theme | light | dark | No | light | Widget colour scheme |
mode | embedded | button | No | embedded | button renders a trigger that opens a modal overlay |
public | boolean (presence) | No | absent | Enable public mode where visitors verify by email OTP instead of being identified |
Identified participants
When your users are signed in, call identify() with an async tokenProvider. The provider gets one short-lived Voter session from your server. The widget caches that session, shares it across board, history, allocation, and event requests, and asks the provider for a replacement only near expiry.
Your server must verify its own host session before minting the Voter session. Identity always comes from that verified server session, never from browser-supplied fields.
<script> const widget = document.querySelector('unovote-widget'); widget.identify({ tokenProvider: async () => { const response = await fetch('/api/unovote/voter-session'); if (!response.ok) throw new Error('Could not authorize voter'); return (await response.json()).token; }, }); </script>
The session stays in widget memory and is sent only in the Authorization header—never in a URL, request body, local storage, or cookie. It lasts no more than five minutes and is limited to one organization, one board, and the fixed unovote-widget audience. If the provider is missing, returns an invalid session, or fails, identified interaction fails closed with bounded error copy.
Server session endpoint
Your host app needs one small endpoint. Configure the Unovote organization, board, and signing secret on the server; do not accept them from the request.
import { createVoterSessionToken } from '@unovote/core'; export async function GET(request: Request) { const session = await requireHostSession(request); // your server-side session check const token = await createVoterSessionToken( { externalId: session.userId, organizationId: process.env.UNOVOTE_ORGANIZATION_ID!, boardKey: process.env.UNOVOTE_BOARD_KEY!, email: session.email, displayName: session.name, }, process.env.UNOVOTE_VOTER_SECRET!, ); return Response.json({ token }); }
requireHostSession is the only host-specific part: connect it to the session system your app already uses. Store the organization ID, board key, and Voter secret in server-only environment variables when the secret is created. The Voter secret is displayed only once and cannot be retrieved later; never expose it or accept identity/scope fields from the browser.
Public mode
Public mode lets anonymous visitors vote. They allocate coins first, then enter their email address to confirm their submission via a one-time code.
The code is bound to a single challenge. The widget carries that challenge through send, verify, and submit, so a verification cannot be reused for a different board, a different email, or a second submission. Codes expire after three minutes and are accepted once.
Sends are rate limited per recipient, source IP, board, and organization, so a burst of requests against one address or from one client is refused rather than delivered.
<unovote-widget board="your-board-key" public-api-key="pk_your_key" public> </unovote-widget>
Do not call identify() in public mode. The OTP flow handles participant identity.
Theming
Override widget colours and dimensions with CSS custom properties on the host element.
| Property | Default | Description |
|---|---|---|
--uv-bg | #FBF6EC | Panel background |
--uv-border | #EFE8DA | Borders and dividers |
--uv-brand | #E7B24B | Accent colour for coins, buttons, links |
--uv-on-brand | #1A1A2E | Text rendered on the brand colour |
--uv-text | #1A1A2E | Primary text |
--uv-muted | #8A8577 | Secondary / muted text |
--uv-widget-width | 100% | Width of the host element |
--uv-widget-max-width | none | Max-width of the host element |
--uv-panel-width | 100% | Width of the inner panel |
--uv-panel-max-width | 100% | Max-width of the inner panel |
--uv-widget-min-height | 430px | Minimum panel height |
unovote-widget { --uv-bg: #ffffff; --uv-brand: #6366f1; --uv-on-brand: #ffffff; --uv-widget-max-width: 480px; }
Content Security Policy
If your app sets a Content-Security-Policy header, add these two directives:
script-src 'self' https://cdn.unovote.com;
connect-src 'self' https://app.unovote.com;
The widget loads from cdn.unovote.com and calls the API at app.unovote.com. No other origins are required.
React and Next.js
The web component works in React without any extra package. Load the script once (e.g. in your root layout) and use the element directly in JSX.
// app/layout.tsx (Next.js App Router) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <head> <script src="https://cdn.unovote.com/v1/widget.js" defer /> </head> <body>{children}</body> </html> ); }
// FeatureVoting.tsx 'use client'; import { useEffect, useRef } from 'react'; declare global { namespace JSX { interface IntrinsicElements { 'unovote-widget': React.HTMLAttributes<HTMLElement> & { board?: string; 'public-api-key'?: string; theme?: 'light' | 'dark'; mode?: 'embedded' | 'button'; public?: boolean; }; } } } export function FeatureVoting({ getVoterSession }: { getVoterSession: () => Promise<string> }) { const ref = useRef<HTMLElement>(null); useEffect(() => { const el = ref.current as HTMLElement & { identify?: (b: object) => void }; el?.identify?.({ tokenProvider: getVoterSession }); }, [getVoterSession]); return ( <unovote-widget ref={ref} board="your-board-key" public-api-key="pk_your_key" /> ); }
TypeScript requires the declare global block to recognise custom elements in JSX. Add it once in a global.d.ts file to share it across your project.
Versioning and upgrades
Pin the major version in the URL path: https://cdn.unovote.com/v1/widget.js. Fixes and new features ship inside a major and reach your page automatically, because the bundle is served with a short cache lifetime rather than an immutable one. You do not need to redeploy to pick them up.
A change that breaks the embed contract ships at a new path (/v2/widget.js) and never replaces what /v1/ serves, so your integration will not change under you. When a new major appears, the guide here will describe what changed and you can move at your own pace.
This guarantee starts with the first external embed. Unovote is pre-launch and /v1/ is still being finished, so a contract change can still land inside /v1/ today. If you are integrating now, talk to us first and we will tell you where the contract stands.
If something looks wrong after an automatic update, pinning is not the recovery path — the cache lifetime is 60 seconds, so a fix reaches you as soon as we publish one. Get in touch and we will roll the bundle back.
Get your API key
Open Board settings → Embed in your Unovote dashboard. Copy the board key and public API key shown in the embed snippet. The public API key is safe to include in client-side code.