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:
- The ContextBuilder assembles a context object from the interaction payload, global config, adapter settings, secrets, and functions
- The TemplateParser walks every string value in the subscription config and replaces
{{path}}expressions with resolved values - 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:
| Namespace | Description | Example |
|---|---|---|
| (root) | Interaction base fields | {{resourceId}}, {{actionId}} |
interaction | Interaction metadata payload | {{interaction.email}} |
global | Global config key/values | {{global.appName}} |
adapter | Adapter settings (flattened) | {{adapter.email.apiKey}} |
secrets | Secret mappings from DB | {{secrets.sendgrid_key}} |
env | Environment variables (self-hosted only) | {{env.MY_VAR}} |
function | Built-in functions | {{function.date.now()}} |
Base fields
These are top-level fields from the interaction record, available without a namespace prefix:
| Variable | Description |
|---|---|
{{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:
| Variable | Resolves 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
| Mode | Description | Multi-tenant |
|---|---|---|
| Provenance | Encrypted in database with AES-256-GCM | ✅ |
| External provider | Fetched at runtime from AWS SM, Azure KV, GCP SM, or Vault | ✅ |
| Environment variable | Maps 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
| Function | Description | Example | Output |
|---|---|---|---|
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
| Function | Description | Example |
|---|---|---|
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
| Function | Description | Example |
|---|---|---|
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
| Function | Description | Example |
|---|---|---|
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
| Function | Description | Example |
|---|---|---|
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>")}} → <b> |
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
| Phase | When | Can block? |
|---|---|---|
canExecute | Before anything runs | ✅ — returning false skips the notification |
beforeExecute | After canExecute passes, before adapter call | ❌ |
afterExecute | After successful adapter call | ❌ |
onSuccess | After successful delivery | ❌ |
onError | After 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
| Function | Category | Description | Args |
|---|---|---|---|
filter.amountGreaterThan | Filter | Returns true if interaction.amount > threshold | threshold (number) |
filter.actionEquals | Filter | Returns true if action matches | action (string) |
filter.userInList | Filter | Returns true if userId is in list | userList (array) |
transform.addTimestamp | Transform | Adds processedAt to interaction | — |
transform.maskSensitiveData | Transform | Masks email addresses | — |
validator.requiredFields | Validator | Returns true if all fields present | fields (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:
- Subscription config —
{{variables}}in the subscription'sconfigfield are resolved against the full context - Adapter settings —
{{variables}}in the adapter's global settings are also resolved - Lifecycle hook args —
{{variables}}in hook arguments are resolved, preserving types for simple references - 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.