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.
- name
mobile-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
- You need universal links and app links set up — use deep-links
- The goal is email or SMS messaging — use transactional-email or twilio-messaging
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.
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
| Decision | Default |
|---|---|
| iOS transport | APNs directly with token-based auth (.p8 key, Key ID, Team ID) — one key works for all your apps and never expires |
| Android transport | FCM HTTP v1 API. The legacy fcm/send API and server keys are shut down |
| Cross-platform shortcut | FCM for both (FCM relays to APNs with your uploaded .p8), or Expo Push / a provider (OneSignal, Braze) |
| Token storage | One 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_NOTIFICATIONSis 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 (
onNewTokenon Android, the registration delegate on iOS); upsert on change. - Store
environmentfor 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 returnsBadDeviceToken. - 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 Unregisteredand FCMUNREGISTERED(404) mean delete the token now. Also expire tokens not seen for ~60 days.
Sending: APNs
Required headers:
| Header | Value |
|---|---|
authorization | bearer <JWT> signed ES256 with the .p8; reuse it, refresh between 20 and 60 minutes |
apns-topic | Bundle ID (com.acme.app); VoIP com.acme.app.voip; Live Activities com.acme.app.push-type.liveactivity |
apns-push-type | alert, background, voip, liveactivity, … — must match the payload |
apns-priority | 10 immediate (alerts); 5 for background pushes (required) and non-urgent alerts |
apns-expiration | Unix time after which to drop; 0 = deliver once or drop |
apns-collapse-id | Replace 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
datavalues must be strings. - Retry
UNAVAILABLE/INTERNAL/429 with exponential backoff and jitter, honoringRetry-After. Do not retryINVALID_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
| Type | Delivery | Use |
|---|---|---|
Visible notification (alert / FCM notification) | Shown by the OS even if the app is killed | User-facing messages |
iOS background (content-available: 1, priority 5) | Throttled, best effort, not delivered after the user force-quits the app | Prefetch hints only |
| Android data-only | Delivered to onMessageReceived; high priority can be deprioritized if it never shows UI | Custom 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: 1and an alert) downloads media or decrypts an encrypted body within ~30 seconds, falling back to the original text. - Android: build
BigPictureStyle/MessagingStyleinonMessageReceivedfor 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.
Tap handling and deep links
- 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 (seedeep-links). - Handle all three entry states:
- Foreground/background tap: iOS
userNotificationCenter(_:didReceive:); AndroidPendingIntentextras inonNewIntent/onCreate. - Cold start: read the launching notification (Firebase
getInitialMessage(), ExpouseLastNotificationResponse(), the launch intent on Android) after navigation is ready, then navigate.
- Foreground/background tap: iOS
- If the route requires auth, store the pending route, show sign-in, then continue.
- Log
notification_openedwith 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
| Case | iOS | Android |
|---|---|---|
| App foreground | banner shown per willPresent? | shown by your code? |
| App background | appears, tap routes correctly | appears, tap routes correctly |
| App killed (cold start tap) | routes after launch | routes after launch |
| Permission denied | no crash, settings link works | same, Android 13+ |
| Token refresh / reinstall | backend updated, old token pruned | same |
| Production environment | TestFlight build receives via production APNs | release 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
- Payload validator passes for every template the backend sends.
- The matrix above passes on physical devices for both a development and a TestFlight/release build.
- Uninstall the app, send to the old token: backend receives 410/UNREGISTERED and deletes the token.
- 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
#!/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.