Vault

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 fallback
resolved, 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
StatusDescription
queuedMessage is queued for delivery (async mode)
sendingHerald is actively attempting delivery
sentThe driver accepted the message
deliveredDelivery confirmed by the provider (e.g., webhook callback)
failedDelivery failed after all attempts
bouncedThe 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): Send blocks 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:

MethodPathOperation
POST/sendSingle-channel send
POST/notifyMulti-channel template-based notify

The messages delivery log is available for querying:

MethodPathOperation
GET/messagesList messages (filter by app_id, channel, status)
GET/messages/:idGet a single message

Listing Messages

Messages can be filtered and paginated:

type ListOptions struct {
    Channel string
    Status  Status
    Limit   int
    Offset  int
}

On this page