Creating Notification Templates
Template System: RunReveal uses Handlebars-style templating (via Raymond) to create dynamic notifications that adapt to your detection data.

Using the Template Builder
RunReveal provides a visual template builder in the dashboard to create and manage your notification templates.
🛠️ Quick Start: Creating Your First Template
Step-by-Step Guide
Navigate to Notification Channels in the RunReveal dashboard, then click on the Templates tab.
Path: Dashboard → Notification Channels → Templates
Click the Create Template button. Give your template a descriptive name that reflects its purpose (e.g., "Critical Security Alert", "Daily Digest Summary").
Enter your title template in the Title field. This appears as the email subject line or message header. Keep it concise and include key information like severity and detection name.
[{{detection.severity}}] {{detection.displayName}} - {{detection.resultCount}} results
Enter your body template in the Body field. This is the main content of your notification. Use Handlebars syntax for dynamic content, markdown for formatting, and helpers for advanced features.
{{detection.name}}{{#ifEquals...}}{{table results}}The template editor shows a Preview of how your markdown and formatting will render. Note that the preview displays your template structure with placeholder values—it does not use real detection data.
Testing with Real Data: To see your template with actual detection data, use the Send Test button on your notification channel after saving the template. This sends a test notification using sample detection data to your configured destination (Slack, email, etc.).
- ✓ Markdown rendering
- ✓ Template structure
- ✓ Formatting validation
- ✓ Real variable substitution
- ✓ Conditional logic evaluation
- ✓ Channel-specific formatting
Click Save Template to create your template. Then assign it to detections by editing the detection and selecting your template from the Notification Template dropdown.
💡 Tip: You can also specify templates in Detection-as-Code using the notificationTemplate field.
Assigning Templates to Detections
- Go to Detections → Detection Queries
- Edit the detection you want to customize
- Find the "Notification Template" dropdown
- Select your custom template
- Save the detection
Template Structure
Templates consist of two parts:
📌 Title Template
📄 Body Template
Core Concepts
Channels, templates, and overrides
Before writing your first template, it helps to understand how the pieces fit together:
- Notification channel — where an alert is delivered (a Slack workspace, an email address, a webhook URL, PagerDuty, etc.). You configure channels under Notification Channels.
- Template — how an alert is formatted. A template is simply a Title string and a Body string, both written in Handlebars. The Title becomes the email subject / message header; the Body becomes the message content.
- Assignment / override — which template is used for a given alert.
When RunReveal sends a detection notification, it renders whichever template it resolves first, in this order:
- Detection-level template — a template you assigned directly to the detection (the
notificationTemplatefield in Detection-as-Code, or the Notification Template dropdown on the detection). This overrides everything else. - Channel default — the built-in default for that channel (
default_slack,default_email,default_webhook, and so on) when the detection has no template of its own. - Built-in fallback — a minimal system template used only when nothing else matches.
Every template renders against the same data object. Two top-level values are always available:
channel— the channel type string (slack,email,webhook,discord,jira,pagerduty,linear,google-chat, …). Use it to branch formatting per channel.detection— the executed detection. The exact fields underdetectiondepend on whether the detection is a SQL query or a Sigma rule — this difference is the single most common source of confusion and is covered in Detection data shapes: SQL vs Sigma below.
Other notification types populate a different top-level key instead of detection — AI
agent responses render under agent, and health-check alerts under health. This page
focuses on detection notifications.
1. Inserting Dynamic Values
Access detection properties using dot notation. Variables are wrapped in double curly braces.
Basic Syntax
detection.displayNamedetection.severitydetection.riskScoredetection.resultLink2. Conditional Logic
Conditional helpers allow you to show or hide content based on detection properties.
Comparison Helpers
Equality Checks
ifEquals- Check if values are equalifNotEquals- Check if values differ
Numeric Comparisons
ifGreaterThan- Greater thanifLessThan- Less thanifGreaterThanOrEqual- Greater or equal
String Operations
ifContains- String containsifStartsWith- String starts withifEndsWith- String ends with
Empty Checks
ifNotEmpty- Value existsifEmpty- Value is empty
💡 Example Use Cases
- • Show critical alert banner only when severity equals "Critical"
- • Display high-risk warning when risk score is greater than 80
- • Show error message only when detection.error is not empty
- • Customize content based on channel type (email vs Slack)
3. Data Iteration
Loop through detection results and arrays to display multiple items.
Loop Helper
Basic Loop Structure
Common Patterns
- • Loop through detection results
- • Iterate over categories array
- • Process MITRE techniques
- • Display extracted fields
Best Practices
- • Always check ifNotEmpty before looping
- • Use index for numbering items
- • Use first/last for special formatting
- • Limit display to first N items if needed
4. Table Generation
Automatically format detection results as tables for better readability.
Auto-Detect Columns
table detection.resultsSpecify Columns
tableWithColumns detection.results "User:item.actorEmail|Action:item.action"5. Markdown Rendering
Convert markdown content to HTML for rich formatting in your notifications.
📝 Markdown Support
Supported Markdown Features
Handlebars helper & partial reference
RunReveal renders templates with the Raymond Handlebars engine, plus a set of custom helpers. You do not need to read the Go source to use them — everything supported is listed below.
Built-in Handlebars syntax
These come from Handlebars itself and are always available:
| Syntax | Purpose |
|---|---|
| {{value}} | Insert a value (HTML-escaped) |
| {{{value}}} | Insert a value without escaping |
| {{#if value}}…{{else}}…{{/if}} | Render a block when value is truthy |
| {{#unless value}}…{{/unless}} | Render a block when value is falsy |
| {{#each array}}…{{/each}} | Iterate an array or object |
| {{#with object}}…{{/with}} | Set the block's context to object |
| {{lookup object key}} | Look up a dynamic key/index on an object or array |
Inside an {{#each}} block you can use {{this}} (current item), {{@index}} (0-based
position), {{@key}} (map key), {{@first}}, and {{@last}}.
#with is especially useful for reaching into a specific result row — see
indexed access below.
Conditional helpers
Convenience wrappers that read better than the generic condition helper. Each is a block
helper — the block renders only when the condition is true. All except the empty checks take
a left-hand value and a right-hand value.
| Helper | True when |
|---|---|
| ifEquals a b | a equals b |
| ifNotEquals a b | a does not equal b |
| ifGreaterThan a b | a > b (numeric) |
| ifLessThan a b | a < b (numeric) |
| ifGreaterThanOrEqual a b | a >= b (numeric) |
| ifLessThanOrEqual a b | a <= b (numeric) |
| ifContains a b | string a contains substring b |
| ifNotContains a b | string a does not contain b |
| ifStartsWith a b | string a starts with b |
| ifEndsWith a b | string a ends with b |
| ifEmpty a | a is empty/nil/zero-length |
| ifNotEmpty a | a is present and non-empty |
The generic condition helper
All of the above are shorthands for a single dispatcher helper. Use it directly when you want
one consistent form (it also supports an {{else}} branch and a few array operators the
shorthands don't expose):
Supported operator strings: equals, notEquals, greaterThan, lessThan,
greaterThanOrEqual, lessThanOrEqual, contains, notContains, startsWith, endsWith,
isEmpty, isNotEmpty, arrayContains, arrayNotContains, arrayLength,
arrayLengthGreaterThan, arrayLengthLessThan. Single-operand operators (isEmpty,
isNotEmpty) ignore the third argument — pass "".
Data & formatting helpers
| Helper | Description |
|---|---|
| {{table array}} | Render an array of objects as a Markdown table, auto-detecting columns from the first row. |
| {{tableWithColumns array "spec"}} | Render a table with an explicit column spec (see below). |
| {{#loop array}}…{{/loop}} | Iterate an array, exposing {{item}}, {{index}}, {{first}}, and {{last}}; object fields are also merged into the block so you can write {{fieldName}} directly. |
| {{json value}} | Serialize a value to a JSON string. |
| {{markdownToHTML markdownString}} | Convert a Markdown string to HTML (used mainly in email bodies). |
tableWithColumns column spec
tableWithColumns takes the array and a single spec string. The spec is a
pipe-separated list of Header:fieldPath pairs. Field paths use dot notation and may start
with an optional item. prefix:
A common mistake is passing bare column names as separate arguments (for example
tableWithColumns detection.results "user" "action"). That does not work — the helper
reads only the first string and expects the Header:field|Header:field form, so bare names
produce an empty table. Always use the single "Header:field|…" spec string.
Partials
Partials are pre-written snippets you include with {{> name}}. These are handy for SQL
detections where you want tabular output without writing the markup yourself:
| Partial | Expands to |
|---|---|
| {{> partials.mdTable}} | A Markdown table of detection.results. |
| {{> partials.htmlTable}} | An HTML <table> of the detection columns/values. |
| {{> partials.csv}} | Comma-separated columns and rows. |
| {{> partials.jsonString}} | The result rows serialized as a JSON array. |
| {{> partials.resultLink}} | The detection.resultLink URL. |
| {{> partials.duration}} | The detection run time, formatted. |
The older partials.query.results.* and partials.query.helper.* names are still accepted
as aliases for backward compatibility.
The table, CSV, JSON, and HTML-table partials all read detection.results /
detection.columns / detection.values, which only exist on SQL detections. For Sigma
detections, format detection.event fields directly instead — see the next section.
Detection data shapes: SQL vs Sigma
Both SQL and Sigma detections render under detection, and both share a common set of fields
(detection.displayName, detection.severity, detection.riskScore, detection.resultLink,
detection.categories, and so on). What differs is how the matched data is exposed, and
this is the difference that trips people up most often:
- A SQL detection runs a scheduled query and can match many rows. Its data lives in
detection.results— an array of row objects. - A Sigma detection matches a single streaming event in real time. There is no results
array; the matched event lives in
detection.event— a single object.
Templates written for SQL detections often silently render nothing on Sigma detections.
Sigma detections have no detection.results, detection.resultCount,
detection.columns, or detection.values, so helpers like {{table detection.results}} and
blocks like {{#ifNotEmpty detection.results}} produce empty output. For Sigma, read
detection.event.* instead.
SQL detections expose the full query result set:
| Field | Type | Description |
|---|---|---|
| detection.results | array of objects | One object per matched row, keyed by column name. |
| detection.columns | array of strings | Column names, in query order. |
| detection.values | array of arrays | Row values as positional arrays (parallel to columns). |
| detection.resultCount | number | Number of rows matched. |
| detection.resultLink | string | Link to view the full results in RunReveal. |
The data your template receives looks like this:
Because detection.results is an array, you typically render it as a table or loop over it:
Accessing a single result row
To pull one value out of a specific row, index into the array with bracketed notation:
When you want several fields from the same row without repeating the index, wrap the row in
a {{#with}} block — inside the block, columns are addressed directly:
Template Variables Reference
Every template renders against two top-level values: channel and detection. The tables
below list the properties available on detection — the fields shared by all detections,
then the fields specific to SQL and Sigma detections.
Common fields (all detections)
| Property | Type | Description |
|---|---|---|
| detection.id | string | Unique execution ID for this run. |
| detection.detectionID | string | ID of the detection definition. |
| detection.name | string | Machine name (slug) of the detection. |
| detection.displayName | string | Human-readable detection name. |
| detection.description | string | Detection description. |
| detection.notes | string | Detection notes. |
| detection.detectionType | string | Either sql or sigma. |
| detection.severity | string | Severity (Critical, High, Medium, Low). |
| detection.riskScore | number | Risk score (0–100). |
| detection.categories | array | Category tags. |
| detection.mitreAttacks | array | MITRE ATT&CK tactic IDs. |
| detection.mitreTechniques | array | MITRE ATT&CK technique IDs. |
| detection.triggered | boolean | Whether the detection triggered a notification. |
| detection.executedAt | timestamp | When the detection ran. |
| detection.workspaceID | string | Workspace ID. |
| detection.resultLink | string | Link to view the alert in RunReveal. |
| detection.error | string | Error message if the run failed. |
| detection.fields | array | Extracted key fields for the alert, when present. |
SQL detection fields
Present when detection.detectionType is sql.
| Property | Type | Description |
|---|---|---|
| detection.results | array of objects | Matched rows, keyed by column name. |
| detection.columns | array | Column names in query order. |
| detection.values | array of arrays | Row values as positional arrays. |
| detection.resultCount | number | Number of rows matched. |
| detection.query | string | The query that ran. |
| detection.params | object | Query parameters. |
| detection.runTime | number | Query run time (nanoseconds). |
Sigma detection fields
Present when detection.detectionType is sigma.
| Property | Type | Description |
|---|---|---|
| detection.event | object | The single matched event (see below). |
| detection.sourceType | string | Source type (e.g. okta, aws-cloudtrail). |
| detection.sourceID | string | ID of the source. |
| detection.sourceName | string | Human-readable source name. |
Streaming event fields (Sigma only)
Addressed under the detection.event. prefix (for example detection.event.eventName).
Normalized fields sit at the top level of the event; original log fields live under
detection.event.rawLog.
| Field (detection.event.*) | Type | Description |
|---|---|---|
| eventName | string | Normalized event name. |
| eventID | string | Event ID. |
| eventTime | timestamp | When the event occurred. |
| sourceType | string | Source type. |
| serviceName | string | Service that produced the event. |
| actor | object | Actor object — also actor.id, actor.email, actor.username. |
| srcIP | string | Source IP. Also srcASOrganization, srcCity, srcASCountryCode, etc. |
| dstIP | string | Destination IP. Also dstASOrganization, dstCity, dstASCountryCode, etc. |
| resources | array | Resources involved in the event. |
| enrichments | array | Enrichment data attached to the event. |
| tags | object | Event tags. |
| receivedAt | timestamp | When RunReveal received the event. |
| rawLog | object | The original, unmodified log. Nested fields via detection.event.rawLog dot-paths. |
Channel Variable
channel - The notification channel type (email, slack, discord, webhook, jira, pagerduty, linear, google-chat)
Best Practices
✅ Do
- • Keep titles concise (50-100 characters)
- • Always check ifNotEmpty before iterating
- • Include error handling sections
- • Provide investigation steps
- • Link to full results
- • Test with empty results
- • Use markdown for rich formatting
❌ Don't
- • Assume results always exist
- • Create overly long titles
- • Skip error handling
- • Hard-code values that should be dynamic
- • Forget to test edge cases
- • Use complex nested conditionals unnecessarily
- • Ignore channel-specific formatting needs
Ready-to-Use Templates
Copy-paste starting points for every channel. Each scenario has an SQL detection tab
(scheduled query, tabular detection.results) and a Sigma detection tab (single streaming
event, detection.event) — pick the tab that matches your detection type. Each tab shows the
Title, Body, and a Preview of the rendered output using sample data.
Slack
Slack messages use mrkdwn, which does not render Markdown tables — loop over
detection.results to list rows instead (as below). Tables render normally in email and
webhook payloads.
Title
Body
Preview
Failed Logins
Severity: High | Risk: 75
• [email protected] — login from 1.2.3.4
• [email protected] — login from 5.6.7.8
Discord
Like Slack, Discord doesn't render Markdown tables, so loop over the rows. Discord also
doesn't linkify [label](url) in normal messages, so include the raw detection.resultLink
URL.
Title
Body
Preview
Failed Logins Severity: High | Risk: 75
• [email protected] — login from 1.2.3.4
• [email protected] — login from 5.6.7.8
Google Chat
Title
Body
Preview
Failed Logins Severity: High | Risk: 75
• [email protected] — login from 1.2.3.4
• [email protected] — login from 5.6.7.8
Title
Body
Preview
Failed Logins
Multiple failed logins
2 results found.
| actorEmail | action | srcIP |
|---|---|---|
[email protected] | login | 1.2.3.4 |
[email protected] | login | 5.6.7.8 |
Webhook (JSON body)
Title
Body
Preview
Jira, Linear & incident.io
For issue trackers the Title becomes the issue summary and the Body the description. Linear and incident.io render Markdown; Jira converts basic formatting. Keep to fields plus a link.
Title
Body
Preview
Multiple failed logins
Severity: High Risk score: 75 Results: 2
[email protected]— login from 1.2.3.4[email protected]— login from 5.6.7.8
PagerDuty & VictorOps
Paging channels are intentionally terse: the Title is the alert summary and the Body a short description. The same template works for SQL and Sigma detections.
Title
Body
Preview
Multiple failed logins
Severity: High | Risk: 75
Results: 2
View: https://app.runreveal.com/dash/history?alertID=abc123
Testing Your Templates
Test Scenarios
Data Variations
- • Empty results (resultCount = 0)
- • Single result
- • Multiple results (10+)
- • Missing optional fields
Severity & Risk
- • High risk scores (above 80)
- • Low risk scores (below 20)
- • Different severity levels
- • Error conditions
Related Documentation
- Notification Channels - Configure and manage notification channels
- Detections - Create and manage detection rules
- Writing Detections - Learn how to write effective detections
- Detection as Code - Manage detections with version control