Live play client¶
Consumer guide for mobile (or any) clients that show live questions and
submit answers. Implement against this contract; do not poll
GET /api/question-answers/open on a timer while the WebSocket is healthy.
REST field schemas remain in Swagger. The WebSocket path is not in OpenAPI.
Reference web client: public /play
(src/api/admin/static/js/play_app.js).
Prerequisites¶
| Need | Detail |
|---|---|
| Base URL | https://pr-api-staging.jvsassoc.com |
| Auth | POST /api/auth/register or POST /api/auth/login → Bearer api_key |
| Time | API datetimes are UTC; convert for display on the device |
Recommended flow¶
login/register
→ GET /api/users/me (match_daily_follow_count, match_follow_timezone, notify prefs)
→ PATCH /api/users/me (optional: sync browser IANA match_follow_timezone; notify prefs)
→ POST /api/users/me/devices (push token; also on cold start / rotation)
→ POST /api/matches/{id}/follow (when restricted)
→ WS /api/play/stream?api_key=… (open questions)
→ PUT /api/question-answers (submit / change answer)
→ GET /api/question-answers (history, on demand or slow poll)
→ GET /api/users/me/points (points, on demand or slow poll)
→ DELETE /api/users/me/devices/{device_id} (required on logout; see push guide)
Remote push notifications are follow-scoped for issuances and answerer-scoped for
resolve, and respects notify_on_issuance / notify_on_resolve on the user
plus global admin gates. Device register / logout DELETE / Android
notification-channel vibration contract:
Push notifications.
WebSocket remains the live path.
Use GET /api/question-answers/open only for catch-up after reconnect or
when the socket is down — not every few seconds. It returns unresolved open
issuances for matches the caller follows (empty follows → empty list), for
both restricted and unrestricted users. The shared snapshot is not clamped
to UTC calendar today: live (scheduled / created / delayed / inprogress)
matches always qualify, and other matches qualify when scheduled is within
[now − 48h, now + 24h) UTC.
Live open issuance ids are available only from /open and the play
WebSocket. GET /api/matches/{match_id}/question-issuances (auth required)
returns resolved in-game history for non-admins — useful for post-game UI, not for
discovering live questions. Match questions are not on this endpoint.
Following matches¶
| Field | Meaning |
|---|---|
match_daily_follow_count |
Max non-test follows per local calendar day; default 3 |
null count |
Unrestricted — no daily quota and no answer-follow 403; Play / WS still require an explicit match filter (empty subscribe → none) |
match_follow_timezone |
IANA zone for the daily follow quota window and for “today” when listing the Live Following slate |
| Method | Path | Notes |
|---|---|---|
POST |
/api/matches/{match_id}/follow |
Follow; idempotent; test matches do not consume quota; closed matches → 409 |
DELETE |
/api/matches/{match_id}/follow |
Unfollow anytime. Before start (or any test match) deletes the row and refunds today's quota. After start, non-test follows stay spent (unfollowed_at) so the daily slot is not refunded; re-follow reactivates that row without using another slot. Closed matches still cannot be newly followed (409). |
GET |
/api/matches?followed=true |
Auth required; all matches the caller follows (any schedule day) |
GET |
/api/matches?scheduled_from=&scheduled_to= |
Day’s slate — client converts match_follow_timezone “today” to UTC bounds. Each row’s score is last-known home/away totals from the stream (null before both sides are seen). |
Live /play (and other clients that mirror it) should show only matches on that
timezone calendar day in the Following picker/bar. Intersect
followed=true with the day’s slate for UI; do not surface locked follows from
other days. Server follow rows for other days may remain.
Live /play always requires following (or an explicit WS match_ids filter)
to show questions. GET /open is always scoped to server follows. null
match_daily_follow_count only disables quota and answer-gating.
When match_daily_follow_count is non-null:
- WS delivery is limited to followed matches (
[]subscribe means server followed set). - Answers without a follow return
403withYou must follow this match before answering. - Daily cap errors return
409with used/limit/timezone indetail.
Connect¶
Use wss:// when the API is HTTPS. Browsers cannot set an Authorization
header on WebSocket; the API key is a query parameter.
On connect the server sends a snapshot (unrestricted: empty until you
subscribe with match ids). Then send subscribe:
match_ids |
Meaning |
|---|---|
[] (empty) |
Unrestricted: no matches. Restricted: server followed set |
| one or more UUIDs | Only those internal match ids (Match.id); restricted users are intersected with follows |
Each successful subscribe is answered with a fresh snapshot for that filter.
Server → client events¶
Envelope:
type |
data |
Client action |
|---|---|---|
snapshot |
array of open question objects | Replace local open list (scoped to subscribe filter) |
question.opened |
one open question object | Upsert question by question_issuance_id. For kind=match, answer_locked may become true after first pitch (same event type). The reference /play client removes a locked Match question from the open column and refreshes Recent answers (do not treat lock as a new issue). Locked pending Match answers stay at the top of Recent answers and do not count toward the usual recent-history limit. |
question.closed |
{ "question_issuance_id", "reason", "outcome_text", "resolve_copy_template" } |
Remove question; refresh history/points if the user answered. outcome_text is the winning choice’s on-field line after resolved (null otherwise). resolve_copy_template is the league template ({{resolve_copy}} / {{outcome_text}}). Per-user resolve_copy is not on this event — it arrives on history after scoring. |
reason values:
reason |
Meaning | Client action |
|---|---|---|
resolved |
Feed scored the question | Remove question; refresh history (✓/✗ + points) |
stale |
Complete/closed leftover (no feed/official truth) | Remove question; refresh history (participation credit; is_correct null) |
postponed |
Match postponed; will not continue on this id | Same participation credit as stale |
cancelled |
Match cancelled | Same participation credit as stale |
unnecessary |
Series game will not be played | Same participation credit as stale |
retracted |
Same-event-id corrective invalidated the issue | Remove question; show withdrawn UI; refresh history (Retracted, 0 pts, not a miss) |
Unknown reason values: still remove the open question (do not crash). Window-expiry
without settlement remains client-side via expires_at / seconds_remaining
until the issuance is closed by resolve, stale, postponed/cancelled/unnecessary,
or retract. Match question
(kind=match) questions have null expiry and stay in /open until they close
(so reconnect can still see answer_locked). Do not hide them when
seconds_remaining is null. Skip delay-warning UI when kind=match. After first
pitch the server republishes question.opened with answer_locked=true — stop
accepting edits (PUT returns 410). The reference /play client then drops
the question from the open column and shows the pick under Recent answers at the
top (Pending until resolve/stale/postponed/cancelled/unnecessary; these rows
do not consume the regular recent-history limit). Other clients may keep showing
the locked-in choices if they want.
Retracted questions¶
When a definition has Retract on corrective enabled, a same-eid feed
correction that no longer matches issuance triggers closes the open issuance:
{
"type": "question.closed",
"match_id": "…",
"data": {
"question_issuance_id": "…",
"reason": "retracted",
"outcome_text": null,
"resolve_copy_template": "{{resolve_copy}} - {{outcome_text}}"
}
}
Required client behavior:
- Remove the open question by
question_issuance_id. - Show withdrawn copy (e.g. “Question withdrawn”).
- Refresh
GET /api/question-answers(and points if shown). - On history rows, if
issuance_resolution_reason === "retracted": label Retracted, show 0 points, do not show correct/incorrect. - Late
PUT /api/question-answersafter retract → same409as other closes. snapshot/GET …/opennever include retracted issuances (already closed).
On question.closed:
remove open question by question_issuance_id
if reason == "retracted":
show "Question withdrawn" (or localized equivalent)
refresh GET /api/question-answers (and points if shown)
On history row:
if issuance_resolution_reason == "retracted":
label Retracted; show 0 points; do not show ✓/✗
elif is_correct is null and points_awarded > 0:
treat as participation credit (stale / postponed / cancelled / unnecessary)
else:
normal correct/incorrect UI
Open question object¶
Same fields as REST OpenQuestionOut from
GET /api/question-answers/open, except shared push/snapshot payloads
omit a meaningful current_answer (always null on the wire). Each
choices[] entry includes points (whole points awarded if that choice is
correct for this issuance). Merge:
- server open set from WS
- local / optimistic selection after
PUT /api/question-answers - optional REST
/openif you need server-truth forcurrent_answer
Example question.opened data:
{
"question_issuance_id": "…",
"question_definition_id": "…",
"match_id": "…",
"match_label": "AWAY@HOME",
"match_shortcode": "abcd1234-efgh5678",
"question_text": "Will the next play be a hit?",
"choices": [
{ "identifier": "yes", "text": "Yes", "points": 10.0, "outcome_text": "It was a hit." },
{ "identifier": "no", "text": "No", "points": 5.0, "outcome_text": "It was not a hit." }
],
"issued_at": "2026-07-31T12:00:00+00:00",
"expires_at": "2026-07-31T12:00:45+00:00",
"seconds_remaining": 44.2,
"answer_window_seconds": 45,
"current_answer": null,
"sponsor_brand": null,
"match_sponsor_brand": null,
"is_pivotal_moment": false,
"kind": "in_game",
"answer_locked": false,
"locks_at_match_start": false,
"issued_situation": "Even strength",
"issued_clock": "12:34",
"issued_in_period": "2",
"issued_score": { "home": 2, "away": 1 }
}
kind is "in_game" (timed window) or "match" (Match question; no countdown;
expires_at / seconds_remaining / answer_window_seconds are null;
locks_at_match_start is true; answer_locked is true after first pitch).
For kind=match, sponsor_brand and match_sponsor_brand are both
Match.sponsor_brand (no per-question slot). Issuance and resolve
push notifications use the same device cooldown and per-match caps as in-game
(pivotal_only does not suppress Match question issuance). Resolve push
goes to followers who answered, only when the close reason is resolved.
WebSocket still notifies on open, lock, and close.
issued_situation, issued_clock, issued_in_period, and issued_score are
stamps from the issuing event (human game context, countdown clock, period /
phase, home/away totals). All four are null for Match questions and when the
payload has no matching shape. Clients may show them under the match label.
issued_score is frozen at issue time — it is not a live ticker. Live last-known
score for the match is score on GET /api/matches (list and detail).
is_pivotal_moment is from the definition at issue time (high-stakes /
special moment). Clients may badge the question; it is not a scoring field.
On the server, pivotal questions skip regular match max and the match
normal period cap and use the pivotal budgets instead.
Submit answers (REST)¶
Unchanged:
PUT /api/question-answers
Authorization: Bearer <api_key>
Content-Type: application/json
{
"question_issuance_id": "…",
"selected_answer_identifier": "yes"
}
Optimistic UI is recommended; roll back on 400 / 403 / 409 / 410.
Connection close codes¶
| Code | Meaning | Client action |
|---|---|---|
| 4401 | Missing/invalid api_key |
Fix auth; do not tight-loop reconnect with the same bad key |
| 1013 | This API process is at its WebSocket capacity (PLAY_WS_MAX_CONNECTIONS) |
Treat as temporary. Reconnect with backoff. The limit is per worker/process, not cluster-wide — a retry may land on another instance that still has room. The server does not automatically forward you to a free worker. |
Other normal closes (browser tab, network blip, deploy) should also use the reconnect flow below.
Reconnect / offline¶
- Exponential backoff with jitter when the socket drops (including 1013).
- On resume, the server sends
snapshotagain after connect /subscribe. - Optionally call
GET /api/question-answers/openonce to restorecurrent_answerand cover any missed events. - While the socket is down, a slow REST
/openpoll (e.g. 15s) is acceptable as degraded mode — not the steady-state design.
History and points¶
Keep on REST (not pushed today):
GET /api/question-answers?limit=…— newest first; locked pending Match answers are returned first and do not consumelimit. After a feed resolve,resolve_copyis the stamped right/wrong phrase andoutcome_textis the winning choice’s on-field line (both null while open;resolve_copystays null for stale/retract).GET /api/users/me/points
Compose the friendly line from question.closed.resolve_copy_template (or
cached League.default_resolve_copy) plus history resolve_copy and
outcome_text. Placeholders: {{resolve_copy}}, {{outcome_text}}.
If outcome_text is blank, show resolve_copy only. Do not pick a new
phrase in the client.
Refreshing after question.closed (when the user had answered) is enough for
most UIs. The closed payload includes outcome_text and
resolve_copy_template immediately; resolve_copy lands on history after
scoring finishes.
Report this (user feedback)¶
Players can report a problem on a resolved answer (in-game or Match question). Existing answer and history APIs are unchanged. Use this collection when you add a Report this control.
Show the link only when the history row’s resolved is true. Do not
offer it on locked pending Match answers.
1. Form catalog¶
GET /api/user-feedback/form
(no auth; cacheable). Map the history row’s kind to a subject_types[]
entry. Use that entry’s subject_type and topics[] for the dropdown.
Honor description_max_length and description_required — do not hardcode
subject types, topics, or the description cap.
{
"subject_types": [
{
"subject_type": "in_game_question_answer",
"kind": "in_game",
"label": "In-game question",
"topics": [
{"topic": "incorrect_resolution", "label": "Incorrect resolution"},
{"topic": "question_issued_incorrectly", "label": "Question issued incorrectly"},
{"topic": "other", "label": "Other"}
]
},
{
"subject_type": "match_question_answer",
"kind": "match",
"label": "Match question",
"topics": [
{"topic": "incorrect_resolution", "label": "Incorrect resolution"},
{"topic": "question_issued_incorrectly", "label": "Question issued incorrectly"},
{"topic": "other", "label": "Other"}
]
}
],
"subject_type_count": 2,
"description_max_length": 250,
"description_required": false
}
2. Submit¶
POST /api/user-feedback (Bearer):
POST /api/user-feedback
Authorization: Bearer <api_key>
Content-Type: application/json
{
"subject_type": "in_game_question_answer",
"subject_id": "11111111-1111-1111-1111-111111111111",
"topic": "incorrect_resolution",
"description": "Home scored after the window."
}
subject_id is the history row’s id (QuestionAnswerListItemOut.id).
description is optional; empty string is stored as null.
3. Already sent (optional)¶
GET /api/user-feedback lists the
caller’s rows (optional match_id, subject_type). Hide Report this
when that (subject_type, subject_id) is already present. A POST 409
with “already submitted” is the same state.
Statuses¶
| Status | Meaning |
|---|---|
| 201 | Created |
| 404 | Answer missing or not owned by the caller |
| 409 | Issuance not resolved, or feedback already submitted |
| 422 | Unknown subject_type or topic, or field validation |
Reference UI: Live /play Recent answers. Operators
review rows on User Feedback.
Related¶
- API getting started — curl play loop
- API resources
- Live (admin) —
/playoperator page - Environment configuration — ops knobs (
PLAY_*, ALB/Caddy routing)