Features


Inspecting Results

Both TriggerAsync and direct channel sends return result objects that let you inspect success/failure per channel.

TriggerResult — from TriggerAsync

When you call notify.TriggerAsync("event.name", context), the returned TriggerResult contains per-channel outcomes:

// Namespace: RecurPixel.Notify
var result = await notify.TriggerAsync("order.placed", context);

// Read the aggregate result
if (result.AllSucceeded)
    logger.LogInformation("Order notification sent to all channels");
else if (result.AnySucceeded)
    logger.LogWarning("Partial send: some channels succeeded, some failed");
else
    logger.LogError("All channels failed");

// Inspect individual channel results
foreach (var channelResult in result.ChannelResults)
{
    logger.LogInformation(
        "Channel {Ch}/{Prov} → {Status}",
        channelResult.Channel,
        channelResult.Provider ?? "default",
        channelResult.Success ? "✓ sent" : $"✗ {channelResult.Error}");
}

// Get only the failures
foreach (var failure in result.Failures)
{
    logger.LogError(
        "Channel {Ch} failed with error: {Error}",
        failure.Channel,
        failure.Error);
}

TriggerResult properties:

Property Type Description
EventName string The event name passed to TriggerAsync
UserId string? User ID from the context (for tracing)
ChannelResults IReadOnlyList<NotifyResult> One result per channel that was dispatched
AllSucceeded bool True if every channel in ChannelResults has Success == true
AnySucceeded bool True if at least one channel succeeded
Failures IReadOnlyList<NotifyResult> All results where Success == false

NotifyResult — individual channel outcome

Each entry in ChannelResults is a NotifyResult:

foreach (var result in triggerResult.ChannelResults)
{
    Console.WriteLine($"Channel: {result.Channel}");                  // "email", "sms", etc.
    Console.WriteLine($"Provider: {result.Provider}");                // "sendgrid", "twilio", etc.
    Console.WriteLine($"Success: {result.Success}");                  // true or false
    Console.WriteLine($"ProviderId: {result.ProviderId}");            // Message ID from provider
    Console.WriteLine($"Recipient: {result.Recipient}");              // Email, phone, user ID, etc.
    Console.WriteLine($"Error: {result.Error}");                      // Error message (null if Success=true)
    Console.WriteLine($"UsedFallback: {result.UsedFallback}");        // true if this was a fallback channel
    Console.WriteLine($"SentAt: {result.SentAt}");                    // DateTime.UtcNow when sent
}
Property Type Description
Success bool Did this channel send successfully?
Channel string Logical channel name: “email”, “sms”, “push”, “inapp”, etc.
Provider string? Provider name if multi-provider: “sendgrid”, “twilio”, etc.
NamedProvider string? Named provider key if you used Metadata["provider"] routing
ProviderId string? Provider’s message ID (e.g., SendGrid message ID)
Recipient string? Who received it: email address, phone, user ID, etc.
Error string? Error message from the provider (null if Success == true)
UsedFallback bool True if this channel fired because a previous fallback failed
SentAt DateTime When the send occurred

BulkTriggerResult — from BulkTriggerAsync

Bulk send returns one TriggerResult per input context:

// Namespace: RecurPixel.Notify
var contexts = new List<NotifyContext> { /* ... */ };
var bulkResult = await notify.BulkTriggerAsync("auth.welcome", contexts);

// Aggregate check — did ALL users succeed across ALL channels?
if (bulkResult.AllSucceeded)
    logger.LogInformation("All {Count} users notified successfully", bulkResult.Results.Count);

// Were ANY successful?
if (bulkResult.AnySucceeded)
    logger.LogWarning("Partial success: {Success}/{Total} users", 
        bulkResult.Results.Count(r => r.AllSucceeded), 
        bulkResult.Results.Count);

// Inspect failures
foreach (var failure in bulkResult.Failures)
{
    logger.LogError(
        "User {UserId} failed: {Channels}",
        failure.UserId,
        string.Join(", ", failure.Failures.Select(f => $"{f.Channel}:{f.Error}")));
}
Property Type Description
Results IReadOnlyList<TriggerResult> One TriggerResult per input context (same order)
AllSucceeded bool True if EVERY user’s EVERY channel succeeded
AnySucceeded bool True if at least one user had at least one success
Failures IReadOnlyList<TriggerResult> All TriggerResult entries where AllSucceeded == false

Retry

Automatically retry failed sends with configurable delay and exponential backoff.

Global retry

"Notify": {
  "Retry": {
    "MaxAttempts": 3,
    "DelayMs": 500,
    "ExponentialBackoff": true
  }
}

