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 onCompletevalidate 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.

See Template Role Outputs.


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 $fromMillis and $zoneOffset. The offset is a property of a zone at an instant — across a DST boundary the same zone has two different offsets — so $zoneOffset needs 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]');

$zoneOffset is for instants, not date-only values. Use it when you format a timestamp ($millis(), $now(), a stored signedAt) — 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 $toMillis reads 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 $addMonths and 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 prints 01/16/2024 — tomorrow. Use $fromMillis($millis(), pic, $zoneOffset($millis())) instead.

$fromMillis()'s timezone argument only accepts a fixed ±HHMM offset. An IANA name such as 'America/Los_Angeles' produces NaN/NaN/NaN silently, and a hardcoded offset is wrong for half the year (-0800 renders 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 ±HHMM with no colon: '+0530' works, '+05:30' is silently misread as a different offset (5.5 hours out, no error). $zoneOffset always 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.transform is 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

  1. Start simple: Build expressions incrementally, testing each part.

  2. Use Scenarios: Test expressions with different data sets using SignStack Scenarios.

  3. Check for typos: Property names are case-sensitive ($.client_info vs $.ClientInfo).

  4. Handle nulls: Use $exists() to check before accessing nested properties.

  5. AI assistance: Describe your logic in plain language and ask an LLM to generate the JSONata.


Further Resources