Provenance
Notifications

Template Engine

Dynamic variable substitution, built-in functions, secrets, and the full resolution context available in subscription configs and lifecycle hooks.

Provenance includes a template engine that resolves {{variables}} inside subscription configs, adapter settings, and lifecycle hook arguments. This page documents every namespace available in the template context.

How it works

When a notification is processed from the queue:

  1. The ContextBuilder assembles a context object from the interaction payload, global config, adapter settings, secrets, and functions
  2. The TemplateParser walks every string value in the subscription config and replaces {{path}} expressions with resolved values
  3. Multi-pass resolution (up to 3 passes) handles nested templates where one variable resolves to another template string
{
  "to": "{{interaction.email}}",
  "subject": "Order {{interaction.orderId}} — {{function.date.now()}}",
  "apiKey": "{{secrets.sendgrid_key}}"
}

Context namespaces

The full context object available during template resolution:

NamespaceDescriptionExample
(root)Interaction base fields{{resourceId}}, {{actionId}}
interactionInteraction metadata payload{{interaction.email}}
globalGlobal config key/values{{global.appName}}
adapterAdapter settings (flattened){{adapter.email.apiKey}}
secretsSecret mappings from DB{{secrets.sendgrid_key}}
envEnvironment variables (self-hosted only){{env.MY_VAR}}
functionBuilt-in functions{{function.date.now()}}

Base fields

These are top-level fields from the interaction record, available without a namespace prefix:

VariableDescription
{{interactionId}}Unique interaction UUID
{{resourceId}}Resource identifier
{{resourceTypeId}}Resource type UUID
{{actionId}}Action UUID
{{originId}}Origin UUID
{{userId}}User identifier
{{uowId}}Unit of Work correlation ID
{{externalReference}}External reference string
{{createdDate}}Interaction creation timestamp
{{timestamp}}Alias for createdDate
{{subscriberArn}}Subscriber ARN or adapter reference
{{subscriberName}}Subscriber display name

Interaction payload

The interaction namespace maps to the JSON metadata stored with the interaction. The available fields depend entirely on what your application sends.

POST /api/interactions
{
  "resourceId": "order-456",
  "resourceTypeId": "...",
  "actionId": "...",
  "interaction": {
    "email": "user@example.com",
    "name": "Jane",
    "orderId": "ORD-789",
    "total": 99.99,
    "items": [{ "sku": "A1", "qty": 2 }]
  }
}

These become:

VariableResolves to
{{interaction.email}}user@example.com
{{interaction.name}}Jane
{{interaction.orderId}}ORD-789
{{interaction.total}}99.99
{{interaction.items}}The full array (as string in templates)

Nested paths work: {{interaction.address.city}} resolves through dot notation.


Global config

Values from the Global Configuration settings (GET /api/global-config). These are key/value pairs stored in the database with type coercion (string, number, boolean, object).

{
  "subject": "{{global.appName}} — New order",
  "replyTo": "{{global.supportEmail}}"
}

Set global config values via the UI (Settings → Global Config) or the API:

PUT /api/global-config
{ "key": "appName", "value": "My Store", "type": "string" }

Adapter settings

Values from Adapter Settings (GET /api/adapter-settings), flattened with the adapter type as prefix. These are typically credentials or shared configuration for a specific adapter.

{
  "authorization": "Bearer {{adapter.email.apiKey}}",
  "from": "{{adapter.email.defaultFrom}}"
}

Adapter settings are configured per adapter type in the UI (Settings → Adapter Settings) or via:

POST /api/adapter-settings
{
  "adapterType": "email",
  "settings": {
    "apiKey": "SG.xxx",
    "defaultFrom": "noreply@example.com"
  }
}

The settings object is flattened, so settings.apiKey becomes adapter.email.apiKey in the template context.


Secrets

Secrets are sensitive values (API keys, tokens, passwords) stored securely and resolved at runtime. Reference them with {{secrets.path}}.

{
  "apiKey": "{{secrets.sendgrid_key}}",
  "webhookSecret": "{{secrets.stripe_webhook_secret}}"
}

Storage modes

ModeDescriptionMulti-tenant
ProvenanceEncrypted in database with AES-256-GCM
External providerFetched at runtime from AWS SM, Azure KV, GCP SM, or Vault
Environment variableMaps to process.env variable❌ (blocked)

External providers require a provider connection configured in Settings → Secret Providers. The connection stores encrypted credentials for the external service.

See Secrets and Secret Providers for full documentation.

Managing secrets

Secrets are managed in the UI under Settings → Secrets, or via the API:

# Create a Provenance-stored secret
POST /api/secrets
{ "secretPath": "sendgrid_key", "provider": "provenance", "value": "SG.xxx" }

# Create an external provider secret
POST /api/secrets
{ "secretPath": "stripe_key", "provider": "aws-sm", "providerPath": "prod/stripe", "secretProviderId": "..." }

The template variables endpoint (GET /api/template-variables) returns all available secret paths for autocomplete.


Environment variables

In self-hosted (single-tenant) mode, {{env.VAR_NAME}} resolves to process.env.VAR_NAME.

{
  "apiKey": "{{env.SENDGRID_API_KEY}}"
}

Blocked in multi-tenant mode. The env namespace returns tenant secret mappings instead of process environment variables.


Functions

Built-in functions are called with {{function.category.name(args)}}. Arguments can be literals, numbers, or context variable paths.

Date functions

