Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPush NotificationsPush Notifications — Behavior

Push Notifications — Behavior

State machines

Device token

register ──> active (last_seen_at touched, deleted_at = null) │ │ │ ├── re-register (same token, new owner) → owner updated; deleted_at cleared │ ├── revokeTokenForUser (sign-out) → deleted_at set │ └── receipt processor: DeviceNotRegistered → deleted_at set

Notification send

notifyUsers(ids, payload) │ ├── filterByPreferences(ids, category) (drop opted-out users) ├── fetch active device tokens for survivors ├── enqueue BullMQ job { tokens, payload, tokenProjects? } │ │ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ Push processor (async) │ │ - filterDeliveryTokens → [{ token, expoProjectId }] │ │ - one bucket per Expo project, plus one bucket per │ │ unknown-project token (isolated) │ │ - per bucket: chunk by 100 via Expo SDK, send each chunk │ │ (buckets concurrent, bounded by the SDK's own limiter) │ │ - pair {ticket, token} inside the chunk that produced it │ │ - per ticket: │ │ status='ok' → collect id │ │ status='error' + 'DeviceNotRegistered' → revokeToken │ │ status='error' + other → log (no token) │ │ - every chunk failed → throw (BullMQ retries) │ │ some chunks failed → complete; the rest already went │ │ - enqueue check-receipts in 15min │ └────────────────────────────────────────────────────────────┘ │ ▼ (15 min later) check-receipts: fetch by ticketIds; soft-delete tokens for DeviceNotRegistered errors

Why buckets. Expo answers a request whose messages span two Expo projects with PUSH_TOO_MANY_EXPERIENCE_IDS and rejects all of it. The container app was relinked from the FitKit EAS project to the Taikan one on 2026-08-22, so an org with one member on an old build and one on a new build lost every push in the fan-out (Sentry TAIKAN-BACKEND-4P). A token with no recorded project could belong to either, so it is sent alone rather than assumed into today’s project.

Invariants

InvariantEnforcement
expo_push_token is globally uniqueDB unique
Re-registering moves ownership atomicallyINSERT … ON CONFLICT DO UPDATE on token
Soft-delete preserves audit (no hard delete in app code)deleted_at only
Caller-owns revoke gaterevokeTokenForUser checks userId === row.userId
Prefs default opt-in (missing key → enabled)isChannelOptedOut returns false for missing keys
Push send paths are fire-and-forget at callerAll callers void-await enqueue errors
BullMQ retry: 3 attempts, exponential backoff 5sJob options in service
Scheduled push resolves tokens at SEND time, not schedule timescheduleNotifyUser snapshots tokens once; comment notes tokens may change but the trade-off is accepted
A ticket is only ever matched to the token from its own chunkProcessor builds {ticket, token} pairs inside the chunk loop. Flattening successful tickets and indexing back into the full message list misattributed everything after a failed chunk, revoking a token that was never rejected
Push tokens never appear in logs or SentryTicket/chunk warnings carry codes, counts and project ids only; Expo’s details (which holds the conflicting raw tokens) is dropped in expoErrorInfo
A stored expo_project_id is never replaced by NULLConflict-update omits the column unless a verified project was sent
An unverified project id is never storedresolveExpoProjectId drops anything outside the binary’s known projects; the device still registers
No request mixes two Expo projectsProcessor buckets by project and isolates unknown-provenance tokens one per request

Golden paths

Mobile sign-in registers a device

  1. App boots; gets Expo push token.
  2. POST /devices/register with { expoPushToken, platform, appId?, locale?, expoProjectId? }. expoProjectId is the EAS project the token was minted under; the server keeps it only if it matches a project that binary is known to use, and otherwise records no provenance rather than refusing the registration.
  3. Service upserts row; if the token belongs to a different user, ownership transfers (the device was signed in as someone else previously).

Coach assigns a workout → member’s phone buzzes

  1. Workout-assignments service inserts row.
  2. Calls push.notifyUser(memberUserId, { title, body, category: 'workoutAssigned', route: '/workout/...', data }).
  3. notifyUsers([id], ...) filters prefs, fetches tokens, enqueues job.
  4. Processor sends via Expo; ticket id collected.
  5. Phone receives push; user taps; Expo router opens route.

Member opts out of newComment push

  1. PATCH /users/me/notification-preferences with { newComment: { push: false } }.
  2. Service merges into JSONB prefs.
  3. Next notifyUsers(..., category: 'newComment') excludes this user.

Class reminder T-30min

  1. Booking confirmed.
  2. Bookings service computes sendAt = startsAt - 30min.
  3. push.scheduleNotifyUser(userId, payload, sendAt) → BullMQ job with delay.
  4. Job fires; pushes to current tokens.

Billing notice

  1. BillingDebtNotificationsService decides a member must hear about a debt or a card: on SUBSCRIPTION_DEBT_OPENED (debt opened) or billing.card_unusable, from the hourly subscription lifecycle pass (last day to pay, cancelled for non-payment), or from the daily 08:00 UTC card-expiry job (14 / 7 / 1 days).
  2. Debt notices claim their stage on subscriptions.debt_notice_stage (compare-and-set) before sending, so each is sent at most once per debt. It reads the member’s notification_prefs once: email is sent unless billing/email is off; push is queued only when billing/push is on, and notifyUser filters again.
  3. push.notifyUser(userId, { category: 'billing', route: '/(tabs)/profile/payments', orgId, data: { subscriptionId, stage } }) (card expiry: { daysLeft }), with copy from pushCopy.billingDebtOpened / billingDebtLastDay / billingDebtCancelled / billingCardUnusable / billingCardExpiring rendered in the member’s locale (amounts in the org currency, pay-by day as a plain date).
  4. A member who opted out of both channels still has the stage claimed, so the old notice is not sent if they opt back in. Operations: scheduled jobs › Subscription lifecycle.

Receipt cleanup

  1. 15min after send, check-receipts job runs.
  2. Fetches receipts from Expo by ticket id.
  3. For any with DeviceNotRegistered, soft-delete the corresponding token, resolved through the {ticketId → token} map captured at send time from the chunk that produced the ticket.

Edge cases

ScenarioBehavior
User has no device tokensnotifyUsers early-returns; no enqueue
User has only opted-out devices (all categories)Filtered out at filterByPreferences
Token validity changes mid-flight (user signed out between enqueue + send)Token still in queue; Expo will succeed if still registered; receipt may report DeviceNotRegistered → soft-deleted
Multiple devices per userAll tokens fetched; chunked across BullMQ payload
Expo HTTP failureLogged; BullMQ retries (3 attempts, exponential 5s)
BullMQ unavailablequeue.add rejects → caught in .catch, logged. Push lost (no fallback)
Pref JSONB malformedDefaults used (opt-in)
Scheduled push before send time → user uninstallsTokens fetched at schedule (snapshot stale by send time); send to dead tokens; receipt cleans up
Scheduled push after class is canceledCaller is responsible for cancelling the BullMQ job id
Audience mixes old- and new-project tokensOne request per project, so a rejection can only cost its own project. A single mixed request would be rejected wholesale (PUSH_TOO_MANY_EXPERIENCE_IDS)
Token has no recorded expo_project_idSent in a request of its own. Re-registration from a current build fills the column in and it rejoins its project’s bucket
Queued job predates the column, or its payload carries no organizationIdGrouped by each token’s recorded project like any other job; tokens with no project either way are isolated. The job’s own tokenProjects snapshot is only a fallback for a token whose row has since lost its project
One project’s request fails, another’s succeedsJob completes (retrying would re-deliver the successful project); the failure is logged and carried in the push_delivery Sentry context with its Expo code and project ids

Side effects

ActionWrites DBQueues
Register devicedevice_tokens upsert—
Revoke devicedevice_tokens update (deleted_at)—
notifyUser(s)—BullMQ send job
scheduleNotifyUser—BullMQ send job with delay
Push processordevice_tokens update on DeviceNotRegisteredenqueues check-receipts (15min delay)
Update prefsnotification_prefs upsert—

Permissions

ActionAuth
Register / revoke deviceAuthenticated; revoke checks ownership
Prefs read / writeSelf only
notifyUser (service-to-service)Internal — no HTTP surface

Managed members

Spec: managed-members/behavior.md §4.6, §4.7.

  • A push about a child is delivered to the devices of their confirmed adults (NotificationRecipientsService, used by apps/api/src/push-notifications/push-notifications.service.ts:162), never to the child, who has no device.
  • The payload carries subjectUserId; tapping it switches the app to that child before opening the screen.