{{ ... }} (callback URL, HTTP body, email subject, transform output, validation rule), you can reference an upstream value, index into arrays, transform it with filters, or insert a built-in helper.
Basic syntax
A template expression has three parts: a path, optional filters, and the surrounding{{ }} markers.
{{ ... }} token.
Path
A path is a dotted identifier, optionally with array indices.
The first segment of every path must be the name of an upstream node (or a built-in placeholder or helper, see below). Subsequent segments index into that node’s output, which has the shape
{ success, payload, metadata, error }. Paths are case sensitive and may use letters, digits, and underscores.
Reading across an array
Naming a property on an array plucks it: the path resolves to the list of that property’s values, not to one of them.Every part of Ingestly that resolves a reference (templates, edge conditions, validation rules, field mappings, connector write-back) uses this one grammar, so a path that works in one place works in all of them. The
[] notation you see in the data picker marks “this crosses an array” for display, and is not something you type: write a real index like [0], or leave the segment out to pluck.Each node in the editor has a Name field. Default names are auto-assigned (
extract, extract2, vars, etc.) but you can rename a node to anything that’s unique within the workflow. Whatever name you pick is the identifier used at the head of every template expression.Built-in placeholders
These resolve from the run context:Source document file
The$document head resolves from the run’s source document: the original file that triggered the run, whether it was uploaded or emailed. It is a global head, valid in any template field, because a workflow can have several triggers but only one fires per run.
The metadata paths (
name, contentType, size, extension) are always cheap to resolve. base64 is different: the file bytes are fetched and encoded lazily, only when a template actually references {{$document.file.base64}}, and they are never persisted. The encoded value never enters run output, step history, or the live preview. Anywhere it would otherwise appear, Ingestly shows a [binary N bytes, type] placeholder instead.
Use {{$document.file.base64}} to send the source file as a binary body on a callback or HTTP node, or to attach it to a record.
A node name can never start with
$, so a reserved head like $document, $input, $writeback, $item or $index never collides with a node you have named document, input, writeback, item or index. Nothing is reserved without the $ prefix, so a node may legitimately be named input, writeback, inputJson, item or index.The live input
The$input head resolves to the node that actually delivered this node’s input: the single wired predecessor whose connection fired on this run.
It exists so a template never has to name a branch. Consider a Reconcile that routes matched rows straight on and mismatched rows through a Review:
{{reconcile.payload.lines}} in the Business Central node would be wrong on a mismatch run, and {{review.payload.lines}} would be wrong on a matched one. {{$input.payload.lines}} is right on both:
Before
$input, an either-or join like this needed a Merge node to pick a source. It no longer does. Merge still earns its place when several inputs genuinely arrive together, which is what its Key Match, Zip and Concat modes are for.
Inside a Loop container, $input follows the same rule against the container’s own nodes: it resolves to the previous node in the body, for the item being processed. The first node in a body has no previous node (its input is the loop item itself, read as {{<loop node name>.payload.item}}), so $input has no answer there. The builder warns you when a node uses $input where nothing can ever deliver it, and inside a loop body it names the loop item path to use instead. That warning does not block you from saving.
Autocomplete offers the fields beneath $input, not only the head itself. Because the shape depends on which branch fires, it suggests the paths that every branch which could deliver has in common, so a field only one branch declares is not suggested even though it resolves on that branch. You can still type such a path: a path is accepted when any branch that could deliver has it, and only a path no branch has at all is flagged. A field whose type differs between branches is offered without a type, so type-aware filtering does not narrow it.
The head is offered only where it has exactly one possible answer. On a node where two connections can both deliver, $input does not appear in autocomplete at all, which is the same rule the save-time check enforces.
A related gap affects $each: looping with $each over {{$input.payload.lines}} gets no {{$item.*}} column autocomplete and no item-column validation. It degrades safely - item paths stay unchecked rather than falsely flagged - and it is still the only correct choice when the branch’s shape isn’t known up front, but an author switching an $each from a named node to $input will notice the loss and should expect it.
Built-in helpers
These produce a value at the time the template is evaluated.
You can chain filters onto helpers:
{{now | formatDate: 'yyyy-MM-dd'}}.
Filters
Add a filter with the pipe character:{{ value | filter }}. Filters with arguments use a colon: {{ value | filter: 'arg1', 'arg2' }}. You can chain as many filters as you need, applied left to right.
Filter arguments
Each argument is one of:
Path references must satisfy the same shape as the head path (
identifier(.identifier|[N])*). The runtime resolves them at filter-application time, so they can reference upstream node fields (extract.payload.divisor), built-in helpers ({{end | dateDiff: 'days', start}}), or array elements ({{items[0] | concat: items[1]}}). If the path resolves to a type that doesn’t match what the filter expects (e.g., div argument resolves to a string), the editor flags it before the run starts.
String filters
truncate counts the postfix toward the limit; padStart and padEnd pad with a space when no pad string is given.
Number filters
Format filters
Turn a raw number into a display string.formatCurrency takes an ISO 4217 currency code (for example USD, EUR, GBP) and pins the currency symbol from that code.
Array filters
slice uses Python-style bounds (negative indices count from the end). groupBy groups an array of objects by the string value at key, returning an array of { key, items } groups. reverse also reverses a string’s characters.
Date filters
Operate on ISO 8601 strings (or anything that can be parsed as one). The add/subtract and start/end filters return an ISO 8601 UTC string.formatDate uses .NET-style format strings. Common patterns: yyyy-MM-dd, yyyy-MM-ddTHH:mm:ssZ, MM/dd/yyyy, HH:mm. Negative arguments to the add* filters subtract.
Encoding filters
General filters
Array constructs
Beyond the filters above, four constructs transform arrays and pick branches. They use the same| syntax but are written by name.
$when, so they accept &&, || and ( ) too: {{lines | filter: $.qty > 0 && $.kind == 'FEE'}}.
Live preview
When you edit a template field in a node editor, Ingestly shows a live preview of the resolved value. Select a document from the run toolbar so the preview evaluates against that document’s most recent run data. This lets you confirm a path and its filter chain produce the value you expect before you run the workflow. Without a selected document, the editor still validates the path and filter names but cannot show a resolved value.Type-aware autocomplete
When you type inside a{{ ... }} field in a node editor, Ingestly suggests:
- Upstream node fields at the start of the expression (after
{{) - Built-in helpers (
now,today,uuid,nowUnix) alongside fields - Filters after a
|, scoped to the type of the preceding value (an array path showslength,first,join; a string path showsupper,lower,trim) - Filter arguments as snippet placeholders you can tab through
Inside JSON bodies
When you write a template inside a JSON value position, like"qty": "{{payload.qty}}", Ingestly emits the resolved value as raw JSON so types are preserved. A number stays unquoted, an object stays structured, an array stays an array.
Filter arguments inside a JSON-position template should use single quotes (
'a') rather than double quotes, to avoid clashing with the surrounding JSON string.Row expansion in JSON bodies (when)
A JSON body can fan out over a runtime array. Inside any JSON array, an object that carries a$each key is a row template: it is repeated once per element of the referenced array, and the $each key itself is removed from the output.
{{$item}}is the current array element, and{{$item.field}}reads a field from it{{$index}}is the zero-based position- Values that are exactly one
{{$item...}}or{{$index}}token keep their type (a number stays a number, an object stays structured) - Tokens that mix text and bindings, like
"row {{$index}}: {{$item.mark}}", substitute as text - Other
{{node.path}}tokens are left for normal substitution
$when key to filter rows. The condition supports the comparison operators ==, !=, <, <=, >, and >=, the text operators contains, starts with, and ends with, a bare truthiness test, and a leading ! that negates the value or group after it:
contains, starts with, and ends with compare case-insensitively, so 'Fuel Surcharge' matches contains 'SURCHARGE'. == and != stay case-sensitive, since equality usually checks a code or identifier where case carries meaning.$case instead. Two loops over different arrays, like {{$writeback.updates}} and {{$writeback.adds}}, are the intended use of this pattern. A reconcile node stamps classified extra rows with __label, so this pairs naturally with Expected extras.
An object with only $when (no $each) is included or dropped as a whole. Row templates nest: a field inside a row can hold another array with its own $each over {{$item.subArray}}, and the inner template’s item refers to the inner element.
Combining conditions
Join conditions with&& (both must hold) and || (either may hold). && binds tighter than ||, and ( ) overrides that.
A && B || C reads as (A && B) || C. Add brackets when you mean the other grouping:
! negates the value or group directly after it, so !{{$item.taxable}} and !({{$item.qty}} > 0 && {{$item.kind}} == 'FEE') both read the way you would expect. To negate a single comparison, use != or put the comparison in brackets. An older condition written as !{{$item.qty}} >= 5 still runs, but the builder now asks you to rewrite it as {{$item.qty}} < 5 or !({{$item.qty}} >= 5).Picking a shape per row ($case)
When rows of one array need different shapes, add a$case key holding an ordered list of shapes. The first shape whose $when passes is merged into the row, and a shape with no $when always matches, so the last entry is the catch-all. The array is iterated once, so the output keeps the source order.
- Keys outside
$case, likelineTypeandquantityabove, are shared by every row. - The winning shape’s keys are merged over the shared keys, so a shape can override a shared value.
- A key that only one shape sets, like
description2, is absent from rows that pick another shape. - The outer
$whenstill filters which rows exist at all, and runs before the shape is picked. - A shape cannot contain its own
$eachor$case. The builder rejects a$casewhere a shape tries to nest another row template.
$case also works on an object that is not a row template, where it picks one shape once:
$case that matches no shape resolves to null instead of dropping anything.
Row expansion applies to the HTTP action, callback, Business Central, and QuickBooks body fields when they parse as JSON. A $each reference that does not resolve to an array contributes no rows.
Inside a Loop body, an HTTP, callback, Business Central, or QuickBooks body field is the one place a bare
{{$item}} / {{$index}} still work: inside a $each row template they keep meaning the $each row, exactly as above. Everywhere else in that same body (the URL, headers, or a field outside a $each row), reference the loop item with the qualified {{<loop node name>.payload.item}} / .payload.index instead; a bare {{$item}} or {{$index}} there resolves to nothing.Common recipes
Last item from an extracted line items array, with a fallback:yyyy-MM-dd string:
When a template doesn’t resolve
A path can fail to resolve for several reasons: the field is missing, the upstream node didn’t run, the index is out of bounds, or the path is malformed. What happens next depends on the workflow’s Strict references setting.Strict references
Every workflow carries a Strict references setting, in Workflow settings in the editor. It decides what an unresolved reference costs.
Workflows you create now, including ones started from a template, are strict by default. Workflows that existed before the setting was introduced are lenient, so nothing about their behavior changed. Copying a workflow, or copying it to another environment, carries the setting across.
Under strict references, two things rescue a path that would otherwise fail:
{{maybe.payload.note | default: ''}}resolves to an empty string on purpose.{{a.payload.x | coalesce: b.payload.x}}falls back to another path.
Before you turn it on
Open Workflow settings and look under the switch. Ingestly scans this workflow’s recent runs and reports how many of them rendered a reference empty, listing each token, the node it is on, how often it missed, and when it was last seen. Turning strict on would fail every one of those, so fix ordefault them first.
If the panel reports no unresolved references in the window, the switch is safe to flip.
Finding them after the fact
A step that rendered references empty carries an amber badge in the run detail reading “N references rendered empty on this step”, listing the exact tokens. That badge is how you find a hole in a lenient workflow before a downstream system does. If the filter name is unknown or a filter call is malformed (wrong arg count, bad argument type), the substitution yields an empty string and the node surfaces a validation error. The node you named is on a branch that did not run. A node is readable by name only when the connection from it to the node reading it actually fired - Ingestly used to let any node that ran be read by name, and no longer does. If a Reconcile node routes out itsmismatch port, its matched connection did not fire on that run, so a node downstream of matched cannot read Reconcile by name, even though Reconcile itself ran. This is what stops a template resolving against a branch nobody took. Reference the branch that did run, or use {{$input.*}} to resolve to whichever branch fired.