Skip to content

Granit.Notifications

Granit.Notifications is a multi-channel notification engine built on a fan-out pattern. A single INotificationPublisher.PublishAsync() call fans out to every registered channel (InApp, Email, SMS, WhatsApp, Mobile Push, SignalR, SSE, Web Push, Zulip) after filtering through user preferences. Delivery attempts are recorded in an immutable audit trail (ISO 27001). By default, notifications dispatch via an in-process Channel<T> — add Granit.Notifications.Wolverine for durable outbox-backed dispatch with exponential backoff retry.

  • DirectoryGranit.Notifications/ Core: fan-out engine, InApp channel, definitions, entity tracking
    • Granit.Notifications.EntityFrameworkCore EF Core persistence (all stores)
    • Granit.Notifications.Endpoints Minimal API REST endpoints
    • Granit.Notifications.Wolverine Durable outbox dispatch via IMessageBus
    • DirectoryGranit.Notifications.Email/ Email channel abstraction (Keyed Services)
      • Granit.Notifications.Email.Smtp MailKit SMTP provider
      • Granit.Notifications.Email.AzureCommunicationServices Azure Communication Services email
      • Granit.Notifications.Email.Scaleway Scaleway TEM email provider
      • Granit.Notifications.Email.SendGrid SendGrid email provider
    • DirectoryGranit.Notifications.Sms SMS channel abstraction (Keyed Services)
      • Granit.Notifications.Sms.AzureCommunicationServices Azure Communication Services SMS
      • Granit.Notifications.Sms.AwsSns AWS SNS SMS provider
    • Granit.Notifications.WhatsApp WhatsApp Business API channel
    • Granit.Notifications.Brevo Unified Brevo provider (Email + SMS + WhatsApp)
    • DirectoryGranit.Notifications.MobilePush/ Mobile push abstraction + token store
      • Granit.Notifications.MobilePush.GoogleFcm Firebase Cloud Messaging provider
      • Granit.Notifications.MobilePush.AzureNotificationHubs Azure Notification Hubs push
      • Granit.Notifications.MobilePush.AwsSns AWS SNS Platform Application push
    • Granit.Notifications.SignalR Real-time SignalR channel + Redis backplane
    • Granit.Notifications.WebPush W3C Web Push (VAPID, RFC 8030)
    • Granit.Notifications.Sse Server-Sent Events channel (.NET 10)
    • Granit.Notifications.Twilio Twilio SMS + WhatsApp provider
    • Granit.Notifications.Zulip Zulip chat integration