Per-event retry

Per-event config overrides the global setting for that event. orchestratorOptions is the second parameter of AddRecurPixelNotify:

// Inside the orchestratorOptions => { ... } lambda
orchestratorOptions.DefineEvent("auth.otp", e => e
    .UseChannels("sms")
    .WithRetry(maxAttempts: 5, delayMs: 300)
);

Backoff schedule

With ExponentialBackoff: true and DelayMs: 500, the delays between attempts are:

Attempt Delay
1 → 2 500 ms
2 → 3 1,000 ms
3 → 4 2,000 ms

Each attempt doubles. Retries stop when MaxAttempts is reached or a send succeeds.


Fallback Chains

If a channel fails after exhausting retries, the next channel in the fallback chain is tried automatically. Fallback operates at the channel level — it is not the same as within-channel provider fallback.

Global fallback

"Notify": {
  "Fallback": {
    "Chain": ["whatsapp", "sms", "email"]
  }
}

Per-event fallback

orchestratorOptions.DefineEvent("order.placed", e => e
    .UseChannels("email", "sms")
    .WithFallback("whatsapp", "sms", "email")
);

How it works

Primary: email → fails after 3 retries
Fallback: sms  → succeeds → done

Primary: email → fails
Fallback: sms  → fails
Fallback: whatsapp → succeeds → done

NotifyResult.UsedFallback is true whenever a fallback provider was used. Your delivery hook can track which channels are failing in production.


Within-Channel Provider Fallback

Different from cross-channel fallback — this is a secondary provider for the same channel. If your primary email provider (SendGrid) goes down, the fallback (SMTP) takes over automatically.

"Email": {
  "Provider": "sendgrid",
  "Fallback": "smtp",
  "SendGrid": { "ApiKey": "SG.xxx", "FromEmail": "no-reply@yourapp.com" },
  "Smtp": { "Host": "smtp.office365.com", "Port": 587, "Username": "...", "Password": "...", "UseSsl": true, "FromEmail": "no-reply@yourapp.com" }
}

No code change required. The library handles the switch transparently. NotifyResult.Provider tells you which provider actually sent the message.


Named Provider Routing

Route different send types to different provider instances. Common use case: transactional email via Postmark (highest deliverability), marketing email via AWS SES (lowest cost).

"Email": {
  "Provider": "sendgrid",
  "Providers": {
    "transactional": { "Type": "postmark" },
    "marketing":     { "Type": "awsses" }
  },
  "SendGrid": { ... },
  "Postmark": { ... },
  "AwsSes":   { ... }
}

Tag the payload:

// Routes to Postmark
await notify.Email.SendAsync(new NotificationPayload
{
    To       = user.Email,
    Subject  = "Your OTP",
    Body     = $"OTP: {otp}",
    Metadata = new() { ["provider"] = "transactional" }
});

// Routes to AwsSes
await notify.Email.SendAsync(new NotificationPayload
{
    To       = user.Email,
    Subject  = "This week's deals",
    Body     = promoHtml,
    Metadata = new() { ["provider"] = "marketing" }
});

If Metadata["provider"] is set to a name that does not exist in the Providers config, an InvalidOperationException is thrown immediately. Named provider mismatches are loud failures, never silent reroutes.


Conditions

Per-channel send conditions evaluated at runtime against the NotifyContext. Prevents sending to unverified numbers, users with push disabled, or any other runtime guard.

orchestratorOptions.DefineEvent("order.placed", e => e
    .UseChannels("email", "sms", "push")
    .WithCondition("sms",  ctx => ctx.User.PhoneVerified)
    .WithCondition("push", ctx => ctx.User.PushEnabled && ctx.User.DeviceToken != null)
);

If a condition returns false, that channel is skipped entirely for this trigger. No retry, no fallback, no OnDelivery call — it simply doesn’t fire.


Parallel Dispatch

When an event targets multiple channels, all channels are dispatched in parallel via Task.WhenAll. Email, SMS, and Push fire simultaneously — not sequentially.

// All three start at the same time
await notify.TriggerAsync("order.placed", context);
// ↳ Email fires  ┐
// ↳ SMS fires    ├─ all parallel
// ↳ Push fires   ┘

Total latency is determined by the slowest channel, not the sum of all channels.


Bulk Send

Send to many recipients in a single call. Providers with native batch APIs use them automatically — you don’t need to know or care which path runs.

Direct bulk on a single channel

