Skip to content
Document DB v8.0 Interceptors, Temporal Support, Telemetry Collection, All Calculations, String Based APIs, & Orleans Storage Providers! Feed The Machine Here

Sending

IPushManager is the entry point. Every send returns a PushSendResult with a BatchId and per-device Results, plus rollup counts (Sent, Failed, TokensRemoved, Skipped). One device failing never aborts the batch.

await pushManager.SendToUser("user-42", notification); // all of a user's devices
await pushManager.SendToTags(["sports"], notification, TagMatch.Any); // by tag/segment
await pushManager.SendToTokens(["tokenA", "tokenB"], notification); // explicit tokens
await pushManager.Broadcast(notification); // everyone

For anything more specific, use a PushFilter — all set clauses are AND-combined:

await pushManager.Send(notification, new PushFilter
{
Platforms = [DevicePlatform.iOS],
Tags = ["beta"],
TagMatch = TagMatch.All,
Environment = PushEnvironment.Production,
AppId = "consumer" // multi-app servers (see below)
});

PushFilter is a structured, declarative description of the audience — deliberately not an Expression<Func<>> so it stays AOT/trim-safe and translates cleanly to any repository backend.

PushNotification carries cross-cutting fields; per-platform specifics live in option objects.

new PushNotification
{
Title = "Title",
Message = "Body",
Badge = 1,
Sound = "default",
DeepLink = "app://route",
CollapseId = "score-update", // coalesce: newer replaces undelivered older
TimeToLive = TimeSpan.FromHours(1),
Priority = PushPriority.High,
Data = new Dictionary<string, string> { ["matchId"] = "123" },
Apple = new ApplePushOptions { Subtitle = "Subtitle", ThreadId = "t1", Category = "MSG" }
};

Title/Message are nullable on purpose — a silent / background push carries only data:

new PushNotification
{
Apple = new ApplePushOptions { ContentAvailable = true }, // low priority enforced automatically
Data = new Dictionary<string, string> { ["sync"] = "inbox" }
};

Implement IPushInterceptor and register additively with push.AddInterceptor<T>(); they run in order. Mutate the notification by replacing context.Notification, or return InterceptorResult.Skip to drop a device. OnSent/OnFailed are for analytics/receipts. Interceptor exceptions are caught and logged — they never break a batch.

public sealed class LocalizationInterceptor : IPushInterceptor
{
public Task<InterceptorResult> BeforeSend(PushSendContext context, CancellationToken ct = default)
{
if (context.Registration.Tags.Contains("opted-out"))
return Task.FromResult(InterceptorResult.Skip);
var title = Localize(context.Notification.Title, context.Registration.Locale);
context.Notification = context.Notification with { Title = title };
return Task.FromResult(InterceptorResult.Continue);
}
public Task OnSent(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
public Task OnFailed(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
}

Providers return a normalized PushDeliveryStatus. The manager automatically removes tokens reported TokenExpired/InvalidToken (e.g. APNs 410 Unregistered / BadDeviceToken) and applies rotated tokens back to the repository. Disable via PushManagerOptions.AutoPruneDeadTokens = false.

RateLimited is surfaced on the result but not retried — backoff is the caller’s responsibility by design.

Register one keyed provider per app; devices carry the matching AppId; the manager routes by it.

services.AddPushNotifications(push =>
{
push.AddApns("consumer", o => { o.BundleId = "com.example.consumer"; /* … */ });
push.AddApns("driver", o => { o.BundleId = "com.example.driver"; /* … */ });
});
await pushManager.Send(notification, new PushFilter { AppId = "driver", Tags = ["on-shift"] });

Delivery runs with bounded concurrency — tune with push.Configure(o => o.MaxDegreeOfParallelism = 25). Set it to 1 for repositories/providers that aren’t thread-safe.

When a transport implements IPushBatchProvider, the manager delivers devices that share the same notification in a single call instead of one request per device. The FCM provider does this — it packs up to 500 devices into one multipart /batch request — so broadcasts and topic fan-out use far fewer round trips. It’s on by default and requires no change at the call site:

// Hits one FCM /batch request per 500 Android devices instead of one request each:
await pushManager.Broadcast(new PushNotification { Title = "Service back online" });
// Opt out (force one request per device for every provider):
push.Configure(o => o.EnableBatching = false);

Devices are grouped by the identical notification instance, so an interceptor that rewrites the notification per device (e.g. localization) naturally falls back to per-device sends for those devices. Per-device dead-token pruning, token rotation, metrics, and OnSent/OnFailed are all preserved. Batched sends emit a single push.deliver.batch trace span (tagged with push.batch_size).

To make a custom transport batchable, implement IPushBatchProvider (MaxBatchSize plus a SendBatch that returns one result per registration, in the same order). Web Push has no multicast endpoint, so it stays one request per device (still fanned out concurrently by the manager).