Skip to content

Push notifications (mobile)

Remote push notifications for live play. REST field schemas are in Swagger. Admin tuning lives under Push Notifications.

WebSocket /api/play/stream remains the live path for open questions. Push is best-effort — a missed or late banner must not be treated as source of truth.

Why

Pivotal moments close in about a minute. Players not already on the Play tab need a banner with a deep link into the match/question. Resolve pushes are optional product noise (global default off).

Decision layers

A send must clear all of these (order approximates the Celery path):

  1. Match flagMatch.push_notifications_enabled (live rows default on; Clone Past Match defaults off; editable mid-match on match details — subsequent sends only).
  2. Global admin gatesstored_settings key push_notification_settings (notify_on_issuance, notify_on_resolve, pivotal_only, cooldowns, caps, templates). See admin.
  3. Event eligibility — issuance after live issue; resolve only when notify_question_closed reason is resolved (never retracted / stale).
  4. Audience — match followers (issuance) or followers who also answered (resolve).
  5. User preferencesUser.notify_on_issuance / User.notify_on_resolve (default on).
  6. Device eligibilitytoken_type=expo, disabled_at IS NULL.
  7. Anti-spam — per-device-row cooldown (min_seconds_between_device); separate per-user-per-match caps for issuance vs resolve. Cooldown is keyed by push_devices.id, not by push token — orphan duplicate rows with the same token used to bypass “one phone” intuition until token reclaim / fan-out dedupe.
  8. Freshness — in-game issuance: age / remaining window. Match question issuance: age and not yet locked (no 60s window). Resolve: resolve age. Re-checked before each Expo batch chunk and after rate-limit waits.
  9. Global Expo pacing — Redis sliding window (~500/sec under Expo’s 600/s project ceiling); batches ≤100 messages.

Device registration

Authenticated Bearer (psk_…) endpoints:

Method Path Notes
POST /api/users/me/devices Upsert on device_id201 create, 200 refresh / reclaim
DELETE /api/users/me/devices/{device_id} Required on logout — unregister; 204 idempotent

Required body fields: device_id (client UUID in secure store), token (ExponentPushToken[…] or ExpoPushToken[…]), token_type (expo), platform (ios|android). Schema also accepts apns/fcm for a future native path; send today only uses expo rows.

Optional: environment, app_version, build_number, os_version, device_model, locale, timezone.

Client requirements (mobile)

  1. Persist one device_id — generate a UUID once and store it in SecureStore (or equivalent). Reuse it for every POST while the install lives. Do not mint a new device_id on each app launch or version bump.
  2. POST when signed in — after notification permission, login, every cold start while signed in, OS token rotation, and late permission grant. Failed POST must not block app entry — retry on the next trigger. The API does not aggressively rate-limit this write.
  3. DELETE on logout (required) — before clearing the session / API key, call:

DELETE /api/users/me/devices/{device_id}

Use the same SecureStore device_id you registered with. Treat 204 as success even if the row was already gone (idempotent). Skip or ignore network failures only as best-effort; still clear local auth. Without this call, a later reinstall that gets a new push token can leave the old row active until Expo reports DeviceNotRegistered. 4. Android vibration — create a notification channel on startup (before token register) with high importance and a vibrationPattern. Expo remote push has no vibrate payload field; Android 8+ uses the channel. iOS vibrates from the server’s sound: "default" unless Silent / Focus suppresses it. See Sound, priority, and vibration.

push_devices.token is globally unique (DB constraint). Same push token + new device_id reclaims the existing row (200) — that stops twin banners for one phone — but logout DELETE is still required so a signed-out user stops receiving pushes on that device.

Upsert rules:

  1. Same device_id → update in place (always bump updated_at; clears disabled_at so a re-register can revive a dead token)
  2. Same device_id under a different user → reassign the row
  3. Same push token already on another row (any device_id / user) → reclaim that row in place (device_id / user / metadata updated; 200). Conflicting rows are deleted first when device_id and token point at different rows so the unique constraint holds
  4. Logout → client must DELETE /api/users/me/devices/{device_id}

