Vault

Multi-Tenant Patterns

Per-org email providers, per-user locale preferences, and scope resolution patterns.

Herald is designed for multi-tenant applications from the ground up. Every entity is scoped by appID, and scoped configurations support app, organization, and user-level overrides. This guide covers the key patterns for multi-tenant notification delivery.

Scope Resolution Chain

Herald resolves the notification provider for each send operation through a four-level fallback chain:

user scope → org scope → app scope → first enabled provider

The scope resolver (scope.Resolver) checks each level in order and returns the first match. This allows fine-grained provider overrides without affecting other tenants.

func (r *Resolver) ResolveProvider(
    ctx context.Context,
    appID, orgID, userID string,
    channel string,
) (*ResolveResult, error) {
    // 1. User-scoped override
    if userID != "" {
        if result := r.tryScope(ctx, appID, ScopeUser, userID, channel); result != nil {
            return result, nil
        }
    }
    // 2. Org-scoped override
    if orgID != "" {
        if result := r.tryScope(ctx, appID, ScopeOrg, orgID, channel); result != nil {
            return result, nil
        }
    }
    // 3. App-scoped default
    if result := r.tryScope(ctx, appID, ScopeApp, appID, channel); result != nil {
        return result, nil
    }
    // 4. Fallback: first enabled provider by priority
    // ...
}

The resolved result includes both the provider and the scoped configuration, so sender fields (from_email, from_name, from_phone, default_locale) are automatically applied.

Per-Organization Email Provider

A common requirement is letting each organization use its own email provider (for example, a whitelabel SaaS product where each org sends from their own domain).

Step 1: Create providers for each org

// Acme Corp uses Amazon SES.
acmeProvider := &provider.Provider{
    ID:      id.NewProviderID(),
    AppID:   "myapp",
    Name:    "Acme SES",
    Channel: "email",
    Driver:  "smtp",
    Credentials: map[string]string{
        "host":     "email-smtp.us-east-1.amazonaws.com",
        "port":     "587",
        "username": "AKIA...",
        "password": "...",
    },
    Settings: map[string]string{
        "from": "noreply@acme.com",
    },
    Enabled:   true,
    CreatedAt: now,
    UpdatedAt: now,
}
_ = store.CreateProvider(ctx, acmeProvider)

// Globex uses Resend.
globexProvider := &provider.Provider{
    ID:      id.NewProviderID(),
    AppID:   "myapp",
    Name:    "Globex Resend",
    Channel: "email",
    Driver:  "resend",
    Credentials: map[string]string{
        "api_key": "re_...",
    },
    Settings: map[string]string{
        "from": "noreply@globex.io",
    },
    Enabled:   true,
    CreatedAt: now,
    UpdatedAt: now,
}
_ = store.CreateProvider(ctx, globexProvider)

Step 2: Set org-scoped configuration

// Link Acme Corp to their SES provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:              id.NewScopedConfigID(),
    AppID:           "myapp",
    Scope:           scope.ScopeOrg,
    ScopeID:         "org-acme",
    EmailProviderID: acmeProvider.ID.String(),
    FromEmail:       "noreply@acme.com",
    FromName:        "Acme Corp",
    CreatedAt:       now,
    UpdatedAt:       now,
})

// Link Globex to their Resend provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:              id.NewScopedConfigID(),
    AppID:           "myapp",
    Scope:           scope.ScopeOrg,
    ScopeID:         "org-globex",
    EmailProviderID: globexProvider.ID.String(),
    FromEmail:       "noreply@globex.io",
    FromName:        "Globex Inc",
    CreatedAt:       now,
    UpdatedAt:       now,
})

Step 3: Send with org context

When sending, include the OrgID in the request. The scope resolver automatically picks the correct provider:

// This sends via Acme's SES provider, from noreply@acme.com.
result, err := h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    OrgID:    "org-acme",
    Channel:  "email",
    Template: "welcome",
    To:       []string{"alice@acme.com"},
    Data:     map[string]any{"name": "Alice"},
})