var payloads = users.Select(u => new NotificationPayload
{
    To      = u.DeviceToken,
    Subject = "Flash sale — 2 hours only",
    Body    = "Tap to shop now.",
}).ToList();

var result = await notify.Push.SendBulkAsync(payloads);

Console.WriteLine($"{result.SuccessCount}/{result.Total} sent");
foreach (var failure in result.Failures)
    logger.LogWarning("Failed for {Recipient}: {Error}", failure.Recipient, failure.Error);

Bulk orchestrated trigger (multi-channel, multi-user)

var contexts = customers.Select(c => new NotifyContext
{
    User = new NotifyUser { Email = c.Email, PushEnabled = c.PushEnabled, DeviceToken = c.DeviceToken },
    Channels = new()
    {
        ["email"] = new() { Subject = "Promo", Body = promoHtml },
        ["push"]  = new() { Subject = "Promo", Body = "Tap to view." }
    }
}).ToList();

var result = await notify.BulkTriggerAsync("promo.blast", contexts);

Console.WriteLine($"{result.SuccessCount}/{result.Total} users notified");

BulkNotifyResult (direct channel bulk)

Property Description
AllSucceeded true if every send succeeded
AnySucceeded true if at least one send succeeded
Failures List of NotifyResult for failed sends
SuccessCount Count of successful sends
FailureCount Count of failed sends
Total Total payloads submitted
Channel Channel name shared by all results in this batch
UsedNativeBatch true if the provider’s own batch API was used

BulkTriggerResult (orchestrated bulk)

Property Description
AllSucceeded true if every user’s every channel succeeded
AnySucceeded true if at least one user/channel succeeded
Failures List of TriggerResult where at least one channel failed
SuccessCount Users where all channels succeeded
FailureCount Users where at least one channel failed
Total Total users processed
Results All TriggerResult entries in input order

Bulk configuration

"Notify": {
  "Bulk": {
    "ConcurrencyLimit": 10,
    "MaxBatchSize": 1000,
    "AutoChunk": true
  }
}

ConcurrencyLimit controls how many concurrent single sends run when a provider has no native bulk API. MaxBatchSize is the maximum payload count per native API call. AutoChunk automatically splits large payloads into multiple calls when the total exceeds MaxBatchSize.


Delivery Hook

Called after every individual send attempt — including each result within a bulk operation. orchestratorOptions is the second parameter of AddRecurPixelNotify.

Simple hook (no DI dependencies)

orchestratorOptions.OnDelivery(async result =>
{
    Console.WriteLine($"[{result.Channel}] {(result.Success ? "OK" : "FAIL")} → {result.Recipient}");
    await Task.CompletedTask;
});

Typed hook with scoped service

The typed overload resolves TService in a fresh DI scope per call — safe for DbContext and other scoped services:

orchestratorOptions.OnDelivery<IApplicationDbContext>(async (result, db) =>
{
    await db.NotificationLogs.AddAsync(new NotificationLog
    {
        Channel       = result.Channel,
        Provider      = result.Provider,
        NamedProvider = result.NamedProvider,
        Recipient     = result.Recipient,
        Status        = result.Success ? "sent" : "failed",
        ProviderId    = result.ProviderId,
        UsedFallback  = result.UsedFallback,
        Error         = result.Error,
        SentAt        = result.SentAt
    });
    await db.SaveChangesAsync();
});

Multiple OnDelivery registrations are composable — all fire in registration order.

NotifyResult fields

Field Description
Success Whether the send succeeded
Channel Channel name (e.g. "email", "sms")
Provider Provider key that actually sent (e.g. "sendgrid", "smtp")
NamedProvider Named provider used for routing, if any (e.g. "transactional")
UsedFallback true if a fallback provider was used
ProviderId Provider’s own message ID for tracking
Recipient Recipient identifier (NotificationPayload.To)
Error Error message if Success is false
SentAt UTC timestamp of the send attempt
EventName Event name passed to TriggerAsync. null for direct sends.
BulkBatchId Shared ID across all results in one BulkTriggerAsync call.
Subject Notification subject from the payload.

You own the log table. We call the hook. The hook is the only persistence boundary.


InApp Channel: UseHandler vs OnDelivery

The InApp channel is special — it requires you to wire the handler that IS the delivery, not just audit it.

Key distinction

  • UseHandler — The code that EXECUTES the send. For InApp, this is where you write to your database (or push to SignalR, or whatever your “send” means). The handler’s return value determines if the send succeeded.
  • OnDelivery — An audit hook that fires AFTER every send (via UseHandler or any other channel). It’s for logging, metrics, or external notifications. The hook’s success/failure does not affect the send result.