User preferences

On User / UserOut, editable by the player and by admins:

Field Default Meaning
notify_on_issuance true Opt in/out of issuance (question issued) pushes
notify_on_resolve true Opt in/out of resolve pushes
Method Path Notes
GET /api/users/me Includes both prefs
PATCH /api/users/me Partial update; player-allowed
PATCH /api/users/{id} Admin

Global admin Notify on resolve still defaults off. A user with notify_on_resolve=true receives nothing until the global gate is enabled. Per-user prefs never override a global master switch that is off.

Admin UI: Users (create/edit checkboxes) and triage on Push Notifications.

When the server sends

Event Trigger Audience Notes
Question issued After live issue commit (notify_question_opened) or Match question issue (notify_match_question_opened) Followers with an eligible push device + notify_on_issuance In-game: global pivotal-only default. Match questions bypass pivotal_only (they are never pivotal) and share the same device cooldown + per-match issuance cap.
Question resolved After feed / official resolve (notify_question_closed, reason=resolved) Followers who answered that issuance + notify_on_resolve In-game and Match questions. Global resolve gate default off; retract/stale/postpone/cancel never enqueue

Deep-link data (code-owned; type matches the play WS event name):

{
  "type": "question.opened",
  "question_issuance_id": "…",
  "match_id": "…",
  "is_pivotal_moment": true
}

Match question issuance and resolve pushes use the same question_issuance_id (the Match question issuance UUID) and add "kind": "match". Resolve uses "type": "question.closed". Use question_issuance_id, not a vague question_id.

Pivotal issuance messages set Expo interruptionLevel: time-sensitive (iOS Focus breakthrough for the short pivotal window). Non-pivotal issuance and all resolve messages omit that field. Use the Push HTTP API kebab-case enum — not the local Notifications SDK’s timeSensitive camelCase (Expo returns HTTP 400).

Sound, priority, and vibration

Every Expo message sets:

Field Value Why
sound default iOS system sound. Vibration follows the device Silent / Focus / vibrate settings. Expo documents this field as iOS-only.
priority high Android would otherwise default to normal (doze delay, no heads-up). High priority lets the OS present immediately so the channel can sound/vibrate.

Expo’s Push HTTP API has no vibrate field. On Android 8+, vibration is owned by the notification channel the app creates before the first push arrives (Notifications.setNotificationChannelAsync with a vibrationPattern and high importance). Do not invent a channelId on the server unless the app has already created that exact channel — a missing channel drops the banner. Leave channelId unset so Expo uses its Default channel; the app can still reconfigure that channel for vibration.

Message templates

Issuance title/body come from admin templates with {{name}} placeholders (double braces — distinct from QD jsonpath {{$.path}}):

Placeholder Issuance
{{match_label}} yes
{{match_shortcode}} yes (or empty)
{{question_text}} formatted question (truncated)

Resolve Expo does not use a push-settings body template. After scoring, the send path composes League Defaults default_resolve_copy with the stamped resolve_copy phrase and winning outcome_text. Blank outcome returns the phrase only.

Unknown {{…}} in issuance templates are left unchanged. Soft caps: title 100 / body 200 chars; {{question_text}} truncated at 120.

Constraints (engineering rails)

Not editable in admin StoredSetting form; values live in lib.push.constants and are summarized on the admin page.

General

Constraint Value
Expo project ceiling 600/s
Our global Redis send budget 500/sec across Celery workers
Expo HTTP batch size ≤100 messages/request
Receipt ticket Redis TTL 3600s (beat polls; DeviceNotRegistereddisabled_at)
Title / body soft caps 100 / 200; question text placeholder 120
Fan-out One Celery task per issuance or resolve event

Issuance

Constraint Value
Freshness Abandon if age >10s or remaining answer window <10s
Freshness re-check Before each Expo chunk and after rate-limit wait
Expo ttl Remaining answer window, capped at 60s

Resolve

Constraint Value
Freshness Abandon if resolved more than 180s ago
Freshness re-check Before each Expo chunk and after rate-limit wait
Expo ttl 60s (informational; short-lived)

