go-notificationgo-notification
Examples

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

main.go
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

main.go
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

main.go
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

main.go
// 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.

terminal.sh
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-example

The 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".