go-notificationgo-notification
Guides

Building a Custom Channel

Implement the Channel interface end-to-end. Full example for a hypothetical "Pushover" driver.

This walks through building a new driver from scratch — config, message type, formatting, HTTP call, retry semantics, tests. The example target is Pushover, a simple push-notification service that isn't built in.

A channel implements two methods (Name, Send) and usually also Formatter (Format) so the notifier knows how to turn a notification into your message type. See the Channel Interface reference.

1. Package layout

snippet.plaintext
channel/
  pushover/
    pushover.go       # Config, Channel, Send, Format
    message.go        # Message + builder
    pushover_test.go

2. Config struct

main.go
package pushover

import "time"

type Config struct {
    Token   string // Pushover application token
    User    string // default user key (overridable per-recipient)
    Timeout time.Duration
}

There is no redact struct tag in this library. Keep secrets out of logs by running any logged string through middleware.Redact — see Security.

3. Message type & builder

main.go
package pushover

type Message struct {
    title    string
    body     string
    priority int
    sound    string
}

func NewMessage() *Message                  { return &Message{} }
func (m *Message) SetTitle(s string) *Message    { m.title = s; return m }
func (m *Message) SetBody(s string) *Message     { m.body = s; return m }
func (m *Message) SetPriority(n int) *Message    { m.priority = n; return m }
func (m *Message) SetSound(s string) *Message    { m.sound = s; return m }

// Notification is the per-channel interface notifications implement.
type Notification interface {
    ToPushover(notification.Notifiable) *Message
}

4. Channel: Name, Format, Send

Format extracts your *Message from a notification; Send receives that already-formatted message as any.

main.go
package pushover

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "strings"
    "time"

    notification "github.com/gopackx/go-notification"
    "github.com/gopackx/go-notification/middleware"
)

const apiURL = "https://api.pushover.net/1/messages.json"

type Channel struct {
    cfg  Config
    http *http.Client
}

func New(cfg Config) *Channel {
    if cfg.Timeout == 0 {
        cfg.Timeout = 30 * time.Second
    }
    return &Channel{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}

func (c *Channel) Name() string { return "pushover" }

// Format implements notification.Formatter.
func (c *Channel) Format(n notification.Notification, to notification.Notifiable) (any, error) {
    f, ok := n.(Notification)
    if !ok {
        return nil, notification.ErrNoFormatter
    }
    msg := f.ToPushover(to)
    if msg == nil {
        return nil, notification.ErrNoFormatter
    }
    return msg, nil
}

// Send delivers the already-formatted message.
func (c *Channel) Send(ctx context.Context, to notification.Notifiable, message any) error {
    msg, ok := message.(*Message)
    if !ok {
        return notification.ErrInvalidMessage
    }

    userKey := to.RouteNotificationFor(c.Name())
    if userKey == "" {
        userKey = c.cfg.User // fall back to the configured default
    }
    if userKey == "" {
        return fmt.Errorf("pushover: no user key: %w", notification.ErrNoRoute)
    }

    form := url.Values{}
    form.Set("token", c.cfg.Token)
    form.Set("user", userKey)
    form.Set("title", msg.title)
    form.Set("message", msg.body)
    form.Set("priority", fmt.Sprintf("%d", msg.priority))
    if msg.sound != "" {
        form.Set("sound", msg.sound)
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode()))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    resp, err := c.http.Do(req)
    if err != nil {
        return err // network error → retryable (leave unwrapped)
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
        body, _ := io.ReadAll(resp.Body)
        // 4xx (bad token/params) won't be fixed by retrying → mark permanent.
        return fmt.Errorf("pushover %d: %s: %w", resp.StatusCode, body, middleware.ErrPermanent)
    }
    if resp.StatusCode >= 300 {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("pushover %d: %s", resp.StatusCode, body) // 5xx/429 → retryable
    }
    return nil
}

Key things to notice:

  • Retry is opt-out. The notifier retries every error by default. Wrap non-retryable failures (4xx) with middleware.ErrPermanent; leave transient ones (network, 5xx, 429) plain. There is no RetryableError type.
  • Send gets the message, not the notification. The notification was already converted by Format.
  • Context plumbing. Pass ctx to http.NewRequestWithContext so timeouts and cancellation work.

5. Register & use

RegisterChannel takes a single argument — the driver registers under its own Name():

main.go
import "yourapp/channel/pushover"

notifier.RegisterChannel(pushover.New(pushover.Config{
    Token: os.Getenv("PUSHOVER_TOKEN"),
}))

type IncidentFired struct{ Service, Severity string }

func (n IncidentFired) Via(_ notification.Notifiable) []string {
    return []string{"pushover"}
}

func (n IncidentFired) ToPushover(_ notification.Notifiable) *pushover.Message {
    return pushover.NewMessage().
        SetTitle("Incident: " + n.Service).
        SetBody("Severity " + n.Severity).
        SetPriority(1).
        SetSound("siren")
}

Shortcut: channel.Func

For a quick adhoc channel that doesn't need its own message type, skip the struct entirely:

main.go
notifier.RegisterChannel(channel.Func("pushover", func(ctx context.Context, to notification.Notifiable, msg any) error {
    // deliver msg
    return nil
}))

6. Tests

Use httptest.NewServer to stub the upstream. Make apiURL injectable (a Config field with a default) so tests can point at the stub:

main.go
func TestPushoverSend(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _ = r.ParseForm()
        if r.Form.Get("token") != "test-token" {
            http.Error(w, "bad token", 401)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write([]byte(`{"status":1,"request":"abc"}`))
    }))
    defer srv.Close()

    ch := &Channel{cfg: Config{Token: "test-token"}, http: srv.Client()}
    // apiURL = srv.URL  // via an injectable field

    err := ch.Send(context.Background(), testUser{}, pushover.NewMessage().SetTitle("hi"))
    require.NoError(t, err)
}

7. Checklist before shipping

  • Name() returns a stable, lowercase identifier.
  • Format returns notification.ErrNoFormatter when the notification doesn't support this channel.
  • Send type-asserts the message and returns notification.ErrInvalidMessage on mismatch.
  • Non-retryable failures wrap middleware.ErrPermanent; transient ones stay plain.
  • Context is honored (timeouts cancel).
  • Errors include enough info to debug without leaking secrets (use middleware.Redact when logging).
  • A happy-path test and a permanent-failure test.
  • The ToPushover signature matches the channel's Notification interface exactly — a typo is silent (it just won't be discovered).

8. Publishing back (optional)

If your custom driver is generally useful, consider contributing it. The built-in drivers follow this exact shape (embedding a Base for the standard Name/Format), so a PR that matches is a drop-in fit. See Contributing.