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

ChannelShipped providersFuture providers
emailemailservice (DI Sender)ses, acs, smtp, sendgrid
smstwiliosns, acs
whatsapptwiliometa
web_pushvapid
mobile_pushfcmapns, azure, aws
in_appbuilt-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:

  1. If NotifyRequest.Addresses[userID][channel] is set, use it.
  2. Otherwise, if the channel is in_app, use the user id itself.
  3. 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 (ErrSubscriptionGone on HTTP 410) and FCM (ErrUnregisteredToken on UNREGISTERED / NOT_FOUND) expose "the recipient endpoint is gone" as a sentinel error. The provider never deletes the corresponding Device row 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.

Related