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):
- Match flag —
Match.push_notifications_enabled(live rows default on; Clone Past Match defaults off; editable mid-match on match details — subsequent sends only). - Global admin gates —
stored_settingskeypush_notification_settings(notify_on_issuance,notify_on_resolve,pivotal_only, cooldowns, caps, templates). See admin. - Event eligibility — issuance after live issue; resolve only when
notify_question_closedreason isresolved(neverretracted/stale). - Audience — match followers (issuance) or followers who also answered (resolve).
- User preferences —
User.notify_on_issuance/User.notify_on_resolve(default on). - Device eligibility —
token_type=expo,disabled_at IS NULL. - Anti-spam — per-device-row cooldown (
min_seconds_between_device); separate per-user-per-match caps for issuance vs resolve. Cooldown is keyed bypush_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. - 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.
- 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_id — 201 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)¶
- Persist one
device_id— generate a UUID once and store it in SecureStore (or equivalent). Reuse it for everyPOSTwhile the install lives. Do not mint a newdevice_idon each app launch or version bump. POSTwhen signed in — after notification permission, login, every cold start while signed in, OS token rotation, and late permission grant. FailedPOSTmust not block app entry — retry on the next trigger. The API does not aggressively rate-limit this write.DELETEon 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:
- Same
device_id→ update in place (always bumpupdated_at; clearsdisabled_atso a re-register can revive a dead token) - Same
device_idunder a different user → reassign the row - Same push
tokenalready on another row (anydevice_id/ user) → reclaim that row in place (device_id/ user / metadata updated; 200). Conflicting rows are deleted first whendevice_idandtokenpoint at different rows so the unique constraint holds - 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; DeviceNotRegistered → disabled_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)¶
- After each
ticket_ok, the send path stores Redis keyexpo:push:ticket:{ticket_id}→{attempt_id, push_device_id}(TTL 1h). - Beat scans those keys and calls Expo’s receipt API.
- For each receipt:
- ok → attempt
receipt_ok, bump devicelast_success_at, delete Redis key - error → attempt
receipt_error; onDeviceNotRegisteredsetpush_devices.disabled_atso we stop targeting that token; delete Redis key - Task lock
poll_expo_push_receiptsprevents 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¶
- Admin → Advanced → Push Notifications → lookup by email/UUID
- Check
outcome/skip_reason/ devicedisabled_at - Confirm match follow, user prefs, and global gates
- Ticket+receipt ok but no banner → client OS / Focus / permissions
Related¶
- Admin push notifications
- Play client
- Users admin (notify prefs)
- Data model (
push_devices, attempts)