Quickstart
The normal operator path starts at /signin. Continue with an email code. That one screen creates the workspace and opens it. Then create an account-owned decision and return to it from Home. Anonymous creation remains available when you need a campaign-scoped private link instead of an account.
| Path | Who uses it | What it does |
|---|---|---|
/signin | Host | Continue with a short-lived email code. The same screen creates the workspace and returns to it. There is no separate sign-up form. |
/signup | Host | Permanent redirect to /signin. Bookmarks never reach a second form. |
/home | Signed-in host | See open decisions, responses owed, closed loops, and public records. |
/create | Host | Create an account-owned decision. Anonymous creation receives a campaign capability. |
/join | Participant | Continue with email, redeem a client enrollment, or continue from an approved partner account. The same screen creates the account and opens it. |
/me | Signed-in participant | Discover eligible decisions, receipts, responses, outcomes, and followed work across client relationships. |
/me/notifications | Signed-in participant | Review lifecycle notices and mark them read. |
/me/connections | Signed-in participant | Review active client relationships without exposing the global account id to a client. |
/c/:slug | Your users | The ballot, using the campaign's one configured identity mode. |
/c/:slug/results | Public or authorized participant | Scores once permitted, plus the response and successor you publish. |
/manage/:id | Authorized host | Close, respond, export, and inspect the audit log from a clean URL. |
/settings/integrations | Signed-in host | Manage clients, keys, origins, participant scopes, partner identity, webhooks, and custom domains. |
/manage/:id. Keep the one-time receipt until the
campaign belongs to your account.
A decision is bounded: one question, a stated electorate, a deadline you commit to, and a response you owe when it closes. Rules become immutable the moment it opens: you cannot change the options or the eligibility after people start voting, because a decision whose rules move is not evidence of anything.
Choosing a method
Pick by what the result has to support, not by habit. The method and its score meaning stay visible beside the Decision Contract.
| Method | Use when | Score means |
|---|---|---|
| Approval | Several options could ship and you want to know what is acceptable, not just what is favourite. | Number of ballots that marked the option acceptable. |
| Ranked (Borda) | You need an order of preference across the whole field. | Borda points: first choice earns N, next N−1, and so on. Not instant-runoff. |
| Pairwise | Visual or conceptual choices people judge better side by side than in a list. | Head-to-head wins across every unordered option pair, compared once per ballot. |
Approval is the safe default. Ranked punishes options that are widely disliked more than approval does. Pairwise gives the most reliable signal on aesthetics and the least legible explanation afterwards. If you will have to defend the result in public, prefer approval.
Embedding
Put the full ballot and results flow on a registered partner page. The scoped embed is available now. It has been exercised in Chrome and Firefox with third-party cookies blocked. Safari and a production sibling-domain deployment are not yet verified.
<player-vote campaign="your-decision-slug"></player-vote>
<script src="https://player.vote/embed.js" async></script>
| Surface | Current behavior |
|---|---|
/embed/c/:slug | Ballot inside the strict frame. |
/embed/c/:slug/results | Result and published response inside the same frame. |
/embed/:slug | Legacy ballot path. Client-bound decisions still use the exact client policy. |
setVoterToken(token) | JS-only, memory-only assertion input after the frame handshake. |
The loader derives one fixed player.vote origin from its own script URL.
A production page cannot override it. Load the script from the local player.vote origin for local
testing. The compatibility [data-player-vote] mount ignores data-origin.
sandbox="allow-scripts allow-same-origin"
The sandbox grants no popup or top-navigation authority. The direct Open this decision on player.vote fallback is a normal link in the parent page, not a link inside the frame.
For a client-bound decision, frame-ancestors contains only
exact current origins for an active, non-revoked client. No origin or a disabled client means
frame-ancestors 'none'. A registered framing origin grants no partner API authority;
/api/v1 still rejects browser Origin requests.
default-src 'none'; script-src 'self' 'nonce-…'; style-src 'nonce-…';
img-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self';
frame-ancestors …
Embed responses are no-store and omit
X-Frame-Options. Other HTML keeps frame-ancestors 'none' and
X-Frame-Options: DENY.
The parent sends {v:1,type:"init"} after frame load. Each
side checks the exact window source and origin. Child messages use only ready,
submitted, closed, and resize. They contain no email,
voter id, token, choice, score, result row, or response body. Unknown types are ignored.
Email mode creates or resumes a global participant session, then loads
authorized campaign state. The final cast sends the participant bearer in
Authorization and sends the campaign cast proof, choice, and bound
embed_origin in the body. The flow sets no participant or receipt cookie. Deleting an
origin or disabling a client blocks the next state or cast request. Partner mode keeps the loader
token only in memory and sends it in voter_token.
Client-bound pages show escaped source URL and snapshot text. They do not
render partner logos or option images. Read the embed privacy note.
The full repository contract is docs/api/v1.md.
Public record
Every host gets a public page listing their decisions and what came of each,
at /roadmap/:studioId. The link is on your manage page.
| Lane | What lands there |
|---|---|
| Deciding now | Open ballots. Scores stay hidden until they close. |
| Awaiting response | Closed, but you have not published a response yet. |
| Decided | Response published, no outcome recorded yet. |
| Outcomes | You recorded what actually shipped. |
Signed-in decisions for the same host and product join the same public record. For an older hostless decision, use the private manage link in Home's Claim a decision control. The link proves control; a matching product or host name does not.
respond_by date without publishing a response is marked Response overdue,
and the header carries the share of closed decisions you answered. That is the point of a
public record: close the loop before you share the link widely.
Groups
An overall score can hide the thing you most needed to know: that two kinds of user wanted opposite things. Put a group after each address on the invite list and the results break down by group.
[email protected], creators
[email protected], creators
[email protected], casuals
A line holding several addresses is still read as addresses, so
pasting a plain comma-separated list keeps working. Voters can also state a group themselves
on the ballot, and a partner sign-in token can carry one as seg.
The CSV export carries the same three things: turnout per group,
scores per group, and a segments_withheld_too_small line naming any group held
back.
Read API
Public, unauthenticated, CORS-enabled. It returns exactly what a visitor to the decision page can already see, so there is no key to leak and nothing new is exposed.
GET /api/v1/decisions/:slug
{
"ok": true,
"decision": {
"slug": "acme-which-onboarding-flow",
"title": "Which onboarding flow should we build first?",
"status": "open",
"contract": { "owner": "Head of Product", "result_type": "binding",
"method": "approval", "respond_by": "2026-08-01" },
"options": [ { "id": "op_…", "label": "Guided product tour" } ],
"ballots": 412,
"results": null,
"results_withheld_until_close": true,
"response": null
}
}
results: null is not zero. Scores are withheld while a
decision is open, to stop bandwagon voting. Check
results_withheld_until_close before you render anything. Treating
null as "nobody voted" would be wrong, and ballots is populated
the whole time so you can still show turnout.
Once closed, results becomes an array of
{id, label, score, pct} and response carries what the host
published. The API and the results page share one reveal rule, so they can never disagree
about what is public. Responses are cached for 15 seconds.
Connect your product
The server API lets a backend create and manage decisions owned by one integration client. Sign in at /signin, then create a client for each system that will talk to us on the Connect page. Each client issues keys with exact scopes.
pvk_<key-id>_<secret>
The secret is shown once and stored only as a hash, so we cannot
recover it for you. Issue a new key instead. A key carries delegated server authority, so it
belongs in a backend and never in a browser or a mobile app. A request that arrives with an
Origin header is refused outright, because that is a browser calling.
Connect keeps credential receipts in the page, not in navigation. It later shows only safe key prefixes. It also shows webhook event history and supports endpoint edits, versioned key rotation, one-event replay, endpoint removal, and permanent client disable. Disabling a client revokes its keys, stops its assertion authority, disables its endpoints, and resolves pending deliveries as failed.
| Endpoint | Needs | Does |
|---|---|---|
POST /api/v1/campaigns | campaigns:write | Open a decision. |
GET /api/v1/campaigns/:ref | campaigns:read | Read one by our id, your external_ref, or its slug. |
POST /api/v1/campaigns/:ref/close | campaigns:write | End voting and reveal the scores. |
POST /api/v1/campaigns/:ref/response | campaigns:write | Publish the response, outcome, and optional successor. |
GET /api/v1/campaigns/:ref/results | campaigns:read | Scores, under the same withholding rule as everywhere else. |
POST /api/v1/participants | participants:write | Create or update one client-scoped participant relationship. |
GET /api/v1/participants/:externalRef | participants:read | Read the calling client's relationship only. |
POST /api/v1/participants/:externalRef/enrollment | participants:write | Issue a show-once enrollment code. |
GET /api/v1/campaigns/:ref/grants | campaigns:read | List active view and vote grants. |
POST /api/v1/campaigns/:ref/grants | campaigns:write | Create or update one relationship or email grant. |
DELETE /api/v1/campaigns/:ref/grants/:grantId | campaigns:write | Revoke one active grant. |
POST /api/v1/campaigns/:ref/voter-tokens | voter_tokens:mint | Mint a five-minute partner voter token. |
campaigns:write does not grant
campaigns:read. Grant reads use campaigns:read; grant changes use
campaigns:write. Select both participant scopes when one backend must provision and
read relationships.
curl -X POST https://player.vote/api/v1/campaigns \
-H "Authorization: Bearer $PLAYER_VOTE_KEY" \
-H "Idempotency-Key: roadmap-region-2026-q3" \
-H "Content-Type: application/json" \
--data '{
"product": "Northwind",
"title": "Which region do we open next?",
"external_ref": "roadmap-2026-q3",
"source_ref": "region-plan-2026-q3",
"source_url": "https://northwind.example/roadmap/regions",
"options": [
{ "label": "EU", "external_ref": "eu" },
{ "label": "NA", "external_ref": "na" }
],
"respond_by": "2026-09-01"
}'
409 idempotency_conflict. external_ref is a separate stable
business id and also prevents a second campaign from claiming that client reference.
Participant accounts
One player.vote account can return to decisions across several client
relationships. Each client sees only its own relationship id, external_ref, label,
segment, status, and permitted attributes. The partner API never returns the global participant
id.
| Authority | Where it lives | What it proves |
|---|---|---|
| Participant bearer | pv_participant_session on the player.vote origin | The current global participant account. |
| Campaign cast proof | pv_participant_cast:<campaign> on the player.vote origin | One short-lived authorized email-mode cast. |
| Partner voter token | JavaScript memory only | One approved issuer subject for one client and campaign. |
| Host session | Separate existing host cookie | Operator authority. It never becomes participant authority. |
Participant API requests send the bearer in
Authorization. They do not use participant authentication or receipt cookies. Sign
out and erasure remove the local bearer and revoke server sessions. Erasure removes presentation
and active relationship data; it does not rewrite historical ballots or allow a second ballot.
POST /api/v1/participants
{
"external_ref": "customer-42",
"identity": { "authority": "client:northwind", "subject": "user-42" },
"segment": "enterprise",
"attributes": { "plan": "pro" }
}
Client-supplied email is relationship metadata, not verified global email
identity. player.vote verifies email itself. Enrollment returns one pve_… code only
on the first successful response. Repeating its idempotency key returns
409 already_issued; the raw code is never replayed. The join path contains no code.
A campaign grant targets exactly one relationship reference or email
invitation. can_view and can_vote are independent. An email invitation
does not let the client link that address to a global account. See the full server contract in
docs/api/v1.md.
Webhooks
Add an endpoint to a client and select its lifecycle events. player.vote keeps one immutable logical event and one stable delivery id per subscribed endpoint. A scheduled Worker sweep sends due rows outside the decision request path.
| Event | Created when |
|---|---|
campaign.opened | A client-bound decision opens. |
campaign.closed | A client-bound decision closes. |
results.revealed | Results become public. |
studio_response.published | The host publishes its response. |
The sweep uses a lease, a five-second request timeout, and manual redirect handling. Network errors, 408, 425, 429, and 5xx responses retry. Redirects and other 4xx responses stop. A delivery gets at most six attempts. Event and delivery history remains for 30 days.
Connect shows recent events, attempts, receiver status, errors, and the next retry. A host can change the URL and subscriptions, rotate to a new signing-key version, replay one terminal event, or remove the endpoint. Rotation sends current and previous signatures for 24 hours.
Each request carries x-player-vote-event,
x-player-vote-event-id, x-player-vote-delivery-id,
x-player-vote-timestamp, and x-player-vote-signature.
const crypto = require('crypto');
const rawBody = req.rawBody; // Exact request bytes before JSON.parse.
const timestamp = req.headers['x-player-vote-timestamp'];
const signatures = req.headers['x-player-vote-signature'].split(',').map((part) => part.trim());
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('stale timestamp');
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const version = WEBHOOK_SECRET.match(/^whsec_v(\d+)_/)?.[1];
const received = signatures.find((part) => part.startsWith(`v${version}=`))
?.slice(String(version).length + 2) || '';
if (!version || received.length !== expected.length
|| !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
throw new Error('invalid signature');
}
timestamp.rawBody. Verify it before JSON.parse. Reject old timestamps
and store processed event ids so a replay cannot apply one change twice.
WEBHOOK_SIGNING_ROOT
and an exact WEBHOOK_HOSTNAME_ALLOWLIST. Production accepts HTTPS on port 443 and
does not follow redirects. See docs/webhooks.md for the complete contract.
Partner sign-in
If your product already knows a user, your approved backend can request one short-lived player.vote token for that stable partner subject. The browser does not receive an API key or reusable signing secret.
Approval is manual because a ballot labeled “asserted by Northwind” is a claim about Northwind. We verify the issuer name and set its maximum assertion class. Ask at [email protected]. An unapproved client cannot create a partner-sign-in decision or mint voter tokens.
Create the decision with "identity_mode": "partner_assertion".
Use a backend key with voter_tokens:mint to request the token:
POST /api/v1/campaigns/:ref/voter-tokens
{
"subject": "user-42",
"kind": "partner_account",
"segment": "enterprise",
"ttl_seconds": 300
}
| Property | Rule |
|---|---|
| Lifetime | One to 300 seconds. Five minutes is the hard maximum. |
| Binding | Exact client, decision, issuing API key, identity-key version, and issuer class. |
| Storage | Raw subject and token are not stored. JTI use is stored only as a hash. |
| Browser handoff | setVoterToken(token) after the strict frame handshake. Memory only. |
POST /api/campaigns/:id/vote
{ "voter_token": "<opaque token>", "approve": ["op_…"] }
A successful cast stores the machine kind and the token-derived assertion class on the voter row. The authenticated management export keeps that cast-time class if the issuer later changes its kind or label.
A product can send one token to
POST /api/participant/continue to create or resume the global participant session.
That consumes the token. Mint a fresh token for the ballot.
Your own domain
Serve your public record from your own hostname, so the page your users read is yours rather than ours.
- Claim the hostname on the Connect page.
- Publish the
TXTrecord it gives you at_player-vote.<your-domain>. - Point the hostname at us with a
CNAME, then press check.
The claim does nothing until that TXT record resolves. Pointing a hostname at us proves nothing on its own. Otherwise anyone willing to aim a CNAME at our edge could take over a record page.
The certificate is requested for you the moment the TXT record checks
out, never before: we do not ask anyone to issue for a name you have not proved. Issuing
takes a few minutes after your CNAME resolves, and the Connect page tells you
which of the two you are waiting on. Until it says the certificate is active, the hostname
will not load over https, and that is the only step between a proven domain and a live one.
MCP
Point an assistant at your decisions. Streamable HTTP transport, no key, read-only.
POST https://player.vote/mcp
| Tool | Does |
|---|---|
get_decision | One decision by slug: contract, options, ballot count, and once closed, scores and the published response. |
list_host_decisions | Every decision on a host record, each tagged with its lane, so an assistant can see whether a host closes the loop. |
Scope is identical to the read API: an assistant sees exactly what a visitor sees, including the withholding rule. Both surfaces share one serializer, so they cannot disagree. There are no write tools. Creating or closing a decision needs a host credential, and an MCP server that could do it without one would be a privilege boundary rather than a convenience.
What a result means
A result is useful only when its identity source, counting rule, and limits are stated plainly.
- Email mode: one ballot per email address, after a one-time code to that address.
- Partner mode: one ballot per issuer-supplied subject, with a single-use signed token.
- One campaign uses one identity mode. The two identity domains cannot mix.
- Rules are immutable once the decision opens.
- Live scores are hidden while open, by default.
- Every lifecycle state change is written to the campaign audit log.
Not built yet
These capabilities are accepted product direction, but they are not current shipped surfaces. They are listed so you can plan around the gaps rather than discover them.
- Persistent public and private feedback boards, portal intake, comments, moderation, statuses, duplicate merge, and subscriptions.
- Complete organization, commercial-account, product-hierarchy, plan, region, and lifecycle context across every feedback, support, and planning workflow. Global participant continuity and client relationships are available now.
- AI-assisted duplicate detection, themes, summaries, sentiment, trends, and linked insights.
- Explicit prioritization views, product hierarchy, opportunities, initiatives, objectives, dependencies, and portfolio planning.
- Complete internal and public roadmaps, changelog, targeted notifications, and help center.
- Shared support inbox, tickets, assignment, internal notes, knowledge, workflow automation, and AI-assisted replies.
- Automated imports from Featurebase, Feature Upvote, Canny, Productboard, and UserVoice.
- Multi-seat roles, admin SSO, SCIM-style workforce provisioning, retention policy, and enterprise administration.
- Self-serve checkout. Paid tiers are arranged by email; see pricing.
- Ready-made DevMeme, ark, mmo.bar, Discord, and Steam connectors. The API, generic embed, and partner assertion primitives exist; the adapters do not.
- Self-serve issuer approval. Vouching for your own users is reviewed by a person, on purpose. See partner sign-in.