Menu
Start free
Developer and operator documentation

Run the current decision workflow. Connect your product.

This page states current behavior. When a surface has a release gap, the gap is marked where it matters and repeated in Not built yet.

Start with the question you need to answer.

The complete technical reference remains in one reading column below.

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.

PathWho uses itWhat it does
/signinHostContinue with a short-lived email code. The same screen creates the workspace and returns to it. There is no separate sign-up form.
/signupHostPermanent redirect to /signin. Bookmarks never reach a second form.
/homeSigned-in hostSee open decisions, responses owed, closed loops, and public records.
/createHostCreate an account-owned decision. Anonymous creation receives a campaign capability.
/joinParticipantContinue with email, redeem a client enrollment, or continue from an approved partner account. The same screen creates the account and opens it.
/meSigned-in participantDiscover eligible decisions, receipts, responses, outcomes, and followed work across client relationships.
/me/notificationsSigned-in participantReview lifecycle notices and mark them read.
/me/connectionsSigned-in participantReview active client relationships without exposing the global account id to a client.
/c/:slugYour usersThe ballot, using the campaign's one configured identity mode.
/c/:slug/resultsPublic or authorized participantScores once permitted, plus the response and successor you publish.
/manage/:idAuthorized hostClose, respond, export, and inspect the audit log from a clean URL.
/settings/integrationsSigned-in hostManage clients, keys, origins, participant scopes, partner identity, webhooks, and custom domains.
Account authority and campaign authority are separate. Signing in recovers account-owned decisions. A legacy or anonymous decision uses its private manage link until you claim it. The first successful use exchanges the raw capability for a scoped HttpOnly cookie and redirects to clean /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.

MethodUse whenScore means
ApprovalSeveral 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.
PairwiseVisual 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>
SurfaceCurrent behavior
/embed/c/:slugBallot inside the strict frame.
/embed/c/:slug/resultsResult and published response inside the same frame.
/embed/:slugLegacy 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.

LaneWhat lands there
Deciding nowOpen ballots. Scores stay hidden until they close.
Awaiting responseClosed, but you have not published a response yet.
DecidedResponse published, no outcome recorded yet.
OutcomesYou 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.

It shows what you owe, not just what you did. A decision you close past its 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.

Small groups keep their turnout, not their scores. A group of one has a tally that is that person's ballot, so the breakdown is withheld below five ballots and the page says so. The group is never hidden outright, because dropping it silently would make the remaining groups look unanimous.

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.

EndpointNeedsDoes
POST /api/v1/campaignscampaigns:writeOpen a decision.
GET /api/v1/campaigns/:refcampaigns:readRead one by our id, your external_ref, or its slug.
POST /api/v1/campaigns/:ref/closecampaigns:writeEnd voting and reveal the scores.
POST /api/v1/campaigns/:ref/responsecampaigns:writePublish the response, outcome, and optional successor.
GET /api/v1/campaigns/:ref/resultscampaigns:readScores, under the same withholding rule as everywhere else.
POST /api/v1/participantsparticipants:writeCreate or update one client-scoped participant relationship.
GET /api/v1/participants/:externalRefparticipants:readRead the calling client's relationship only.
POST /api/v1/participants/:externalRef/enrollmentparticipants:writeIssue a show-once enrollment code.
GET /api/v1/campaigns/:ref/grantscampaigns:readList active view and vote grants.
POST /api/v1/campaigns/:ref/grantscampaigns:writeCreate or update one relationship or email grant.
DELETE /api/v1/campaigns/:ref/grants/:grantIdcampaigns:writeRevoke one active grant.
POST /api/v1/campaigns/:ref/voter-tokensvoter_tokens:mintMint 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"
  }'
Keep one idempotency key for one intended mutation. The same key and the same normalized payload replay the original status and body. The same key with a different payload returns 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.

AuthorityWhere it livesWhat it proves
Participant bearerpv_participant_session on the player.vote originThe current global participant account.
Campaign cast proofpv_participant_cast:<campaign> on the player.vote originOne short-lived authorized email-mode cast.
Partner voter tokenJavaScript memory onlyOne approved issuer subject for one client and campaign.
Host sessionSeparate existing host cookieOperator 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.

EventCreated when
campaign.openedA client-bound decision opens.
campaign.closedA client-bound decision closes.
results.revealedResults become public.
studio_response.publishedThe 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');
}
Verify the raw bytes first. The signature covers timestamp.rawBody. Verify it before JSON.parse. Reject old timestamps and store processed event ids so a replay cannot apply one change twice.
Production delivery fails closed. Configure 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
}
PropertyRule
LifetimeOne to 300 seconds. Five minutes is the hard maximum.
BindingExact client, decision, issuing API key, identity-key version, and issuer class.
StorageRaw subject and token are not stored. JTI use is stored only as a hash.
Browser handoffsetVoterToken(token) after the strict frame handshake. Memory only.
POST /api/campaigns/:id/vote
{ "voter_token": "<opaque token>", "approve": ["op_…"] }
One identity domain per campaign. A partner campaign rejects email-mode cast proofs, and an email campaign rejects partner voter tokens. At use time, player.vote rechecks the client, issuer approval, issuing key, campaign, identity version, and assertion ceiling. The single-use JTI, participant link, voter, and ballot commit together. Invalid ballot data does not consume the token. A fresh token for the same issuer subject still cannot cast twice.

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.

  1. Claim the hostname on the Connect page.
  2. Publish the TXT record it gives you at _player-vote.<your-domain>.
  3. 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
ToolDoes
get_decisionOne decision by slug: contract, options, ballot count, and once closed, scores and the published response.
list_host_decisionsEvery 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.
What this does not prove. One ballot per email address is not one ballot per person. An issuer-supplied subject is only as strong as that issuer's account controls. We state the identity source and do not claim more. Full detail is on the integrity page.

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.

Run the workflow or inspect its trust boundary.

The current product, integration surface, and documented limits stay connected.