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
Two endpoints are public reads: aggregate numbers with no ids in them, free to fetch from any site.
GET /api/round/stats?date=YYYY-MM-DD(date optional, default today, UTC): the ranked round of that day: players, the score histogram in 100-point bins from −3000, and the mean overconfidence. Cached for 60 seconds.GET /api/kpi: the latest daily numbers: monthly and daily players per surface, communities per platform and the engagement rates, with the date they were computed for (as_of). Updated once a day after 00:10 UTC; cached for 5 minutes.
Both answer with the header Access-Control-Allow-Origin: *, so a page on another site can read them with a plain fetch. Send simple GET requests (no custom headers). No key and no account are needed.
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. The numbers change at most once a minute (stats) or once a day (KPI), so cache them rather than polling.
Attribution
If you show these numbers, please credit Who's Bluffing with a link to whosbluffing.com. The KPI counts follow the definitions on the research page; please keep the note that someone who plays on two surfaces counts twice.
Open data
Answers are never available through the API. 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)
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, 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. 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}: 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.
Daily API contract (web implements; Slack consumes)
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.