Last updated 2026-05-28
Channels & Providers
A channel is the kind of delivery rail: in-app, email, web push, mobile push, SMS, WhatsApp. A provider is the concrete backend that handles that rail — Twilio for SMS, VAPID for web push, the emailservice Sender for email, etc. notify lets you mix and match.
When to think in these terms
Whenever you ask "how do I send X over Y": X is the channel, Y is the provider. The channel decides which destination shape applies (an email address, an E.164 phone number, a device token, the user id); the provider decides which API does the talking and which credentials sit on disk.
Channels (the wire-stable set)
type ChannelKind string
const ( ChannelInApp ChannelKind = "in_app" ChannelEmail ChannelKind = "email" ChannelWebPush ChannelKind = "web_push" ChannelMobilePush ChannelKind = "mobile_push" ChannelSMS ChannelKind = "sms" ChannelWhatsApp ChannelKind = "whatsapp")
The proto enum (DeliveryChannel) carries the same set,
with frozen field numbers. Adding a new channel is an additive proto
change plus a new ChannelKind constant in
notify/model.go — never reuse, never renumber.
Provider taxonomy
| Channel | Shipped providers | Future providers |
|---|---|---|
email | emailservice (DI Sender) | ses, acs, smtp, sendgrid |
sms | twilio | sns, acs |
whatsapp | twilio | meta |
web_push | vapid | — |
mobile_push | fcm | apns, azure, aws |
in_app | built-in realtime engine | — |
The Provider interface
Every provider satisfies one tiny contract:
type Provider interface { Kind() ChannelKind // which channel does this provider serve? Name() string // identifier surfaced in logs/metrics Send(ctx context.Context, msg Message) (Receipt, error)}
type Message struct { Notification *Notification // the persisted row (nil for fire-and-forget) To string // channel-specific destination Title string Body string Data map[string]string // structured key/values for deep links}
type Receipt struct { ProviderMessageID string // upstream id (Twilio SID, FCM message name, …) Status DeliveryStatus // "" → StatusDelivered}
On error the orchestrator records the row as StatusFailed
and counts it in NotifyResult.Failed. The rest of the
fan-out continues.
How a channel becomes "active"
A channel is active when a provider has been registered for it.
Unregistered channels are still legal in a NotifyRequest —
the row is stored and counted as Pending, but no provider
call happens.
In library mode you register explicitly:
registry := notify.NewProviderRegistry()registry.Register(twilio.NewSMS(twilioClient))registry.Register(fcm.New(fcmCfg))notifier := notify.NewNotifier(store, registry)
In container mode cmd/notifyd registers providers based on
the configured NOTIFY_<CHANNEL>_PROVIDER blocks. An
empty block leaves the channel disabled.
Address resolution
The orchestrator resolves an address per (user, channel)
in this order:
- If
NotifyRequest.Addresses[userID][channel]is set, use it. - Otherwise, if the channel is
in_app, use the user id itself. - Otherwise, no address — count as
Pending.
The Addresses map is opt-in. For email/SMS/WhatsApp/web-push/mobile-push you almost always supply it (or your producer reads it from your own user store and assembles the map before calling Notify). For in-app you almost never need it.
req := notify.NotifyRequest{ TenantID: "acme", NotificationID: "task-42-comment-7", UserIDs: []string{"alice", "bob"}, Channels: []notify.ChannelKind{notify.ChannelInApp, notify.ChannelEmail}, Title: "New comment", Body: "Bob commented on Task 42", Addresses: map[string]map[notify.ChannelKind]string{ "alice": {notify.ChannelEmail: "alice@example.com"}, "bob": {notify.ChannelEmail: "bob@example.com"}, },}Why providers do not auto-purge dead tokens
Why this design. Both web push (ErrSubscriptionGoneon HTTP 410) and FCM (ErrUnregisteredTokenon UNREGISTERED / NOT_FOUND) expose "the recipient endpoint is gone" as a sentinel error. The provider never deletes the correspondingDevicerow itself — it just surfaces the sentinel. Why? Because the device store is owned by the orchestrator / caller, and a future caller may want to keep the row for audit, retry from a fresher token, or analytics. Providers are stateless from the platform's POV.