Skip to main content

Sending notifications

Use the shipped skill

Prefer the send-notifications skill (in your scaffolded repo's .claude/skills/) over hand-rolling this. It is the checklist form of this page.

A notification is a message that lands in a user's inbox — the bell icon with an unread badge in the top bar, and a full Notifications page — and is pushed live to the browser if the user is online. Config code sends one with a single call from a Data Processor or a document behavior: "the import finished", "3 documents need review".

A notification is a stored row; the live push is only a courtesy on top. A user who was offline sees it on next login. Nothing about this is a new entity kind — there is no Dto, no attribute, no migration and no DI registration to write. You inject one service and call it.

The service​

INotificationService is an ordinary scoped service, so it is constructor-injectable anywhere on the server. That is deliberately different from register writers and the posting service, which are reachable only through the ISubmitScope a document passes to OnSubmitAsync — sending a message is not a posting operation.

MethodRecipient
NotifyCallerAsyncWhoever triggered the current work — the usual choice (see below)
NotifyUserAsync / NotifyUsersAsyncOne or several named users
NotifyRoleAsyncMembers of a role (the role must opt in, see below)
NotifyWithPermissionAsyncEveryone holding a given permission
NotifyAdminsAsyncEveryone the engine treats as an administrator

Every method takes the same Notification value, which says what is being said and never who hears it, so the same message can go to a user and to a role. It returns the number of recipients it was addressed to.

A Notification has a Title, an optional Body, a Severity (Info, Success, Warning, Error), optional Links and an optional DeduplicationKey. Text is a NotificationText: NotificationText.ForKey("Some_Key", arg0, arg1) is a localization resource key resolved in the reader's language when they open it (add the key to every resx file of your localization project, with {0}-style placeholders), while NotificationText.ForLiteral("...") is stored as-is and shown in that one language to everyone. Prefer keys.

Worked example: tell me when the NBU import is done​

The reference configuration's ImportNbuRates Data Processor (the same one the Job scheduler page runs every morning) is the first producer. After it creates the RateImport document it ends with:

// Acme.Application/DataProcessors/ImportNbuRates.cs
using NotificationMessage = Kandra.Application.Abstractions.Services.Notifications.Notification;

public class ImportNbuRatesBehavior(
/* ... */
INotificationService notifications,
IUnitOfWork unitOfWork)
: IDataProcessorBehavior<ImportNbuRatesDto, ImportNbuRatesResult>
{
public async Task<ImportNbuRatesResult> OnProcessAsync(ImportNbuRatesDto input, CancellationToken cancellationToken)
{
// ... fetch rates, build and insert the RateImport as `created` ...

await notifications.NotifyCallerAsync(new NotificationMessage(
Title: NotificationText.ForKey("NbuRatesImport_Done", dto.Lines.Count, dto.Code),
Severity: NotificationSeverity.Success,
Links: [new NotificationLink
{
Kind = NotificationLinkKind.Document,
EntityTypeId = RateImport.TypeId,
EntityId = created.Id,
CaptionSnapshot = $"Rate Import {dto.Code}",
}]), cancellationToken);
await unitOfWork.SaveAsync(cancellationToken);

return new ImportNbuRatesResult { /* ... */ };
}
}

The using ... = ...Notification alias is there because the engine's Notification record shares its name with other types you are likely to have in scope. With the resx entry NbuRatesImport_Done = Imported {0} rate(s) into {1}. (and its Ukrainian and Russian siblings), each reader sees the title in their own language, with a link that opens the created document.

Two details worth copying: the message is sent after the work succeeded (a run that threw never announces success), and it is one summary per run, not one per imported row.

One call, two run modes​

The same processor can be started two ways: a person clicks Execute, or the Job scheduler runs it unattended as the job's Run As user. The producer code above is identical for both, and each lands with the right person:

  • Manual run — NotifyCallerAsync resolves through ICallerContext to the logged-in user.
  • Scheduled run — the scheduler has set the caller override to the job's Run As user, so the same call notifies that user, not whoever happened to create the job.

So don't branch on "am I a job?", and don't hard-code a recipient to make the scheduled case work — that breaks the other path. Call NotifyCallerAsync and let ICallerContext decide. (If you want an administrator to hear about it regardless, add a separate NotifyAdminsAsync.)

You do not need to write "I failed" notifications for scheduled processors: the scheduler already notifies the admins and the Run As user when a job fails (once per failure streak, with a 24-hour cooldown), and sends one "recovered" message when it succeeds again. A processor that throws is enough.

Saving: the service stages, you commit​

INotificationService never saves. Each call adds rows to the current unit of work, which is what makes the message atomic with the work it describes:

  • In a document behavior hook (OnSubmitAsync, OnSaveAsync, ...) the document's own save commits the notification together with the document. Do nothing extra.
  • In a Data Processor, call await unitOfWork.SaveAsync(cancellationToken) after notifying, as the example does. Forget it and the staged rows are silently discarded when the request scope ends — no error, no notification.

The live push to open browsers happens only after a successful commit, and is best-effort: a push failure is logged and never fails your save.

Sending to a role, a permission or the admins​

The audience methods are for "somebody needs to look at this", not "tell one person":

await notifications.NotifyAdminsAsync(msg, cancellationToken: ct);
await notifications.NotifyRoleAsync("Accountants", msg, cancellationToken: ct);
await notifications.NotifyWithPermissionAsync(new SomeAuthorizationPoint(), msg, cancellationToken: ct);

Three guards keep them from becoming a spam cannon:

  • Roles opt in. NotifyRoleAsync throws unless the role's Can receive broadcast notifications switch is on in the Roles admin page. If the role exists but has no members, it logs a warning and sends to nobody. Roles are matched by name.
  • A fan-out cap. More than 50 recipients throws. Pass new AudienceOptions { MaxRecipients = 200 } when that is deliberate.
  • The sender is excluded from their own audience send unless you set IncludeSender = true.

Links attach clickable references to a notification. Two kinds exist:

KindSetOpens
DocumentEntityTypeId (the document's TypeId) and EntityIdthe document's page
JobRunEntityId (the scheduled run id)the scheduler's run history

CaptionSnapshot is the link text, and you write it at send time. It is deliberately a copy, not a lookup, so the link still reads correctly if the target is later renamed or deleted. There is no dictionary-link kind today; link to a document, or put the name in the title.

Idempotent producers: DeduplicationKey​

A processor that runs repeatedly over the same facts — say, a scheduled scan for invoices with no waybill — would otherwise nag about the same invoice on every run. Give each logical message a stable key:

new NotificationMessage(
Title: NotificationText.ForKey("Invoice_NoWaybill", invoice.Code),
Severity: NotificationSeverity.Warning,
DeduplicationKey: $"hint:InvoiceWithoutWaybill:{invoice.Id}")

A recipient who already has a notification with that key is skipped silently, and the call counts only those actually notified (possibly 0). The key is checked per recipient, and a notification that was already read still blocks a repeat. Leave the key off for one-shot "run finished" messages, which should always arrive.

Verify​

  1. Run the processor from its page and watch the bell badge increase. Open Notifications and check the localized title and that the link opens the right document.
  2. For a scheduled processor, create the job with a Run As user other than yourself and press Trigger now: the notification arrives for the Run As user, not for you.
  3. If you used a DeduplicationKey, run it twice and confirm there is still only one notification.

What is not built yet​

So you don't plan around something that isn't there:

  • Chat (user-to-user and support conversations) does not exist. This page will be extended when it does.
  • Workflow hints — a scheduled processor that nags about missing follow-ups — need no engine work (it is exactly the DeduplicationKey pattern above), but there is no ready-made example in the reference configuration yet.
  • Retention is fixed. Notifications you have read are deleted after 90 days (checked every six hours); unread ones are kept indefinitely. There is no per-installation setting for it.
  • Not a place for handlers. Handlers are mid-edit server round-trips; don't send notifications from them.

See also​