Push notifications from Go
Go programs are typically quiet. They run, they finish, they log to stdout. You find out by checking.
Here’s how to make a Go program tell you directly.
The simplest version: net/http
No dependencies. trigger.fyi is a plain HTTP POST:
package main
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
func notify(title string) {
key := os.Getenv("TRIGGER_FYI_SECRET_KEY")
if key == "" {
return
}
client := &http.Client{Timeout: 3 * time.Second}
client.Post(
fmt.Sprintf("https://trigger.fyi/%s", key),
"text/plain",
strings.NewReader(title),
)
}
func main() {
// ... your work ...
notify("Job complete")
}No error handling needed — it’s fire-and-forget. If the call fails, you don’t want it to affect your program.
With metadata: a small helper
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Options struct {
Body string `json:"body,omitempty"`
Level string `json:"level,omitempty"`
Meta map[string]string `json:"-"`
}
func Send(title string, opts ...Options) {
key := os.Getenv("TRIGGER_FYI_SECRET_KEY")
if key == "" {
return
}
payload := map[string]any{"title": title}
if len(opts) > 0 {
o := opts[0]
if o.Body != "" {
payload["body"] = o.Body
}
if o.Level != "" {
payload["level"] = o.Level
}
if len(o.Meta) > 0 {
payload["meta"] = o.Meta
}
}
b, _ := json.Marshal(payload)
client := &http.Client{Timeout: 3 * time.Second}
client.Post(
fmt.Sprintf("https://trigger.fyi/%s", key),
"application/json",
bytes.NewReader(b),
)
}
func Log(title string) {
Send(title, Options{Level: "log"})
}
func Critical(title string, opts ...Options) {
if len(opts) > 0 {
opts[0].Level = "critical"
Send(title, opts[0])
} else {
Send(title, Options{Level: "critical"})
}
}Usage:
import "yourmodule/notify"
// Normal push
notify.Send("Job complete", notify.Options{
Body: "847 records in 4m 12s",
Meta: map[string]string{"env": "prod"},
})
// Feed only, no push
notify.Log("Cache warmed")
// High urgency
notify.Critical("Payment failed", notify.Options{Body: customer.Email})In a goroutine
If you don’t want to block at all:
go func() {
notify.Send("Backup complete", notify.Options{
Body: fmt.Sprintf("%d items synced", count),
})
}()Fire-and-forget. The goroutine runs, completes, and exits. Your main program continues immediately.
In a long-running service
func handleSignup(w http.ResponseWriter, r *http.Request) {
user, err := createUser(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
// After response — don't block the handler
go notify.Send("New signup", notify.Options{
Body: user.Email,
Meta: map[string]string{"plan": user.Plan},
})
}In a CLI tool
func main() {
start := time.Now()
if err := run(); err != nil {
notify.Critical("Job failed", notify.Options{Body: err.Error()})
log.Fatal(err)
}
notify.Send("Job complete", notify.Options{
Body: fmt.Sprintf("%.1fs", time.Since(start).Seconds()),
})
}Setup
npx trigger.fyiGenerates a key, subscribes your device. Then:
export TRIGGER_FYI_SECRET_KEY=your_key_hereOr set it in your deployment environment. The key is read at call time — no global state.
The plain text POST path is the most portable. The JSON path lets you add metadata and levels. Both work from anywhere Go runs: binaries, Lambda, Cloud Run, a $5 VPS.