Merge Tag Authoring

Merge tags for uploaded Word templates: field paths, conditions, loops, every supported modifier, lint codes, and what happens to a broken tag

What This Feature Does

Uploaded merge templates are Word (.docx) files that contain merge tags such as {$site_location}. On preview and export, Pay fills those tags from the submission and returns a PDF. This page is the authoring reference: the tag syntax, every supported modifier, the lint codes you will see, and what happens when a tag is broken.

This is not the in-app editor language. The built-in Document Templates editor uses ${field_name} placeholders. Uploaded Word templates use {$field_name}. Mixing the two in one file will not work.

How to choose a print mode, upload a file, and preview is in Uploaded Merge Templates.

The Passthrough Contract

A merge never fails because of tag content.

  • A tag that cannot be parsed, that uses an unknown modifier, that has a bad argument, that is missing $, that uses curly quotes, or that is an unbalanced {if} / {foreach} is written verbatim into the output — exactly as it appears in the Word file.
  • A path that does not match any field or system value becomes an empty string.
  • Only a corrupt container (the .docx is not a readable Word file) or an infrastructure failure (converter timeout, storage, network) fails the job.

The lint report tells you which tags will pass through. It never blocks upload, preview, or export.

Tag Basics

A tag starts with { immediately followed by a non-space character, and ends at the matching }. { site} (space after the brace) is ordinary text.

Inside a tag, " and ' delimit strings, so a } inside quotes does not close the tag. Curly / smart quotes are not delimiters. Word often converts "0" to ”0”; that tag will pass through and lint as SMART_QUOTES.

An unterminated tag ({$name with no closing }) is left as ordinary text.

Variables

