Skip to content

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
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 403 with You must follow this match before answering.
  • Daily cap errors return 409 with used/limit/timezone in detail.

Connect

WS  /api/play/stream?api_key=<plaintext_api_key>

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:

{
  "type": "subscribe",
  "match_ids": []
}
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": "question.opened",
  "match_id": "00000000-0000-0000-0000-000000000001",
  "data": {}
}
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:

  1. Remove the open question by question_issuance_id.
  2. Show withdrawn copy (e.g. “Question withdrawn”).
  3. Refresh GET /api/question-answers (and points if shown).
  4. On history rows, if issuance_resolution_reason === "retracted": label Retracted, show 0 points, do not show correct/incorrect.
  5. Late PUT /api/question-answers after retract → same 409 as other closes.
  6. snapshot / GET …/open never 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 /open if you need server-truth for current_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

  1. Exponential backoff with jitter when the socket drops (including 1013).
  2. On resume, the server sends snapshot again after connect / subscribe.
  3. Optionally call GET /api/question-answers/open once to restore current_answer and cover any missed events.
  4. While the socket is down, a slow REST /open poll (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 consume limit. After a feed resolve, resolve_copy is the stamped right/wrong phrase and outcome_text is the winning choice’s on-field line (both null while open; resolve_copy stays 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.