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
channel/
pushover/
pushover.go # Config, Channel, Send, Format
message.go # Message + builder
pushover_test.go2. Config struct
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
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.
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 noRetryableErrortype. Sendgets the message, not the notification. The notification was already converted byFormat.- Context plumbing. Pass
ctxtohttp.NewRequestWithContextso timeouts and cancellation work.
5. Register & use
RegisterChannel takes a single argument — the driver registers under its own Name():
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:
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:
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. -
Formatreturnsnotification.ErrNoFormatterwhen the notification doesn't support this channel. -
Sendtype-asserts the message and returnsnotification.ErrInvalidMessageon 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.Redactwhen logging). - A happy-path test and a permanent-failure test.
- The
ToPushoversignature matches the channel'sNotificationinterface 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.