Mobile No. 343

Mobile Push Notifications skill for Claude Code

Delivered, opted-in, and opening the right screen. Claude wires push end to end: per-device tokens stored with their APNs environment, correct apns-push-type and priority headers, FCM HTTP v1 messages with string-only data, and pruning on 410 or UNREGISTERED. It designs a pre-prompt before the system permission dialog, routes taps from foreground, background and cold start, and validates every payload template with a bundled script.

  • namemobile-push-notifications
  • size205 lines · ≈2,450 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements push notifications end to end for iOS and Android — APNs token-based auth and headers, FCM HTTP v1 sending, device token lifecycle, permission prompts with good opt-in UX (provisional, Android 13 POST_NOTIFICATIONS, channels), notification vs. data payloads, rich media, deep linking on tap from cold start, and testing with simctl and real devices. Includes a payload validator script. Use when adding push to an app, notifications do not arrive or do not open the right screen, migrating off the legacy FCM API, or designing permission prompts.

Try asking

  • Push notifications work in development but not from TestFlight — find out why
  • Tapping a notification when the app is closed opens the home screen instead of the order
  • Migrate our sender from the legacy FCM API to HTTP v1

Use it when

  • Adding push notifications to an iOS, Android or cross-platform app
  • Notifications fail to arrive in one environment or platform
  • Notification taps do not navigate correctly
  • Designing permission prompts and notification channels

Skip it when

How to check the result

Every payload template passes the validator, and taps route correctly from foreground, background and cold start on physical devices in both development and release builds.

The common mistake it prevents

Sending a TestFlight or App Store token to the APNs sandbox host, which returns BadDeviceToken and looks like a broken integration.

~/.claude/skills/mobile-push-notifications/SKILL.md

--- name: mobile-push-notifications description: Implements push notifications end to end for iOS and Android — APNs token-based auth and headers, FCM HTTP v1 sending, device token… license: MIT ---

Push works when the backend stores a fresh token per device with its environment, sends with the correct platform headers and priority, prunes dead tokens from provider responses, asks permission at a moment the user understands, and every tap opens the intended screen even when the app was killed.

Architecture

App ──register──> APNs / FCM ──token──> App ──POST /devices──> Backend (token, platform, env, app version, locale, user, last_seen)
Backend ──send──> APNs (HTTP/2, .p8 JWT)  or  FCM HTTP v1 (OAuth2 service account) ──> Device
DecisionDefault
iOS transportAPNs directly with token-based auth (.p8 key, Key ID, Team ID) — one key works for all your apps and never expires
Android transportFCM HTTP v1 API. The legacy fcm/send API and server keys are shut down
Cross-platform shortcutFCM for both (FCM relays to APNs with your uploaded .p8), or Expo Push / a provider (OneSignal, Braze)
Token storageOne row per device installation, many per user; never one token per user

Workflow

- [ ] 1. Capabilities: Push Notifications (+ Background Modes > Remote notifications if silent push) on iOS; FCM config on Android
- [ ] 2. Register for remote notifications on every launch; send token + metadata to backend
- [ ] 3. Permission prompt at a meaningful moment (pre-prompt first)
- [ ] 4. Android notification channels created at startup
- [ ] 5. Backend sender with correct headers, priority, TTL, collapse IDs
- [ ] 6. Handle provider responses: delete unregistered tokens, retry 5xx/429 with backoff
- [ ] 7. Tap handling: route from foreground, background, and cold start
- [ ] 8. Test matrix (below) on real devices, dev and production environments

Permission UX

  • Never prompt on first launch with no context. Show an in-app explanation tied to a benefit (“Get notified when your order ships”), then call the system prompt only if the user agrees. On iOS a denied system prompt cannot be shown again; only Settings can change it.
  • iOS provisional authorization (.provisional) delivers quietly to Notification Center without a prompt; good for content apps to demonstrate value before asking.
  • Android 13+ (targetSdk 33+): POST_NOTIFICATIONS is a runtime permission. Below 13, notifications are on by default. Handle “don’t ask again” by linking to app settings.
  • Create Android channels (NotificationChannel) per category (orders, messages, marketing) with honest importance levels; users can mute each separately. Channel importance cannot be changed by the app after creation.
  • Track opt-in rate and re-check authorization status on each foreground; sync it to the backend so you do not send to opted-out devices.