Provider/OS realities: Expo MessageRateExceeded (per-device cooldown helps), APNs/FCM delay/coalesce, Focus / denied permission can still block banners.

Tunable anti-spam (admin)

Defaults until an operator saves:

Setting Default
min_seconds_between_device 45
max_issuances_per_user_per_match 20 (0 = unlimited)
max_resolves_per_user_per_match 20 (0 = unlimited)
pivotal_only true
attempt_retention_days 7

Issuance and resolve match caps use separate Redis counters (~48h TTL). Device cooldown and both caps are global across in-game and Match questions — a Match question send starts the same cooldown and counts toward the matching issuance or resolve cap. pivotal_only does not apply to Match question issuance.

Delivery attempts & skip reasons

push_delivery_attempts records skips and ticket/receipt outcomes for triage (pruned by retention days). Followers with no push_devices row are not written here — infer “no device” from the devices table (or admin device list). Common skip_reason values:

Reason Meaning
match_push_disabled Match.push_notifications_enabled is false
notify_on_issuance_false / notify_on_resolve_false Global gate off (summary row)
pivotal_only Non-pivotal in-game issuance while pivotal-only on (Match questions are not skipped)
stale_issuance / stale_resolve Failed freshness at task start
user_notify_on_issuance_false / user_notify_on_resolve_false User pref off
disabled Device disabled_at set
cooldown Per-device cooldown active
duplicate_token Another row with the same push token already queued this send
match_cap Per-user-per-match cap reached

Attempt outcome values (send vs delivery confirmation):

Outcome When
pending Row created; Expo HTTP send not finished yet
ticket_ok Expo accepted the message (got a ticket id) — not proof the device got a banner
ticket_error Expo rejected the send (or network/timeout / missing ticket)
receipt_ok Beat poll confirmed Expo delivered to APNs/FCM for that ticket
receipt_error Beat poll got a failure receipt (e.g. DeviceNotRegistered)

Chunks abandoned for freshness after pacing use provider error FreshnessExpired; rate-limit deadline → RateLimitDeadlineExceeded.

Structured worker logs remain the durable ops trail beyond retention.

Receipts & hygiene (Beat)

Expo push is two-phase: the send task only gets a ticket (“Expo accepted this payload”). Whether APNs/FCM actually accepted the token is a later receipt. Celery Beat runs that follow-up.

poll_expo_push_receipts (every 1 minute)

  1. After each ticket_ok, the send path stores Redis key expo:push:ticket:{ticket_id}{attempt_id, push_device_id} (TTL 1h).
  2. Beat scans those keys and calls Expo’s receipt API.
  3. For each receipt:
  4. ok → attempt receipt_ok, bump device last_success_at, delete Redis key
  5. error → attempt receipt_error; on DeviceNotRegistered set push_devices.disabled_at so we stop targeting that token; delete Redis key
  6. Task lock poll_expo_push_receipts prevents overlapping polls.

If Redis keys expire before a successful poll, the attempt may stay at ticket_ok with no receipt update — treat that as “accepted by Expo, delivery unconfirmed.”

prune_push_delivery_attempts (daily ~04:15 UTC)

Deletes push_delivery_attempts older than attempt_retention_days (default 7). Does not touch devices or Redis tickets.

Schedule definitions: tasks/beat_schedules.py. Optional env: EXPO_ACCESS_TOKEN when EAS Enhanced Security for Push is on (environment configuration).

Local testing

uv run python scripts/push/send_test_push.py --token 'ExponentPushToken[…]'
uv run python scripts/push/send_test_push.py --device-id <client-uuid>
uv run python scripts/push/send_test_push.py --email player@example.com --wait-receipt

Bypasses gates/follow fan-out. Needs REDIS_URL (rate limiter) and network to exp.host.

Support triage

  1. Admin → Advanced → Push Notifications → lookup by email/UUID
  2. Check outcome / skip_reason / device disabled_at
  3. Confirm match follow, user prefs, and global gates
  4. Ticket+receipt ok but no banner → client OS / Focus / permissions