Push
The push package provides outbound mobile push notifications. It is the mobile counterpart to pkg/mailer, pkg/slack, and pkg/whatsapp. One provider ships in the standard build (Firebase Cloud Messaging); the Sender interface is provider-agnostic so swapping providers is a config-only change.
Inbound webhooks (delivery receipts, click events, APNs token-refresh) are intentionally not in this package — each provider has its own callback shape and signing scheme; the consumer should own that route directly.
Import
import "github.com/gofastadev/gofasta/pkg/push"Key Types
Sender
The interface every provider implements. Inject via DI; the concrete sender is selected at boot by config.
type Sender interface {
Name() string
SendToTokens(ctx context.Context, tokens []string, msg Message) ([]TokenResult, error)
SendToTopic(ctx context.Context, topic string, msg Message) (*SendResult, error)
SubscribeToTopic(ctx context.Context, topic string, tokens []string) (*TopicMembershipResult, error)
UnsubscribeFromTopic(ctx context.Context, topic string, tokens []string) (*TopicMembershipResult, error)
}Why three send verbs instead of one polymorphic Send? Mobile push providers strictly distinguish targeting modes:
- Tokens — addressed, multicast. The provider returns one per-token result so the caller can prune dead tokens.
- Topic — fan-out by subscription. The provider returns one aggregate result; per-recipient outcomes are not visible.
- Subscribe / Unsubscribe — round out the topic story. Providers that don’t support a verb return
ErrUnsupportedso callers can log and move on.
Message
type Message struct {
Title string
Body string
Data map[string]string // structured payload the app reads on tap
Priority Priority // PriorityNormal | PriorityHigh
ImageURL string
Sound string // "default" or empty for silent
Badge *int // iOS badge count; nil = leave unchanged
ClickAction string // Android-only intent name
// Provider-specific escape hatches; rarely needed.
AndroidOverride map[string]any
AppleOverride map[string]any
WebPushOverride map[string]any
}Data values are constrained to strings — that’s the FCM data-message contract, and Apple/Expo follow similar shapes. Encode complex values as JSON if needed.
Priority
Maps to platform-specific priority enums:
type Priority string
const (
PriorityNormal Priority = "normal"
PriorityHigh Priority = "high"
)- FCM Android:
PriorityNormal→"normal",PriorityHigh→"high" - APNs (via FCM):
PriorityNormal→"5",PriorityHigh→"10"
PriorityHigh wakes the app immediately and shows the notification; PriorityNormal can be deferred by the OS to save battery.
TokenResult
Per-token outcome of a multicast send:
type TokenResult struct {
Token string
OK bool
ProviderMsgID string
Error string
ErrorCode string // "UNREGISTERED", "INVALID_ARGUMENT", "QUOTA_EXCEEDED", ...
}
func (r TokenResult) IsTokenInvalidated() boolIsTokenInvalidated() returns true when the per-token error indicates the token should be removed from your registry — drive automatic cleanup with this after every multicast.
SendResult
Provider acknowledgment of a topic-targeted send:
type SendResult struct {
OK bool
ProviderMsgID string
Status string
RawResponseJSON string
}Status reflects what the provider returned at SEND time — not delivery confirmation (which arrives later via webhook).
TopicMembershipResult
Aggregate outcome of a topic (un)subscribe call:
type TopicMembershipResult struct {
SuccessCount int
FailureCount int
Errors []string
}Errors
var ErrUnsupported = errors.New("push: operation not supported by this provider")
var ErrNotConfigured = errors.New("push: no provider configured (set PUSH_PROVIDER)")ErrNotConfigured is what the noop sender returns when no provider is selected — surfacing the misconfig as a runtime error rather than a silent no-op so it shows up in your tests instead of in production.
Key Functions
| Function | Signature | Description |
|---|---|---|
NewSender | func NewSender(cfg *config.PushConfig, logger *slog.Logger) (Sender, error) | Factory: returns the sender selected by cfg.Provider. Used by Wire DI. |
NewFCMSender | func NewFCMSender(cfg FCMConfig, logger *slog.Logger) (*FCMSender, error) | Direct constructor for the FCM sender. |
Configuration
config.yaml
push:
provider: fcm # "" (disabled), or "fcm"
fcm:
credentials_file_path: configs/firebase-service-account.json
# OR inline (useful for containerized deploys):
# credentials_json: |
# {"type":"service_account",...}
project_id: "" # optional override; read from credentials when emptyEnv vars
export GOFASTA_PUSH_PROVIDER=fcm
export GOFASTA_PUSH_FCM_CREDENTIALS_FILE_PATH=configs/firebase-service-account.json
export GOFASTA_PUSH_FCM_PROJECT_ID=my-firebase-projectWhen both credentials_json (inline) and credentials_file_path are set, the inline JSON wins.
Usage
Send to a single device token
import "github.com/gofastadev/gofasta/pkg/push"
results, err := sender.SendToTokens(ctx, []string{deviceToken}, push.Message{
Title: "Order shipped",
Body: "Your order #42 just left the warehouse.",
Priority: push.PriorityHigh,
Data: map[string]string{
"screen": "shipment",
"id": "42",
},
Sound: "default",
})
if err != nil {
return fmt.Errorf("push call failed: %w", err)
}
// Per-token outcomes — the slice has one entry per input token, in order.
for _, r := range results {
if !r.OK {
log.Warn("push failed", "token", r.Token, "code", r.ErrorCode, "err", r.Error)
if r.IsTokenInvalidated() {
// Revoke the token from your registry.
tokenRepo.Delete(ctx, r.Token)
}
}
}Multicast with token cleanup
allTokens := userRepo.AllPushTokens(ctx, userID)
results, err := sender.SendToTokens(ctx, allTokens, msg)
if err != nil {
return err
}
dead := []string{}
for _, r := range results {
if r.IsTokenInvalidated() {
dead = append(dead, r.Token)
}
}
if len(dead) > 0 {
tokenRepo.DeleteMany(ctx, dead)
}Send to a topic
res, err := sender.SendToTopic(ctx, "promos", push.Message{
Title: "20% off this weekend",
Body: "Tap to see eligible products.",
Data: map[string]string{"campaign": "weekend-2026-02"},
})
if err != nil {
return err
}
log.Info("topic push sent", "msg_id", res.ProviderMsgID, "status", res.Status)Topic subscriptions
// Subscribe a batch of tokens server-side. Some providers don't expose this —
// the noop sender does, and FCM does, but adding e.g. APNs-direct later may
// require subscribing client-side via the SDK.
res, err := sender.SubscribeToTopic(ctx, "promos", []string{tokenA, tokenB})
if errors.Is(err, push.ErrUnsupported) {
log.Info("provider does not expose server-side subscribe — handle client-side")
return nil
}
if err != nil {
return err
}
log.Info("subscribed", "ok", res.SuccessCount, "fail", res.FailureCount)Adding a new provider
To add Expo / OneSignal / APNs-direct / AWS SNS / etc.:
- Drop a
<provider>.gofile implementingSenderintopkg/push. Mirror the structure offcm.go. - Add a sub-config struct in
pkg/config/config.goand reference it fromPushConfig. - Add a switch case in
factory.go.
No code outside pkg/push needs to change.
Related Pages
- Mailer — outbound email
- Slack — outbound chat
- WhatsApp — outbound WhatsApp messaging
- Notifications — multi-channel notification orchestration