Token lifecycle

  • Tokens change (reinstall, restore from backup, OS updates, FCM rotation). Fetch on every launch and listen for refresh callbacks (onNewToken on Android, the registration delegate on iOS); upsert on change.
  • Store environment for iOS tokens: development builds get sandbox tokens (api.sandbox.push.apple.com); TestFlight and App Store builds get production tokens (api.push.apple.com). Sending to the wrong host returns BadDeviceToken.
  • On logout, delete the device row (or detach the user) so the next user of the device does not receive the previous user’s notifications.
  • Prune: APNs 410 Unregistered and FCM UNREGISTERED (404) mean delete the token now. Also expire tokens not seen for ~60 days.

Sending: APNs

Required headers:

HeaderValue
authorizationbearer <JWT> signed ES256 with the .p8; reuse it, refresh between 20 and 60 minutes
apns-topicBundle ID (com.acme.app); VoIP com.acme.app.voip; Live Activities com.acme.app.push-type.liveactivity
apns-push-typealert, background, voip, liveactivity, … — must match the payload
apns-priority10 immediate (alerts); 5 for background pushes (required) and non-urgent alerts
apns-expirationUnix time after which to drop; 0 = deliver once or drop
apns-collapse-idReplace an older notification with the same ID (≤ 64 bytes)
{
  "aps": {
    "alert": { "title": "Order shipped", "body": "Arriving Thursday" },
    "sound": "default",
    "thread-id": "order-42",
    "mutable-content": 1
  },
  "route": "/orders/42"
}

Common errors: BadDeviceToken (environment mismatch or malformed), DeviceTokenNotForTopic (wrong bundle ID), InvalidProviderToken (bad JWT, wrong Key ID/Team ID, clock skew), TooManyProviderTokenUpdates (regenerating JWT per request), PayloadTooLarge (> 4 KB).

Sending: FCM HTTP v1

curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d @message.json
{
  "message": {
    "token": "DEVICE_TOKEN",
    "notification": { "title": "Order shipped", "body": "Arriving Thursday" },
    "data": { "route": "/orders/42", "orderId": "42" },
    "android": { "priority": "high", "ttl": "86400s", "collapse_key": "order-42",
                 "notification": { "channel_id": "orders", "tag": "order-42" } },
    "apns": { "headers": { "apns-priority": "10", "apns-collapse-id": "order-42" },
              "payload": { "aps": { "sound": "default", "thread-id": "order-42" } } }
  }
}
  • Get access tokens from a service account via the Firebase Admin SDK or Google auth libraries (scope https://www.googleapis.com/auth/firebase.messaging); never ship the service account in the app.
  • One request per token (v1 has no multicast field); the Admin SDKs batch for you (sendEach). Use topics for broadcast.
  • All data values must be strings.
  • Retry UNAVAILABLE/INTERNAL/429 with exponential backoff and jitter, honoring Retry-After. Do not retry INVALID_ARGUMENT.

Validate payloads before sending:

python3 scripts/check_push_payload.py message.json                        # auto-detects FCM v1 or APNs
python3 scripts/check_push_payload.py silent.json --push-type background

It checks size limits, required keys, string-only FCM data, reserved keys, legacy-API fields, background-push rules, mutable-content without alert, and priority mismatches.

Notification vs. data messages

TypeDeliveryUse
Visible notification (alert / FCM notification)Shown by the OS even if the app is killedUser-facing messages
iOS background (content-available: 1, priority 5)Throttled, best effort, not delivered after the user force-quits the appPrefetch hints only
Android data-onlyDelivered to onMessageReceived; high priority can be deprioritized if it never shows UICustom rendering, sync triggers

Never rely on silent pushes for correctness (sync, security revocation). Treat them as hints and reconcile on next app open (see offline-sync).

Foreground behavior differs: iOS hides notifications while the app is foreground unless userNotificationCenter(_:willPresent:) returns [.banner, .sound, .list]; Android FCM notification messages in foreground go to onMessageReceived and are not shown automatically.

Rich and private content

  • Images and decryption on iOS: a Notification Service Extension (requires mutable-content: 1 and an alert) downloads media or decrypts an encrypted body within ~30 seconds, falling back to the original text.
  • Android: build BigPictureStyle/MessagingStyle in onMessageReceived for data messages.
  • Lock screens show content. Do not put sensitive data (codes, balances, medical info) in payloads unless encrypted end to end; send “You have a new message” plus an ID and fetch details when the user opens the app.
  • Put a route or URL in the payload (route, url), not a screen name the app might rename. Map it with the same router used for deep links (see deep-links).
  • Handle all three entry states:
    • Foreground/background tap: iOS userNotificationCenter(_:didReceive:); Android PendingIntent extras in onNewIntent/onCreate.
    • Cold start: read the launching notification (Firebase getInitialMessage(), Expo useLastNotificationResponse(), the launch intent on Android) after navigation is ready, then navigate.
  • If the route requires auth, store the pending route, show sign-in, then continue.
  • Log notification_opened with campaign ID for analytics.

Testing matrix

# iOS Simulator: payload file must contain "Simulator Target Bundle" or pass the bundle ID
xcrun simctl push booted com.acme.app payload.apns
CaseiOSAndroid
App foregroundbanner shown per willPresent?shown by your code?
App backgroundappears, tap routes correctlyappears, tap routes correctly
App killed (cold start tap)routes after launchroutes after launch
Permission deniedno crash, settings link workssame, Android 13+
Token refresh / reinstallbackend updated, old token prunedsame
Production environmentTestFlight build receives via production APNsrelease build

Use Apple’s Push Notifications Console in the developer portal or the Firebase console “Send test message” for one-off sends to a device token.

Verification

  1. Payload validator passes for every template the backend sends.
  2. The matrix above passes on physical devices for both a development and a TestFlight/release build.
  3. Uninstall the app, send to the old token: backend receives 410/UNREGISTERED and deletes the token.
  4. Logout then login as another user: only the new user’s notifications arrive.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_push_payload.py 203 lines · 10.2 KB
#!/usr/bin/env python3
"""Validate an APNs payload or an FCM HTTP v1 request body (stdlib only).

Usage:
  python3 check_push_payload.py FILE.json [--type apns|fcm] [--push-type alert|background|voip|liveactivity]

Type is auto-detected: {"message": {...}} -> FCM v1, {"aps": {...}} -> APNs.
Errors exit 1; warnings are printed but do not fail. Exit 2 on unreadable input.
"""
import argparse
import json
import sys

APNS_LIMIT = 4096
APNS_VOIP_LIMIT = 5120
FCM_LIMIT = 4096
INTERRUPTION_LEVELS = {"passive", "active", "time-sensitive", "critical"}
FCM_RESERVED_PREFIXES = ("google.", "gcm.")
FCM_RESERVED_KEYS = {"from", "notification", "message_type", "collapse_key"}

errors, warnings = [], []


def err(msg):
    errors.append(msg)


def warn(msg):
    warnings.append(msg)


def size_of(obj):
    return len(json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))


