API
Who's Bluffing runs on a small JSON API that the web game, the Slack and Discord apps and the classroom dashboard all use. This page starts with what anyone may read, then gives the full contracts: rounds (the game today) and the retired daily game.
Public API
The public player count is GET /api/players, returning {total_players}. It is cached for 60 seconds and carries Access-Control-Allow-Origin: *, so another site can read it with a simple GET. No key or account is needed.
Total Players counts distinct anonymous IDs with a completed round or full assessment, or an in-channel daily chat answer. Page views and unfinished rounds do not count. The same ID is deduplicated across sources; a person using several IDs can count several times.
Activity windows are private. /api/kpi requires the owner header x-kpi-key and returns the total, DAU, MAU (rolling 30 days), and annual active users (rolling 365 days), plus a UTC date. Legacy statistics endpoints require the same header. Missing or wrong keys return 401; private responses are not publicly cacheable and have no CORS header.
The other endpoints (starting a round, answers, flags, the chat apps' calls) exist for the Who's Bluffing apps. Building on them is welcome, but expect them to change without notice, keep traffic low, and never send personal data.
Rate limits
Requests to /api/* are rate-limited per IP address (about 120 a minute); beyond that the API answers 429 for a minute. Cache the public count rather than polling more than once a minute.
Attribution
If you show these numbers, please credit Who's Bluffing with a link to whosbluffing.com. Keep the note that anonymous IDs are not a precise count of individual people. Historical research definitions are preserved on the research page.
Open data
The public player-count endpoint contains no answers or player IDs. The release plan is fixed in the pre-registration: anonymous row-level data for both studies, with every exclusion flag kept, will be published on OSF, Hugging Face and Kaggle under CC BY-NC 4.0, each release with a data card that describes how it was collected, its sample bias and what it should not be used for. Classroom sessions are pooled without class codes. Details: Open data on the research page and the pre-registration.
Rounds API contract (replaces the daily-item loop for the viral game; the full assessment at /test keeps intervals)
Current product contract (2026-10-07): use mode=quick; mode=ranked returns 410. Old rk-<date> challenge links remain readable. History and Languages each have 80 authored questions with mixed formats, subtopic caps, and subject reuse avoidance; History also balances eras and regions. These packs do not fall back to generated comparisons.
- Saved quick rounds (
GET /api/round?round_id=...) return their originalpackanddifficulty, so a friend's next round keeps those choices. Migration0010_round_pack.sqladds the pack without changing saved questions or answers. Older Memes rounds recover their pack from the question IDs; other older rounds with no recorded pack use All, and missing legacy difficulty uses Normal. GET /api/round?mode=quick&rematch=<quick round id>&seen=...creates a fresh round in the original topic and difficulty. Its previous ten question IDs are strictly excluded even when seen questions must be reused. Normal 400/404/503 errors apply; no migration or new scoring rules.- Challenge comparison also returns
ownand, after completing,my_token(the caller's public result token). Owners get their most recent 20replies: [{public_token, nickname}]; other visitors cannot list those replies. Each friend link can be opened by either player to see the comparison. No anonymous IDs are exposed. - Completed comparisons against another player include
record: {played, wins, losses, ties}across rounds in which one challenged the other. The record uses scores at completion; the displayed current-round comparison still excludes retired questions. Counts deduplicate by the existing play primary key, and depend on the same anonymous browser IDs being retained. - Completion validates that
challenge, when supplied, belongs to another player of the same round. Refreshes and nickname updates do not create new completions or change existing challenge attribution. GET /api/playersis the only public product count:{total_players}(cached 60 seconds, CORS*).GET /api/kpirequiresx-kpi-key: <KPI_KEY>and returns{as_of, total_players, dau, mau, yau}. DAU uses one UTC date; MAU and YAU use rolling 30/365 UTC dates includingas_of. Counts deduplicate anonymous IDs across completed rounds, completed assessments, and daily chat answers. Page views and unfinished rounds do not count. One person with several IDs may count several times.POST /api/kpi/runretains private historical research snapshots and returns the same four product counts for itsas_ofdate.- Legacy aggregate reads
/api/stats,/api/daily/stats, and/api/round/statsalso require the owner key. Unauthorized requests return 401; authorized responses haveCache-Control: private, no-storeand no CORS header. Keys belong in headers, never URLs or client-side code.
The original contract below documents stored data and legacy replay behaviour; the access and creation rules above supersede its public-read and ranked-start descriptions.
Authored quick questions (2026-10-05): new all and ai quick rounds prefer items/quick_curated.json, with generated comparisons as fallback. IDs are q plus five digits; existing comparisons keep p plus five digits. IDs and answers are immutable after deployment; flawed questions are retired, never rewritten under an existing ID. Ranked schedules, daily chat questions, dares and the full assessment are unchanged.
pack=memesdraws from 64 authored meme questions (26 easy, 18 medium, 20 hard) in the same bank. It is offered only when the varied sampler can fill a whole round at that difficulty. Memes stay out of All; a shared source identifies questions about the same meme so it cannot appear twice in one round.- The difficulty mixes remain 10 easy, 3/4/3 normal and 0/4/6 brutal. Authored difficulty labels are editorial estimates, exempt from the generated bank's pageview, ratio and AI-tier rules. They are not measured accuracy or a claim of psychometric equivalence.
- All-topic rounds have at most two questions per topic and one AI question when available, at a varying position. All three authored packs have at most three of each question format, at most two chronology questions, and no adjacent identical formats. Unseen questions are preferred; seen questions can return when needed to fill a balanced round. New rounds return 503 if ten live questions cannot satisfy these constraints.
- Other quick packs prefer a spread of comparison templates where the eligible pool is large enough, then separate similar prompts in the play order. Generated pairs use equivalent wording selected deterministically by pair ID; choices, truth and sources are unchanged, including when replaying a challenge.
- Authored answers use the existing reveal shape:
a_valueandb_valueare the stringsCorrectandIncorrect,unitis empty, sources are HTTPS URLs, andfuncontains the explanation. None of these fields is sent before the answer. Scoring, flags, completion and saved challenge rounds use the existing endpoints. Synthetic option IDs belong only to their authored question, so retirement does not affect unrelated facts. - Quick selection checks the selected pack's authored questions (220 for All, 24 for AI, 64 for Memes) plus the generated sampler's at most 16 candidates. Retirement reads are chunked below D1's 100-binding limit. Authored IDs are bundled only into the server; they are not copied to the public JSON banks.
All JSON. Dates are UTC YYYY-MM-DD. surface ∈ {web, slack, discord, room}. community is slack:<team hash>, discord:<guild hash> or room:<code>. A play = one completed round (10 answered) on web/room/discord/slack, or one in-channel Slack or Discord answer to the daily question.
GET /api/round?mode=ranked|quick&seen=<comma ids, ≤300>→{round_id, mode, date, items:[{id, prompt, a, b}]}— 10 comparison pairs. Ranked: the day's fixed 10 fromdaily/rounds.json(same for everyone; only referenced or fact-checked items). Quick: random unseen pairs, difficulty mix by ratio. Never includes values or truth.POST /api/round/answerbody{round_id, item_id, choice:0|1, conf:50|60|70|80|90|100, rt_ms, anon_id, surface, community?}→{correct, truth:{a_value, b_value, unit, a_source, b_source}, points, total}wherepoints = 100 − 400·(c − y)²with c = conf/100, y = 1 if correct. Idempotent per (anon_id, round_id, item_id).POST /api/round/completebody{round_id, anon_id, surface, community?, nickname?}→{score, accuracy, mean_conf, overconfidence, brier, type, streak, rank_today?, players_today?, challenge_url, share_text, roast?}. Marks the play.type∈ {Bluffer, Hot-headed, Calibrated, Modest, Hedger} from overconfidence = mean_conf − accuracy (≥ +15, +5..+15, −5..+5, −15..−5, ≤ −15 in percentage points).GET /c/:round_id/:player(Pages Function, HTML) — challenge page with per-link OG tags ("{nickname} scored {score}. Can you beat them?") that starts the same round; after completion shows the side-by-side viaGET /api/round/:round_id/compare?me=<anon_id>&them=<player>→{me:{...}, them:{nickname, score, accuracy, mean_conf, overconfidence, type}}.playeris a short public token minted at complete time, never the anon_id.GET /api/round/stats?date=→{players, score_hist, mean_overconfidence}for the ranked round (cache 60 s).POST /api/eventbody{type: "share"|"challenge_view"|"play_again", anon_id?, round_id?}→{ok}; anonymous counters only.- Chat platforms (Slack, Discord):
GET /api/round/daily-question?date=→{round_id: "dq-<date>", item_id, prompt, a, b}(one global question per UTC day); answers via/api/round/answerwith that round_id, surface=slack|discord and the prefixed community; a later answer to the same item by the same anon_id before the reveal replaces the earlier one when the body carriesrevision: true;GET /api/round/reveal?date=&community=→{n, pct_a, pct_b, correct, a_value, b_value, unit, a_source, b_source, biggest_bluff:{anon_id, conf, choice}}(the bot decides whether to name them: roast mode off → "someone was 95% sure…"). Full rounds inside chat (/bluff play) use the normal quick-round endpoints with surface=discord|slack. - Communities in
/api/kpi= distinctcommunityvalues with ≥ 1 play in 30 days, reported per platform (workspaces, guilds, rooms). GET /api/kpiaddsrounds_per_player_day,d1_return,d7_return,challenge_conversion(completed rounds / challenge views),share_rate(share events / completed rounds), each for the trailing 30 days.
Daily-item endpoints (/api/daily/*) remain for the full assessment and existing data; the home page no longer uses them.
Clarifications (2026-10-03, after the Slack build):
- Daily chat question rounds
dq-<date>accept answers and revisions while<date>is today or yesterday (UTC); later → 409{error:"locked"}. A revision withoutrevision: truefor an item already answered returns the first result (idempotent). The server does not know each community's reveal hour; bots refuse answers after their own reveal. GET /api/round/daily-question?date=accepts today or yesterday and returns that day's question;GET /api/round/revealworks for any date up to today (with the bot header while the question is open, see No early peeking).- Status codes: 400 invalid body; 404 unknown round or item; 409 locked; 429 rate limited.
- Bot identity: the Slack and Discord workers send
x-bluff-bot: <BOT_KEY>on every call (BOT_KEY is a shared secret set on the web project and on each bot). The WAF rate-limit rule on/api/*exempts requests carrying the right header. - No early peeking: answers to
dq-<date>rounds return{locked:true, points_pending:true}with no truth or points, whatever the surface; points are computed at reveal time.GET /api/round/revealrequires the bot header while the question still takes answers, i.e. for today and yesterday (403 otherwise); older dates are public.
Additions (2026-10-04, from the web build; all backward compatible):
- Round ids:
rk-<date>(ranked, fromdaily/rounds.json),dq-<date>(daily question), or 12 characters ofA-Z2-9(quick, stored on the server). Another shape is 400. GET /api/round?round_id=<rk-… or quick id>→ the same body as withmode, for a round that already exists (what a challenge link plays); 404 for an unknown or future round, 400 for adq-id. Retired pairs are left out, so such a round can have fewer than 10 items.POST /api/round/answerfor ranked and quick rounds also returnschoiceandconf: the stored first answer, so a resend after a lost reply shows what was kept. (Daily-question answers return only{locked, points_pending}.)POST /api/round/completeacceptschallenge: <player token>when the round was started from a challenge link (whatchallenge_conversioncounts).nicknameis 1–24 letters, digits, spaces or. ' _ -; a repeat complete returns the same play, and a repeat with a new nickname updates it.challenge_urlis absolute (https://<host>/c/<round_id>/<player>) and endsshare_text. Requests through the privatebots.host return links on the corresponding public host, where friends can play. For a ranked round completed on its day or the next,rank_today= 1 + players with a higher score, andtoday={date, players, score_hist, bin_from, bin_width, mean_overconfidence}(fresh, including this play;/api/round/statsis cached 60 s).GET /api/round/stats?date=→{date, players, score_hist, bin_from: -3000, bin_width: 100, mean_overconfidence}plustotal_played(completed rounds on every surface plus chat daily-question answers, all time) andtotal_players(distinct anonymous ids behind them): 41 counts, bin k holds scores from −3000 + 100k to −2901 + 100k (the last bin holds 1000 only); ranked rounds completed on the day or the next.GET /api/round/:round_id/compare: both players are rescored over the round's live pairs;meis null until that player has completed the round.- Flags:
POST /api/flag {item_id: <pair id>, round_id, anon_id, reason?}→{ok}, only from an anon_id that answered that pair in that round (403 otherwise; 400 withoutround_id; 404 unknown pair). The third distinct flagger retires the pair and both of its values; any pair sharing a retired value is left out of new rounds, of scoring and of ranked statistics, and every ranked day up to today that used one of those values is recomputed. A pool id (w####) still flags a daily-game item as before. - Daily-question points are settled at the reveal (that community's pending answers, in the same request) or by the daily KPI run (questions that no longer take answers, up to 35 days back); until then the stored
correctandpointsare null. challenge_viewis counted by the challenge page itself on every HTML load; clients sendshare(once per share action) andplay_again.
Familiarity (2026-10-04): every item carries views_month (average monthly English Wikipedia pageviews by users over the last 3 full months); a pair's fame = the lesser of its two items'.
GET /api/round?mode=quick&difficulty=easy|normal|brutal(defaultnormal; anything else 400):normal= both items ≥ 20,000 views/month, at most one pair per round under 50,000, mix 3 easy / 4 medium / 3 hard;easy= both ≥ 50,000 and an easy pair (ratio ≥ 3, or ≥ 50 years apart), 10 of them;brutal= any pair whose items are both referenced or fact-checked (obscure allowed), 4 medium + 6 hard. The body addsdifficulty; the quick round stores it. Ranked rounds and challenge links ignore it.- Ranked rounds and the daily chat question use only pairs whose items are both ≥ 50,000 views/month and referenced or fact-checked.
Lab claims (2026-10-04): after a quick round in the AI pack a player can say which lab they are with. Self-reported and anonymous.
POST /api/round/labbody{round_id, anon_id, lab},lab∈ {openai, anthropic, google, xai, meta, other} →{ok: true, lab, board}, whereboardis the body ofGET /api/labs?range=all, fresh (this claim included). Valid only for a quick round in the AI pack (every pair from one of the pack'sai_*categories) that thisanon_idhas completed. 400: a bad body, an unknown lab, a ranked or daily-question round, a round outside the AI pack. 404: an unknown round (unknown round), or one thisanon_idhas not completed (finish this round first). One claim per round: a repeat with another lab moves it (the old lab's totals lose it, the new lab's gain it); the same lab again changes nothing. The claim stores the lab, the play's day (round_plays.day), the round's difficulty, the stored play's score and overconfidence, and the time; nothing else. A challenge link replays the same round id, so a challenger and a friend would share one claim; the web game offers the claim only on AI rounds the player started (a challenge round loads as pack All).GET /api/labs?range=all|30d(defaultall; anything else 400) →{as_of, range, min_players: 10, labs: [{lab, name, players, mean_score, mean_overconfidence}]}. Always all six labs, with display names (OpenAI, Anthropic, Google, xAI, Meta, Other).playerscounts claimed rounds, so someone who claims three rounds counts three times.mean_scoreandmean_overconfidence(points more sure than right, one decimal) are null below 10 players. Labs with 10 or more players come first bymean_score, highest first, then the rest byplayers, most first; ties keep the order above.30d= claims whose play day is one of the 30 UTC days ending today.as_of= when the board was computed (ISO time). Cached 60 s per range; no CORS header (not one of the public reads).
Party claims (2026-10-05): the same board for the Politics pack (quick-round pack=politics: categories pol_elected, pol_timeline, pol_numbers, pol_money, pol_drama, drawn by tier like the AI pack; never in ranked rounds or the chat question). After a quick round in the Politics pack a player can say which side they are on. Self-reported and anonymous; stored in the lab tables with board = parties (lab claims carry board = labs).
POST /api/round/partybody{round_id, anon_id, party},party∈ {democrat, republican, independent, none} →{ok: true, party, board}, whereboardis the body ofGET /api/parties?range=all, fresh (this claim included). Valid only for a quick round in the Politics pack (every pair from one of itspol_*categories) that thisanon_idhas completed. 400: a bad body, an unknown party (party must be democrat, republican, independent or none), a ranked, daily-question or dare round, a round outside the Politics pack (only quick rounds in the Politics pack can be claimed). 404:unknown round,finish this round first. One claim per round, moved or kept exactly as a lab claim, storing the same fields.GET /api/parties?range=all|30d(defaultall; anything else 400) →{as_of, range, min_players: 10, parties: [{party, name, players, mean_score, mean_overconfidence}]}. Always all four sides, with display names (Democrats, Republicans, Independents, No side); the counting, the 10-player threshold, the order and30dare the lab board's. Cached 60 s per range; no CORS header. The page is/parties; the end screen asks "Which side are you on?" after a Politics quick round and adds no line to the share text. Dares (2026-10-05, briefs/BRIEF_dares.md; all backward compatible):- A dare is a fixed round about a named public figure's field, round id
dr-<slug>(slug: a lowercase letter, then 1–30 lowercase letters, digits or hyphens). Its pairs come fromdares/dares.json(picked once byweb/scripts/build-dares.js, bundled asfunctions/_dares.json); nothing is stored inrounds. Answers and completion use the same endpoints and tables as any round;round_plays.modeisdare. An unknown or removed dare is 404, like an unknown round. GET /api/round?round_id=dr-<slug>→{round_id, mode: "dare", date: <issued>, pack, difficulty, dare: {slug, name, address}, items}.POST /api/round/completefor a dare addsrank(1 + completed plays of that round with a higher score),players(its completed plays, this one included) anddare: {slug, name, address};challenge_urlis the dare's page (https://<host>/dare/<slug>), andshare_textis "I scored {score} on the ten questions written for {name}. He hasn't taken it yet. <host>/dare/<slug>" (She hasn't / They haven't, from the dare's pronoun), or "… {name} scored {their score}. …" once the dare is played.GET /api/dare/:slug→{slug, name, address, org, status, issued, dare_url, players, best, mean_score, mean_overconfidence, their_score, rival: {slug, address, players, mean_score, status} | null, as_of}over the round's completed plays (best,mean_score,mean_overconfidencenull until someone has played; means to one decimal).status∈ {open, played, declined};their_score={score, url, date}when played, else null. 400 for a malformed slug, 404 for an unknown or removed dare.GET /api/dares→{as_of, dares: [{slug, name, address, org, status, players, mean_score, their_score, issued}]}: every dare on the board (not removed), most played first, ties in the order ofdares/dares.json.- Both reads are cached 60 s per data centre and carry
Access-Control-Allow-Origin: *(aggregates only). Pages (Pages Functions, HTML, cached 60 s):GET /dare/:slug(plays the round) andGET /dares(the board). - Members of Congress (2026-10-05): dares in the Politics pack at Normal, addressed "Senator …" or "Representative …", heading "Ten questions about Congress". The board (
GET /api/dares,/dares, their pages) shows them in equal numbers per party (an independent counts with the party they caucus with): a filled dare of the larger side stays off the board, like an unfilled one, until the other side has one more. Response shapes are unchanged.
Daily API contract (web implements; Slack consumes)
Access amendment (2026-10-06): /api/daily/stats and /api/kpi are owner-only (x-kpi-key, 401 without it, private/no-store). /api/kpi now returns {as_of,total_players,dau,mau,yau}. Only /api/players exposes a public product count, {total_players}. The retired daily range game's plays are not part of the current product counts.
Base: the web deployment (https://<host>/api). All JSON. Dates are UTC YYYY-MM-DD. surface ∈ {web, slack, classroom}.
GET /api/daily?date=YYYY-MM-DD(date optional, default today UTC) →{date, number, items:[{id, prompt, unit, accept:[lo,hi]}]}— never includes answers.POST /api/daily/answerbody{date, item_id, low, high, anon_id, surface, rt_ms, community?}→{hit, truth, source, log_ratio_error}; idempotent per (anon_id, date, item_id): a second answer returns the first result.datemay be today or yesterday (UTC) so a form opened before midnight can still be submitted; any other date → 400.community(optional string: Slack team hash or class code) is stored on the player record.POST /api/daily/completebody{date, anon_id, surface, community?}→{hits, n, streak, share_text, today:{players, avg_hits, hist:[n0..n5]}}. Marks the play complete (this is what MAU counts). Same date rule as answer.GET /api/daily/stats?date=→{players, avg_hits, hist:[n0..n5]}(passed plays only, cache 60 s).POST /api/flagbody{item_id, anon_id, reason}→{ok}; 3 distinct flags retire an item pending review.GET /api/kpi→{as_of, mau, dau, mau_by_surface:{web, slack, classroom}, communities:{workspaces, classrooms}}from the daily KPI job.
Anonymous ids: web = random id in localStorage; Slack = hex sha256(team_id + ":" + user_id + ":" + SALT) computed in the Slack worker (SALT is a Worker secret that must never change); never the raw Slack ids. Server stores players(anon_id, surface, first_seen, last_seen, plays) and plays(anon_id, date, surface, hits, completed_at).
Surface notes: for surface=slack, rt_ms is the modal open-to-submit time divided by 5 (Slack gives no per-question timing) and is approximate; response-time exclusions in PREREG apply to the web surface only.
Source: web/docs/public-api.md, docs/api-rounds.md, docs/api-daily.md on GitHub.