JSONata Expressions Reference
SignStack uses JSONata as its expression and transformation language. JSONata is an open-source query and transformation language for JSON maintained independently of SignStack — it's the same language used in tools like IBM App Connect and Ballerina, and you can experiment with it standalone in the JSONata Exerciser. This page is a working reference for the subset you'll reach for when building templates and blueprints; the official JSONata documentation is always the source of truth for the language itself.
Because JSONata is a widely-adopted, well-documented language, the skills you build here transfer to any JSON-querying task. It's also very well understood by modern LLMs:
- Inside Studio — the built-in AI assistant can generate JSONata expressions directly from plain-English descriptions, with full awareness of your template's inputs and your blueprint's entity shapes. This is usually the fastest path.
- Outside Studio — tools like ChatGPT, Claude, or Copilot will generally produce correct JSONata on the first try from a plain-English description. You can paste generated expressions into the JSONata Exerciser to verify against sample data before dropping them into a resource.
Where Expressions Are Used
| Location | YAML Property | Purpose |
|---|---|---|
| Template Fields | value.expression |
Calculate field display values |
| Template Fields | includeIf |
Control field visibility (returns boolean) |
| Template Data | data.transform |
Map inputs to HTML render context |
| Template Roles | output.transform |
Extract data after role completes signing |
| Template Roles | required |
Dynamic requirement (boolean or expression) |
| Blueprint Steps | includeIf |
Control step inclusion (returns boolean) |
| Blueprint Steps | onComplete.validate |
Validate data before updates run (returns boolean) |
| Blueprint Steps | onComplete.updates[].transform |
Transform entity data after step completion |
| Blueprint Envelopes | includeIf |
Control envelope inclusion |
| Blueprint Documents | includeIf |
Control document inclusion |
| Blueprint Documents | iterator |
Generate multiple document instances |
| Blueprint Documents | fileId.expression |
Dynamic file reference |
| Blueprint Participants | name.expression, email.expression |
Dynamic participant info |
| Blueprint Participants | required |
Dynamic requirement (boolean or expression) |
| Blueprint Custom Fields | value.expression |
Resolve custom field values sent in webhook payloads |
| Blueprint/Template Inputs | required |
Dynamic requirement (boolean or expression) |
| Custom Functions | body |
The function's JSONata expression |
Expression Context
The variables an expression can see depend on where the expression lives. There are three distinct contexts:
1. Template field / data expressions
Anywhere inside a template — data.transform, field value.expression, includeIf. Entity inputs sit at the root, plus JSONata built-ins and any custom functions referenced by the template.
| Variable | What it is |
|---|---|
$.<inputKey> |
Entity inputs passed to the template |
$<alias>() |
Custom functions referenced by the template |
$now(), $exists(), etc. |
JSONata built-ins |
$.client_info.name // Entity 'client_info' field
$.deal_info.value // Numeric property
$format_currency($.amount) // Custom function — by resource key
2. Template role output.transform
A role's output.transform runs when the role finishes signing. It sees:
| Variable | What it is |
|---|---|
$.<inputKey> |
Entity inputs passed to the template |
$.fields.<fieldKey> |
Field values the signer entered, captured from the signed document |
$<alias>() |
Custom functions referenced by the template |
$now(), $exists(), etc. |
JSONata built-ins |
See Template Role Outputs for the full picture.
3. Blueprint step onComplete — validate and updates[].transform
In a step's onComplete.validate or onComplete.updates[].transform, the expression's root context is an object with three keys — inputs, docOutputs, childOutputs.
| Variable | What it is |
|---|---|
$.inputs.<entityKey> |
Every blueprint input at its current value |
$.docOutputs.<documentKey>.<roleKey> |
Payload from the role's output.transform (participant steps only) |
$.childOutputs.<childStepKey>.<entityKey> |
Each child's final entity snapshots (parent group steps only) |
$<alias>() |
Custom functions referenced by the blueprint |
$now(), $exists(), $count(), etc. |
JSONata built-ins |
See Step onComplete — Expression Context for the full reference.
Basic Syntax
Accessing Data
// Direct property access
$.client_info.name; // "Jane"
// Nested properties
$.client_info.address.city; // Access nested object
// Array access
$.items[0]; // First item
$.items[-1]; // Last item
$.items[0].name; // Property of first item
Operators
Comparison
| Operator | Meaning | Example |
|---|---|---|
> |
Greater than | $.deal_info.value > 50000 |
>= |
Greater than or equal | $.deal_info.value >= 50000 |
< |
Less than | $.deal_info.value < 50000 |
<= |
Less than or equal | $.deal_info.value <= 50000 |
= |
Equal (single =, not ==) |
$.status = "active" |
!= |
Not equal | $.status != "archived" |
Logical
| Operator | Meaning | Example |
|---|---|---|
and |
Both must be true | $.tier = "gold" and $.amount > 1000 |
or |
Either can be true | $.tier = "gold" or $.tier = "platinum" |
not(...) |
Negation | not($exists($.cancelledAt)) |
Arithmetic
$.deal_info.quantity * $.deal_info.unitPrice
$.deal_info.total - $.deal_info.discount
$.deal_info.value / 100
Ternary (Conditional)
// condition ? value_if_true : value_if_false
$.client_info.tier = 'Premium' ? 0.1 : 0.05;
// Nested ternary
$.deal_info.value > 100000 ? 'Large' : $.deal_info.value > 50000 ? 'Medium' : 'Small';
String Concatenation
// Use & for string concatenation
$.client_info.firstName & ' ' & $.client_info.lastName;
// Results in: "Jane Doe"
Built-in Functions
String Functions
// Case conversion
$uppercase($.client_info.name); // "JANE"
$lowercase($.client_info.email); // "jane@example.com"
// Trimming
$trim(' hello '); // "hello"
// Substring
$substring($.client_info.name, 0, 1); // "J" (first character)
// Length
$length($.client_info.name); // 4
// Contains
$contains($.client_info.email, '@'); // true
// Replace
$replace($.phone, '-', ''); // Remove dashes
// Split
$split('a,b,c', ','); // ["a", "b", "c"]
// Join
$join(['a', 'b', 'c'], ', '); // "a, b, c"
// Pad
$pad($.invoiceNumber, 6, '0'); // "000123"
Number Functions
// Formatting (currency, decimals)
$formatNumber($.deal_info.value, '$#,##0.00'); // "$50,000.00"
$formatNumber($.rate, '0.00%'); // "5.25%"
// Rounding
$round(3.456, 2); // 3.46
$floor(3.9); // 3
$ceil(3.1); // 4
// Math
$abs(-5); // 5
$sqrt(16); // 4
$power(2, 3); // 8
// Aggregation
$sum($.items.price); // Sum of all prices
$average($.items.quantity); // Average quantity
$min($.items.price); // Minimum price
$max($.items.price); // Maximum price
$count($.items); // Number of items
Date Functions
JSONata ships $now, $millis, $fromMillis and $toMillis, which cover most date work.
SignStack adds exactly two functions — the only two things JSONata cannot compute:
a DST-correct UTC offset, and calendar month arithmetic. Formatting stays with $fromMillis.
// The zone's UTC offset, DST-correct — feeds $fromMillis's third argument.
// $zoneOffset(at, tz?): the instant comes first and is REQUIRED; the zone is optional.
$zoneOffset($millis()); // -> '+0530' (configured zone, at now)
$zoneOffset($millis(), 'Europe/Berlin'); // -> '+0200' (explicit zone, DST applied)
$zoneOffset($ms, 'Europe/Berlin'); // -> the offset at THAT stored instant
// Calendar month arithmetic -> 'YYYY-MM-DD'
$addMonths('2024-01-15', 12); // -> 2025-01-15
$addMonths('2024-01-31', 1); // -> 2024-02-29 (clamped to month end)
$addMonths('2024-01-15', -1); // -> 2023-12-15 (negative = subtract)
$addMonths('2024-01-15', 3); // -> 2024-04-15 (quarters = n * 3)
Formatting stays with $fromMillis
$zoneOffset exists only to fill $fromMillis's third argument. That argument needs
a fixed ±HHMM offset — it rejects IANA names ('Europe/Berlin' yields NaN), and a
hardcoded offset is an hour wrong for half the year. Everything about formatting is
already JSONata's:
$fromMillis($millis(), '[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01][Z]', $zoneOffset($millis()))
// -> 2026-07-20T07:49:13+02:00
$fromMillis($millis(), '[M01]/[D01]/[Y0001]', $zoneOffset($millis())); // today, configured zone
$fromMillis($millis(), '[MNn] [D1], [Y0001] at [h]:[m01] [PN]', $zoneOffset($millis()));
Pass the same instant to both
$fromMillisand$zoneOffset. The offset is a property of a zone at an instant — across a DST boundary the same zone has two different offsets — so$zoneOffsetneeds the exact instant it will apply the offset at. Give it the same value you give$fromMillis:$fromMillis($ms, pic, $zoneOffset($ms)). The instant is required, so there is no shorter form that silently reads "now" and renders a stored January date with July's offset.
When formatting an instant, never omit the offset.
$fromMillis($millis(), pic)renders the UTC date, which is tomorrow for any US signer in the evening. (Date-only values are the exception — see the next section — because they carry no time of day.)
Everything else uses the stock built-ins
Only the two above are added, because they are the only two things JSONata cannot compute. The rest are ordinary compositions:
// Format a date-only value — NO offset. A date-only value is already the calendar day
// you want; applying an offset shifts it (2024-03-01 in a -0800 zone becomes 02/29).
$fromMillis($toMillis($.startDate), '[M01]/[D01]/[Y0001]');
// Add days — a day is exactly 86400000 ms in UTC space (still date-only, so no offset)
$fromMillis($toMillis($.startDate) + 30 * 86400000, '[Y0001]-[M01]-[D01]');
// Whole days between two dates
($toMillis($.endDate) - $toMillis($.startDate)) / 86400000;
// Format the result of $addMonths (returns a date-only string, so no offset)
$fromMillis($toMillis($addMonths($.startDate, 12)), '[MNn] [D1], [Y0001]');
$zoneOffsetis for instants, not date-only values. Use it when you format a timestamp ($millis(),$now(), a storedsignedAt) — a moment in time that lands on different calendar days in different zones. A date-only value ('2024-03-01') has no time-of-day, so$toMillisreads it as UTC midnight; format it straight back with no third argument and the calendar day round-trips.
Month math on an instant — convert to the zone first
$addMonths operates on a calendar date. If your anchor is an instant — $now(),
$millis(), a stored signedAt — reduce it to a YYYY-MM-DD in the target zone
first, then add months. Handing the raw instant straight to $addMonths reduces it in
UTC, which is the previous/next calendar day for a signer near midnight in another
zone.
// "12 months from signing, in the signer's zone"
(
$anchor := $fromMillis($signedAtMs, '[Y0001]-[M01]-[D01]', $zoneOffset($signedAtMs)); // instant -> local day
$end := $addMonths($anchor, 12); // calendar math, no offset
$fromMillis($toMillis($end), '[MNn] [D1], [Y0001]') // format, no offset
)
// signedAt 2024-03-01T02:00Z, signer in New York (still Feb 29 locally) -> "February 28, 2025"
// (feeding the raw instant instead: $addMonths($signedAtMs, 12) reduces in UTC -> "March 1, 2025", a day off)
The rule that ties it all together: apply the offset exactly once — at the moment you turn an instant into a calendar date. After that you are in "calendar-land," where
$addMonthsand every formatting step take NO offset. This is the single decision behind every date-only-vs-instant note above: an offset belongs only on the instant→calendar-date crossing, never after it.Leaving the zone argument off
$zoneOffset($signedAtMs)resolves it to the workflow's zone at run time, so the same published blueprint localizes correctly for every executor, wherever they are.
Which timezone is used?
Three rungs, first match wins:
| Source | Set where | |
|---|---|---|
| 1 | An explicit zone argument in the expression | $zoneOffset($ms, 'Asia/Tokyo') |
| 2 | The workflow timezone | options.timezone on POST /workflows, --timezone on signstack run, or the "custom timezone" box in Studio's Start Workflow dialog |
| 3 | The namespace timezone | Studio → namespace settings, or PATCH /orgs/{orgId}/namespaces/{namespaceKey} |
Rung 2 only exists when a workflow does. Previews — Studio's live expression preview,
signstack preview, a blueprint or template render — have no workflow, so they land on
rung 3 and show the namespace's zone.
The workflow zone is frozen at creation. A workflow copies rung 2 (or rung 3 if you didn't set one) onto itself when it is created, and every document it renders uses that copy. Editing the namespace timezone afterwards does not move dates on workflows already in flight, so a signer never sees a date change between opening a document and signing it.
Rendering never fails because of a timezone. Invalid zones are rejected when you save the template or blueprint, and when you create the workflow — so a typo surfaces while a human is looking at the error, not on a signed PDF.
Use a full Region/City IANA name (America/New_York, Europe/Berlin), or UTC.
Abbreviations like EST and fixed offsets like Etc/GMT+8 are rejected on save:
they don't observe daylight saving, so they'd be an hour out for part of the year
(EST resolves to America/Panama, which is wrong from March to November).
Why not $now() + $fromMillis()?
$fromMillis()formats in UTC. Because$now()is a UTC instant,$fromMillis($toMillis($now()), '[M01]/[D01]/[Y0001]')renders the UTC date. For a signer in Los Angeles at 17:00 on Jan 15 that prints01/16/2024— tomorrow. Use$fromMillis($millis(), pic, $zoneOffset($millis()))instead.
$fromMillis()'s timezone argument only accepts a fixed±HHMMoffset. An IANA name such as'America/Los_Angeles'producesNaN/NaN/NaNsilently, and a hardcoded offset is wrong for half the year (-0800renders a July Pacific instant an hour early). Use$zoneOffset($ms)to produce the right one — that is exactly what it is for.Note the offset format is
±HHMMwith no colon:'+0530'works,'+05:30'is silently misread as a different offset (5.5 hours out, no error).$zoneOffsetalways emits the correct form.
The stock $now(), $millis(), $toMillis() and $fromMillis() are all available for
cases where you genuinely want UTC.
Array Functions
// Filter
$.items[status = "active"] // Items where status is active
$.items[price > 100] // Items with price > 100
// Map/Transform
$.items.name // Array of all names
$.items.(name & " - " & $string(price)) // Transform each item
// Sort
$sort($.items, function($a, $b) { $a.price > $b.price })
// Reverse
$reverse($.items)
// Append (concatenate two arrays)
$append($.items, [newItem])
// Shuffle (random order)
$shuffle($.items)
// Zip (pairwise combine)
$zip(["a", "b", "c"], [1, 2, 3]) // [["a",1], ["b",2], ["c",3]]
// Reduce
$reduce($.items.price, function($acc, $val) { $acc + $val }, 0)
Object Functions
// Merge objects (crucial for onComplete transforms)
$merge([$.client_info, { "status": "verified" }])
// Get keys
$keys($.client_info) // ["name", "email", "tier"]
// Lookup
$lookup($.client_info, "name") // "Jane"
// Spread (decompose object into key/value pairs)
$spread($.client_info) // [{"name": "Jane"}, {"email": "..."}, ...]
// Iterate over key/value pairs
$each($.client_info, function($v, $k) { $k & "=" & $v })
// Pick/rename specific keys with object construction
$.client_info.{ "name": name, "contact": email }
Type Functions
// Check existence
$exists($.client_info.middleName); // false if undefined
// Type checking
$type($.deal_info.value); // "number"
$type($.client_info.name); // "string"
$type($.items); // "array"
// Conversion
$string($.deal_info.value); // "50000"
$number('123.45'); // 123.45
$boolean($.client_info.isActive); // true/false
Conditional Functions
// Assert (throws if condition false)
$assert($.deal_info.value > 0, 'Value must be positive');
// Error (throws custom error)
$error('Invalid configuration');
Common Patterns
Safe Property Access
Handle potentially missing data:
// Using $exists
$exists($.client_info.middleName) ? $.client_info.middleName : '';
// Using default with ternary
$.client_info.tier ? $.client_info.tier : 'Standard';
Conditional Display
For includeIf (must return boolean):
// Show only for premium clients with large deals
$.client_info.tier = "Premium" and $.deal_info.value > 50000
// Show only if field exists
$exists($.deal_info.specialTerms)
// Show based on array content
$count($.items) > 0
Entity Updates (onComplete Transforms)
A transform's return value replaces the target entity wholesale — it is not auto-merged. To preserve existing fields, fold them in yourself with $merge. Read the previous state via $.inputs.<entityKey> and read what was just signed via $.docOutputs.<doc>.<role>:
// Add/update properties on the client_info entity
$merge([
$.inputs.client_info,
{
"name": $.docOutputs.intake_form.client.full_name,
"email": $.docOutputs.intake_form.client.email,
"updatedAt": $now()
}
])
// Conditional update from a signing decision
$merge([
$.inputs.deal_info,
{
"status": $.docOutputs.approval.reviewer.approved
? "approved"
: "rejected"
}
])
Without the $merge, the entity would be reduced to just the fields you returned — every other field would be dropped.
Role
output.transformis where you project raw signed-field values into a clean payload (accessed as$.fields.<fieldKey>). Blueprints then consume that payload via$.docOutputs.<doc>.<role>— they never touch raw fields directly. See Template Role Outputs.
Formatting Values
// Currency
$formatNumber($.deal_info.value, '$#,##0.00');
// Percentage
$formatNumber($.rate / 100, '0.00%');
// Phone number
$substring($.phone, 0, 3) & '-' & $substring($.phone, 3, 3) & '-' & $substring($.phone, 6);
// Full name
$trim($.firstName & ' ' & ($exists($.middleName) ? $.middleName & ' ' : '') & $.lastName);
Array Transformations
// Sum line items
$sum($.lineItems.(quantity * unitPrice))
// Filter and count
$count($.items[status = "pending"])
// Get names as comma-separated list
$join($.participants.name, ", ")
// Check if any item matches condition
$count($.items[priority = "high"]) > 0
Debugging Tips
-
Start simple: Build expressions incrementally, testing each part.
-
Use Scenarios: Test expressions with different data sets using SignStack Scenarios.
-
Check for typos: Property names are case-sensitive (
$.client_infovs$.ClientInfo). -
Handle nulls: Use
$exists()to check before accessing nested properties. -
AI assistance: Describe your logic in plain language and ask an LLM to generate the JSONata.
Further Resources
- Official JSONata Documentation
- JSONata Exerciser - Test expressions online
- Templates — Dynamic Fields with Expressions - Template expression patterns
- Step
onComplete— Expression Context - onComplete transforms reference