{$field_name}
{$id1.0.data.idworker}
{$rows[0].name}
  • The $ after { is required. {field_name} is not a variable — it lints as MISSING_SIGIL and prints as {field_name}.
  • Paths are dotted. Numeric segments (id1.0.data) and bracket indexes (rows[0]) both work.
  • Path segments may contain hyphens. Auto-generated field names like textarea-6c5 are valid: {$textarea-6c5}.
  • The first path segment is the form field Name (the internal identifier in the field configuration panel), not the label and not the field id.
  • Table / repeat fields are arrays of row objects. Column values sit on the row under a lower snake case key derived from the column header (Used for whole shiftused_for_whole_shift). If two headers slug to the same key, Pay adds _2, _3, … in column order. Internal row ids are not exposed.

Modifiers and chaining

A modifier is a pipe after the path. Arguments are separated by : and can be quoted text, numbers, true / false, or another $variable. They chain left to right.

{$id|pad:5:"0"}
{$name|trim|upper|truncate:20}
{$amount|currency}

Names are case-sensitive. {$name|Upper} is an unknown modifier and the whole tag prints verbatim.

Built-in extras

Export attaches reserved payload keys. Tags are still written with the $ sigil:

KeyTagValue
id{$id}Submission id
date{$date}Formatted submitted-at string (display)
submission_date{$submission_date}Same formatted date (canonical key)
submitted_at{$submitted_at}Raw ISO 8601 timestamp — use this with date_format
title{$title}Form title

{$date} and {$submission_date} are pre-formatted display strings. Passing them to date_format returns empty. Use {$submitted_at|date_format:"%d/%m/%Y"} when you need to reformat the submission timestamp.

Do not give a form field one of those names.

Linked project, client and work pack

Six more reserved keys describe what the submission is attached to:

KeyTagValue
project_name{$project_name}Linked project name
project_number{$project_number}Project code, falling back to its external id
client_name{$client_name}Linked client name
client_number{$client_number}Client external id
workpack_name{$workpack_name}Linked work pack name
workpack_number{$workpack_number}Work pack document number

Pay fills these from whichever link it can find:

  • A submission raised from a work pack activity takes them from that work pack, its project and its client.
  • Otherwise a top-level Project or Client Resource field on the form supplies them. Add one Project dropdown and {$project_name}, {$project_number}, {$client_name} and {$client_number} all fill in — the client is read from the project, so you do not need a second dropdown for it. Add a Client field as well when the form's client is not the project's.
  • A Resource field inside a table is per row, not per submission, so it does not feed these. Reach those values through {foreach} instead.
  • A multi-select Project or Client field uses its first selection.

Anything still unlinked prints -.

Record details — {$linked.…}

A Resource field's own tag prints the label the user picked: {$siteProject} gives "Harbour Bridge". To reach the rest of that record, use the linked namespace — the field name, then the property:

{$linked.siteProject.name}
{$linked.siteProject.number}
{$linked.siteProject.external_id}
{$linked.siteProject.client.name}
{$linked.headContractor.number}

Every Resource field the form answers gets an entry, keyed by the field's Name from the form builder. The full list for your form is on the Print Formatting page under Available variables → Linked records, click-to-copy like every other tag.

  • display is the label the picker showed; name, id and external_id come from the record.
  • A single-select Project field also carries number (its project code) and a nested client with name and number — that is the project's own client, which is not necessarily the form's when you have both fields. A single-select Client field carries number.
  • A multi-select Resource field gives a list, so loop it: {foreach $linked.sites as $item}{$item.name}{/foreach}. A list carries only what the picker stored (display, name, external_id, id) — number and client are single-select only.
  • A form field actually named linked keeps the name — its own tag wins and the namespace is not added.

Conditions — {if} / {elseif} / {else}

{if $hours > 0}
  Hours: {$hours}
{elseif $hours == 0}
  No hours
{else}
  Not recorded
{/if}

A value counts as empty when it is missing, false, 0, '0', blank text, or an empty list. Everything else counts as set, including the words 'false' and 'no'.

Checkbox, switch, and tick-box table cells arrive as the word Yes or an empty string (never No, because the word No would count as set). Test them with {if $field}, not {if $field == "No"}.

Numeric text compares as a number, so {if $hours > 0} is true when the answer is "30".

Supported operators: >, <, >=, <=, ==, !=, ===, !==, plus && / || in the condition text.

A losing {if} branch is removed from the document, including whole table rows that live only in that branch. Unbalanced {if} / {elseif} / {else} / {/if} tags print verbatim (UNBALANCED_BLOCK).

Loops — {foreach} / {foreachelse}

Two equivalent forms are accepted:

{foreach $crew as $row}
  {$row.worker}
{/foreach}

{foreach from=$crew item=row}
  {$row.worker}
{/foreach}

Row keys come from the column header in lower snake case (a leading digit gets an underscore: 1st Shift{$row._1st_shift}). A header Worker{$row.worker}; Used for whole shift{$row.used_for_whole_shift}. Copy tags from the uploaded-template panel when unsure.

Nested sub-table columns use the same rule inside the parent row:

{foreach $worker_time as $row}
  {$row.worker} — {$row.role}
  {foreach $row.equipment as $eq}
    {$eq.asset} ({$eq.hours})
  {/foreach}
{/foreach}

With a key:

{foreach $crew as $key => $row}...{/foreach}
{foreach from=$crew item=row key=key}...{/foreach}

{foreachelse} runs when the source is empty:

{foreach $crew as $row}
  {$row.worker_name}
{foreachelse}
  No crew listed
{/foreach}

Loop properties: {$row@index}, {$row@iteration}, {$row@first}, {$row@last}.

Modifiers can be applied to the source before looping, which is how sort and multisort are used:

{foreach $crew|sort:"worker_name" as $row}
  {$row.worker_name}
{/foreach}

Table-row loops

If the {foreach} / {/foreach} pair sits in a Word table row, Pay clones that whole row once per item. Put the open tag in the first cell and the close tag in the last cell of the template row. Nested loops are expanded innermost-first.

Inline and whole-paragraph loops (not in a table row) repeat that inline span or paragraph instead.

Table helpers — {tablerow} / {tableif}

Shorthand for table-row loops and row-level conditions. Pay treats them exactly like {foreach} and {if}:

{tablerow from=$products item=_p}
  {$_p.Name} {$_p.Quantity} {$_p.Price}
{/tablerow}

{tablerow $products as $_p}
  {tableif $_p.Type == 'shirt'}
    {$_p.Name}
  {/tableif}
{/tablerow}

{tablerow} clones the Word table row once per item (same rules as a table-row {foreach}). {tableif} removes the whole row when the condition is false.

Use a truthy field to hide empty rows ({tableif $Product1}). empty() is not a condition function — write {tableif $Product1} instead of {tableif !empty($Product1)}.

{literal}

Anything between {literal} and {/literal} is copied as text, including tags inside it.

{literal}{$this_is_not_a_tag}{/literal}

Use this when the PDF must show a tag example, or when a { would otherwise start a tag.

Modifier Reference

These are all the modifiers Pay supports. Anything else used as a modifier is reported as UNKNOWN_MODIFIER and the tag prints as written. {assign}, {math}, {include}, barcode, and QR tags are not supported and print as written.

Text modifiers

capitalize

Arguments: uc_digits=false, lc_rest=false.

Title-cases words that start with a letter. Optionally also capitalize words that contain digits; optionally lowercase the rest first.

{$site_name|capitalize}
{$site_name|capitalize:true:true}

cat

Arguments: one or more values to append.

{$first_name|cat:" "|cat:$last_name}
{$docket_no|cat:"-A"}

count_characters

Arguments: include_spaces=false.

{$notes|count_characters}
{$notes|count_characters:true}

count_paragraphs

Arguments: none. Counts newline-separated blocks.

{$notes|count_paragraphs}

count_sentences

Arguments: none. Counts word + ., ?, or !.

{$notes|count_sentences}

count_words

Arguments: none. Counts Unicode letter-started words.

{$notes|count_words}

date_format

Arguments: format="%b %e, %Y", optional default_date.

Formats a date. Use % codes (%d, %m, %Y, %b, %e, %H, %M, …) or single-letter codes (d/m/Y, j M Y). Empty, 0000-00-00, and 0000-00-00 00:00:00 become ''. The word "now" gives the time the export ran. Times are shown in your account's timezone.

Use {$submitted_at|date_format:…} for the submission timestamp. Pre-formatted keys such as {$submission_date} and {$date} are display strings and return empty from this modifier.

{$submitted_at|date_format:"%d/%m/%Y"}
{$inspection_date|date_format:"%b %e, %Y":"now"}

default

Arguments: one or more fallbacks. If the value is empty (null / missing / ''), returns the first non-empty string or number argument.

{$nickname|default:"Not provided"}

escape

Arguments: type="html", (second argument unused), double_encode=true.

Types: html, htmlall, url, urlpathinfo, quotes, hex, hexentity, decentity, javascript, mail, nonstd. Unknown type returns the string unchanged.

{$notes|escape}
{$url|escape:"url"}

indent

Arguments: chars=4, fill=" ". Prefixes every line.

{$notes|indent:4:" "}

lower

Arguments: none.

{$name|lower}

nl2br

Arguments: xhtml=true. Replaces newlines with <br /> (or <br> when xhtml is false).

{$notes|nl2br}

regex_replace

Arguments: search (a /pattern/flags regular expression), replace="".

An invalid pattern leaves the original string.

{$id|regex_replace:"/[^0-9]/":""}

replace

Arguments: search, replace="". Literal replace. Missing search leaves the original string.

{$name|replace:"Inc":"Pty Ltd"}

spacify

Arguments: spacer=" ". Joins every character with the spacer.

{$code|spacify:"-"}

string_format

Arguments: format (%d, %s, %f, …). Missing or invalid format prints the value unchanged.

{$qty|string_format:"%04d"}

strip

Arguments: replacement=" ". Collapses runs of whitespace.

{$notes|strip}

strip_tags

Arguments: replace_with_space=true. Strips HTML tags.

{$html|strip_tags}

substr

Arguments: start, optional length. Negative values count from the end.

{$id|substr:0:4}
{$id|substr:-4}

truncate

Arguments: length=80, etc="...", break_words=false, middle=false.

Truncates to length counting etc. Word-aware unless break_words is true. middle splits the kept text around etc. length=0 returns ''.

{$notes|truncate:80:"..."}
{$notes|truncate:40:"…":true}

unescape

Arguments: type="html". html / htmlall / entity decode common entities; url is decodeURIComponent. Unknown type returns the original string.

{$html|unescape}

upper

Arguments: none.

{$name|upper}

wordwrap

Arguments: width=80, break="\n", cut=false. cut splits overlong words; otherwise they stay intact until the next space.

{$notes|wordwrap:40}

Formatting and data modifiers

pad

Arguments: width, pad=" ". Left-pads to width. No-op when width ≤ length or pad is empty.

{$id|pad:5:"0"}

insert_image

Arguments: widthPx, heightPx.

The source must be an https://… URL or a data:image/… URI. Signature and drawing answers arrive as data:image/… URLs in the merge payload (photo and file-upload answers are file names, not images). Always use this modifier to render them:

{$sign|insert_image:200:80}
{$draw|insert_image:300:150}

A bare {$sign} renders nothing and is reported as an error on the template check: a signature answer is a long data:image/… string, and printing it would fill the page with base64. Add the modifier and the image appears. If an image cannot be fetched, the tag prints as written and a warning appears in the lint report.

Uploaded photos and files

A photo or file-upload answer behaves differently from a signature. The field's own tag prints the file name{$sitePhoto} gives IMG_2841.jpg — which is usually what an evidence table wants. The picture lives beside it under files:

What you wantTag
The file name{$sitePhoto}
The photo{$files.sitePhoto|insert_image:200:80}
A photo column in a table{$row.files.photo|insert_image:200:80}
Every photo, when one answer holds several{foreach $files.sitePhoto_all as $photo}{$photo|insert_image:200:80}{/foreach}

In a table, put the photo tag inside the loop exactly like any other row tag:

{foreach $inspections as $row}
  {$row.location} — {$row.photo}
  {$row.files.photo|insert_image:200:80}
{/foreach}

The rules:

  • Only images get a files entry. A PDF, spreadsheet or other attachment keeps printing its file name and has no image tag — one that could never render would be worse than none.
  • {$files.<field>} is the first image. One photo per answer is the common case, so it needs no loop.
  • {$files.<field>_all} appears only when an answer holds more than one image, and is a list to loop. Inside a table row it is {$row.files.<column>_all}.
  • Photos are shrunk for print. The original stays untouched in the form; the document gets a print-sized copy, so a page of phone photos does not become an unopenable file.
  • A form field actually named files keeps the name — its own tag wins and the namespace is not added.

Every one of these is listed in Available variables on the Print Formatting page, click-to-copy.

{$logo_url|insert_image:250:50}

phone_format

Arguments: pattern="(%3) %3-%4". Each %N consumes the next N digits. Too few digits → original input.

{$phone|phone_format}
{$phone|phone_format:"%2 %4 %4"}

number_format

Arguments: decimals=0, dec_point=".", thousands=",". Rounds half away from zero. Non-numeric → 0.

{$hours|number_format:2:".":","}

currency

Arguments: same as number_format. With no arguments, defaults to 2 decimals. Prefixes $. Negatives render as -$#,##0.00.

{$amount|currency}
{$amount|currency:2:".":","}

age

Arguments: none. Whole years from the date to the time the export ran, in your account's timezone. Empty or unreadable dates → ''; future dates → 0.

{$date_of_birth|age}

sort

Arguments: optional field, dir="asc". Stable sort of an array. Optional field name when items are objects; desc reverses. Non-arrays are returned unchanged. Input is not mutated.

{foreach $crew|sort:"worker_name" as $row}{$row.worker_name}{/foreach}
{foreach $crew|sort:"worker_name":"desc" as $row}{$row.worker_name}{/foreach}

multisort

Arguments: pairs of 'Field':'asc'|'desc'. Stable multi-key sort of an array of objects. Non-arrays are returned unchanged. Input is not mutated.

{foreach $crew|multisort:"role":"asc":"worker_name":"asc" as $row}{$row.worker_name}{/foreach}

checkbox

Arguments: none. A set value → , otherwise .

{$ppe_ok|checkbox}

checkmark

Arguments: none. A set value → , otherwise ''.

{$ppe_ok|checkmark}

ucfirst

Arguments: none. Uppercases the first character only.

{$name|ucfirst}

ucwords

Arguments: none. Uppercases the first character of each word.

{$name|ucwords}

trim

Arguments: optional charlist. Removes leading and trailing whitespace, or the listed characters (.. ranges are supported).

{$name|trim}
{$code|trim:"0..9"}

Lint Codes

Shown after upload and under preview. Errors describe tags that will print verbatim. Warnings never change the PDF.

CodeSeverityWhat it means for an author
MISSING_SIGILerrorWrite {$name}, not {name}. The tag will print as written.
SMART_QUOTESerrorRetype arguments with straight " or '. Curly quotes are not string delimiters. The tag will print as written.
UNKNOWN_MODIFIERerrorThat name is not in the list above. The whole tag will print as written.
UNKNOWN_FOREACH_MODIFIERerrorThe modifier on a {foreach} source (for example {foreach $rows|sortt:'name' as $row}) is not in the list above. The loop prints nothing.
UNBALANCED_BLOCKerrorAn {if} / {foreach} is missing its close, or a close/else has no open. Those tags will print as written.
BAD_ARGerrorA modifier argument could not be read. The tag will print as written.
UNPARSEABLEerrorThe tag could not be parsed. It will print as written.
EMPTY_TAGwarningAn empty {}. Remove it.
SPLIT_TAG_RESIDUEwarningWord split the tag across differently formatted runs. Re-type the whole tag in one style.
UNRESOLVED_PATHwarningThe path is missing from the sample or submission payload. The merge still succeeds.
IMAGE_WITHOUT_INSERT_IMAGEerrorThe tag points at an image answer but has no insert_image modifier, so it renders nothing. Add |insert_image:W:H.

Limitations

  • One print mode per form. A form prints from either its uploaded Word template or the in-app editor. Switching keeps both, so switching back needs no re-upload. See Uploaded Merge Templates.
  • Uploads are Word .docx files and the output is PDF. PDF, Excel, PowerPoint and HTML files are not accepted as form print templates; fillable PDF forms need rebuilding as Word merge templates.
  • {assign}, {math}, and {include} are not supported. They print as written.
  • Barcode / QR modifiers are not supported.

Authoring Tips

  • Turn off Word's smart quotes for the template, or retype every quoted modifier argument.
  • Keep each tag in a single run (one font, one size, one colour). Mixed formatting inside a tag is how SPLIT_TAG_RESIDUE happens.
  • Use the field Name from the form builder, not the label. Hyphens in auto-generated names are valid.
  • For signatures and drawings, use {$field|insert_image:W:H} — never a bare variable tag.
  • For table columns, use lower snake case row keys ({$row.used_for_whole_shift}), not the header text as typed in Word.
  • For booleans, use {if $field} — answers are Yes or empty, not Yes/No.
  • For dates you need to reformat, tag {$submitted_at} (ISO), not {$submission_date} or {$date}.
  • Preview and export share one payload shape. If preview looks right but export does not, the submission data differs — not the merge envelope.
  • Preview after every upload. Sample data will not match a real submission, but it will surface lint and layout issues.
  • Put {foreach} around a whole table row when you want one output row per item.

What's Next