Send push/SMS 5 minutes before the AI coaching call so users expect it, answer it, and look forward to it. Transform interruption into anticipated event.
Our entire product depends on users answering the daily AI call. Without a preview notification, most calls go to voicemail = zero engagement = churn.
This is a P0 feature because without it, the call channel — our core differentiator — doesn't work.
| Field | Content | Source |
|---|---|---|
title | {coach_name} is calling in 5 min! {emoji} | user.coach_persona |
body | Day {streak_count}: {today_topic}. {contextual_line} | call_schedule.topic + user.streak |
emoji | Rotates: 💪 🔥 🌱 ⭐ 🏆 | Based on streak milestone |
language | Match user's locale (en/fr) | user.locale |
deep_link | Opens app to "incoming call" screen | Fixed route |
// EN "Coach Max is calling you in 5 min! 💪 Day 12 — fitness check-in. Make sure to answer!" // FR "Coach Max t'appelle dans 5 min ! 💪 Jour 12 — point fitness. Décroche !"
SMS must be ≤ 160 chars to avoid multi-part charges. The template above is ~95 chars.
preview_notification at T-5min (6:55 AM) and initiate_call at T (7:00 AM).preview_notification job. Fetches user's coach persona, streak count, today's topic, locale, and notification preferences.notification_logs: sent, delivered, failed, opened. Track for analytics and retry logic.initiate_call job fires. Twilio/Vapi makes the AI call. The user already expects it.When: User has valid FCM/APNs token + notifications enabled
Cost: Free (Firebase)
Pros: Rich content, deep link, instant, free
Cons: User can disable, not guaranteed delivery on Android (Doze mode)
Default for: All users with the app installed
When: No push token (no app installed, web-only user, or push disabled)
Cost: ~$0.0075/SMS (Twilio, US) / ~€0.065/SMS (France)
Pros: Works on any phone, no app needed, high delivery rate
Cons: Cost per message, no deep link, 160 char limit
Default for: Pre-app users, SMS-preference users
When: User opts into "never miss a notification" setting
Cost: Free if push succeeds, $0.0075 if SMS needed
Logic: Send push → wait 30s for delivery receipt → if no receipt, fire SMS
Pros: Highest reliability
Default for: Premium tier users
def select_notification_path(user): if user.notification_pref == "sms_only": return PathB_SMS if user.notification_pref == "push_and_sms": return PathC_PushWithFallback if user.fcm_token and user.push_enabled: return PathA_Push if user.phone_number: return PathB_SMS # no push token, no phone = can't notify log.warning(f"No notification channel for user {user.id}") return None
notification_logsCREATE TABLE notification_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), call_id UUID REFERENCES scheduled_calls(id), type VARCHAR(20) NOT NULL, -- 'preview_push', 'preview_sms', 'fallback_sms' channel VARCHAR(10) NOT NULL, -- 'push', 'sms' status VARCHAR(20) NOT NULL, -- 'queued','sent','delivered','failed','opened' title TEXT, body TEXT, provider_id VARCHAR(100), -- FCM message_id or Twilio SID error_msg TEXT, sent_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ, opened_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_notif_user ON notification_logs(user_id, created_at DESC); CREATE INDEX idx_notif_call ON notification_logs(call_id);
users table| Column | Type | Description |
|---|---|---|
fcm_token | TEXT | Firebase Cloud Messaging device token |
push_enabled | BOOLEAN | Whether push is enabled on device |
notification_pref | VARCHAR(20) | 'push_only' | 'sms_only' | 'push_and_sms' |
notification_lead_min | INTEGER | Minutes before call to notify (default: 5) |
scheduled_calls table| Column | Type | Description |
|---|---|---|
preview_sent | BOOLEAN | Was preview notification sent for this call? |
preview_sent_at | TIMESTAMPTZ | When the preview was sent |
topic | VARCHAR(100) | Today's check-in topic (for notification body) |
/api/v1/notifications/register-deviceCalled by mobile app on login/startup to register the FCM/APNs token.
// Request body { "fcm_token": "dGhpcyBpcyBhIHRva2Vu...", "platform": "ios" | "android", "push_enabled": true } // Response: 200 OK { "status": "registered" }
/api/v1/users/notification-preferencesUser updates their notification preference (push, SMS, both).
// Request body { "notification_pref": "push_and_sms", "notification_lead_min": 5 } // Response: 200 OK { "status": "updated" }
/api/v1/notifications/historyGet notification history for a user (for debugging and analytics).
// Query params: ?limit=20&offset=0 // Response: 200 OK { "notifications": [ { "id": "uuid", "type": "preview_push", "status": "delivered", "title": "Coach Max is calling in 5 min!", "sent_at": "2026-03-15T06:55:00Z", "delivered_at": "2026-03-15T06:55:02Z" } ] }
send_preview_notification(call_id)Background worker function triggered by the job scheduler at T-5min. This is the core function that handles everything.
async def send_preview_notification(call_id: UUID): call = await get_scheduled_call(call_id) user = await get_user(call.user_id) coach = get_coach_persona(user.coach_persona_id) # build message title, body = build_preview_message( coach_name=coach.name, streak_day=user.current_streak, topic=call.topic, locale=user.locale ) # select path and send path = select_notification_path(user) if path == PathA_Push: result = await send_push(user.fcm_token, title, body) elif path == PathB_SMS: sms_body = build_sms_message(coach.name, user.current_streak, call.topic, user.locale) result = await send_sms(user.phone_number, sms_body) elif path == PathC_PushWithFallback: result = await send_push(user.fcm_token, title, body) if not result.delivered_within(seconds=30): sms_body = build_sms_message(coach.name, user.current_streak, call.topic, user.locale) result = await send_sms(user.phone_number, sms_body) # log it await log_notification(user.id, call.id, path, result) # mark call as preview-sent await update_call(call.id, preview_sent=True, preview_sent_at=now())
| Key | EN | FR |
|---|---|---|
preview.title |
Coach {name} is calling in {min} min! {emoji} | Coach {name} t'appelle dans {min} min ! {emoji} |
preview.body.default |
Day {streak}: {topic}. | Jour {streak} : {topic}. |
preview.body.milestone_7 |
Day 7 — one full week! Let's celebrate. | Jour 7 — une semaine complète ! On fête ça. |
preview.body.milestone_21 |
DAY 21! You made it. Final check-in. 🏆 | JOUR 21 ! Tu l'as fait. Dernier point. 🏆 |
preview.sms |
Coach {name} calling in {min} min! {emoji} Day {streak} — {topic}. Answer! | Coach {name} t'appelle dans {min} min ! {emoji} Jour {streak} — {topic}. Décroche ! |
| Topic Key | EN | FR |
|---|---|---|
fitness_checkin | your fitness goal check-in | point objectif fitness |
weekly_weighin | weekly weigh-in & meal review | pesée & bilan repas de la semaine |
mood_check | mood & energy check | point humeur & énergie |
meal_review | yesterday's meals review | bilan repas d'hier |
exercise_plan | today's exercise plan | planning sport du jour |
streak_celebrate | streak celebration! | fête du streak ! |
| Scenario | Behavior | Priority |
|---|---|---|
| User changes call time after notification is scheduled | Cancel old preview_notification job, create new one at new T-5min |
P0 |
| User cancels today's call | Cancel both preview_notification and initiate_call jobs |
P0 |
| FCM token is expired/invalid | Push fails → auto-fallback to SMS if phone number exists. Mark push_enabled = false. On next app open, re-register token. |
P0 |
| SMS delivery fails | Log error. Do NOT retry (call is in 5 min, no time). The call still happens regardless. | P1 |
| User is in Do Not Disturb mode | Nothing we can do. Push will be queued by OS. SMS may get through depending on DND settings. Recommend users add app to DND exceptions in onboarding. | P2 |
| User has no push token AND no phone number | Log warning. No notification sent. The call still happens — user just won't get the preview. | P1 |
| Timezone mismatch | All times stored as UTC. Convert to user's timezone for display. Scheduler uses UTC internally. |
P0 |
| User in different timezone than usual (traveling) | V1: use stored timezone. V2: detect via IP/device and ask "Are you in a different timezone?" | P2 |
| Notification job runs but call is already done (race condition) | Check scheduled_calls.status before sending. If status != 'pending', skip. |
P0 |
| iOS 18+ Live Activities | V2 feature: show a countdown Live Activity widget on lock screen. Out of scope for MVP. | P2 |
| Task | W1Feb 10-14 | W2Feb 17-21 | W3Feb 24-28 |
|---|---|---|---|
| ▶ Phase 1 — Foundation & Path A (Push) | |||
| DB schema — tables + migrations | |||
| Job scheduler — T-5min queue | |||
| Firebase setup — FCM + APNs | |||
| Path A — Push notification send | |||
| Device registration — API endpoint | |||
| ▶ Phase 2 — Path B (SMS) & Templates | |||
| Path B — SMS via Twilio | |||
| Message templates — i18n EN/FR | |||
| Notification prefs — API + UI | |||
| ▶ Phase 3 — Path C (Fallback) & Ship | |||
| Path C — Push + SMS fallback logic | |||
| Edge cases — reschedule, cancel, expired token | |||
| Unit tests — 3 paths + edge cases | |||
| QA & Deploy — staging → prod | |||
| Service | Purpose | Cost | Setup |
|---|---|---|---|
| Firebase Cloud Messaging | Push notifications (iOS + Android) | Free (unlimited) | Firebase project + APNs key (iOS) + FCM server key |
| Twilio SMS | SMS fallback notifications | ~$0.0075/SMS (US), ~€0.065/SMS (FR) | Already needed for calls. Same account, add SMS capability. |
| Job Scheduler | Schedule notification jobs at T-5min | N/A (infra) | Celery Beat, Bull, or pg_cron. Must support: create, cancel, reschedule. |
notification_pref and push permissionsBaseline: ~20% (no preview)
Target: ≥70% (with preview)
How: answered_calls / total_calls
Target: ≥60%
How: opened / delivered from notification_logs
Target: ≥95%
How: delivered / sent for push channel
Target: ≤15%
How: fallback_sms / total_previews (cost control)