| name | push-notifications-backend |
| description | Send push to mobile via FCM HTTP v1 and APNs over HTTP/2 with JWT, handle payload limits and token invalidation. Use when building or operating the push delivery service. |
Push Notifications Backend
Instructions
FCM (Android, iOS via Firebase) and APNs (iOS direct) are the two mainstream providers. Design the backend so business services never call them directly; a dedicated notification-service owns all provider integration.
1. Architecture
producer (any service) -> enqueue(event)
|
v
┌─────────────────┐
│ push-scheduler │ (dedup, targeting, throttling)
└─────────────────┘
|
v
┌─────────────────┐
│ push-sender │ (FCM/APNs clients, retries)
└─────────────────┘
|
v
┌─────────────────┐
│ delivery-logger │ (metrics, token invalidation)
└─────────────────┘
Queue options: Redis Streams, SQS, Kafka. Use a durable queue -- push is at-least-once.
2. FCM HTTP v1
Deprecate the legacy server-key API. Use the v1 endpoint:
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
Authorization: Bearer <oauth2-token-from-service-account>
import { GoogleAuth } from "google-auth-library";
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/firebase.messaging"] });
async function sendFcm(token: string, notification: { title: string; body: string }, data: Record<string, string>) {
const client = await auth.getClient();
const url = `https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`;
const res = await client.request({
url,
method: "POST",
data: {
message: {
token,
notification,
data,
android: { priority: "HIGH" },
apns: { headers: { "apns-priority": "10" } },
},
},
});
return res.data;
}
Cache the OAuth2 access token for ~55 minutes; do not re-mint per request.
3. APNs with JWT
Use HTTP/2 with provider-token (JWT) authentication, not the certificate-based flow.
type APNs struct {
KeyID string
TeamID string
PrivateKey *ecdsa.PrivateKey
Client *http.Client
}
func (a *APNs) providerToken() string {
claims := jwt.MapClaims{"iss": a.TeamID, "iat": time.Now().Unix()}
tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tok.Header["kid"] = a.KeyID
s, _ := tok.SignedString(a.PrivateKey)
return s
}
func (a *APNs) Send(deviceToken string, payload []byte, topic string) error {
req, _ := http.NewRequest("POST",
"https://api.push.apple.com/3/device/"+deviceToken, bytes.NewReader(payload))
req.Header.Set("authorization", "bearer "+a.cachedProviderToken())
req.Header.Set("apns-topic", topic)
req.Header.Set("apns-push-type", "alert")
req.Header.Set("apns-priority", "10")
resp, err := a.Client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
return handleApnsResponse(resp)
}
Provider tokens are valid for ~1 hour; rotate at ~50 minutes. Keep a single HTTP/2 connection warm per worker.
4. Payload Limits
- APNs alert: 4 KB. VoIP: 5 KB.
- FCM: 4 KB.
Design payloads under 3 KB to leave headroom. Long content lives on the server; the push carries an id the app fetches.
{
"notification": { "title": "New message", "body": "Ada: see you at 5" },
"data": { "type": "message", "thread_id": "thr_01HX7...", "message_id": "msg_01HX7..." }
}
5. Token Management
- Client registers its push token at login and on every refresh (tokens rotate).
- Store
(user_id, device_id, provider, token, updated_at).
- On send, handle provider errors:
- FCM:
UNREGISTERED, INVALID_ARGUMENT (bad token) -> purge.
- APNs:
410 Unregistered, 400 BadDeviceToken -> purge.
- On logout, mark token inactive immediately.
6. Retry and Reliability
- Retry transient errors (5xx, network) with exponential backoff + jitter, cap at ~5 attempts.
- Do not retry terminal errors (
400, 403, 404, 410). Log and purge as needed.
- Deduplicate by
(user_id, event_id) with a TTL keyed in Redis so a retry of the producer does not double-send.
7. Prioritization
high / priority 10: user-visible, urgent.
normal / priority 5: user-visible, batched by OS.
- Silent pushes: see
silent-push skill. Keep them sparse; they share budgets.
8. Observability
Emit per send:
queue_latency_ms, send_latency_ms, provider_status, error_code, token_age_days.
Aggregate a delivery-success SLO per provider; alert when drops exceed a threshold for 10 minutes.
9. Client Consumption
iOS (Swift, AppDelegate):
func application(_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
let hex = token.map { String(format: "%02x", $0) }.joined()
Task { await api.registerPushToken(hex, platform: "apns") }
}
Android (Kotlin, FirebaseMessagingService):
class MyFms : FirebaseMessagingService() {
override fun onNewToken(token: String) {
CoroutineScope(Dispatchers.IO).launch { api.registerPushToken(token, platform = "fcm") }
}
}
Checklist