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
.docxis 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 asMISSING_SIGILand 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-6c5are 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 shift→used_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:
| Key | Tag | Value |
|---|---|---|
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:
| Key | Tag | Value |
|---|---|---|
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.
displayis the label the picker showed;name,idandexternal_idcome from the record.- A single-select Project field also carries
number(its project code) and a nestedclientwithnameandnumber— that is the project's own client, which is not necessarily the form's when you have both fields. A single-select Client field carriesnumber. - 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) —numberandclientare single-select only. - A form field actually named
linkedkeeps 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 want | Tag |
|---|---|
| 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
filesentry. 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
fileskeeps 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.
| Code | Severity | What it means for an author |
|---|---|---|
MISSING_SIGIL | error | Write {$name}, not {name}. The tag will print as written. |
SMART_QUOTES | error | Retype arguments with straight " or '. Curly quotes are not string delimiters. The tag will print as written. |
UNKNOWN_MODIFIER | error | That name is not in the list above. The whole tag will print as written. |
UNKNOWN_FOREACH_MODIFIER | error | The modifier on a {foreach} source (for example {foreach $rows|sortt:'name' as $row}) is not in the list above. The loop prints nothing. |
UNBALANCED_BLOCK | error | An {if} / {foreach} is missing its close, or a close/else has no open. Those tags will print as written. |
BAD_ARG | error | A modifier argument could not be read. The tag will print as written. |
UNPARSEABLE | error | The tag could not be parsed. It will print as written. |
EMPTY_TAG | warning | An empty {}. Remove it. |
SPLIT_TAG_RESIDUE | warning | Word split the tag across differently formatted runs. Re-type the whole tag in one style. |
UNRESOLVED_PATH | warning | The path is missing from the sample or submission payload. The merge still succeeds. |
IMAGE_WITHOUT_INSERT_IMAGE | error | The 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
.docxfiles 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_RESIDUEhappens. - 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 areYesor empty, notYes/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
- Upload, preview, and export: Uploaded Merge Templates
- In-app editor language (different syntax): Document Templates
- Exporting submissions: Submissions

