🖨 Print ← War Room
Product Requirements Document

Preview Notification

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.

Priority
P0 — Critical
Owner
Ata (Backend)
Status
Spec Ready
Sprint
MVP
Est. Effort
3–5 days
01 — Context

Why This Feature is Critical

Core Problem

80% of people don't answer calls from unknown numbers

Our entire product depends on users answering the daily AI call. Without a preview notification, most calls go to voicemail = zero engagement = churn.

Expected Impact

Answer rate: 20% → 70–90%

  • Branded caller ID alone = +56% answer rate (Hiya data)
  • Preview notification + saved contact + scheduled time = estimated 70–90% answer rate
  • Streak system tied to calls = 5x retention (Duolingo benchmark)
  • Accountability = 2.7x more effective than encouragement alone

This is a P0 feature because without it, the call channel — our core differentiator — doesn't work.

02 — User Stories

User Stories

As a user with a scheduled daily call,
I want to receive a push notification 5 min before my call,
So that I know it's coming and I answer it instead of ignoring it.
As a user who missed the push notification,
I want to receive an SMS fallback 5 min before,
So that I still know the call is coming even if push is off.
As a user,
I want to see today's check-in topic in the notification,
So that I can mentally prepare and the call feels personal, not random.
As a user whose coach name is "Max",
I want to see "Coach Max is calling in 5 min!" (not a generic message),
So that it feels like a real person checking in on me.
03 — Notification Design

What the User Sees

Push Notification Mockup

💪
Call Me Maybe
Coach Max is calling in 5 min! 🔥
Today: your fitness goal check-in. How's your streak going?
now
🌱
Call Me Maybe
Dr. Luna calls in 5 min 🌙
Day 14 of your streak! Today: weekly weigh-in & meal review.
now
📢
Call Me Maybe
Mama Rosa appelle dans 5 min! ❤️
Jour 7 — une semaine complète! On fait le point ensemble.
now

Notification Content Rules

FieldContentSource
title{coach_name} is calling in 5 min! {emoji}user.coach_persona
bodyDay {streak_count}: {today_topic}. {contextual_line}call_schedule.topic + user.streak
emojiRotates: 💪 🔥 🌱 ⭐ 🏆Based on streak milestone
languageMatch user's locale (en/fr)user.locale
deep_linkOpens app to "incoming call" screenFixed route

SMS Fallback Message

// 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.

04 — System Flow

How It Works (End-to-End)

1
Call Scheduler creates job
When user sets their call time (e.g. 7:00 AM), the scheduler creates two jobs: preview_notification at T-5min (6:55 AM) and initiate_call at T (7:00 AM).
2
T-5 min: Notification Service triggers
Worker picks up the preview_notification job. Fetches user's coach persona, streak count, today's topic, locale, and notification preferences.
3
Build notification payload
Template engine fills in the message: coach name, streak day, topic, emoji. Two payloads are built: push (rich) and SMS (plain, ≤160 chars).
4
Send via preferred channel
Path A — Push: If user has a valid FCM/APNs token → send push notification via Firebase Cloud Messaging.
Path B — SMS: If no push token OR user opted for SMS → send via Twilio.
Path C — Both: If user has push + opted into SMS backup → send push first, SMS only if push delivery fails within 30s.
5
Log delivery status
Store delivery receipt in notification_logs: sent, delivered, failed, opened. Track for analytics and retry logic.
6
T-0: Call initiates
5 min later, the initiate_call job fires. Twilio/Vapi makes the AI call. The user already expects it.
05 — Delivery Paths

The 3 Notification Paths

Path A

📴 Push Notification Only

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

Path B

💬 SMS Only

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

Path C

🔔 Push + SMS Fallback

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

Path Selection Logic

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
06 — Data Model

Database Schema

New table: notification_logs

CREATE 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);

Columns to add to users table

ColumnTypeDescription
fcm_tokenTEXTFirebase Cloud Messaging device token
push_enabledBOOLEANWhether push is enabled on device
notification_prefVARCHAR(20)'push_only' | 'sms_only' | 'push_and_sms'
notification_lead_minINTEGERMinutes before call to notify (default: 5)

Columns to add to scheduled_calls table

ColumnTypeDescription
preview_sentBOOLEANWas preview notification sent for this call?
preview_sent_atTIMESTAMPTZWhen the preview was sent
topicVARCHAR(100)Today's check-in topic (for notification body)
07 — API Endpoints

API Endpoints

Endpoint 1

POST   /api/v1/notifications/register-device

Called 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" }
Endpoint 2

PUT   /api/v1/users/notification-preferences

User updates their notification preference (push, SMS, both).

// Request body
{
  "notification_pref": "push_and_sms",
  "notification_lead_min": 5
}

// Response: 200 OK
{ "status": "updated" }
Endpoint 3

GET   /api/v1/notifications/history

Get 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"
    }
  ]
}
Internal / Worker

WORKER   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())
08 — Templates

Message Templates (i18n)

KeyENFR
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 Examples

Topic KeyENFR
fitness_checkinyour fitness goal check-inpoint objectif fitness
weekly_weighinweekly weigh-in & meal reviewpesée & bilan repas de la semaine
mood_checkmood & energy checkpoint humeur & énergie
meal_reviewyesterday's meals reviewbilan repas d'hier
exercise_plantoday's exercise planplanning sport du jour
streak_celebratestreak celebration!fête du streak !
09 — Edge Cases

Edge Cases & Error Handling

ScenarioBehaviorPriority
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
10 — Acceptance Criteria

Definition of Done

Must pass all
Push notification arrives on iOS and Android device exactly 5 min before the scheduled call time (±30s tolerance)
Notification title contains the user's coach persona name (not generic)
Notification body contains the current streak day count and today's topic
SMS fallback fires within 30s if push delivery fails (Path C)
SMS message is ≤ 160 characters
French locale users receive French text, English locale receives English
Rescheduling a call cancels the old preview job and creates a new one
Canceling a call cancels the preview notification job
notification_logs table records every notification with correct status
No duplicate notifications — if preview already sent for a call_id, do not resend
Tapping the notification opens the app to the "incoming call" or "call prep" screen
Unit tests cover all 3 paths + edge cases (expired token, no phone, cancel, reschedule)
11 — Timeline

Implementation Gantt — February 2026

Task W1Feb 10-14 W2Feb 17-21 W3Feb 24-28
Phase 1 — Foundation & Path A (Push)
DB schema — tables + migrations
2d
Job scheduler — T-5min queue
2d
Firebase setup — FCM + APNs
1d
Path A — Push notification send
2d
Device registration — API endpoint
1d
Phase 2 — Path B (SMS) & Templates
Path B — SMS via Twilio
2d
Message templates — i18n EN/FR
1d
Notification prefs — API + UI
2d
Phase 3 — Path C (Fallback) & Ship
Path C — Push + SMS fallback logic
2d
Edge cases — reschedule, cancel, expired token
2d
Unit tests — 3 paths + edge cases
2d
QA & Deploy — staging → prod
1d
Core infra & Path A
Path B (SMS)
Path C (Fallback)
Setup & APIs
Edge cases
Tests
Deploy
Summary

3 weeks — Feb 10 → Feb 28

  • Week 1 (Feb 10-14): DB schema, job scheduler, Firebase setup, device registration API
  • Week 2 (Feb 17-21): Path A (push), Path B (SMS), message templates, notification prefs
  • Week 3 (Feb 24-28): Path C (fallback), edge cases, unit tests, QA & deploy to prod
12 — Dependencies

External Dependencies

ServicePurposeCostSetup
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.

Blocked by

Blocks

13 — Success Metrics

How We Measure Success

Primary KPI

Call Answer Rate

Baseline: ~20% (no preview)
Target: ≥70% (with preview)
How: answered_calls / total_calls

Secondary

Notification Open Rate

Target: ≥60%
How: opened / delivered from notification_logs

Secondary

Push Delivery Rate

Target: ≥95%
How: delivered / sent for push channel

Guardrail

SMS Fallback Rate

Target: ≤15%
How: fallback_sms / total_previews (cost control)

Call Me Maybe © 2026 — PRD by Aaron for Ata

War Room · Tech Spec