Integration: Apple Push Notification service (APNs)

Summary

mam-main talks to APNs via HTTP/2 using token-based authentication (a .p8 key, not a .p12 cert) for standard pushes. Voice-over-IP pushes use a separate cert and a separate sender because Apple treats VoIP as a distinct push topic with stricter rules.

Implementation lives in includes/push-notification-manager/.


Components

Class Path Role
MAM_IosPushSender ios/ Standard APNs sender (production → sandbox fallback) via the bundled Pushok library
MAM_IosVoipPushSender ios/ VoIP APNs sender (separate token, production → sandbox fallback)
MAM_IosPayloadBuilder ios/ Builds the APNs payload (returns a PushokPayload)
MAM_IosCredentials ios/ Loads key id + team id + bundle id + auth-key file path from options

JWT provider-token signing and the HTTP/2 transport are handled by the bundled Pushok library (PushokAuthProvider, PushokClient), not by a mam-main class. The auth/MAM_ApnsTokenProvider.php and repo/MAM_PushTokenRepository.php files are empty stubs (planned extraction, not yet implemented); device tokens are read directly via get_user_meta() in the mam_push_notification_manager bridge.

The senders implement MAM_PushSenderInterface (contracts/):

interface MAM_PushSenderInterface {
    public function send( MAM_PushNotification $notification ): MAM_PushResult;
}

Credential storage

Asset WordPress option
APNs auth-key file ios_pn_pem_certs — the filename; MAM_IosCredentials resolves it to mam-main/pemcerts/<file>
APNs key id ios_pn_key_id
APNs team id ios_pn_team_id
iOS bundle id ios_pn_app_bundle_id (frozen — predates mam_* rename)

⚠️ Plaintext storage. Encryption-at-rest is a tracked hardening task. Don’t expose credential options through any new UI without first addressing this.

MAM_IosCredentials is the only legitimate read path for these options — never read them directly elsewhere.


JWT provider tokens

APNs uses short-lived JWT provider tokens signed with the .p8 auth key. In mam-main this is handled by the bundled Pushok library’s AuthProviderToken, not a mam-main class:

  1. MAM_IosCredentials supplies the auth-key file path, key id, team id, and bundle id
  2. PushokAuthProviderToken::create() reads the key and signs the JWT (kid: <key_id>, iss: <team_id>, iat: <now>)
  3. Pushok caches and reuses the provider token across the notifications in a send
  4. PushokClient sends over HTTP/2

Apple rejects stale tokens with ExpiredProviderToken; Pushok re-signs as needed. (The MAM_ApnsTokenProvider class named in earlier drafts is an unimplemented stub — see the components note above.)


Device tokens

Stored in usermeta:

Key Purpose
mam_app_ios_pn_token Standard APNs token
mam_ios_voip_token VoIP APNs token (separate token)
push_enabled User-side opt-in flag
push_status Last delivery status
push_status_updated Timestamp (update_user_meta( …, 'push_status_updated', time() ))

Tokens are written by the mobile app on first launch via the user-roles AJAX endpoints. They’re read at send time via get_user_meta() in the mam_push_notification_manager bridge (there is no MAM_PushTokenRepository — that file is an empty stub).

⚠️ APNs vs APNs VoIP require separate tokens. A standard APNs token will NOT receive VoIP pushes and vice versa.


Payload structure

MAM_IosPayloadBuilder produces:

{
  "aps": {
    "alert": { "title": "...", "body": "..." },
    "sound": "default",
    "badge": 3,
    "content-available": 1
  },
  "data": { ...arbitrary, deeplink etc. }
}

For VoIP, additional required fields enforced by MAM_IosVoipPushSender:

{
  "aps": {
    "alert": { ... },
    "sound": "ringtone",
    "interruption-level": "time-sensitive"
  },
  "callkit_payload": { ... }
}

⚠️ VoIP has a strict iOS deadline — the app must call CallKit within ~5 seconds of receiving the push or iOS kills the process and bans further VoIP pushes for the bundle.


Send flow

do_action('mam_notification_send_pn', $msg)
        │
        ▼
mam_push_notification_manager::send_push_notification()  ← legacy bridge
        │ wraps in MAM_PushNotification
        ▼
MAM_PushDispatcher::dispatch( $notification )
        │
   ┌────┴────────────────────┐
   ▼                         ▼
$pn->type === 'voip'         standard
   & $pn->hasIos()?          │
   YES                       │
   ▼                         ▼
MAM_IosVoipPushSender    MAM_IosPushSender
   │                         │
   ▼                         ▼
APNs HTTP/2 (VoIP        APNs HTTP/2 (standard
token, prod→sandbox)     .p8 + JWT, prod→sandbox)
   │                         │
   ▼                         ▼
MAM_PushResult (per-token status + errors)

Common errors (from MAM_PushResult->errors)

APNs error Meaning Action
BadDeviceToken Token isn’t valid for this app/environment Drop the stored token; the device will re-register
Unregistered Token was valid but the app is uninstalled Drop the token
ExpiredProviderToken JWT is too old Provider re-signs and retries automatically
BadCertificateEnvironment Production cert sent to sandbox or vice versa Check local-app-ios-app-channel
PayloadTooLarge Payload >4KB Reduce body length, move data to data block
TooManyRequests Rate limit Back off and retry

Tokens flagged with BadDeviceToken / Unregistered should be dropped from usermeta to avoid wasting future sends — tracked as a hardening item; not currently automatic.


Gotchas

  • APNs vs VoIP require separate tokens (VoIP is a distinct push topic).
  • JWT staleness is handled automatically by the Pushok library; don’t try to cache tokens yourself elsewhere.
  • Connection pooling not implemented. MAM_IosPushSender doesn’t currently pool HTTP/2 connections across requests. High-volume sends pay TCP setup per request. Tracked.
  • VoIP 5-second deadline. Payload builder enforces required fields, but the app team must implement CallKit handoff correctly.
  • Plaintext credentials are a known launch-readiness gap.
  • Sandbox vs production — pushing to a TestFlight build with a production cert (or vice versa) silently fails with BadCertificateEnvironment.

  • Recipe: Configure push notifications
  • Integration: Firebase Cloud Messaging (FCM)
  • Notification channels: email, SMS, push
  • Hook: mam_notification_send_pn

Metadata

Field Value
Article type Plugin Overview
Plugin slug mam-main
Applies to plugin version 2.1.11+
Category Plugin Reference
Audience PHP developer
Last verified 2026-05-02
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!