Skip to content

Notification Channels — Email, SMS, Push, Realtime

Every delivery channel implements INotificationChannel. The engine resolves all registered channels and routes delivery commands by name. See email-specific details and SMS, push, and real-time channels for provider configuration.

Every delivery channel implements this interface. The engine resolves all registered INotificationChannel services and routes by Name:

public interface INotificationChannel
{
string Name { get; }
Task SendAsync(NotificationDeliveryContext context,
CancellationToken cancellationToken = default);
}

Channels not registered are silently skipped with a warning log (graceful degradation pattern).

public static class NotificationChannels
{
public const string InApp = "InApp";
public const string SignalR = "SignalR";
public const string Email = "Email";
public const string Sms = "Sms";
public const string WhatsApp = "WhatsApp";
public const string Push = "Push"; // W3C Web Push
public const string MobilePush = "MobilePush";
public const string Sse = "Sse";
public const string Zulip = "Zulip";
}

| Channel | Registration | Provider resolution | |---------|-------------|---------------------| | InApp | Built-in (auto-registered) | N/A | | Email | AddGranitNotificationsEmail() | Keyed Service: "Smtp", "Brevo", "AzureCommunicationServices", "Scaleway", "SendGrid" | | SMS | AddGranitNotificationsSms() | Keyed Service: "Brevo", "AzureCommunicationServices", "AwsSns", "Twilio" | | WhatsApp | AddGranitNotificationsWhatsApp() | Keyed Service: "Brevo", "Twilio" | | Mobile Push | AddGranitNotificationsMobilePush() | Keyed Service: "GoogleFcm", "AzureNotificationHubs", "AwsSns" | | SignalR | AddGranitNotificationsSignalR() | Direct (NotificationHub) | | Web Push | AddGranitNotificationsPush() | VAPID (Lib.Net.Http.WebPush) | | SSE | AddGranitNotificationsSse() | Native .NET 10 SSE | | Zulip | AddGranitNotificationsZulip() | Zulip Bot API |

public class TeamsNotificationChannel(IRecipientResolver resolver)
: INotificationChannel
{
public string Name => "Teams";
public async Task SendAsync(
NotificationDeliveryContext context,
CancellationToken cancellationToken = default)
{
RecipientInfo? recipient = await resolver
.ResolveAsync(context.RecipientUserId, cancellationToken)
.ConfigureAwait(false);
if (recipient is null) return;
// Send via Microsoft Graph API...
}
}
// Register
services.AddSingleton<INotificationChannel, TeamsNotificationChannel>();

This interface resolves contact information from user identifiers. By default the application implements it — each application knows its own user model. Apps built on Granit.Identity can instead opt into the ready-made resolver from Granit.Identity.Notifications.

public interface IRecipientResolver
{
Task<RecipientInfo?> ResolveAsync(
string userId, CancellationToken cancellationToken = default);
}
public sealed record RecipientInfo
{
public required string UserId { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; } // E.164 format
public string? PreferredCulture { get; init; } // BCP 47
public string? DisplayName { get; init; }
}

Ready-made resolver: Granit.Identity.Notifications

Section titled “Ready-made resolver: Granit.Identity.Notifications”

Apps on Granit.Identity no longer have to hand-roll a resolver. The opt-in Granit.Identity.Notifications package ships IdentityRecipientResolver, a single adapter over IIdentityUserReader — the unified identity read abstraction — that covers local (OpenIddict) and every federated provider (Keycloak, Entra ID, Cognito, Google) with no per-provider code. In federated mode the reader already resolves through the user cache, so there is no extra round-trip to the external IdP per notification.

Reference the module and register the resolver:

[DependsOn(typeof(GranitIdentityNotificationsModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitIdentityRecipientResolver();
}
}

The resolver registers with TryAddScoped, so an application-supplied IRecipientResolver always takes precedence — adopting the bridge never blocks a later custom override.

| RecipientInfo field | Source | |-----------------------|--------| | Email | The identity user’s Email | | DisplayName | Canonical User.DisplayName; otherwise FirstName + LastName, falling back to Username | | PhoneNumber | Canonical User.PhoneNumber when present; otherwise the first non-empty provider metadata key | | PreferredCulture | Canonical User.PreferredLocale when present; otherwise the first non-empty metadata key, then DefaultCulture |

When the identity backend returns the canonical User aggregate (ADR-051), its first-class PhoneNumber / PreferredLocale win. Otherwise the resolver probes a configurable, ordered list of provider metadata keys and takes the first non-empty value.

Bound to the Identity:RecipientResolver section via IdentityRecipientResolverOptions:

| Setting | Default | Purpose | |---------|---------|---------| | PhoneNumberMetadataKeys | ["phoneNumber", "phone_number"] | Metadata keys probed in order for the phone number (Keycloak / Cognito) | | PreferredCultureMetadataKeys | ["locale", "preferredLanguage"] | Metadata keys probed in order for the BCP-47 culture (Keycloak / Cognito / Graph) | | DefaultCulture | null | Culture applied when none is resolved; null leaves it to the pipeline |

{
"Identity": {
"RecipientResolver": {
"PhoneNumberMetadataKeys": ["phoneNumber", "phone_number"],
"PreferredCultureMetadataKeys": ["locale", "preferredLanguage"],
"DefaultCulture": null
}
}
}