// This sends via Globex's Resend provider, from noreply@globex.io.
result, err = h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    OrgID:    "org-globex",
    Channel:  "email",
    Template: "welcome",
    To:       []string{"bob@globex.io"},
    Data:     map[string]any{"name": "Bob"},
})

Via HTTP API

curl -X POST http://localhost:8080/herald/send \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "myapp",
    "org_id": "org-acme",
    "channel": "email",
    "template": "welcome",
    "to": ["alice@acme.com"],
    "data": {"name": "Alice"}
  }'

Per-User Locale Preferences

Herald supports locale-specific template versions. The locale can be resolved through the scoped configuration chain or specified explicitly on the send request.

Step 1: Create templates with multiple locale versions

tmpl := &template.Template{
    ID:      id.NewTemplateID(),
    AppID:   "myapp",
    Slug:    "invoice",
    Name:    "Invoice Notification",
    Channel: "email",
    Enabled: true,
}
_ = store.CreateTemplate(ctx, tmpl)

// English version
_ = store.CreateVersion(ctx, &template.Version{
    ID:         id.NewTemplateVersionID(),
    TemplateID: tmpl.ID,
    Locale:     "en",
    Subject:    "Invoice #{{.invoice_id}}",
    HTML:       "<p>Dear {{.name}}, your invoice is ready.</p>",
    Text:       "Dear {{.name}}, your invoice is ready.",
    Active:     true,
})

// French version
_ = store.CreateVersion(ctx, &template.Version{
    ID:         id.NewTemplateVersionID(),
    TemplateID: tmpl.ID,
    Locale:     "fr",
    Subject:    "Facture #{{.invoice_id}}",
    HTML:       "<p>Cher(e) {{.name}}, votre facture est prête.</p>",
    Text:       "Cher(e) {{.name}}, votre facture est prête.",
    Active:     true,
})

// Japanese version
_ = store.CreateVersion(ctx, &template.Version{
    ID:         id.NewTemplateVersionID(),
    TemplateID: tmpl.ID,
    Locale:     "ja",
    Subject:    "請求書 #{{.invoice_id}}",
    HTML:       "<p>{{.name}} 様、請求書の準備ができました。</p>",
    Text:       "{{.name}} 様、請求書の準備ができました。",
    Active:     true,
})

Step 2: Set user-level locale via scoped config

// French-speaking user
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:            id.NewScopedConfigID(),
    AppID:         "myapp",
    Scope:         scope.ScopeUser,
    ScopeID:       "user-jean",
    DefaultLocale: "fr",
    CreatedAt:     now,
    UpdatedAt:     now,
})

// Japanese-speaking user
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:            id.NewScopedConfigID(),
    AppID:         "myapp",
    Scope:         scope.ScopeUser,
    ScopeID:       "user-yuki",
    DefaultLocale: "ja",
    CreatedAt:     now,
    UpdatedAt:     now,
})

Step 3: Send with explicit locale or let scope resolve it

// Explicit locale on the request.
result, _ := h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    Channel:  "email",
    Template: "invoice",
    Locale:   "fr",
    To:       []string{"jean@example.fr"},
    Data:     map[string]any{"name": "Jean", "invoice_id": "INV-001"},
})

// If Locale is empty, Herald falls back to the config default_locale ("en").
result, _ = h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    Channel:  "email",
    Template: "invoice",
    To:       []string{"alice@example.com"},
    Data:     map[string]any{"name": "Alice", "invoice_id": "INV-002"},
})

Per-User Notification Preferences

Users can opt in or out of specific notification types per channel using the preference system.

Setting preferences

// Bob opts out of marketing emails but keeps transactional and push.
_ = store.SetPreference(ctx, &preference.Preference{
    ID:     id.NewPreferenceID(),
    AppID:  "myapp",
    UserID: "user-bob",
    Overrides: map[string]preference.ChannelPreference{
        "marketing-weekly": {
            Email: boolPtr(false),
            Push:  boolPtr(true),
        },
        "product-updates": {
            Email: boolPtr(false),
            Push:  boolPtr(false),
        },
    },
    UpdatedAt: now,
})

How preferences affect delivery

When Send is called with a UserID and Template, Herald checks the user's preferences before sending:

