PushKit

NuGet Downloads

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

Terminal
dotnet add package PushKit

1. 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

PlatformToken TypeToken FormatSDKBackend Sender
AndroidFCM tokendK3x9F2m:APA91b...Firebase Android SDKIFcmSender
iOS (Firebase)FCM tokendK3x9F2m:APA91b...Firebase iOS SDKIFcmSender
iOS (Native APNs)APNs tokena1b2c3d4e5f6... (hex)UIKit / UNUserNotificationCenterIApnSender
WebFCM tokendK3x9F2m:APA91b...Firebase JS SDKIFcmSender

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:

Terminal window
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 config
builder.Services.AddFcmSender(opts => {
opts.ProjectId = Environment.GetEnvironmentVariable("FIREBASE_PROJECT_ID")!;
opts.ServiceAccountJson = Environment.GetEnvironmentVariable("FIREBASE_SA_JSON");
});
// Option C β€” APNs only, inline config
builder.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

android/app/build.gradle
dependencies {
implementation 'com.google.firebase:firebase-messaging:24.0.0'
}
// Apply plugin at the bottom
apply plugin: 'com.google.gms.google-services'

3.2 Firebase Messaging Service

android/app/src/main/java/com/example/app/MyFirebaseMessagingService.kt
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/Podfile
target 'YourApp' do
pod 'Firebase/Messaging'
end

4.2 AppDelegate.swift

import UIKit
import FirebaseMessaging
@main
class 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: - MessagingDelegate
extension 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 UIKit
import UserNotifications
@main
class 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

src/firebase.js
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

src/push.js
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

/public/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 background
messaging.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 β†’ IApnSender

7. 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 call
var invalidIds = GetDeviceIdsByTokens(batch.InvalidTokens);
await _repo.DeactivateManyAsync(invalidIds);

8. Quick Reference

FCM Error Codes

Error CodeHTTPMeaningAction
UNREGISTERED404App uninstalled / token expired❌ Remove from DB
INVALID_ARGUMENT400Malformed token❌ Remove from DB
QUOTA_EXCEEDED429Rate limit hit♻️ Polly retries automatically
UNAVAILABLE503FCM temporarily down♻️ Polly retries automatically
INTERNAL500FCM internal error♻️ Polly retries automatically
SENDER_ID_MISMATCH403Wrong Firebase projectπŸ”§ Fix ProjectId in config

APNs Error Codes

ReasonHTTPMeaningAction
BadDeviceToken400Token is malformed❌ Remove from DB
Unregistered410App was uninstalled❌ Remove from DB
DeviceTokenNotForTopic400Wrong bundle IDπŸ”§ Fix BundleId in config
TooManyRequests429Rate limited by Apple♻️ Polly retries automatically
InternalServerError500Apple server error♻️ Polly retries automatically
BadTopic400Invalid apns-topic headerπŸ”§ Check BundleId matches app

PushResult Cheatsheet

result.IsSuccess // true = provider accepted the message
result.MessageId // FCM: "projects/.../messages/123" | APNs: apns-id header value
result.HttpStatus // 200, 400, 404, 429, 500 ...
result.ErrorCode // "UNREGISTERED", "BadDeviceToken", "QUOTA_EXCEEDED" etc.
result.ErrorMessage // Human-readable description
result.IsTokenInvalid // true β†’ remove from database NOW
result.IsRetryable // true β†’ Polly already tried; consider queueing
// Batch
batch.TotalCount // total tokens attempted
batch.SuccessCount // successfully delivered
batch.FailureCount // failed
batch.InvalidTokens // IEnumerable<string> β€” remove these from DB
batch.RetryableTokens // IEnumerable<string> β€” may retry later
batch.Results // IReadOnlyList<PushResult> β€” full per-token detail

9. Setup Checklist

Firebase Setup

  • Firebase project created
  • google-services.json added to Android app/ directory
  • GoogleService-Info.plist added 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
  • .p8 file downloaded and stored safely
  • Base64 key content extracted (no header/footer/newlines)

Backend

  • appsettings.json has ProjectId, service account path/JSON, and APNs credentials
  • AddPushKit(configuration) called in Program.cs
  • /device/register endpoint accepts token, platform, userId
  • DeviceToken table has Platform column
  • SmartPushService routes by Platform field
  • Invalid token cleanup implemented after every send

Android

  • FirebaseMessagingService implemented and registered in AndroidManifest.xml
  • onNewToken() calls /device/register with platform = "android"
  • onMessageReceived() handles data payload

iOS

  • Push Notifications capability enabled in Xcode
  • Background Modes β†’ Remote Notifications enabled
  • FCM path: MessagingDelegate.didReceiveRegistrationToken posts with platform = "ios_fcm"
  • APNs path: didRegisterForRemoteNotificationsWithDeviceToken converts Data β†’ hex, posts with platform = "ios_apn"

Web

  • firebase-messaging-sw.js is 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