Vault

Scoped Configuration

Configure per-scope provider overrides, sender info, and default locale through Herald's resolution chain.

Herald's scoped configuration system allows notification settings to be customized at three levels: App, Org (organization), and User. Each scope can override which provider handles a channel, what sender information is used, and what default locale applies. The resolver walks the scope chain from most specific to least specific to determine the final configuration.

Resolution Chain

When Herald needs to resolve a provider for a notification, the scope resolver checks these levels in order:

User -> Org -> App -> Priority-based fallback

The first scope that has a provider override for the requested channel wins. If no scoped configuration exists at any level, Herald falls back to selecting the highest-priority enabled provider for that channel.

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 sorted by priority
    providers, _ := r.providerStore.ListProviders(ctx, appID, channel)
    // ... sort by priority, return first enabled
}

Config Entity

The scope.Config struct represents a scoped configuration record:

type Config struct {
    ID              id.ScopedConfigID `json:"id"`
    AppID           string            `json:"app_id"`
    Scope           ScopeType         `json:"scope"`           // "app", "org", "user"
    ScopeID         string            `json:"scope_id"`        // the app/org/user ID
    EmailProviderID string            `json:"email_provider_id,omitempty"`
    SMSProviderID   string            `json:"sms_provider_id,omitempty"`
    PushProviderID  string            `json:"push_provider_id,omitempty"`
    FromEmail       string            `json:"from_email,omitempty"`
    FromName        string            `json:"from_name,omitempty"`
    FromPhone       string            `json:"from_phone,omitempty"`
    DefaultLocale   string            `json:"default_locale,omitempty"`
    CreatedAt       time.Time         `json:"created_at"`
    UpdatedAt       time.Time         `json:"updated_at"`
}

Scoped config IDs use the hscf prefix.

Scope Types

const (
    ScopeApp  ScopeType = "app"
    ScopeOrg  ScopeType = "org"
    ScopeUser ScopeType = "user"
)
ScopeScopeIDDescription
appApplication IDApplication-wide defaults
orgOrganization IDPer-organization overrides
userUser IDPer-user overrides

Per-Scope Provider Overrides

Each scope can override which provider handles a given channel by setting the corresponding provider ID field:

// App-level: use Resend for email, Twilio for SMS
appConfig := &scope.Config{
    AppID:           "app_01abc...",
    Scope:           scope.ScopeApp,
    ScopeID:         "app_01abc...",
    EmailProviderID: "hpvd_01resend...",
    SMSProviderID:   "hpvd_01twilio...",
}

// Org-level: Acme Corp uses their own SMTP server
orgConfig := &scope.Config{
    AppID:           "app_01abc...",
    Scope:           scope.ScopeOrg,
    ScopeID:         "org_01acme...",
    EmailProviderID: "hpvd_01smtp...",
}

With this configuration:

  • Users in Acme Corp have their emails sent via SMTP (org override)
  • All other users have their emails sent via Resend (app default)
  • SMS goes through Twilio for everyone (app default, no org override for SMS)

The ProviderIDFor method extracts the right provider ID for a given channel:

func (c *Config) ProviderIDFor(channel string) string {
    switch channel {
    case "email":
        return c.EmailProviderID
    case "sms":
        return c.SMSProviderID
    case "push":
        return c.PushProviderID
    default:
        return ""
    }
}

Sender Info Overrides

Scoped configurations can override the sender details used when delivering notifications:

FieldDescriptionUsed By
FromEmailSender email addressEmail channel
FromNameSender display nameEmail channel
FromPhoneSender phone numberSMS channel

During delivery, Herald applies sender info from the resolved scoped config:

if resolved.Config != nil {
    outbound.From = resolved.Config.FromEmail
    outbound.FromName = resolved.Config.FromName
    if channel == "sms" {
        outbound.From = resolved.Config.FromPhone
    }
}
// Fallback to provider settings
if outbound.From == "" {
    outbound.From = prov.Settings["from"]
}

This allows different organizations or users to have their own branded sender identities:

// Acme Corp sends from their own domain
orgConfig := &scope.Config{
    AppID:     "app_01abc...",
    Scope:     scope.ScopeOrg,
    ScopeID:   "org_01acme...",
    FromEmail: "notifications@acme.com",
    FromName:  "Acme Corp",
    FromPhone: "+15559876543",
}

Default Locale

Each scope can set a DefaultLocale that influences which template version is rendered:

appConfig := &scope.Config{
    AppID:         "app_01abc...",
    Scope:         scope.ScopeApp,
    ScopeID:       "app_01abc...",
    DefaultLocale: "en",
}

orgConfig := &scope.Config{
    AppID:         "app_01abc...",
    Scope:         scope.ScopeOrg,
    ScopeID:       "org_01fr...",
    DefaultLocale: "fr",
}

When sending a notification, the locale is resolved in this order:

  1. Explicit Locale field on the send request
  2. Scoped config DefaultLocale from the resolved scope
  3. Herald's global DefaultLocale (default: "en")

Store Interface

type Store interface {
    GetScopedConfig(ctx context.Context, appID string, scopeType ScopeType, scopeID string) (*Config, error)
    SetScopedConfig(ctx context.Context, cfg *Config) error
    DeleteScopedConfig(ctx context.Context, configID id.ScopedConfigID) error
    ListScopedConfigs(ctx context.Context, appID string) ([]*Config, error)
}

SetScopedConfig uses upsert semantics -- if a config already exists for the (app_id, scope, scope_id) combination, it is replaced.

API Endpoints

Scoped configuration is managed through the /config route group:

MethodPathOperation
GET/configList all scoped configs for an app
PUT/config/appSet app-level config
PUT/config/org/:orgIdSet org-level config
PUT/config/user/:userIdSet user-level config
DELETE/config/org/:orgIdDelete org-level config
DELETE/config/user/:userIdDelete user-level config

Set App Config Example

{
  "app_id": "app_01abc...",
  "email_provider_id": "hpvd_01resend...",
  "sms_provider_id": "hpvd_01twilio...",
  "push_provider_id": "hpvd_01fcm...",
  "from_email": "noreply@myapp.com",
  "from_name": "MyApp Notifications",
  "from_phone": "+15551234567",
  "default_locale": "en"
}

Set Org Config Example

PUT /herald/config/org/org_01acme...
{
  "app_id": "app_01abc...",
  "email_provider_id": "hpvd_01smtp...",
  "from_email": "notifications@acme.com",
  "from_name": "Acme Corp"
}

Only the fields you set are used as overrides. Empty string fields are ignored during resolution (the resolver falls through to the next scope level or provider defaults).

Resolution Example

Consider an application with the following scoped configurations:

ScopeScopeIDEmail ProviderFromEmail
Appapp_01abcResend (hpvd_01resend)noreply@myapp.com
Orgorg_01acmeSMTP (hpvd_01smtp)hello@acme.com
Useruser_01alice(none)alice@acme.com

When sending an email to Alice (who belongs to Acme Corp):

  1. User scope (user_01alice): No email provider override, so continue
  2. Org scope (org_01acme): Email provider = SMTP. Resolution stops here
  3. The resolved provider is SMTP, and the config carries FromEmail: "hello@acme.com"

If the user scope had an EmailProviderID set, that would have been used instead. The resolver always returns the first match in the chain.

On this page