PackageRoleDepends on
Granit.NotificationsFan-out engine, InApp channel, definitions, entity trackingGranit.Guids, Granit.Timing, Granit.Querying
Granit.Notifications.EntityFrameworkCoreEF Core stores (all entities + mobile push tokens)Granit.Notifications, Granit.Notifications.MobilePush, Granit.Persistence
Granit.Notifications.EndpointsMinimal API endpoints (inbox, preferences, subscriptions, followers)Granit.Notifications, Granit.Notifications.MobilePush, Granit.Validation, Granit.ApiDocumentation
Granit.Notifications.WolverineDurable outbox-backed dispatch via IMessageBusGranit.Notifications, Granit.Wolverine
Granit.Notifications.EmailEmail channel abstraction, Keyed Services resolutionGranit.Notifications
Granit.Notifications.Email.SmtpMailKit SMTP provider (Keyed Service "Smtp")Granit.Notifications.Email
Granit.Notifications.Email.AzureCommunicationServicesAzure Communication Services email provider (Keyed Service "AzureCommunicationServices")Granit.Notifications.Email
Granit.Notifications.Email.ScalewayScaleway TEM email provider (Keyed Service "Scaleway")Granit.Notifications.Email
Granit.Notifications.Email.SendGridSendGrid email provider (Keyed Service "SendGrid")Granit.Notifications.Email
Granit.Notifications.BrevoUnified Brevo provider (Email + SMS + WhatsApp)Granit.Notifications.Email, Granit.Notifications.Sms, Granit.Notifications.WhatsApp
Granit.Notifications.SmsSMS channel abstraction, Keyed Services resolutionGranit.Notifications
Granit.Notifications.Sms.AzureCommunicationServicesAzure Communication Services SMS provider (Keyed Service "AzureCommunicationServices")Granit.Notifications.Sms
Granit.Notifications.Sms.AwsSnsAWS SNS SMS provider (Keyed Service "AwsSns")Granit.Notifications.Sms
Granit.Notifications.WhatsAppWhatsApp Business API channelGranit.Notifications
Granit.Notifications.MobilePushMobile push abstraction + device token storeGranit.Notifications
Granit.Notifications.MobilePush.GoogleFcmFirebase Cloud Messaging (FCM HTTP v1 API)Granit.Notifications.MobilePush
Granit.Notifications.MobilePush.AzureNotificationHubsAzure Notification Hubs push provider (Keyed Service "AzureNotificationHubs")Granit.Notifications.MobilePush
Granit.Notifications.MobilePush.AwsSnsAWS SNS Platform Application push provider (Keyed Service "AwsSns")Granit.Notifications.MobilePush
Granit.Notifications.SignalRReal-time SignalR channel + Redis backplaneGranit.Notifications
Granit.Notifications.WebPushW3C Web Push (VAPID, RFC 8030/8291/8292)Granit.Notifications
Granit.Notifications.SseServer-Sent Events channel (.NET 10 native SSE)Granit.Notifications
Granit.Notifications.TwilioTwilio SMS + WhatsApp provider (Keyed Service "Twilio")Granit.Notifications.Sms, Granit.Notifications.WhatsApp
Granit.Notifications.ZulipZulip Bot API chat integrationGranit.Notifications
graph TD
    N[Granit.Notifications] --> G[Granit.Guids]
    N --> T[Granit.Timing]
    N --> Q[Granit.Querying]

    NEF[Granit.Notifications.EntityFrameworkCore] --> N
    NEF --> MP[Granit.Notifications.MobilePush]
    NEF --> P[Granit.Persistence]

    NE[Granit.Notifications.Endpoints] --> N
    NE --> MP
    NE --> V[Granit.Validation]
    NE --> AD[Granit.ApiDocumentation]

    NW[Granit.Notifications.Wolverine] --> N
    NW --> W[Granit.Wolverine]

    EM[Granit.Notifications.Email] --> N
    SMTP[Granit.Notifications.Email.Smtp] --> EM
    ACSEM[Granit.Notifications.Email.AzureCommunicationServices] --> EM
    SCW[Granit.Notifications.Email.Scaleway] --> EM
    SG[Granit.Notifications.Email.SendGrid] --> EM
    BR[Granit.Notifications.Brevo] --> EM
    BR --> SMS[Granit.Notifications.Sms]
    BR --> WA[Granit.Notifications.WhatsApp]

    SMS --> N
    ACSSMS[Granit.Notifications.Sms.AzureCommunicationServices] --> SMS
    SNSSMS[Granit.Notifications.Sms.AwsSns] --> SMS
    TW[Granit.Notifications.Twilio] --> SMS
    TW --> WA
    WA --> N
    MP --> N
    FCM[Granit.Notifications.MobilePush.GoogleFcm] --> MP
    ANH[Granit.Notifications.MobilePush.AzureNotificationHubs] --> MP
    SNSMP[Granit.Notifications.MobilePush.AwsSns] --> MP
    SR[Granit.Notifications.SignalR] --> N
    WP[Granit.Notifications.WebPush] --> N
    SSE[Granit.Notifications.Sse] --> N
    ZU[Granit.Notifications.Zulip] --> N