// This is skipped because Bob opted out of marketing-weekly emails.
result, _ := h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    UserID:   "user-bob",
    Channel:  "email",
    Template: "marketing-weekly",
    To:       []string{"bob@example.com"},
    Data:     map[string]any{"name": "Bob"},
})
// result.Error = "user opted out"

// This still sends because Bob has push enabled for marketing-weekly.
result, _ = h.Send(ctx, &herald.SendRequest{
    AppID:    "myapp",
    UserID:   "user-bob",
    Channel:  "push",
    Template: "marketing-weekly",
    To:       []string{"bob-device-token"},
    Data:     map[string]any{"name": "Bob"},
})

Preference management via HTTP API

# Get preferences
curl "http://localhost:8080/herald/preferences?app_id=myapp&user_id=user-bob"

# Update preferences
curl -X PUT http://localhost:8080/herald/preferences \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "myapp",
    "user_id": "user-bob",
    "overrides": {
      "marketing-weekly": {
        "email": false,
        "push": true
      }
    }
  }'

Scope Override Hierarchy Example

This example demonstrates all three scope levels working together:

// App-level: default SMTP provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:              id.NewScopedConfigID(),
    AppID:           "myapp",
    Scope:           scope.ScopeApp,
    ScopeID:         "myapp",
    EmailProviderID: defaultSMTP.ID.String(),
    FromEmail:       "noreply@myapp.com",
    DefaultLocale:   "en",
    CreatedAt:       now,
    UpdatedAt:       now,
})

// Org-level: Acme uses their own SES.
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:              id.NewScopedConfigID(),
    AppID:           "myapp",
    Scope:           scope.ScopeOrg,
    ScopeID:         "org-acme",
    EmailProviderID: acmeSES.ID.String(),
    FromEmail:       "noreply@acme.com",
    FromName:        "Acme",
    CreatedAt:       now,
    UpdatedAt:       now,
})

// User-level: Acme's CEO uses a personal sender name.
_ = store.SetScopedConfig(ctx, &scope.Config{
    ID:        id.NewScopedConfigID(),
    AppID:     "myapp",
    Scope:     scope.ScopeUser,
    ScopeID:   "user-ceo-acme",
    FromName:  "John from Acme",
    FromEmail: "john@acme.com",
    CreatedAt: now,
    UpdatedAt: now,
})

Resolution results:

Request contextResolved providerFrom emailFrom name
No org, no userDefault SMTPnoreply@myapp.com(default)
org_id: org-acmeAcme SESnoreply@acme.comAcme
org_id: org-acme, user_id: user-ceo-acme(from user config, or falls back to org)john@acme.comJohn from Acme

Scoped Config via HTTP API

# Set app-level config
curl -X PUT http://localhost:8080/herald/config/app \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "myapp",
    "email_provider_id": "hpvd_...",
    "from_email": "noreply@myapp.com",
    "default_locale": "en"
  }'

# Set org-level config
curl -X PUT http://localhost:8080/herald/config/org/org-acme \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "myapp",
    "email_provider_id": "hpvd_...",
    "from_email": "noreply@acme.com",
    "from_name": "Acme Corp"
  }'

# Set user-level config
curl -X PUT http://localhost:8080/herald/config/user/user-ceo-acme \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "myapp",
    "from_email": "john@acme.com",
    "from_name": "John from Acme"
  }'

# List all configs for an app
curl "http://localhost:8080/herald/config?app_id=myapp"

# Delete an org config
curl -X DELETE "http://localhost:8080/herald/config/org/org-acme?app_id=myapp"

Pattern Summary

PatternEntityKey fieldPurpose
Per-org providerscope.ConfigScopeOrg + provider IDsEach org sends from its own email/SMS provider
Per-org senderscope.ConfigFromEmail, FromName, FromPhoneCustom sender identity per org
Per-user localescope.ConfigDefaultLocaleLocale-aware template rendering
Per-user opt-outpreference.PreferenceOverrides mapUsers control which notifications they receive
Scope chainscope.Resolveruser -> org -> app -> defaultHierarchical override resolution

On this page