FunctionDescriptionExampleOutput
date.now()Current ISO timestamp{{function.date.now()}}2025-01-15T14:30:00.000Z
date.timestamp()Current epoch (ms){{function.date.timestamp()}}1736952600000
date.year()Current year{{function.date.year()}}2025
date.month()Current month (1-12){{function.date.month()}}1
date.day()Current day of month{{function.date.day()}}15
date.hour()Current hour (0-23){{function.date.hour()}}14
date.minute()Current minute (0-59){{function.date.minute()}}30
date.formatDate(value, pattern)Format a date{{function.date.formatDate(createdDate, "short")}}1/15/2025 2:30:00 PM
date.addDays(n)Add days to now{{function.date.addDays(7)}}ISO string 7 days from now

Format patterns: iso (default), date, time, locale, short

Math functions

FunctionDescriptionExample
math.random()Random float 0–1{{function.math.random()}}
math.randomInt(min, max)Random integer{{function.math.randomInt(1, 100)}}
math.round(n)Round{{function.math.round(3.7)}}4
math.floor(n)Floor{{function.math.floor(3.7)}}3
math.ceil(n)Ceiling{{function.math.ceil(3.2)}}4
math.abs(n)Absolute value{{function.math.abs(-5)}}5

String functions

FunctionDescriptionExample
string.upper(str)Uppercase{{function.string.upper("hello")}}HELLO
string.lower(str)Lowercase{{function.string.lower("HELLO")}}hello
string.capitalize(str)Capitalize first{{function.string.capitalize("hello")}}Hello
string.slug(str)URL slug{{function.string.slug("Hello World!")}}hello-world
string.truncate(str, len)Truncate{{function.string.truncate("long text", 4)}}long...

ID functions

FunctionDescriptionExample
id.uuid()UUID v4{{function.id.uuid()}}
id.shortcode(len)Random alphanumeric{{function.id.shortcode(6)}}A3F9K2
id.timestamp()Epoch as string{{function.id.timestamp()}}
id.nanoid(len)NanoID-style{{function.id.nanoid(12)}}

Encoding functions

FunctionDescriptionExample
encode.base64(str)Base64 encode{{function.encode.base64("hello")}}aGVsbG8=
encode.url(str)URL encode{{function.encode.url("a b")}}a%20b
encode.html(str)HTML escape{{function.encode.html("<b>")}}&lt;b&gt;
encode.json(obj)JSON stringify{{function.encode.json(interaction)}}

Using context variables as function arguments

Function arguments can reference context paths. The parser resolves them before calling the function:

{
  "slug": "{{function.string.slug(interaction.name)}}",
  "encoded": "{{function.encode.base64(interaction.email)}}"
}

Lifecycle hooks

Subscriptions can define lifecycle hooks that run at specific points during notification processing. Hooks are configured in the lifecycle_hooks field of a subscription.

Hook phases

PhaseWhenCan block?
canExecuteBefore anything runs✅ — returning false skips the notification
beforeExecuteAfter canExecute passes, before adapter call
afterExecuteAfter successful adapter call
onSuccessAfter successful delivery
onErrorAfter failed delivery

Hook structure

{
  "lifecycle_hooks": {
    "canExecute": [
      {
        "function": "filter.amountGreaterThan",
        "args": ["{{interaction.total}}"]
      }
    ],
    "beforeExecute": [
      {
        "function": "transform.addTimestamp",
        "args": []
      }
    ],
    "onError": [
      {
        "function": "validator.requiredFields",
        "args": [["email", "name"]]
      }
    ]
  }
}

Template resolution in hook args

Hook arguments support the same {{variable}} syntax. For simple variable references like {{interaction.total}}, the resolved value preserves its original type (number, boolean, etc.) rather than converting to string. This is important for functions like filter.amountGreaterThan that expect numeric arguments.

Built-in hook functions

FunctionCategoryDescriptionArgs
filter.amountGreaterThanFilterReturns true if interaction.amount > thresholdthreshold (number)
filter.actionEqualsFilterReturns true if action matchesaction (string)
filter.userInListFilterReturns true if userId is in listuserList (array)
transform.addTimestampTransformAdds processedAt to interaction
transform.maskSensitiveDataTransformMasks email addresses
validator.requiredFieldsValidatorReturns true if all fields presentfields (array)

Custom functions

When custom functions are enabled (CUSTOM_FUNCTIONS.ENABLED = true), user-defined functions stored in the custom_functions table are also available. These execute in a secure VM2 sandbox with a 5-second timeout.

Hook execution log

Every hook execution is recorded in the hook_execution_log field of the queue item, including:

  • Function name and resolved args
  • Success/failure status
  • Execution time (ms)
  • Error message (if failed)

This log is visible in the Queue UI when viewing notification details.


Autocomplete API

The GET /api/template-variables endpoint returns all available template variables for UI autocomplete, grouped by category:

{
  "baseFields": [
    { "key": "interactionId", "description": "Unique interaction identifier" },
    { "key": "resourceId", "description": "Resource identifier" }
  ],
  "secrets": [
    { "key": "secrets.sendgrid_key", "description": "SendGrid API key" }
  ],
  "functions": [
    { "key": "date.now", "description": "Current ISO date string", "example": "{{function.date.now()}}" }
  ]
}

This endpoint powers the autocomplete in the subscription config editor in the UI.


Resolution order

When the queue processor handles a notification:

  1. Subscription config{{variables}} in the subscription's config field are resolved against the full context
  2. Adapter settings{{variables}} in the adapter's global settings are also resolved
  3. Lifecycle hook args{{variables}} in hook arguments are resolved, preserving types for simple references
  4. Multi-pass — Up to 3 passes handle cases where a resolved value itself contains {{templates}}

If a variable path cannot be resolved, the original {{path}} string is left in place unchanged.