Setup

UseHandler must be registered before calling AddRecurPixelNotify:

// Program.cs

// ① InApp handler (the send implementation)
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,
            CreatedAt = DateTime.UtcNow
        });
        await db.SaveChangesAsync();
        
        return new NotifyResult
        {
            Success = true,
            Channel = "inapp",
            SentAt = DateTime.UtcNow
        };
    }));

// ② Everything else (including OnDelivery hook)
builder.Services.AddRecurPixelNotify(
    notifyOptions => { /* ... */ },
    orchestratorOptions =>
    {
        // ④ OnDelivery — fires AFTER UseHandler has executed
        orchestratorOptions.OnDelivery<IApplicationDbContext>(async (result, db) =>
        {
            // This runs after every send, including the InApp send()
            // Use it for audit logging, not for the send itself
            await db.NotificationAudits.AddAsync(new NotificationAudit
            {
                Channel  = result.Channel,
                Provider = result.Provider,
                Status   = result.Success ? "sent" : "failed",
                Error    = result.Error,
                CreatedAt = DateTime.UtcNow
            });
            await db.SaveChangesAsync();
        });
    });

Common mistake

Do NOT put your send logic in OnDelivery. It fires after the send, so your logic will never execute if you return a failed NotifyResult:

// ❌ WRONG — This will not execute if the send fails
orchestratorOptions.OnDelivery(async result =>
{
    if (result.Success)
    {
        // This runs only if result.Success is already true
        // Too late to make the send succeed or fail
    }
});

// ✓ RIGHT — Use UseHandler for InApp delivery
builder.Services.AddInAppChannel(opts =>
    opts.UseHandler(async notification =>
    {
        // This runs BEFORE the result is determined
        // You decide what the result will be
        try
        {
            await db.Notifications.AddAsync(...);
            await db.SaveChangesAsync();
            return new NotifyResult { Success = true };
        }
        catch (Exception ex)
        {
            return new NotifyResult { Success = false, Error = ex.Message };
        }
    }));

Add your own channel by implementing INotificationChannel:

public class MyPagerDutyChannel : NotificationChannelBase
{
    private readonly IHttpClientFactory _http;
    private readonly ILogger<MyPagerDutyChannel> _logger;
    private readonly string _routingKey;

    public MyPagerDutyChannel(IHttpClientFactory http, ILogger<MyPagerDutyChannel> logger, string routingKey)
    {
        _http = http;
        _logger = logger;
        _routingKey = routingKey;
    }

    public override string ChannelName => "pagerduty";

    public override async Task<NotifyResult> SendAsync(NotificationPayload payload, CancellationToken ct = default)
    {
        try
        {
            // your delivery logic
            return new NotifyResult { Success = true, Channel = ChannelName, SentAt = DateTime.UtcNow };
        }
        catch (Exception ex)
        {
            return new NotifyResult { Success = false, Channel = ChannelName, Error = ex.Message, SentAt = DateTime.UtcNow };
        }
    }
}

Register it:

builder.Services.AddKeyedSingleton<INotificationChannel, MyPagerDutyChannel>("pagerduty");

Use it:

await notify.TriggerAsync("critical.alert", new NotifyContext
{
    Channels = new() { ["pagerduty"] = new() { Subject = "DB down", Body = errorDetails } }
});

Extend NotificationChannelBase (not INotificationChannel directly) so your channel gets bulk loop support automatically.


Dashboard

The optional RecurPixel.Notify.Dashboard package automatically captures every send result and stores it via INotificationLogStore. It then serves a filterable delivery log UI and REST API from inside your ASP.NET Core app — no separate service required.

TriggerAsync → INotifyDeliveryObserver → INotificationLogStore → Dashboard UI + REST API

What it captures per send:

  • Channel, provider, recipient, subject
  • Success / failure + error message + provider message ID
  • Event name, bulk batch ID, fallback flag, named provider
  • UTC timestamp

UI features: filterable table, per-channel/per-status filter, date range, bulk batch drill-down.

REST API endpoints (all under /<prefix>/api/):

Endpoint Description
GET /api/logs Paged, filtered log list
GET /api/logs/batch/{batchId} All entries for one bulk send
GET /api/stats Today’s counts and success rate

Minimal setup:

builder.Services.AddNotifyDashboard(opts => opts.RequireRole = "Admin");
builder.Services.AddNotifyDashboardEfCore(options =>
    options.UseSqlServer(connectionString));

app.UseNotifyDashboard();

See Dashboard for the full reference, auth options, custom stores, and migration steps.