Full E-Commerce Example
A realistic multi-channel notification setup for an e-commerce app — order lifecycle, OTPs, admin alerts.
This brings together everything. A small e-commerce backend that uses go-notification for:
- Customer comms — order shipped, delivery confirmed.
- OTP — 2FA via SMS.
- Admin alerts — new signup, payment failure.
- In-app — every event is also stored in the database channel.
Runnable version
A compact version of this program ships in _examples/full-example of the
repository. The examples are a separate Go module, so run it with
cd _examples && go run ./full-example. It uses SMTP for mail and an
in-memory SQLite database (via the cgo-free modernc.org/sqlite driver) so
it needs no database server; see Run it below.
Domain types
type Customer struct {
ID int64
Name, Email string
Phone string // E.164
DeviceToken string
WantsMail bool
WantsWhatsApp bool
}
func (c Customer) GetID() string { return strconv.FormatInt(c.ID, 10) }
func (c Customer) RouteNotificationFor(channel string) string {
switch channel {
case "mail":
return c.Email
case "whatsapp":
return c.Phone
case "sms":
return c.Phone
case "push":
return c.DeviceToken
case "database":
return c.GetID()
}
return ""
}
type Admin struct {
ID int64
Email string
Slack string // channel ID like "#ops"
}
func (a Admin) GetID() string { return strconv.FormatInt(a.ID, 10) }
func (a Admin) RouteNotificationFor(channel string) string {
switch channel {
case "mail":
return a.Email
case "slack": // the Slack driver is registered with Name: "slack"
return a.Slack
case "database":
return a.GetID()
}
return ""
}Route on the registered name
Slack and Telegram both default to the channel name "chat", so the boot
code below registers them as "slack" and "telegram". RouteNotificationFor
and Via() must use those names — a case "chat" would return "" and the
channel would be skipped silently.
Notifications
type OrderShipped struct {
OrderID string
TrackingURL string
}
func (n OrderShipped) Via(notifiable notification.Notifiable) []string {
c := notifiable.(Customer)
channels := []string{"database", "push"}
if c.WantsMail { channels = append(channels, "mail") }
if c.WantsWhatsApp { channels = append(channels, "whatsapp") }
return channels
}
func (n OrderShipped) ToMail(u notification.Notifiable) *mail.Message {
return mail.NewMessage().
SetSubject("Your order shipped").
Line("Order " + n.OrderID + " is on its way.").
Action("Track package", n.TrackingURL)
}
func (n OrderShipped) ToWhatsApp(u notification.Notifiable) *whatsapp.Message {
return whatsapp.NewMessage().
SetText("📦 Order *" + n.OrderID + "* shipped. Track: " + n.TrackingURL)
}
func (n OrderShipped) ToPush(u notification.Notifiable) *push.Message {
return push.NewMessage().
SetTitle("Order shipped").
SetBody("Order " + n.OrderID + " is on its way.").
SetData("order_id", n.OrderID)
}
func (n OrderShipped) ToDatabase(u notification.Notifiable) *database.Message {
return database.NewMessage().
SetType("order.shipped").
SetTitle("Order shipped").
SetBody("Order " + n.OrderID + " is on its way.").
AddData("order_id", n.OrderID).
AddData("tracking_url", n.TrackingURL)
}
type LoginOTP struct {
Code string
}
func (n LoginOTP) Via(_ notification.Notifiable) []string {
return []string{"sms"} // OTPs only via SMS for reliability
}
func (n LoginOTP) ToSMS(_ notification.Notifiable) *sms.Message {
return sms.NewMessage().SetText("Your code: " + n.Code + ". Valid 5 minutes.")
}
type PaymentFailed struct {
OrderID string
Reason string
}
func (n PaymentFailed) Via(_ notification.Notifiable) []string {
return []string{"slack", "database", "mail"}
}
func (n PaymentFailed) ToChat(_ notification.Notifiable) *chat.Message {
return chat.NewMessage().
SetText(":warning: Payment failure").
AddSlackAttachment(chat.SlackAttachment{
Color: "danger",
Fields: []chat.SlackField{
{Title: "Order", Value: n.OrderID, Short: true},
{Title: "Reason", Value: n.Reason, Short: true},
},
})
}
func (n PaymentFailed) ToMail(_ notification.Notifiable) *mail.Message {
return mail.NewMessage().
SetSubject("[ops] Payment failed: " + n.OrderID).
Line("A payment failed and needs manual review.").
Line("Reason: " + n.Reason)
}
func (n PaymentFailed) ToDatabase(_ notification.Notifiable) *database.Message {
return database.NewMessage().
SetType("payment.failed").
SetTitle("Payment failed").
SetBody("Order " + n.OrderID + " failed: " + n.Reason).
AddData("order_id", n.OrderID)
}Boot
func NewNotifier(db *sql.DB, env string) *notification.Notifier {
n := notification.New(notification.Config{
WorkerPool: 8,
MaxRetries: 3,
RetryDelay: time.Second,
OnError: func(_ context.Context, no notification.Notifiable, channel string, err error) {
slog.Error("notify failed", "channel", channel, "err", err)
},
OnSuccess: func(_ context.Context, no notification.Notifiable, channel string) {
metrics.NotificationSent.WithLabelValues(channel).Inc()
},
})
// Email — production via SendGrid, dev via Mailtrap
if env == "production" {
n.RegisterChannel(sendgrid.New(sendgrid.Config{
APIKey: os.Getenv("SENDGRID_API_KEY"),
From: mail.Address{Name: "Shop", Address: "noreply@shop.example.com"},
}))
} else {
inboxID, _ := strconv.Atoi(os.Getenv("MAILTRAP_INBOX_ID"))
n.RegisterChannel(mailtrap.New(mailtrap.Config{
Sandbox: true,
APIToken: os.Getenv("MAILTRAP_API_TOKEN"),
InboxID: inboxID,
From: mail.Address{Name: "Shop Dev", Address: "dev@shop.example.com"},
}))
}
// WhatsApp — self-hosted via WAHA
n.RegisterChannel(waha.New(waha.Config{
BaseURL: os.Getenv("WAHA_BASE_URL"),
APIKey: os.Getenv("WAHA_API_KEY"),
SessionID: "default",
}))
// SMS — Twilio
n.RegisterChannel(twilio.New(twilio.Config{
AccountSID: os.Getenv("TWILIO_ACCOUNT_SID"),
AuthToken: os.Getenv("TWILIO_AUTH_TOKEN"),
From: "+14155238886",
}))
// Push — FCM (constructor returns an error, so capture it)
pushDriver, err := fcm.New(fcm.Config{
ProjectID: os.Getenv("FCM_PROJECT_ID"),
ServiceAccountJSON: []byte(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")),
})
if err != nil {
panic(err)
}
n.RegisterChannel(pushDriver)
// Ops chat — Slack incoming webhook and a Telegram bot. Both default to
// the name "chat"; give each its own name so the second doesn't replace
// the first and Via() can address them separately.
n.RegisterChannel(slack.New(slack.Config{
Name: "slack",
WebhookURL: os.Getenv("OPS_SLACK_WEBHOOK"),
}))
n.RegisterChannel(telegram.New(telegram.Config{
Name: "telegram",
BotToken: os.Getenv("TELEGRAM_TOKEN"),
DefaultChatID: os.Getenv("TELEGRAM_CHAT"),
}))
// Database — in-app notifications
if err := migrate.Up(context.Background(), db, database.DialectPostgreSQL, "notifications"); err != nil {
panic(err)
}
store := database.NewSQLStore(db, database.DialectPostgreSQL)
n.RegisterChannel(database.New(database.Config{Store: store}))
return n
}Trigger points
// In the order service
func (s *OrderService) markShipped(ctx context.Context, o *Order) error {
// ... DB updates ...
s.notifier.Send(ctx, o.Customer, OrderShipped{
OrderID: o.ID,
TrackingURL: s.trackingURL(o),
})
return nil
}
// In the auth service. The OTP is time-sensitive, so this call is forced
// synchronous: Send blocks until Twilio accepts the message and the error
// comes back to the caller instead of OnError. Sync() also means the
// request's ctx cancellation applies to the delivery.
func (s *AuthService) requestOTP(ctx context.Context, c Customer) error {
code := s.generateOTP(c.ID)
return s.notifier.Send(ctx, c, LoginOTP{Code: code}, notification.Sync())
}
// In the payment service
func (s *PaymentService) onFailure(ctx context.Context, o *Order, reason string) {
// Alert all admins
s.notifier.SendMulti(ctx, s.admins, PaymentFailed{
OrderID: o.ID,
Reason: reason,
})
}Observability
Metrics worth tracking:
notifications_sent_total{channel, type}— successful deliveries.notifications_failed_total{channel, type}— post-retry failures.notifications_retries_total{channel, type}— a spike here predicts an upstream outage.notifications_duration_seconds{channel}— per-channel latency.
Successful deliveries and post-retry failures can be wired up from the OnSuccess and OnError callbacks on notification.Config.
What you get
With ~200 lines of notification code, this app now has:
- Four outbound channels to customers (mail, WhatsApp, push, SMS).
- Ops alerts in Slack.
- Every event stored as an in-app notification.
- Retries + rate limits + secret redaction for free.
- A dev/prod split where dev never hits real customers.
Telegram is already registered as "telegram"; to send an alert there too, add it to Via() — Slack, Telegram, Discord, and Teams all share *chat.Message, so the existing ToChat() method serves both. Give each a route in RouteNotificationFor (a chat ID for Telegram) and you're done.
Run it
The repository version lives in _examples/full-example. The examples form a separate Go module so the library keeps zero third-party dependencies; the SQLite driver (modernc.org/sqlite, cgo-free) is a dependency of that module only.
export SMTP_HOST=smtp.example.com SMTP_USER=user SMTP_PASS=secret
export TWILIO_SID=ACxxx TWILIO_TOKEN=xxx TWILIO_FROM=+14155238886
export WAHA_API_KEY=xxx
export SLACK_WEBHOOK=https://hooks.slack.com/services/...
export TELEGRAM_TOKEN=123:abc TELEGRAM_CHAT=-100123
export FCM_SA_JSON=/path/to/service-account.json # optional
cd _examples && go run ./full-exampleThe repository version differs from the listing above in a few ways: it uses the SMTP driver for mail, stores in-app notifications in an in-memory SQLite database, and sends three notifications — a PaymentSucceeded to the user (mail, WhatsApp, database, push), a LoginOTP over SMS with notification.Sync(), and a ServerAlert routed to "slack" and "telegram".