Delivery Pipeline
Understand Herald's notification delivery pipeline including Send, Notify, message lifecycle, and provider resolution.
Herald's delivery pipeline is the core of the notification engine. It resolves templates, checks user preferences, selects the right provider through the scope chain, and dispatches messages through the appropriate driver. There are two entry points: Send for single-channel delivery and Notify for multi-channel template-based delivery.
Send vs Notify
Herald provides two methods for sending notifications, each suited to different use cases.
engine.Send()
Send delivers a notification on a single channel to one or more recipients. You specify the channel, and optionally a template or raw content:
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "app_01abc...",
Channel: "email",
Template: "auth.password-reset",
To: []string{"alice@example.com"},
Data: map[string]any{
"user_name": "Alice",
"app_name": "MyApp",
"reset_url": "https://myapp.com/reset?token=xyz",
},
})You can also send without a template by providing Subject and Body directly:
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "app_01abc...",
Channel: "email",
To: []string{"alice@example.com"},
Subject: "Account Update",
Body: "Your account settings have been updated.",
})engine.Notify()
Notify sends a template-based notification across multiple channels in a single call. Herald iterates over the specified channels and calls Send for each one:
results, err := h.Notify(ctx, &herald.NotifyRequest{
AppID: "app_01abc...",
Template: "auth.password-changed",
Channels: []string{"email", "inapp"},
To: []string{"alice@example.com"},
UserID: "user_01xyz...",
Data: map[string]any{
"user_name": "Alice",
"app_name": "MyApp",
},
})If delivery fails on one channel, Herald logs a warning and continues with the remaining channels. The returned []*SendResult slice contains one result per channel.
Delivery Pipeline Steps
When Send is called, Herald executes the following steps in order:
1. Preference Check
If a UserID and Template are provided, Herald checks whether the user has opted out of this notification type on the requested channel:
if req.UserID != "" && req.Template != "" {
pref, _ := h.store.GetPreference(ctx, req.AppID, req.UserID)
if pref != nil && pref.IsOptedOut(req.Template, channel) {
return &SendResult{Status: message.StatusSent, Error: "user opted out"}, nil
}
}If the user has opted out, Herald returns early without sending. See Preferences for details.
2. Template Resolution and Rendering
If a Template slug is specified and no raw Body is provided, Herald looks up the template by slug, app, and channel, then renders it for the requested locale:
tmpl, err := h.store.GetTemplateBySlug(ctx, req.AppID, req.Template, channel)
rendered, err := h.renderer.Render(tmpl, locale, req.Data)The locale falls back through the chain: exact match, language prefix, then default. See Templates for the rendering details.
3. Provider Resolution
Herald resolves the best provider for the channel using the scope chain:
User scope -> Org scope -> App scope -> Priority-based fallbackresolved, err := h.resolver.ResolveProvider(ctx, req.AppID, req.OrgID, req.UserID, channel)The resolver returns both the Provider and any Config overrides (sender info, locale). If no provider is found at any level, Herald returns ErrNoProviderConfigured.
4. Driver Lookup
Once a provider is resolved, Herald retrieves the corresponding driver from the driver registry:
drv, err := h.drivers.Get(prov.Driver)The driver name on the provider (e.g., "smtp", "resend", "twilio") must match a registered driver. See Drivers for the built-in options.
5. Message Construction
Herald builds the outbound message, injecting provider credentials and scoped sender info:
outbound := &driver.OutboundMessage{
Subject: rendered.Subject,
HTML: rendered.HTML,
Text: rendered.Text,
Title: rendered.Title,
Data: driverData, // credentials + settings
}
// Sender info from scoped config or provider settings
if resolved.Config != nil {
outbound.From = resolved.Config.FromEmail
outbound.FromName = resolved.Config.FromName
}6. Delivery and Logging
For each recipient, Herald creates a message log entry, sends via the driver, and updates the status:
msg := &message.Message{
ID: id.NewMessageID(),
Status: message.StatusSending,
Attempts: 1,
// ... other fields
}
_ = h.store.CreateMessage(ctx, msg)
result, sendErr := drv.Send(ctx, outbound)
if sendErr != nil {
_ = h.store.UpdateMessageStatus(ctx, msg.ID, message.StatusFailed, sendErr.Error())
} else {
_ = h.store.UpdateMessageStatus(ctx, msg.ID, message.StatusSent, "")
}7. Inbox Entry (In-App Channel)
When the channel is inapp and a UserID is provided, Herald also creates an inbox notification entry:
if channel == string(ChannelInApp) && req.UserID != "" {
_ = h.store.CreateNotification(ctx, &inbox.Notification{
ID: id.NewInboxID(),
UserID: req.UserID,
Type: req.Template,
Title: rendered.Title,
Body: rendered.Text,
// ...
})
}Message Status Lifecycle
Every notification delivery is logged as a message.Message record. Messages progress through these states:
queued -> sending -> sent -> delivered
\-> failed
\-> bounced| Status | Description |
|---|---|
queued | Message is queued for delivery (async mode) |
sending | Herald is actively attempting delivery |
sent | The driver accepted the message |
delivered | Delivery confirmed by the provider (e.g., webhook callback) |
failed | Delivery failed after all attempts |
bounced | The recipient address bounced |
const (
StatusQueued Status = "queued"
StatusSending Status = "sending"
StatusSent Status = "sent"
StatusFailed Status = "failed"
StatusBounced Status = "bounced"
StatusDelivered Status = "delivered"
)Message Entity
The message.Message struct captures the full delivery context:
type Message struct {
ID id.MessageID `json:"id"`
AppID string `json:"app_id"`
EnvID string `json:"env_id,omitempty"`
TemplateID string `json:"template_id,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
Channel string `json:"channel"`
Recipient string `json:"recipient"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Status Status `json:"status"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Async bool `json:"async"`
Attempts int `json:"attempts"`
SentAt *time.Time `json:"sent_at,omitempty"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}Message IDs use the hmsg prefix. The Body field is truncated to the configured TruncateBodyAt limit (default: 4096 characters) to keep the delivery log compact.
Sync vs Async
The Async flag on SendRequest and NotifyRequest controls the delivery mode:
- Sync (default):
Sendblocks until the driver returns a result. The caller receives the final status immediately. - Async: The message is initially recorded with status
queued. Delivery happens in the background.
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "app_01abc...",
Channel: "email",
Template: "auth.welcome",
To: []string{"alice@example.com"},
Async: true, // return immediately, deliver in background
Data: map[string]any{
"user_name": "Alice",
"app_name": "MyApp",
},
})Metadata
Both SendRequest and NotifyRequest accept a Metadata map. This is stored alongside the message in the delivery log and on inbox entries, enabling you to attach application-specific context:
Metadata: map[string]string{
"order_id": "ord_123",
"event_type": "order.shipped",
},SendResult
The return value of Send contains the outcome:
type SendResult struct {
MessageID id.MessageID `json:"message_id"`
Status message.Status `json:"status"`
ProviderID string `json:"provider_id,omitempty"`
Error string `json:"error,omitempty"`
}API Endpoints
The delivery pipeline is exposed through two API routes:
| Method | Path | Operation |
|---|---|---|
POST | /send | Single-channel send |
POST | /notify | Multi-channel template-based notify |
The messages delivery log is available for querying:
| Method | Path | Operation |
|---|---|---|
GET | /messages | List messages (filter by app_id, channel, status) |
GET | /messages/:id | Get a single message |
Listing Messages
Messages can be filtered and paginated:
type ListOptions struct {
Channel string
Status Status
Limit int
Offset int
}