PushKit
Platforms covered: Android Β· iOS (FCM + Native APNs) Β· Web
Stack: .NET Core 10 Β· FCM HTTP v1 Β· APNs HTTP/2 JWT
Features
- Firebase Cloud Messaging (FCM) HTTP v1 API support
- Native Apple Push Notification Service (APNs) with HTTP/2 JWT
- Cross-platform unified API for Android, iOS, and Web
- Automatic retry with Polly policies
- Batch sending with parallel processing
- Smart platform routing
- Token cleanup and error handling
Installation
dotnet add package PushKit1. Architecture Overview
The Core Problem β Tokens Look the Same
You cannot tell the platform by reading a token. They all look like random strings:
FCM Android token: dK3x9F2mQ8:APA91bHPR...FCM iOS token: dK3x9F2mQ8:APA91bHPR... β identical format!FCM Web token: dK3x9F2mQ8:APA91bHPR... β identical format!APNs iOS token: a1b2c3d4e5f6789abc... β different (hex, 64 chars)FCM tokens for Android, iOS (Firebase), and Web are indistinguishable by format. Platform must be stored in your database.
Solution: The client tells you its platform when registering. You store it in the database alongside the token.
Platform Routing Table
| Platform | Token Type | Token Format | SDK | Backend Sender |
|---|---|---|---|---|
| Android | FCM token | dK3x9F2m:APA91b... | Firebase Android SDK | IFcmSender |
| iOS (Firebase) | FCM token | dK3x9F2m:APA91b... | Firebase iOS SDK | IFcmSender |
| iOS (Native APNs) | APNs token | a1b2c3d4e5f6... (hex) | UIKit / UNUserNotificationCenter | IApnSender |
| Web | FCM token | dK3x9F2m:APA91b... | Firebase JS SDK | IFcmSender |
2. Backend Setup (.NET Core 10)
2.1 Database Model
Add a DeviceTokens table. The Platform column is what makes routing possible.
public sealed class DeviceToken{ public string Id { get; set; } = Guid.NewGuid().ToString(); public string UserId { get; set; } = string.Empty; public string Token { get; set; } = string.Empty; public DevicePlatform Platform { get; set; } public DateTime RegisteredAt { get; set; } = DateTime.UtcNow; public DateTime? LastUsedAt { get; set; } public bool IsActive { get; set; } = true;}
public enum DevicePlatform{ Android, // FCM token β Firebase SDK on Android IosFcm, // FCM token β Firebase SDK on iOS IosApn, // Native APNs hex token β direct Apple HTTP/2 Web // FCM token β Firebase JS SDK in browser}2.2 Configuration
{ "PushKit": { "Fcm": { "ProjectId": "your-firebase-project-id", "ServiceAccountKeyFilePath": "/secrets/firebase.json", "MaxRetryAttempts": 3, "RetryBaseDelayMs": 500, "RequestTimeoutSeconds": 30, "BatchParallelism": 100 }, "Apn": { "P8PrivateKey": "MIGHAgEAMBMGByq...base64keynoheadersnewlines...", "P8PrivateKeyId": "ABCDE12345", "TeamId": "FGHIJ67890", "BundleId": "com.yourcompany.yourapp", "Environment": "Production", "BatchParallelism": 50 } }}For Docker / Kubernetes, inject credentials via environment variables:
export PushKit__Fcm__ServiceAccountJson="$(cat firebase.json)"export PushKit__Apn__P8PrivateKey="MIGHAgEAMBMGByq..."2.3 Program.cs Registration
using PushKit.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Option A β from appsettings.json (recommended)builder.Services.AddPushKit(builder.Configuration);
// Option B β FCM only, inline configbuilder.Services.AddFcmSender(opts => { opts.ProjectId = Environment.GetEnvironmentVariable("FIREBASE_PROJECT_ID")!; opts.ServiceAccountJson = Environment.GetEnvironmentVariable("FIREBASE_SA_JSON");});
// Option C β APNs only, inline configbuilder.Services.AddApnSender(opts => { opts.P8PrivateKey = Environment.GetEnvironmentVariable("APN_P8_KEY")!; opts.P8PrivateKeyId = Environment.GetEnvironmentVariable("APN_KEY_ID")!; opts.TeamId = Environment.GetEnvironmentVariable("APN_TEAM_ID")!; opts.BundleId = "com.yourcompany.app"; opts.Environment = ApnEnvironment.Production;});
builder.Services.AddScoped<SmartPushService>();2.4 Token Registration Endpoint
Every client calls this after getting their token. The platform field is what makes everything work.
app.MapPost("/device/register", async ( RegisterDeviceRequest req, IDeviceTokenRepository repo) =>{ await repo.UpsertAsync(new DeviceToken { UserId = req.UserId, Token = req.Token, Platform = Enum.Parse<DevicePlatform>(req.Platform, ignoreCase: true) }); return Results.Ok(new { registered = true });});
public record RegisterDeviceRequest( string UserId, string Token, string Platform // "android" | "ios_fcm" | "ios_apn" | "web");2.5 Smart Push Service (Platform Router)
This service loads all tokens for a user and automatically routes each one to the correct sender.
public sealed class SmartPushService{ private readonly IFcmSender _fcm; private readonly IApnSender _apn; private readonly IDeviceTokenRepository _repo; private readonly ILogger<SmartPushService> _logger;
public SmartPushService( IFcmSender fcm, IApnSender apn, IDeviceTokenRepository repo, ILogger<SmartPushService> logger) { _fcm = fcm; _apn = apn; _repo = repo; _logger = logger; }
/// <summary> /// Sends to ALL devices of a user β routes by Platform automatically. /// </summary> public async Task SendToUserAsync( string userId, string eventType, object payload, CancellationToken ct = default) { var tokens = await _repo.GetActiveByUserAsync(userId); var tasks = tokens.Select(d => SendToDeviceAsync(d, eventType, payload, ct)); await Task.WhenAll(tasks); }
private async Task SendToDeviceAsync( DeviceToken device, string eventType, object payload, CancellationToken ct) { var json = JsonSerializer.Serialize(payload);
var result = device.Platform switch { DevicePlatform.Android => await SendFcmAsync(device.Token, eventType, json, ct), DevicePlatform.IosFcm => await SendFcmAsync(device.Token, eventType, json, ct), DevicePlatform.Web => await SendFcmAsync(device.Token, eventType, json, ct), DevicePlatform.IosApn => await SendApnAsync(device.Token, eventType, json, ct), _ => throw new ArgumentOutOfRangeException() };
if (result.IsTokenInvalid) { _logger.LogWarning("Stale token removed β {Platform}, User: {UserId}", device.Platform, device.UserId); await _repo.DeactivateAsync(device.Id); } }
private Task<PushResult> SendFcmAsync( string token, string eventType, string payload, CancellationToken ct) { var msg = PushMessageBuilder.Create() .WithData("event", eventType) .WithData("payload", payload) .WithAndroid(priority: AndroidPriority.High, ttlSeconds: 86400) .Build();
return _fcm.SendToTokenAsync(token, msg, ct); }
private Task<PushResult> SendApnAsync( string token, string eventType, string payload, CancellationToken ct) { var msg = ApnMessageBuilder.Create() .WithAlert("New Update", eventType) .WithCustomData("event", eventType) .WithCustomData("payload", payload) .WithSound("default") .Build();
return _apn.SendAsync(token, msg, ct); }}3. Android Implementation
3.1 Add Firebase Dependency
dependencies { implementation 'com.google.firebase:firebase-messaging:24.0.0'}
// Apply plugin at the bottomapply plugin: 'com.google.gms.google-services'3.2 Firebase Messaging Service
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { // Called once on first install, and again if token rotates lifecycleScope.launch { registerTokenWithBackend(token) } }
private suspend fun registerTokenWithBackend(token: String) { val userId = getLoggedInUserId() // your own logic apiService.registerDevice( RegisterDeviceRequest( userId = userId, token = token, platform = "android" // β always "android" ) ) }
override fun onMessageReceived(remoteMessage: RemoteMessage) { val event = remoteMessage.data["event"] ?: return val payload = remoteMessage.data["payload"] ?: "{}"
when (event) { "ORDER_SHIPPED" -> showOrderShippedNotification(payload) "FLASH_SALE" -> showFlashSaleNotification(payload) else -> showDefaultNotification(event, payload) } }}3.3 AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application> <service android:name=".MyFirebaseMessagingService" android:exported="false"> <intent-filter> <action android:name="com.google.firebase.MESSAGING_EVENT" /> </intent-filter> </service> </application></manifest>4. iOS Implementation
Option A: Firebase Cloud Messaging (FCM)
4.1 Add Firebase to iOS
# ios/Podfiletarget 'YourApp' do pod 'Firebase/Messaging'end4.2 AppDelegate.swift
import UIKitimport FirebaseMessaging
@mainclass AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure() Messaging.messaging().delegate = self
// Request permission UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .badge, .sound] ) { granted, _ in if granted { DispatchQueue.main.async { application.registerForRemoteNotifications() } } }
return true }
// APNs token received β register with Firebase func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Messaging.messaging().apnsToken = deviceToken }}
// MARK: - MessagingDelegateextension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let token = fcmToken else { return } registerTokenWithBackend(token: token, platform: "ios_fcm") }
private func registerTokenWithBackend(token: String, platform: String) { let userId = getLoggedInUserId() // your own logic APIClient.registerDevice( userId: userId, token: token, platform: platform ) }}Option B: Native APNs (Direct to Apple)
Use this if you want to skip Firebase SDK entirely and send pushes directly via Appleβs HTTP/2 API. This is what PushKitβs IApnSender uses.
import UIKitimport UserNotifications
@mainclass AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .badge, .sound] ) { granted, _ in if granted { DispatchQueue.main.async { application.registerForRemoteNotifications() } } }
return true }
// APNs token received β convert to hex string func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() registerTokenWithBackend(token: token, platform: "ios_apn") }
private func registerTokenWithBackend(token: String, platform: String) { let userId = getLoggedInUserId() APIClient.registerDevice( userId: userId, token: token, platform: platform ) }}5. Web Implementation
5.1 Firebase Config
import { initializeApp } from 'firebase/app';import { getMessaging } from 'firebase/messaging';
const firebaseConfig = { apiKey: "AIzaSyXXXXXXXXXXXXXXXX", authDomain: "your-app.firebaseapp.com", projectId: "your-app-12345", storageBucket: "your-app.appspot.com", messagingSenderId: "123456789012", appId: "1:123456789012:web:abcdef123456"};
export const app = initializeApp(firebaseConfig);export const messaging = getMessaging(app);5.2 Register Push Token
import { messaging } from './firebase.js';import { getToken, onMessage } from 'firebase/messaging';
const VAPID_KEY = 'YOUR_WEB_PUSH_VAPID_KEY_FROM_FIREBASE_CONSOLE';
export async function registerPushToken(userId) { // 1. Ask for permission const permission = await Notification.requestPermission(); if (permission !== 'granted') { console.warn('Push notification permission denied'); return; }
// 2. Register service worker and get FCM token const registration = await navigator.serviceWorker .register('/firebase-messaging-sw.js');
const token = await getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: registration });
// 3. Send to YOUR backend with platform = "web" await fetch('/device/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: userId, token: token, platform: 'web' // β always "web" }) });
console.log('Push token registered:', token.substring(0, 20) + '...');}
// Handle messages when browser tab is OPEN (foreground)onMessage(messaging, (payload) => { const event = payload.data?.event; const data = JSON.parse(payload.data?.payload ?? '{}');
switch (event) { case 'ORDER_SHIPPED': handleOrderShipped(data); break; case 'FLASH_SALE': showFlashSale(data); break; default: console.log('Push received:', event, data); }});5.3 Service Worker
This file must be served from the root of your domain: https://yoursite.com/firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js');importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-messaging-compat.js');
firebase.initializeApp({ apiKey: 'AIzaSyXXXXXXXXXXXXXXXX', authDomain: 'your-app.firebaseapp.com', projectId: 'your-app-12345', storageBucket: 'your-app.appspot.com', messagingSenderId: '123456789012', appId: '1:123456789012:web:abcdef123456'});
const messaging = firebase.messaging();
// Handle messages when tab is CLOSED or in backgroundmessaging.onBackgroundMessage((payload) => { const event = payload.data?.event; const data = JSON.parse(payload.data?.payload ?? '{}');
// Show a native browser notification self.registration.showNotification('New Update', { body: event, icon: '/icon-192x192.png', badge: '/badge-72x72.png', data: data });});6. Complete Registration Flow
App Install / Login β βΌClient SDK generates token β βββ Android β onNewToken() β platform = "android" βββ iOS (FCM) β didReceiveRegistrationToken() β platform = "ios_fcm" βββ iOS (APNs) β didRegisterForRemoteNotifs() β Dataβhex β platform = "ios_apn" βββ Web β getToken() β platform = "web" β βΌ POST /device/register { userId, token, platform } β βΌ DB: DeviceTokens table { UserId, Token, Platform, IsActive } β βΌ SmartPushService.SendToUserAsync() β βββ Platform = Android/IosFcm/Web β IFcmSender βββ Platform = IosApn β IApnSender7. Error Handling & Token Cleanup
After every send operation, handle the result β never ignore it.
var result = await _fcm.SendToTokenAsync(token, message);
if (result.IsSuccess){ await _repo.UpdateLastUsedAsync(device.Id); _logger.LogInformation("Delivered to {Platform}", device.Platform);}else if (result.IsTokenInvalid){ // App uninstalled or token rotated β remove permanently await _repo.DeactivateAsync(device.Id); _logger.LogWarning("Stale token removed for user {UserId}", device.UserId);}else if (result.IsRetryable){ // Polly already retried 3 times. Queue for later via Hangfire / a message queue. _logger.LogWarning("Transient failure [{Code}] β may retry later", result.ErrorCode);}else{ _logger.LogError("Push failed [{Code}]: {Message}", result.ErrorCode, result.ErrorMessage);}Batch Result Cleanup
var batch = await _fcm.SendBatchAsync(tokens, message);
_logger.LogInformation("Batch: {Ok}/{Total} delivered", batch.SuccessCount, batch.TotalCount);
// Remove all permanently invalid tokens in one DB callvar invalidIds = GetDeviceIdsByTokens(batch.InvalidTokens);await _repo.DeactivateManyAsync(invalidIds);8. Quick Reference
FCM Error Codes
| Error Code | HTTP | Meaning | Action |
|---|---|---|---|
UNREGISTERED | 404 | App uninstalled / token expired | β Remove from DB |
INVALID_ARGUMENT | 400 | Malformed token | β Remove from DB |
QUOTA_EXCEEDED | 429 | Rate limit hit | β»οΈ Polly retries automatically |
UNAVAILABLE | 503 | FCM temporarily down | β»οΈ Polly retries automatically |
INTERNAL | 500 | FCM internal error | β»οΈ Polly retries automatically |
SENDER_ID_MISMATCH | 403 | Wrong Firebase project | π§ Fix ProjectId in config |
APNs Error Codes
| Reason | HTTP | Meaning | Action |
|---|---|---|---|
BadDeviceToken | 400 | Token is malformed | β Remove from DB |
Unregistered | 410 | App was uninstalled | β Remove from DB |
DeviceTokenNotForTopic | 400 | Wrong bundle ID | π§ Fix BundleId in config |
TooManyRequests | 429 | Rate limited by Apple | β»οΈ Polly retries automatically |
InternalServerError | 500 | Apple server error | β»οΈ Polly retries automatically |
BadTopic | 400 | Invalid apns-topic header | π§ Check BundleId matches app |
PushResult Cheatsheet
result.IsSuccess // true = provider accepted the messageresult.MessageId // FCM: "projects/.../messages/123" | APNs: apns-id header valueresult.HttpStatus // 200, 400, 404, 429, 500 ...result.ErrorCode // "UNREGISTERED", "BadDeviceToken", "QUOTA_EXCEEDED" etc.result.ErrorMessage // Human-readable descriptionresult.IsTokenInvalid // true β remove from database NOWresult.IsRetryable // true β Polly already tried; consider queueing
// Batchbatch.TotalCount // total tokens attemptedbatch.SuccessCount // successfully deliveredbatch.FailureCount // failedbatch.InvalidTokens // IEnumerable<string> β remove these from DBbatch.RetryableTokens // IEnumerable<string> β may retry laterbatch.Results // IReadOnlyList<PushResult> β full per-token detail9. Setup Checklist
Firebase Setup
- Firebase project created
-
google-services.jsonadded to Androidapp/directory -
GoogleService-Info.plistadded to iOS Xcode project - Firebase Web config object copied to
firebase.js - VAPID key generated and copied for Web
Apple Setup (APNs native path only)
- APNs key created in Apple Developer Portal
- Key ID (10 chars) noted
- Team ID (10 chars) noted
-
.p8file downloaded and stored safely - Base64 key content extracted (no header/footer/newlines)
Backend
-
appsettings.jsonhasProjectId, service account path/JSON, and APNs credentials -
AddPushKit(configuration)called inProgram.cs -
/device/registerendpoint acceptstoken,platform,userId -
DeviceTokentable hasPlatformcolumn -
SmartPushServiceroutes byPlatformfield - Invalid token cleanup implemented after every send
Android
-
FirebaseMessagingServiceimplemented and registered inAndroidManifest.xml -
onNewToken()calls/device/registerwithplatform = "android" -
onMessageReceived()handles data payload
iOS
- Push Notifications capability enabled in Xcode
- Background Modes β Remote Notifications enabled
- FCM path:
MessagingDelegate.didReceiveRegistrationTokenposts withplatform = "ios_fcm" - APNs path:
didRegisterForRemoteNotificationsWithDeviceTokenconvertsData β hex, posts withplatform = "ios_apn"
Web
-
firebase-messaging-sw.jsis served from root path/ -
getToken()called after permission granted - Token posted with
platform = "web" -
onMessage()handles foreground messages -
onBackgroundMessage()in service worker handles background messages
Always test on real devices. FCM and APNs push delivery to simulators/emulators is unreliable or unsupported.
Support
- GitHub Repository
- NuGet Package
- Report issues via GitHub Issues