def check_aps(payload, where, push_type, check_size=True):
    if not isinstance(payload, dict):
        err(f"{where}: payload must be a JSON object"); return
    aps = payload.get("aps")
    if not isinstance(aps, dict):
        err(f"{where}: missing 'aps' dictionary"); return
    if check_size:
        limit = APNS_VOIP_LIMIT if push_type == "voip" else APNS_LIMIT
        n = size_of(payload)
        if n > limit:
            err(f"{where}: payload is {n} bytes, APNs limit is {limit}")
        elif n > limit * 0.9:
            warn(f"{where}: payload is {n} bytes, close to the {limit}-byte limit")

    alert, content_available = aps.get("alert"), aps.get("content-available")
    if content_available not in (None, 1):
        err(f"{where}: aps.content-available must be 1 when present")
    if push_type == "background" or (content_available == 1 and not alert):
        for k in ("alert", "sound", "badge"):
            if k in aps:
                warn(f"{where}: background push should not include aps.{k}; send apns-push-type: background with apns-priority 5")
        if push_type != "background":
            warn(f"{where}: content-available only payload — send header apns-push-type: background and apns-priority: 5")
    if push_type == "alert" and not alert and not aps.get("badge") and not aps.get("sound"):
        err(f"{where}: alert push needs aps.alert, aps.badge or aps.sound")

    if alert is not None:
        if isinstance(alert, dict):
            known = {"title", "subtitle", "body", "launch-image", "title-loc-key", "title-loc-args",
                     "subtitle-loc-key", "subtitle-loc-args", "loc-key", "loc-args"}
            for k in alert:
                if k not in known:
                    warn(f"{where}: unknown aps.alert key '{k}'")
            if not any(k in alert for k in ("title", "body", "loc-key", "title-loc-key")):
                err(f"{where}: aps.alert has no title, body or loc-key")
            for k in ("loc-args", "title-loc-args", "subtitle-loc-args"):
                if k in alert and not (isinstance(alert[k], list) and all(isinstance(x, str) for x in alert[k])):
                    err(f"{where}: aps.alert.{k} must be an array of strings")
        elif not isinstance(alert, str):
            err(f"{where}: aps.alert must be a string or dictionary")

    if "badge" in aps and (not isinstance(aps["badge"], int) or aps["badge"] < 0):
        err(f"{where}: aps.badge must be a non-negative integer (0 clears the badge)")
    if "sound" in aps:
        s = aps["sound"]
        if isinstance(s, dict):
            if s.get("critical") == 1:
                warn(f"{where}: critical alerts need Apple's critical alerts entitlement")
            if "volume" in s and not (isinstance(s["volume"], (int, float)) and 0 <= s["volume"] <= 1):
                err(f"{where}: aps.sound.volume must be between 0 and 1")
        elif not isinstance(s, str):
            err(f"{where}: aps.sound must be a string or dictionary")
    if aps.get("mutable-content") not in (None, 1):
        err(f"{where}: aps.mutable-content must be 1 when present")
    if aps.get("mutable-content") == 1 and not alert:
        err(f"{where}: mutable-content requires a visible alert; the service extension never runs for silent pushes")
    if "interruption-level" in aps and aps["interruption-level"] not in INTERRUPTION_LEVELS:
        err(f"{where}: aps.interruption-level must be one of {sorted(INTERRUPTION_LEVELS)}")
    if aps.get("interruption-level") == "time-sensitive":
        warn(f"{where}: time-sensitive requires the Time Sensitive Notifications capability")
    if "relevance-score" in aps and not (isinstance(aps["relevance-score"], (int, float)) and 0 <= aps["relevance-score"] <= 1):
        err(f"{where}: aps.relevance-score must be between 0 and 1")
    for k in ("thread-id", "category", "target-content-id"):
        if k in aps and not isinstance(aps[k], str):
            err(f"{where}: aps.{k} must be a string")
    if push_type == "liveactivity":
        for k in ("event", "timestamp", "content-state"):
            if k not in aps:
                err(f"{where}: Live Activity push needs aps.{k}")
        if aps.get("event") not in (None, "start", "update", "end"):
            err(f"{where}: aps.event must be start, update or end")
    known_aps = {"alert", "badge", "sound", "content-available", "mutable-content", "category", "thread-id",
                 "target-content-id", "interruption-level", "relevance-score", "filter-criteria",
                 "stale-date", "content-state", "timestamp", "event", "dismissal-date", "attributes-type",
                 "attributes", "input-push-token", "input-push-channel"}
    for k in aps:
        if k not in known_aps:
            warn(f"{where}: unknown aps key '{k}' — custom data belongs outside 'aps'")
    for k, v in payload.items():
        if k != "aps" and isinstance(v, str) and any(t in k.lower() for t in ("password", "token", "secret", "ssn")):
            warn(f"{where}: custom key '{k}' looks sensitive; payloads are visible to the OS and in logs")


