Skip to content

iOS VoIP Push Wake — Design & Runbook

Validated E2E on staging 2026-07-03 (killed / background / locked / offline / reconnect scenarios, Hari-verified "working perfect"). This is the reference for how an inbound call reaches the Astradial iOS app in every app state, and how to debug it. History + individual bugs: troubleshooting Errors 78–83.

Why this exists

iOS kills backgrounded VoIP sockets; a killed/backgrounded app is not registered. Apple's only sanctioned wake is a PushKit VoIP push, and iOS 13+ requires every such push to immediately report a CallKit call. We run Asterisk (not Flexisip), so the push pipeline is ours end-to-end: platform API + APNs + dialplan hook + app.

Call flow (wake decision v2 — PRs #494…#497)

Caller dials extension
  └─ dialplan (per-user, generated; gated on voip_push_token + INTERNAL_API_URL)
     Ringing()                                ← caller hears ringback during decision
     WAKERES = CURL /api/v1/asterisk/voip-push-wait?endpoint=…&plain=1&caller=…
       │  server: ALWAYS sends VoIP push {caller, push-id}, apns-expiration:0
       │  ┌ qualify-verify: cached "reachable" → pjsip qualify → re-read after 3.5s
       │  │   alive → "yes" (reason verified)
       │  ├ app ack of push-id ≤5s (POST /api/v1/users/voip-push-ack)
       │  │   → poll for its re-REGISTER ≤15s → "yes" (reason acked)
       │  └ neither → "no" (reason no-ack) — total ~5-6s
     GotoIf($["${WAKERES}"="yes"]?available)   ← trusts wake over stale DEVSTATE
     Set(DEVSTATE…) → NOT_INUSE/engaged → (available) Dial ; UNAVAILABLE → failover

App side on push (PushKitManager, main queue): ① POST the push-id ack (before any core work — the delivery receipt APNs doesn't provide), ② report the CallKit call immediately (caller number from the push payload), ③ begin the wake keepalive (incomingCallWakeInProgress — CoreContext must not stop the Core mid-wake), ④ refreshRegisters() (NEVER processPushNotification(nil) — the empty call-id trips liblinphone's pushkit dedup and kills the wake). When the INVITE lands (.IncomingReceived): re-key the "" placeholder to the real call-id, then terminate liblinphone's own pushkit phantom session (unmergeable because our push has no call-id; it otherwise blocks accept ~10s — DEF-007).

Registration honesty

  • Server AOR: maximum_expiration=300, minimum_expiration=30, qualify_frequency=60, qualify_timeout=3.0.
  • App Settings → SIP Registration: 30s/1m/2m/5m (default 5m); changing it forces an immediate re-REGISTER.
  • Token lifecycle: app POSTs /users/voip-push-token on SIP attach (and on every didUpdate/token refresh — the endpoint must be known first, so the app re-sends the cached token once a SIP line attaches). Token presence flip triggers an org regen (else the wake CURL wouldn't exist until an unrelated deploy). Body:
{ "asterisk_endpoint": "<PJSIP endpoint>", "token": "<hex token>",
  "environment": "dev|prod", "bundle_id": "<app id>",
  "platform": "ios|android" }

Cross-platform: iOS (APNs) & Android (FCM)

The token endpoint is platform-aware (server server.js voip-push-token): platform is stored as voip_push_platform and the wake gateway picks the sender — android → FCM data message, anything else → APNs VoIP push. The wake decision (ack race, qualify verify, failover) is identical; only the transport differs. bundle_id is the APNs topic (<bundle_id>.voip) on iOS and ignored on Android.

  • iOS sends platform:"ios" (VoIP/PushKit token) — live.
  • Android sends platform:"android" (FCM registration token) — the app is in build (astracall-android-plan.md); the server FCM sender services/fcmService.sendDataPush is a stub until FCM_SERVICE_ACCOUNT_JSON is set and the FCM HTTP v1 send is wired. Until then android tokens store fine but the gateway falls through to cached SIP state (same graceful path as APNs-down) — Android calls don't fail to the phone number, they just don't wake-ring yet.

Tunables (api .env)

Var Default Meaning
VOIP_PUSH_ACK_TIMEOUT_MS 5000 ack race window
VOIP_PUSH_TIMEOUT_MS 15000 re-REGISTER wait after ack
APNS_KEY_ID/TEAM_ID/KEY_PATH/BUNDLE_ID .p8 auth (topic <bundle>.voip)
INTERNAL_API_URL/KEY dialplan → api hook

Debugging quick refs

  • Dialplan decision: grep "VoIP wake:" /var/log/asterisk/full.logyes|no.
  • API decision reason: pm2 logs → verified / acked / no-ack / acked-noregister / push-4xx (4xx incl. 410 = dead token).
  • App: Console.app, process Astradial, Include Info Messages; look for [PushKitManager] Incoming VoIP pushPush ack sent → REGISTER 200 → IncomingReceived. NSLog ⏱️ markers don't survive RTF export.
  • Token per device: users.voip_push_token (env dev=sandbox Debug builds, prod=Release). Wrong env → APNs 400 BadDeviceToken.
  • Fake call on reconnect = something re-introduced pushes without apns-expiration: 0.

Known limits / ceilings

  • Push acks: in-memory map, single pm2 fork — move to a shared store if the api is ever clustered.
  • Wake adds ~3.5s (verify) pre-ring for registered-but-unconfirmed contacts; ack path is ~1-3s. All behind ringback.
  • pjsip qualify verification needs qualify enabled (nonqual contacts are trusted as-is — e.g. Alphion ONTs, which don't answer OPTIONS anyway).