[DependsOn(typeof(GranitNotificationsWolverineModule))]
[DependsOn(typeof(GranitNotificationsEntityFrameworkCoreModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// EF Core persistence (replaces in-memory defaults)
context.Builder.AddGranitNotificationsEntityFrameworkCore(
opts => opts.UseNpgsql(context.Configuration
.GetConnectionString("Notifications")));
// Email channel via SMTP
context.Services.AddGranitNotificationsEmail(opts => opts.Provider = "Smtp");
context.Services.AddGranitNotificationsEmailSmtp();
// SignalR real-time channel with Redis backplane
context.Services.AddGranitNotificationsSignalR(
context.Configuration.GetConnectionString("Redis")!);
// Notification endpoints
context.Services.AddGranitNotificationsEndpoints();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
context.App.MapGranitNotificationEndpoints();
context.App.MapHub<NotificationHub>("/hubs/notifications");
}
}

A single PublishAsync() call produces one DeliverNotificationCommand per recipient per channel. The fan-out handler resolves recipients, loads user preferences, and filters out opted-out channels before dispatching.

sequenceDiagram
    participant App as Application
    participant Pub as INotificationPublisher
    participant Bus as Channel / Wolverine
    participant Fan as NotificationFanoutHandler
    participant Pref as INotificationPreferenceReader
    participant Del as NotificationDeliveryHandler
    participant Ch as INotificationChannel[]

    App->>Pub: PublishAsync(type, data, recipientIds)
    Pub->>Bus: NotificationTrigger
    Bus->>Fan: Handle(trigger)
    Fan->>Pref: IsChannelEnabledAsync(userId, type, channel)
    Fan-->>Bus: DeliverNotificationCommand[] (1 per user x channel)
    Bus->>Del: Handle(command)
    Del->>Ch: SendAsync(context)
    Del->>Del: Record NotificationDeliveryAttempt (audit)

Published by INotificationPublisher. Contains the notification type name, severity, serialized data, recipient list, and optional entity reference.

public sealed record NotificationTrigger
{
public Guid NotificationId { get; init; }
public required string NotificationTypeName { get; init; }
public required NotificationSeverity Severity { get; init; }
public required JsonElement Data { get; init; }
public IReadOnlyList<string> RecipientUserIds { get; init; } = [];
public EntityReference? RelatedEntity { get; init; }
public Guid? TenantId { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? Culture { get; init; }
}

Produced by NotificationFanoutHandler. One per recipient per channel. Consumed by NotificationDeliveryHandler which routes to the matching INotificationChannel.

public sealed record DeliverNotificationCommand
{
public required Guid DeliveryId { get; init; }
public required Guid NotificationId { get; init; }
public required string NotificationTypeName { get; init; }
public required NotificationSeverity Severity { get; init; }
public required string RecipientUserId { get; init; }
public required string ChannelName { get; init; }
public required JsonElement Data { get; init; }
public EntityReference? RelatedEntity { get; init; }
public Guid? TenantId { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? Culture { get; init; }
}

The application-facing facade for publishing notifications. Four overloads cover explicit recipients, topic subscribers, and entity followers (Odoo-style).

public interface INotificationPublisher
{
// Explicit recipients
ValueTask PublishAsync<TData>(
NotificationType<TData> notificationType, TData data,
IReadOnlyList<string> recipientUserIds,
CancellationToken cancellationToken = default) where TData : notnull;
// Explicit recipients + entity reference
ValueTask PublishAsync<TData>(
NotificationType<TData> notificationType, TData data,
IReadOnlyList<string> recipientUserIds,
EntityReference? relatedEntity,
CancellationToken cancellationToken = default) where TData : notnull;
// All subscribers of the notification type
ValueTask PublishToSubscribersAsync<TData>(
NotificationType<TData> notificationType, TData data,
CancellationToken cancellationToken = default) where TData : notnull;
// All followers of the entity (Odoo-style chatter)
ValueTask PublishToEntityFollowersAsync<TData>(
NotificationType<TData> notificationType, TData data,
EntityReference relatedEntity,
CancellationToken cancellationToken = default) where TData : notnull;
}
// 1. Define a notification type
public sealed class AppointmentReminder
: NotificationType<AppointmentReminderData>
{
public override string Name => "Appointments.Reminder";
public override IReadOnlyList<string> DefaultChannels =>
[NotificationChannels.InApp, NotificationChannels.Email,
NotificationChannels.MobilePush];
}
public sealed record AppointmentReminderData(
Guid AppointmentId, string PatientName,
DateTimeOffset ScheduledAt, string DoctorName);
// 2. Register in a definition provider
public class AppNotificationDefinitionProvider : INotificationDefinitionProvider
{
public void Define(INotificationDefinitionContext context)
{
context.Add(new NotificationDefinition("Appointments.Reminder")
{
DefaultSeverity = NotificationSeverity.Info,
DefaultChannels = [NotificationChannels.InApp,
NotificationChannels.Email, NotificationChannels.MobilePush],
DisplayName = "Appointment Reminder",
GroupName = "Appointments",
AllowUserOptOut = true,
});
}
}
// 3. Register in DI
services.AddNotificationDefinitions<AppNotificationDefinitionProvider>();
// 4. Publish from application code
public class AppointmentReminderService(INotificationPublisher publisher)
{
private static readonly AppointmentReminder Type = new();
public async Task SendReminderAsync(
Appointment appointment, CancellationToken cancellationToken)
{
await publisher.PublishAsync(
Type,
new AppointmentReminderData(
appointment.Id, appointment.PatientName,
appointment.ScheduledAt, appointment.DoctorName),
[appointment.PatientUserId],
new EntityReference("Appointment", appointment.Id.ToString()),
cancellationToken).ConfigureAwait(false);
}
}

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";
}
ChannelRegistrationProvider resolution
InAppBuilt-in (auto-registered)N/A
EmailAddGranitNotificationsEmail()Keyed Service: "Smtp", "Brevo", "AzureCommunicationServices", "Scaleway", "SendGrid"
SMSAddGranitNotificationsSms()Keyed Service: "Brevo", "AzureCommunicationServices", "AwsSns", "Twilio"
WhatsAppAddGranitNotificationsWhatsApp()Keyed Service: "Brevo", "Twilio"
Mobile PushAddGranitNotificationsMobilePush()Keyed Service: "GoogleFcm", "AzureNotificationHubs", "AwsSns"
SignalRAddGranitNotificationsSignalR()Direct (NotificationHub)
Web PushAddGranitNotificationsPush()VAPID (Lib.Net.Http.WebPush)
SSEAddGranitNotificationsSse()Native .NET 10 SSE
ZulipAddGranitNotificationsZulip()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>();

The application must implement this interface to resolve contact information from user identifiers. It is not provided by Granit — each application knows its own user model.

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; }
}

In-app notification stored in the user’s inbox. The database is the source of truth; email/SMS/push copies are fire-and-forget.

public sealed class UserNotification : Entity, IMultiTenant
{
public Guid NotificationId { get; set; }
public string NotificationTypeName { get; set; }
public NotificationSeverity Severity { get; set; }
public string RecipientUserId { get; set; }
public JsonElement Data { get; set; }
public UserNotificationState State { get; set; } // Unread, Read
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReadAt { get; set; }
public Guid? TenantId { get; set; }
public string? RelatedEntityType { get; set; }
public string? RelatedEntityId { get; set; }
}

INSERT-only audit record for ISO 27001 compliance. Never modified or deleted during the retention period.

public sealed class NotificationDeliveryAttempt : Entity
{
public Guid DeliveryId { get; set; }
public Guid NotificationId { get; set; }
public string NotificationTypeName { get; set; }
public string ChannelName { get; set; }
public string RecipientUserId { get; set; }
public Guid? TenantId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public long DurationMs { get; set; }
public string? ErrorMessage { get; set; }
public bool IsSuccess { get; set; }
}

User opt-in/opt-out per notification type and channel. When no preference exists, the default comes from NotificationDefinition.DefaultChannels.

public sealed class NotificationPreference : AuditedEntity, IMultiTenant
{
public string UserId { get; set; }
public string NotificationTypeName { get; set; }
public string ChannelName { get; set; }
public bool IsEnabled { get; set; } = true;
public Guid? TenantId { get; set; }
}

Topic subscription or entity follower (Odoo-style). When EntityType and EntityId are set, the subscription is an entity follower.

public sealed class NotificationSubscription : CreationAuditedEntity, IMultiTenant
{
public string UserId { get; set; }
public string NotificationTypeName { get; set; }
public Guid? TenantId { get; set; }
public string? EntityType { get; set; } // null = topic subscription
public string? EntityId { get; set; } // null = topic subscription
}

All persistence follows the CQRS pattern with separate reader and writer interfaces. The core package registers in-memory defaults; Granit.Notifications.EntityFrameworkCore replaces them with EF Core implementations.

ReaderWriterStore
IUserNotificationReaderIUserNotificationWriterInbox (UserNotification)
INotificationPreferenceReaderINotificationPreferenceWriterPreferences
INotificationSubscriptionReaderINotificationSubscriptionWriterSubscriptions + entity followers
IMobilePushTokenReaderIMobilePushTokenWriterDevice tokens
INotificationDeliveryWriterDelivery audit trail (write-only)
// Read interfaces — inject only what you need (ISP)
public interface IUserNotificationReader
{
Task<UserNotification?> GetAsync(Guid id, CancellationToken ct = default);
Task<PagedResult<UserNotification>> GetListAsync(
string recipientUserId, Guid? tenantId,
int page = 1, int pageSize = 20, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(
string recipientUserId, Guid? tenantId, CancellationToken ct = default);
Task<PagedResult<UserNotification>> GetByEntityAsync(
string entityType, string entityId, Guid? tenantId,
int page = 1, int pageSize = 20, CancellationToken ct = default);
}
public interface IUserNotificationWriter
{
Task InsertAsync(UserNotification notification, CancellationToken ct = default);
Task MarkAsReadAsync(Guid id, DateTimeOffset readAt, CancellationToken ct = default);
Task MarkAllAsReadAsync(
string recipientUserId, Guid? tenantId,
DateTimeOffset readAt, CancellationToken ct = default);
}

Notification types are declared at startup via INotificationDefinitionProvider and stored in INotificationDefinitionStore (singleton). The fan-out handler uses definitions to determine default channels and whether user opt-out is allowed.

public sealed class NotificationDefinition
{
public string Name { get; }
public NotificationSeverity DefaultSeverity { get; init; } = NotificationSeverity.Info;
public IReadOnlyList<string> DefaultChannels { get; init; } = [];
public string? DisplayName { get; init; }
public string? Description { get; init; }
public string? GroupName { get; init; }
public bool AllowUserOptOut { get; init; } = true;
}

Users can opt in/out per notification type per channel. The fan-out handler checks INotificationPreferenceReader.IsChannelEnabledAsync() before producing delivery commands.

flowchart TD
    T[NotificationTrigger] --> F{AllowUserOptOut?}
    F -->|No| D[Deliver to all channels]
    F -->|Yes| P{User preference?}
    P -->|Enabled / No pref| D
    P -->|Disabled| S[Skip channel]

The GET /notifications/preferences endpoint returns all preferences, and PUT /notifications/preferences creates or updates a preference. The GET /notifications/types endpoint lists all registered notification definitions, enabling UIs to build a preference matrix.

Entities implementing ITrackedEntity automatically generate notifications to followers when tracked properties change. The EntityTrackingInterceptor (in Granit.Notifications.EntityFrameworkCore) detects changes during SaveChanges.

public interface ITrackedEntity
{
static abstract string EntityTypeName { get; }
string GetEntityId();
static abstract IReadOnlyDictionary<string, TrackedPropertyConfig>
TrackedProperties { get; }
}
public sealed record TrackedPropertyConfig
{
public required string NotificationTypeName { get; init; }
public NotificationSeverity Severity { get; init; } = NotificationSeverity.Info;
}
public class Patient : AuditedAggregateRoot, ITrackedEntity
{
public static string EntityTypeName => "Patient";
public string GetEntityId() => Id.ToString();
public static IReadOnlyDictionary<string, TrackedPropertyConfig>
TrackedProperties { get; } = new Dictionary<string, TrackedPropertyConfig>
{
["Status"] = new() { NotificationTypeName = "Patient.StatusChanged" },
["AssignedDoctorId"] = new()
{
NotificationTypeName = "Patient.DoctorReassigned",
Severity = NotificationSeverity.Warning,
},
};
public string Status { get; set; } = string.Empty;
public Guid? AssignedDoctorId { get; set; }
}

When Patient.Status changes, the interceptor publishes an EntityStateChangedData notification to all followers of that patient.

Granit.Notifications.Endpoints maps all endpoints under the /notifications prefix (configurable via NotificationEndpointsOptions.RoutePrefix). All endpoints require authentication.

MethodRouteDescription
GET/notificationsUser’s notification inbox (paged, newest first)
GET/notifications/unread/countUnread notification count
POST/notifications/{id}/readMark a single notification as read
POST/notifications/read-allMark all notifications as read
MethodRouteDescription
GET/notifications/entity/{entityType}/{entityId}Activity feed for a specific entity
MethodRouteDescription
GET/notifications/preferencesUser’s delivery preferences
PUT/notifications/preferencesCreate or update a preference
GET/notifications/typesList all registered notification type definitions
MethodRouteDescription
GET/notifications/subscriptionsUser’s topic subscriptions
POST/notifications/subscriptions/{typeName}Subscribe to a notification type
DELETE/notifications/subscriptions/{typeName}Unsubscribe from a notification type
MethodRouteDescription
POST/notifications/entity/{entityType}/{entityId}/followFollow an entity
DELETE/notifications/entity/{entityType}/{entityId}/followUnfollow an entity
GET/notifications/entity/{entityType}/{entityId}/followersList entity followers

Mapped separately via MapMobilePushTokenEndpoints() under api/notifications/mobile-push/tokens:

MethodRouteDescription
POST/api/notifications/mobile-push/tokensRegister a device token
DELETE/api/notifications/mobile-push/tokens/{deviceToken}Remove a device token
GET/api/notifications/mobile-push/tokensList current user’s device tokens

Granit.Notifications.Wolverine replaces the default in-process Channel<T> publisher with a durable IMessageBus-backed implementation. Notifications are persisted in the Wolverine outbox and survive application crashes.

[DependsOn(typeof(GranitNotificationsWolverineModule))]
public class AppModule : GranitModule { }

The module configures two local queues:

QueueMessageBehavior
notification-fanoutNotificationTriggerDefault parallelism
notification-deliveryDeliverNotificationCommandMaxParallelDeliveries (default 8)

Retry policy on NotificationDeliveryException:

AttemptDelay
110 seconds
21 minute
35 minutes
430 minutes
52 hours
{
"Notifications": {
"MaxParallelDeliveries": 8
}
}
PropertyDefaultDescription
MaxParallelDeliveries8Max concurrent delivery messages (Wolverine queue parallelism)
{
"Notifications": {
"Email": {
"Provider": "Smtp",
"SenderAddress": "[email protected]",
"SenderName": "Clinic Portal"
}
}
}
{
"Notifications": {
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UseSsl": true,
"Username": "user",
"Password": "vault:secret/data/smtp#password",
"TimeoutSeconds": 30
}
}
}
{
"Notifications": {
"Brevo": {
"ApiKey": "vault:secret/data/brevo#api-key",
"DefaultSenderEmail": "[email protected]",
"DefaultSenderName": "Clinic Portal",
"DefaultSmsSenderId": "CLINIC",
"BaseUrl": "https://api.brevo.com/v3",
"TimeoutSeconds": 30
}
}
}
{
"Notifications": {
"Email": {
"SendGrid": {
"ApiKey": "vault:secret/data/sendgrid#api-key",
"DefaultSenderEmail": "[email protected]",
"DefaultSenderName": "Clinic Portal",
"SandboxMode": false,
"TimeoutSeconds": 30
}
}
}
}
{
"Notifications": {
"Twilio": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"AuthToken": "vault:secret/data/twilio#auth-token",
"DefaultSmsFrom": "+15551234567",
"DefaultWhatsAppFrom": "whatsapp:+14155238886",
"MessagingServiceSid": null,
"TimeoutSeconds": 30
}
}
}
{
"Notifications": {
"Sms": {
"Provider": "Brevo",
"SenderId": "CLINIC"
}
}
}
{
"Notifications": {
"MobilePush": {
"Provider": "GoogleFcm",
"GoogleFcm": {
"ProjectId": "my-firebase-project",
"ServiceAccountJson": "vault:secret/data/fcm#service-account",
"BaseAddress": "https://fcm.googleapis.com/",
"TimeoutSeconds": 30
}
}
}
}
{
"AzureCommunicationServices": {
"Email": {
"ConnectionString": "endpoint=...;accesskey=...",
"SenderAddress": "[email protected]",
"TimeoutSeconds": 120
}
}
}
{
"AzureCommunicationServices": {
"Sms": {
"ConnectionString": "endpoint=...;accesskey=...",
"FromPhoneNumber": "+15551234567",
"TimeoutSeconds": 30
}
}
}
{
"Notifications": {
"Sms": {
"AwsSns": {
"Region": "eu-west-1",
"SenderId": "MYAPP",
"SmsType": "Transactional",
"TimeoutSeconds": 30
}
}
}
}
PropertyDefaultDescription
RegionAWS region (required)
SenderIdnullAlphanumeric sender ID (1-11 chars, region-dependent)
SmsType"Transactional""Transactional" or "Promotional"
OriginationNumbernullE.164 originating number
AccessKeyIdnullAWS access key (uses default credential chain when null)
SecretAccessKeynullAWS secret key (required if AccessKeyId set)
{
"Notifications": {
"MobilePush": {
"AwsSns": {
"Region": "eu-west-1",
"PlatformApplicationArn": "arn:aws:sns:eu-west-1:123456789:app/GCM/my-app",
"TimeoutSeconds": 30
}
}
}
}
PropertyDefaultDescription
RegionAWS region (required)
PlatformApplicationArnSNS Platform Application ARN (required)
AccessKeyIdnullAWS access key (uses default credential chain when null)
SecretAccessKeynullAWS secret key (required if AccessKeyId set)
{
"Notifications": {
"AzureNotificationHubs": {
"ConnectionString": "Endpoint=sb://...",
"HubName": "my-notification-hub",
"TimeoutSeconds": 30
}
}
}
{
"Notifications": {
"SignalR": {
"RedisConnectionString": "redis:6379"
}
}
}
{
"Notifications": {
"Push": {
"VapidSubject": "mailto:[email protected]",
"VapidPublicKey": "BFx...",
"VapidPrivateKey": "vault:secret/data/webpush#private-key"
}
}
}
{
"Notifications": {
"Sse": {
"HeartbeatIntervalSeconds": 30
}
}
}
{
"Notifications": {
"Zulip": {
"DefaultStream": "alerts",
"DefaultTopic": "system",
"Bot": {
"BaseUrl": "https://zulip.example.com",
"BotEmail": "[email protected]",
"ApiKey": "vault:secret/data/zulip#api-key",
"TimeoutSeconds": 30
}
}
}
}

Notification channel providers register opt-in health checks:

builder.Services.AddHealthChecks()
.AddGranitSmtpHealthCheck()
.AddGranitAwsSesHealthCheck()
.AddGranitBrevoHealthCheck()
.AddGranitAcsEmailHealthCheck()
.AddGranitAcsSmsHealthCheck()
.AddGranitAzureNotificationHubsHealthCheck()
.AddGranitAwsSnsSmsHealthCheck()
.AddGranitAwsSnsMobilePushHealthCheck()
.AddGranitScalewayEmailHealthCheck()
.AddGranitSendGridHealthCheck()
.AddGranitTwilioHealthCheck()
.AddGranitZulipHealthCheck();
ProviderExtensionProbeTags
SMTPAddGranitSmtpHealthCheck()EHLO handshake via MailKitreadiness
SESAddGranitAwsSesHealthCheck()GetAccount() — Degraded if sending pausedreadiness, startup
BrevoAddGranitBrevoHealthCheck()GET /accountreadiness
ACS EmailAddGranitAcsEmailHealthCheck()SendAsync probereadiness
ACS SMSAddGranitAcsSmsHealthCheck()SendAsync probereadiness
Azure Notification HubsAddGranitAzureNotificationHubsHealthCheck()Hub description retrievalreadiness
SNS SMSAddGranitAwsSnsSmsHealthCheck()SNS API connectivity checkreadiness, startup
SNS Mobile PushAddGranitAwsSnsMobilePushHealthCheck()SNS Platform Application checkreadiness, startup
Scaleway TEMAddGranitScalewayEmailHealthCheck()GET /emails?page_size=1readiness, startup
SendGridAddGranitSendGridHealthCheck()GET /scopesreadiness, startup
TwilioAddGranitTwilioHealthCheck()GET /Accounts/{sid}.jsonreadiness, startup
ZulipAddGranitZulipHealthCheck()GET /api/v1/users/me (Bot auth)readiness

All checks sanitize error messages — credentials, hostnames, and API keys are never exposed in the health check response. Every check enforces a 10-second defensive timeout via .WaitAsync() to prevent blocking Kubernetes probe cycles.

All fan-out and delivery operations are traced via ActivitySource:

Activity nameDescription
notifications.fanoutFan-out of a NotificationTrigger into delivery commands
notifications.deliverDelivery of a single DeliverNotificationCommand via a channel

Tags: notifications.type, notifications.channel, notifications.delivery_id, notifications.notification_id, notifications.recipient_count, notifications.delivery_count, notifications.success.

CategoryKey typesPackage
ModuleGranitNotificationsModule, GranitNotificationsEntityFrameworkCoreModule, GranitNotificationsWolverineModule
PublisherINotificationPublisher, NotificationType<TData>Granit.Notifications
ChannelsINotificationChannel, NotificationChannels, NotificationDeliveryContextGranit.Notifications
DefinitionsNotificationDefinition, INotificationDefinitionProvider, INotificationDefinitionContext, INotificationDefinitionStoreGranit.Notifications
EntitiesUserNotification, NotificationDeliveryAttempt, NotificationPreference, NotificationSubscription, UserNotificationStateGranit.Notifications
CQRSIUserNotificationReader, IUserNotificationWriter, INotificationPreferenceReader, INotificationPreferenceWriter, INotificationSubscriptionReader, INotificationSubscriptionWriter, INotificationDeliveryWriterGranit.Notifications
RecipientIRecipientResolver, RecipientInfoGranit.Notifications
Entity trackingITrackedEntity, TrackedPropertyConfig, EntityStateChangedData, EntityReferenceGranit.Notifications
MessagesNotificationTrigger, DeliverNotificationCommandGranit.Notifications
HandlersNotificationFanoutHandler, NotificationDeliveryHandlerGranit.Notifications
OptionsNotificationsOptions, EmailChannelOptions, SmtpOptions, BrevoOptions, ScalewayEmailOptions, SendGridEmailOptions, AcsEmailOptions, SmsChannelOptions, AcsSmsOptions, AwsSnsSmsOptions, TwilioOptions, MobilePushChannelOptions, GoogleFcmOptions, AzureNotificationHubsOptions, AwsSnsMobilePushOptions, SignalRChannelOptions, PushChannelOptions, SseChannelOptions, ZulipChannelOptions, ZulipBotOptionsvarious
EmailIEmailSender, EmailMessageGranit.Notifications.Email, Granit.Notifications.Email.AzureCommunicationServices, Granit.Notifications.Email.Scaleway, Granit.Notifications.Email.SendGrid
SMSISmsSender, SmsMessageGranit.Notifications.Sms, Granit.Notifications.Sms.AwsSns, Granit.Notifications.Twilio
WhatsAppIWhatsAppSender, WhatsAppMessageGranit.Notifications.WhatsApp, Granit.Notifications.Twilio
Mobile PushIMobilePushSender, MobilePushMessage, IMobilePushTokenReader, IMobilePushTokenWriter, MobilePushTokenInfo, MobilePlatformGranit.Notifications.MobilePush, Granit.Notifications.MobilePush.AwsSns
SignalRNotificationHub, SignalRNotificationMessageGranit.Notifications.SignalR
Web PushIPushSubscriptionReader, IPushSubscriptionWriter, PushSubscriptionInfoGranit.Notifications.WebPush
SSEISseConnectionManager, SseConnection, SseNotificationMessageGranit.Notifications.Sse
ZulipIZulipSender, ZulipMessageGranit.Notifications.Zulip
ExceptionsNotificationDeliveryExceptionGranit.Notifications
EndpointsNotificationEndpointsOptions, MobilePushTokenEndpointsGranit.Notifications.Endpoints
ExtensionsAddGranitNotifications(), AddGranitNotificationsEntityFrameworkCore(), AddGranitNotificationsEmail(), AddGranitNotificationsEmailSmtp(), AddGranitNotificationsEmailAcs(), AddGranitNotificationsEmailScaleway(), AddGranitNotificationsEmailSendGrid(), AddGranitNotificationsBrevo(), AddGranitNotificationsSms(), AddGranitNotificationsSmsAcs(), AddGranitNotificationsSmsAwsSns(), AddGranitNotificationsTwilio(), AddGranitNotificationsWhatsApp(), AddGranitNotificationsMobilePush(), AddGranitNotificationsMobilePushGoogleFcm(), AddGranitNotificationsMobilePushAzureNotificationHubs(), AddGranitNotificationsMobilePushAwsSns(), AddGranitNotificationsSignalR(), AddGranitNotificationsPush(), AddGranitNotificationsSse(), AddGranitNotificationsZulip(), AddNotificationDefinitions<T>(), MapGranitNotificationEndpoints(), MapMobilePushTokenEndpoints()various