def check_fcm(body, push_type):
    msg = body.get("message")
    if not isinstance(msg, dict):
        err("FCM v1 body must be {\"message\": {...}} (legacy /fcm/send format was shut down)"); return
    targets = [k for k in ("token", "topic", "condition") if k in msg]
    if len(targets) != 1:
        err(f"message must have exactly one of token/topic/condition (found {targets or 'none'})")
    if "registration_ids" in msg or "to" in msg:
        err("'to'/'registration_ids' are legacy API fields; send one request per token or use topics")
    notif, data = msg.get("notification"), msg.get("data")
    if notif is not None:
        if not isinstance(notif, dict):
            err("message.notification must be an object")
        else:
            for k in notif:
                if k not in ("title", "body", "image"):
                    err(f"message.notification.{k} is not valid in v1; platform options go in android/apns/webpush")
    if data is not None:
        if not isinstance(data, dict):
            err("message.data must be an object")
        else:
            for k, v in data.items():
                if not isinstance(v, str):
                    err(f"message.data.{k} must be a string (stringify numbers, booleans and JSON)")
                if k in FCM_RESERVED_KEYS or k.startswith(FCM_RESERVED_PREFIXES):
                    err(f"message.data key '{k}' is reserved")
    n = size_of({k: msg[k] for k in ("notification", "data") if k in msg})
    if n > FCM_LIMIT:
        err(f"notification+data is {n} bytes, FCM limit is {FCM_LIMIT}")
    if notif is None and data is None and "apns" not in msg and "android" not in msg:
        err("message has no notification, data, android or apns content")

    android = msg.get("android")
    if isinstance(android, dict):
        if android.get("priority") not in (None, "high", "normal", "HIGH", "NORMAL"):
            err("android.priority must be high or normal")
        ttl = android.get("ttl")
        if ttl is not None and not (isinstance(ttl, str) and ttl.endswith("s")):
            err("android.ttl must be a duration string like \"3600s\"")
        an = android.get("notification")
        if isinstance(an, dict) and "channel_id" not in an:
            warn("android.notification.channel_id not set — Android 8+ falls back to the default channel")
        if notif is None and data is not None and android.get("priority") in ("high", "HIGH"):
            warn("high-priority data-only messages that never show a notification can be deprioritized by Android")

    apns = msg.get("apns")
    if isinstance(apns, dict):
        headers = apns.get("headers", {})
        if headers.get("apns-priority") not in (None, "5", "10"):
            err("apns.headers.apns-priority must be the string \"5\" or \"10\"")
        if "payload" in apns:
            check_aps(apns["payload"], "apns.payload", headers.get("apns-push-type", push_type), check_size=False)
            ca = apns["payload"].get("aps", {}).get("content-available") if isinstance(apns["payload"], dict) else None
            if ca == 1 and headers.get("apns-priority") == "10" and not apns["payload"].get("aps", {}).get("alert") and notif is None:
                err("background (content-available) push must use apns-priority \"5\"")
    elif notif is None and data is not None:
        warn("data-only message without apns block — iOS will not wake the app unless apns.payload.aps.content-available is 1")


def main():
    ap = argparse.ArgumentParser(description="Validate APNs / FCM v1 push payloads")
    ap.add_argument("file")
    ap.add_argument("--type", choices=["apns", "fcm"])
    ap.add_argument("--push-type", choices=["alert", "background", "voip", "liveactivity"], default=None)
    args = ap.parse_args()
    try:
        with open(args.file, encoding="utf-8") as fh:
            body = json.load(fh)
    except (OSError, json.JSONDecodeError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    kind = args.type or ("fcm" if isinstance(body, dict) and "message" in body else "apns")
    if kind == "fcm":
        check_fcm(body, args.push_type)
    else:
        check_aps(body, "apns", args.push_type)
    for w in warnings:
        print(f"WARN  {w}")
    for e in errors:
        print(f"ERROR {e}")
    print(f"{kind.upper()} payload: {len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc