Examples
Three complete setups — one per usage tier — plus a real-world production pattern.
Namespace Organization
As of v0.2.0, the library uses a cleaner public API structure:
// Core types and service
using RecurPixel.Notify; // INotifyService, TriggerResult, BulkTriggerResult, NotifyContext, NotifyUser
// NotificationPayload, NotifyResult, NotifyOptions, etc.
// Channel interfaces
using RecurPixel.Notify.Channels; // INotificationChannel, NotificationChannelBase, all channel implementations
// Configuration
using RecurPixel.Notify.Configuration; // EmailOptions, SmsOptions, PushOptions, WhatsAppOptions, all provider credentials
Migrating from v0.1.0-beta.1? If you see compiler errors like “RecurPixel.Notify.Core.Models is not found”, update your using statements to match the new namespaces above.
Tier 1 — Single-channel OTP service
Use case: Send an OTP verification email with no orchestration overhead.
Packages:
dotnet add package RecurPixel.Notify.Email.SendGrid
Configuration (appsettings.json):
{
"Notify": {
"Email": {
"SendGrid": {
"ApiKey": "SG.xxxxxxxxxxxxxxxxxx",
"FromEmail": "no-reply@yourapp.com",
"FromName": "Your App"
}
}
}
}
Registration (Program.cs):
builder.Services.AddSendGridChannel(opts =>
{
opts.ApiKey = builder.Configuration["Notify:Email:SendGrid:ApiKey"]!;
opts.FromEmail = builder.Configuration["Notify:Email:SendGrid:FromEmail"]!;
opts.FromName = builder.Configuration["Notify:Email:SendGrid:FromName"]!;
});
Service:
using RecurPixel.Notify; // INotificationChannel, NotificationPayload, NotifyResult
using RecurPixel.Notify.Channels;
public class OtpService(
[FromKeyedServices("email")] INotificationChannel email,
ILogger<OtpService> logger)
{
public async Task<bool> SendOtpAsync(string toEmail, string otp)
{
var result = await email.SendAsync(new NotificationPayload
{
To = toEmail,
Subject = "Your verification code",
Body = $"Your one-time code is <strong>{otp}</strong>. Valid for 5 minutes."
});
if (!result.Success)
logger.LogWarning("OTP email failed for {Email}: {Error}", toEmail, result.Error);
return result.Success;
}
}
Tier 2 — E-commerce notifications (selective providers)
Use case: Order events dispatched to Email + SMS + In-App with conditions and delivery logging.
Packages:
dotnet add package RecurPixel.Notify
dotnet add package RecurPixel.Notify.Email.SendGrid
dotnet add package RecurPixel.Notify.Sms.Twilio
dotnet add package RecurPixel.Notify.InApp
Configuration (appsettings.json):
{
"Notify": {
"Email": {
"Provider": "sendgrid",
"SendGrid": {
"ApiKey": "SG.xxxxxxxxxxxxxxxxxx",
"FromEmail": "orders@yourapp.com",
"FromName": "Your App Orders"
}
},
"Sms": {
"Provider": "twilio",
"Twilio": {
"AccountSid": "ACxxxxxxxxxxxxxxxxxx",
"AuthToken": "xxxxxxxxxxxxxxxxxx",
"FromNumber": "+15550001234"
}
},
"Retry": {
"MaxAttempts": 3,
"DelayMs": 500,
"ExponentialBackoff": true
}
}
}
Registration (Program.cs):
using RecurPixel.Notify;
using RecurPixel.Notify.Configuration;
// ① InApp handler — must be registered BEFORE AddRecurPixelNotify
// This is the implementation of "sending" an in-app notification (writing to database)
builder.Services.AddInAppChannel(opts =>
opts.UseHandler<IApplicationDbContext>(async (notification, db) =>
{
// UseHandler IS the send operation — you return success/failure here
await db.Notifications.AddAsync(new Notification
{
UserId = notification.UserId,
Title = notification.Subject ?? string.Empty,
Message = notification.Body,
IsRead = false,
SentAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
return new NotifyResult { Success = true };
}));
// ② Main registration
builder.Services.AddRecurPixelNotify(
notifyOptions =>
{
builder.Configuration.GetSection("Notify").Bind(notifyOptions);
},
orchestratorOptions =>
{
// ③ OnDelivery hook — fires AFTER every send (including the InApp send above)
// This is for audit logging, metrics, or external notifications — NOT the send itself
orchestratorOptions.OnDelivery<IApplicationDbContext>(async (result, db) =>
{
await db.NotificationLogs.AddAsync(new NotificationLog
{
Channel = result.Channel,
Provider = result.Provider,
Recipient = result.Recipient ?? "unknown",
Status = result.Success ? "Sent" : "Failed",
ProviderId = result.ProviderId,
Error = result.Error,
SentAt = result.SentAt
});
await db.SaveChangesAsync();
});
// ④ Event definitions
orchestratorOptions
.DefineEvent("order.placed", e => e
.UseChannels("email", "sms", "inapp")
.WithCondition("sms", ctx => ctx.User.PhoneVerified)
.WithRetry(maxAttempts: 3, delayMs: 500))
.DefineEvent("order.cancelled", e => e
.UseChannels("email", "inapp"))
.DefineEvent("auth.welcome", e => e
.UseChannels("email", "inapp"));
});
Key distinction:
UseHandler(step ①): The code that EXECUTES the send. For InApp, where you write to your database.OnDelivery(step ③): An AUDIT hook that fires AFTER the send. Use it for logging, not for the send itself.
Service:
using RecurPixel.Notify;
public class OrderService(INotifyService notify, ILogger<OrderService> logger)
{
public async Task NotifyOrderPlacedAsync(Order order)
{
// TriggerResult contains per-channel outcomes
var result = await notify.TriggerAsync("order.placed", new NotifyContext
{
User = new NotifyUser
{
UserId = order.UserId.ToString(),
Email = order.CustomerEmail,
Phone = order.CustomerPhone,
PhoneVerified = order.Customer.PhoneVerified
},
Channels = new()
{
["email"] = new()
{
Subject = $"Order #{order.Id} Confirmed",
Body = $"<p>Hi {order.CustomerName}, your order has been placed.</p>"
},
["sms"] = new()
{
Body = $"Order #{order.Id} confirmed. Track at yourapp.com/orders/{order.Id}"
},
["inapp"] = new()
{
Subject = "Order confirmed",
Body = $"Order #{order.Id} is being processed.",
Metadata = new() { ["orderId"] = order.Id }
}
}
});
// Check the aggregated result
if (!result.AllSucceeded)
{
// Some or all channels failed — inspect failures
foreach (var failure in result.Failures)
logger.LogWarning("Channel {Ch} failed: {Err}", failure.Channel, failure.Error);
}
}
}
Tier 3 — Full SDK (all channels available)
Use case: New project. Install everything, configure what you need.
Packages:
dotnet add package RecurPixel.Notify.Sdk
dotnet add package RecurPixel.Notify.InApp
Registration (Program.cs):
Identical to Tier 2. The only difference is that all 30+ provider adapters are available in your project — you activate them by adding their credentials to appsettings.json.
builder.Services.AddInAppChannel(opts =>
opts.UseHandler<IApplicationDbContext>(async (notification, db) =>
{
await db.Notifications.AddAsync(new Notification
{
UserId = notification.UserId,
Title = notification.Subject ?? string.Empty,
Message = notification.Body,
IsRead = false
});
await db.SaveChangesAsync();
return new NotifyResult { Success = true };
}));
builder.Services.AddRecurPixelNotify(
notifyOptions =>
{
builder.Configuration.GetSection("Notify").Bind(notifyOptions);
},
orchestratorOptions =>
{
orchestratorOptions
.DefineEvent("order.placed", e => e
.UseChannels("email", "sms", "push", "inapp")
.WithCondition("sms", ctx => ctx.User.PhoneVerified)
.WithCondition("push", ctx => ctx.User.PushEnabled))
.DefineEvent("promo.blast", e => e
.UseChannels("email", "push"))
.OnDelivery<IApplicationDbContext>(async (result, db) =>
{
await db.NotificationLogs.AddAsync(new NotificationLog
{
Channel = result.Channel,
Provider = result.Provider,
Recipient = result.Recipient ?? "unknown",
Status = result.Success ? "Sent" : "Failed",
ProviderId = result.ProviderId,
Error = result.Error,
SentAt = result.SentAt
});
await db.SaveChangesAsync();
});
});
Only providers with credentials in
appsettings.jsonare registered. Unconfigured adapters are not registered at all — zero DI entries, zero runtime overhead.
Production pattern
A real-world setup with InApp persistence, delivery logging, typed metadata, and multiple events. This is the correct form for apps that previously used the removed NotifyOptions.InApp and NotifyOptions.OnDelivery properties.
Key points:
AddInAppChannel(opts => opts.UseHandler<TService>(...))replaces the removednotifyOptions.InApp.OnDeliver(...)patternorchestratorOptions.OnDelivery<TService>(...)replaces the removednotifyOptions.OnDelivery = async result => { ... }assignment (and eliminates theservices.BuildServiceProvider()anti-pattern — the library creates the scope for you)DefineEventis fluent and chainable on the return value of the previous call
// Program.cs
// ① InApp — register before AddRecurPixelNotify
services.AddInAppChannel(opts =>
opts.UseHandler<IApplicationDbContext>(async (notification, db) =>
{
if (!int.TryParse(notification.UserId, out var userId))
return new NotifyResult
{
Success = false,
Error = $"Invalid userId in InApp payload: '{notification.UserId}'"
};
var typeStr = notification.Metadata.TryGetValue("type", out var t) ? t?.ToString() : null;
if (!Enum.TryParse<NotificationType>(typeStr, out var notifType))
notifType = NotificationType.System;
int? referenceId = null;
if (notification.Metadata.TryGetValue("referenceId", out var rid) && rid is not null)
referenceId = Convert.ToInt32(rid);
var referenceType = notification.Metadata.TryGetValue("referenceType", out var rt)
? rt?.ToString()
: null;
await db.Notifications.AddAsync(new Notification
{
UserId = userId,
Title = notification.Subject ?? string.Empty,
Message = notification.Body,
Type = notifType,
ReferenceId = referenceId,
ReferenceType = referenceType,
IsRead = false
});
await db.SaveChangesAsync(CancellationToken.None);
return new NotifyResult { Success = true };
}));
// ② Main registration
services.AddRecurPixelNotify(
notifyOptions =>
{
configuration.GetSection("Notify").Bind(notifyOptions);
},
orchestratorOptions =>
{
// ③ Delivery audit log
// OnDelivery<TService> creates a fresh DI scope per call — no BuildServiceProvider() needed
orchestratorOptions.OnDelivery<IApplicationDbContext>(async (result, db) =>
{
await db.NotificationLogs.AddAsync(new NotificationLog
{
Channel = result.Channel,
Provider = result.Provider,
Recipient = result.Recipient ?? "Unknown",
Status = result.Success ? "Sent" : "Failed",
ProviderId = result.ProviderId,
Error = result.Error,
SentAt = result.SentAt
});
await db.SaveChangesAsync(CancellationToken.None);
});
// ④ Event definitions — DefineEvent returns OrchestratorOptions so calls are chainable
orchestratorOptions
.DefineEvent("auth.welcome",
e => e.UseChannels("email", "inapp", "telegram").WithFallback("telegram"))
.DefineEvent("auth.verify-email",
e => e.UseChannels("email"))
.DefineEvent("auth.password-reset",
e => e.UseChannels("email"))
.DefineEvent("order.placed",
e => e.UseChannels("email", "inapp"))
.DefineEvent("order.cancelled",
e => e.UseChannels("email", "inapp"))
.DefineEvent("order.refund",
e => e.UseChannels("email", "inapp"))
.DefineEvent("subscription.created",
e => e.UseChannels("email", "inapp"))
.DefineEvent("delivery.skipped",
e => e.UseChannels("inapp"))
.DefineEvent("invoice.paid",
e => e.UseChannels("email", "inapp"))
.DefineEvent("vendor.approved",
e => e.UseChannels("email", "inapp"))
.DefineEvent("vendor.suspended",
e => e.UseChannels("email", "inapp"))
.DefineEvent("vendor.payout",
e => e.UseChannels("email", "inapp"));
});
Triggering an event:
var result = await notify.TriggerAsync("order.placed", new NotifyContext
{
User = new NotifyUser
{
UserId = order.UserId.ToString(),
Email = order.CustomerEmail
},
Channels = new()
{
["email"] = new()
{
Subject = $"Order #{order.Id} Confirmed",
Body = emailHtml
},
["inapp"] = new()
{
Subject = "Order confirmed",
Body = $"Order #{order.Id} is being processed.",
Metadata = new()
{
["type"] = "Order",
["referenceId"] = order.Id,
["referenceType"] = "Order"
}
}
}
});
if (!result.AllSucceeded)
foreach (var f in result.Failures)
logger.LogWarning("Notification failed: {Channel} — {Error}", f.Channel, f.Error);