Code Rules
When a preset can't express the logic you need, write a rule in JavaScript. Code rules run inside a secure sandbox: you have field data and a set of helper functions, but no filesystem, no network (unless explicitly allowed), and no Node.js APIs. The code may not reach a prototype or a constructor either (.prototype, .constructor, __proto__), and a rule that does is refused when you save it: write Object.hasOwn(obj, key) instead of Object.prototype.hasOwnProperty.call(obj, key), and Array.from(x) instead of Array.prototype methods called on x.
Every preset generates code against this same public API, so you can start from a preset, use Edit as code next to the generated code, and edit from there. The rule becomes a code rule, and its visual settings are gone for good.
A code rule's level is fixed once the rule is saved, and it decides which of the three surfaces below your code is held to. It also decides whether the rule can carry a Trigger: only document- and transaction-level rules are ever asked whether they should run, so a condition on a field-level code rule would never be read, and the dialog does not offer one. A field check that has to depend on the rest of the document is a document-level rule — or a field preset with a Trigger, which moves itself there. See Business Rules.
Scripting API Reference
All function and variable names in the scripting API use snake_case.
Common Functions (All Levels)
These functions are available in field-level, document-level, and transaction-level rules.
set_error(message)
Set the rule result to error. The message is displayed in the Transaction Viewer.
set_error('Invoice number is missing');
For field-level rules, the bound field name is automatically shown as a clickable tag before the message in the Transaction Viewer, so you don't need to include the field name in the message text. Write just the error description:
// Good - field name tag is added automatically by the UI
set_error('Value is required');
// Avoid - "Invoice Date: " prefix would be redundant
set_error('Invoice Date: Value is required');
set_error() and set_warning() take the message and nothing else. JavaScript quietly
drops extra arguments, so set_error('Job Code', 'Only one of Job Code or Location') would
show reviewers the text "Job Code" and never the explanation. To point at specific fields,
call highlight_fields() separately. Rules that pass too many
arguments to a helper are rejected when you save them.
set_warning(message)
Set the rule result to warning.
set_warning('Amount seems unusually high');
set_success(message)
Set the rule result to success (optional, since rules that finish without calling set_error or set_warning are considered successful).
set_success('All checks passed');
normalize(text, options)
Apply text transformations. Returns the transformed string.
| Option | Type | Effect |
|---|---|---|
trim | boolean | Remove leading/trailing whitespace |
lowercase | boolean | Convert to lowercase |
uppercase | boolean | Convert to uppercase |
removeSpaces | boolean | Collapse all whitespace runs to a single space |
removeSpecialChars | boolean | Strip everything except letters (in any script), digits, and spaces |
foldDiacritics | boolean | Lower-case and remove accents, the same as fold_text below |
const clean = normalize(value, { trim: true, uppercase: true });
fold_text(text)
Lower-case the text and remove accents and other diacritics, for comparing two values that OCR may have read differently. Şişli, SISLI and Sisli all become sisli. Letters with no plain form are mapped (ß to ss, ø to o, ı to i). Whitespace and punctuation are left alone. Every built-in comparison (Compare Fields, catalog lookup, allowed values) uses this same fold.
if (fold_text(get_field('Supplier')) === fold_text(get_field('Supplier On Stamp'))) {
set_success('Supplier matches');
}
strip_diacritics(text)
Remove accents but keep the case, for a value that is written back: Gökçe Ünlü becomes Gokce Unlu. Compatibility characters such as ² are left as they are. This is what the Diacritic Normalization preset calls.
set_value(strip_diacritics(value));
parse_number(value)
Read the number in a value. Extracted amounts are usually text, such as $39.43 or 1.234,50 EUR, and JavaScript's own Number() gives NaN for all of them. parse_number finds the number and works out the separators: when a value has both a comma and a period, the last one is the decimal separator, and a single comma followed by exactly three digits separates thousands. It returns null when the value holds no number, so check for null rather than letting an empty value count as zero. Given a table cell as it sits in a row ({ name, value }), it reads the cell's value.
parse_number('$1,234.50'); // 1234.5
parse_number('1.234,50 EUR'); // 1234.5
parse_number('n/a'); // null
const subtotal = parse_number(get_field('Sub Total'));
if (subtotal === null) set_error('Sub Total is missing');
resolve_country(input, format)
Resolve a country written in any of 25+ languages (or as a code) to a standard code. format
is 'alpha2' (default) or 'alpha3'. Returns null when nothing matches.
resolve_country('Deutschland'); // 'DE'
resolve_country('Netherlands', 'alpha3'); // 'NLD'
fetch(url, options) (requires HTTP allowlist)
Make an HTTP request. Only works when the project's HTTP allowlist is enabled and the target domain is listed. Supports standard fetch options (method, headers, body). Returns { ok, status, data }.
const res = await fetch('https://api.example.com/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: value }),
});
if (res.ok) { /* ... */ }
A call that cannot be made throws: the domain is not allowed, the address is refused, the request times out, or the rule has used up its requests. Unless the rule catches the error, the rule fails with a runtime error, and a rule that cannot run fails the Validate step. A request that timed out or could not connect is tried again first; a destination that is not allowed fails the step at once. Catch the error and report it with set_warning() or set_error() to send the transaction to review instead:
try {
const res = await fetch('https://api.example.com/rate');
set_value(String(res.data.rate));
} catch (e) {
set_warning('Rate service unavailable: ' + e.message);
}
Transaction metadata
Rules can read and write arbitrary key/value metadata attached to the parent transaction. This is how you carry reference data into a rule (PO catalogues, expected suppliers, policy flags), and how a rule can persist enrichment back to the transaction so later steps (or other rules) see it.
Two ways to read metadata are available: the bound object and the helper function:
// Direct read via the bound object
const expectedVendor = transaction_metadata['vendor'];
// Or via the helper (no arg = whole object, with arg = single key)
const allTxnMd = get_transaction_metadata();
const country = get_transaction_metadata('origin_country');
To write metadata you must go through the setter: direct assignment to the bound object is scratch-space only and is discarded when the rule finishes:
// Persists into transaction.metadata after the validation run completes
set_transaction_metadata('verified_vendor', 'ACME-001');
Documents carry their own metadata as well, read and written the same way. At field and document level the helpers act on the current document; at transaction level they take the document id (or its 0-based index) first:
const taxId = get_document_metadata('tax_id'); // field / document level
set_document_metadata('checked_by', 'rule'); // field / document level
const id = get_document_metadata(0, 'source_ref'); // transaction level
set_document_metadata(0, 'checked_by', 'rule'); // transaction level
| Function | Available at | Description |
|---|---|---|
get_transaction_metadata(key?) | field, document, transaction | Read transaction.metadata. No arg returns the whole object. |
set_transaction_metadata(key, value) | field, document, transaction | Write a key into transaction.metadata. Persisted after the run. |
get_document_metadata(key?) | field, document | Read the current document's metadata. |
set_document_metadata(key, value) | field, document | Write a key into the current document's metadata. |
get_document_metadata(doc, key?) | transaction | Read one document's metadata, by id or 0-based index. |
set_document_metadata(doc, key, value) | transaction | Write a key into one document's metadata. |
The metadata-driven presets use exactly this API under the hood.
Metadata keys are free-form: unlike fields, a rule may create a metadata key that did not exist before. That makes metadata the right place for anything the extraction schema does not model.
Return-value pattern
Instead of (or in addition to) calling set_error / set_warning, you can return an object:
return { valid: false, message: 'Value out of range', severity: 'error' };
// severity: 'error' (default) or 'warning'
return { valid: true, message: 'Looks good' };
For field-level rules you can also return a modified value:
return { valid: true, modified_value: value.trim() };
Clickable Field & Document Tags
In set_error() or set_warning() messages, you can embed {field:FieldName} and {document:DocType} tags. These render as clickable badges in the Transaction Viewer:
| Tag | Appearance | Click action |
|---|---|---|
{field:Invoice Number} | Blue badge | Selects the field in the Data Panel |
{document:2. Passport} | Purple badge | Opens the 2nd document in the transaction |
{document:Passport} | Purple badge | Opens the first Passport in the transaction |
This is especially useful in document-level and transaction-level rules where the error involves multiple fields or documents:
// Document-level: reference specific fields
highlight_fields('Invoice Number', 'Total Amount');
set_error('{field:Invoice Number} does not match {field:Total Amount}');
// Transaction-level: reference documents by position
const app = get_document('Application');
const passport = get_document('Passport');
highlight_fields('Passport Number');
set_error(
`Mismatch between {document:${app.document_index + 1}. ${app.document_type}} ` +
`and {document:${passport.document_index + 1}. ${passport.document_type}}`
);
Every document object carries document_index, so build the tag as
`{document:${doc.document_index + 1}. ${doc.document_type}}`.
A bare type name like {document:Passport} opens the first document of that type. If a transaction ever carries two, the badge silently opens the wrong one: the reviewer lands on a plausible document showing a value that doesn't match the message. The indexed form costs one interpolation and is always correct.
Pointing at a field on another document
A {field:...} tag on its own selects the field in the document currently open. To send the reviewer to a field on a different document, write the two tags next to each other, with nothing but a space between them:
// Either order works — and both documents are tagged by position
const email = get_document('Email');
const cr = get_document('Collection Request');
set_warning(
`{document:${email.document_index + 1}. ${email.document_type}} {field:Stated Packages} ` +
`does not match {document:${cr.document_index + 1}. ${cr.document_type}} {field:Pallets}`
);
set_error('{field:Surname} {document:2. Passport} differs from the application');
Any other text between the tags (a comma, the word "in") breaks the pairing, and the field tag falls back to the current document.
For field-level rules, the bound field is automatically rendered as a clickable tag, so you don't need {field:...} tags in field-level messages.
How to Refer to a Field
get_field() and set_field() take the field's display name, exactly as it appears in
the extraction schema. There is no class prefix and no group prefix.
| Field | How the code refers to it |
|---|---|
| A general field (belongs to no class) | get_field('Currency') |
| A field of the document's class | get_field('PO Number') |
| A field inside a group | get_field('Name'), its own name, or the group path get_field('Vendor/Name') |
| A cell of a repeating group (table) | see Working with table cells below |
General fields and class-specific fields are addressed the same way: a rule running on a
document sees the general fields plus that document class's fields as one flat set. If a
general field and a class field happen to share a display name, pass the field id as a second
argument to disambiguate: get_field('Currency', 'a1b2c3d4'). Unlike the name, a field id
never changes when someone renames the field.
Reading a group by its own name (get_field('Vendor')) returns the whole group, not a value.
Read the children individually instead.
Working with table cells
A column name on its own does not identify a value, because a table has many rows. So at
document level both get_field('Amount') and set_field('Amount', ...) fail the rule with a
message telling you a row is needed. There are two ways to work with cells.
1. Let a per-row rule address it. Some presets and visual builders run once per row of a
table — Arithmetic and Compare Fields in per-row mode, and any preset whose field is a table
column. Those rules run with __row_index set to the row being processed, and the plain column
name then refers to that row's cell. A rule you write yourself does not get that mode from the
dialog; use the explicit cell path below, or start from the builder that does. This is the shape
the generated code takes, and what you would be editing after Edit as code:
const table = Object.values(fields).find(f => f.name === 'Line Items');
const row = table.value.instances[__row_index];
const amount = row.col_amt && row.col_amt.value; // cells are keyed by column id
if (!amount) {
set_error('Amount is required on every line');
highlight_fields('Amount');
}
set_field('Amount', String(amount).trim()); // writes this row's cell
2. Address one cell explicitly from a document-level rule, using the cell path
Table[rowIndex]/Column. Row numbers here are 0-based, so [1] is the second row,
the one the viewer labels [2]:
set_field('Line Items[1]/Amount', '99.00');
const amount = get_field('Line Items[1]/Amount');
Each part of the path may be a display name or a field id, and the two can be mixed, so
'Line Items[1]/col_amt' and 'tbl_li[1]/col_amt' reach the same cell. Spaces around the
slash are ignored. A path that matches nothing fails the rule with a message naming the
path, so a mistyped column stops the rule rather than quietly reading nothing.
Names are resolved inside the table that precedes them in the path, so two tables that both
have an Amount column stay apart.
3. Read a whole column from a document-level rule with get_column_values('Table/Column').
It returns one value per row, in row order, and null for a row with no value in that column,
so the position in the list is the row index. This is the way to total a column or check every
row, as the two examples below do.
Add the column itself to the rule's Scope, not only the table, so the rule runs again when a reviewer edits a cell. A path that names no column of a table fails the rule with a message, and so does a call from a per-row rule, which only sees its own row.
Do not loop over get_field('Line Items') for this. That returns the table itself,
{ instances: [...] }, not a list of rows, and each row in it is keyed by column id with
{ name, value } cells, so row['Item Amount'] is undefined on every row.
Example: check a calculation in every row
Each line's Quantity times Unit Price must equal its Item Amount. A line that does
not match gets its Item Amount cell highlighted and the computed value offered as a suggestion,
which the reviewer can accept with one click. Scope: Line Items / Quantity,
Line Items / Unit Price and Line Items / Item Amount.
// Every line: Quantity × Unit Price must equal Item Amount.
const quantities = get_column_values('Line Items/Quantity');
const prices = get_column_values('Line Items/Unit Price');
const amounts = get_column_values('Line Items/Item Amount');
const problems = [];
amounts.forEach((raw, i) => {
const quantity = parse_number(quantities[i]);
const price = parse_number(prices[i]);
if (quantity === null || price === null) return; // nothing to check on this line
const expected = Math.round(quantity * price * 100) / 100;
const amount = parse_number(raw);
if (amount === null || Math.abs(amount - expected) > 0.01) {
problems.push(`line ${i + 1}: ${quantity} × ${price} is ${expected.toFixed(2)}, not ${raw ?? 'empty'}`);
highlight_fields(`Line Items[${i}]/Item Amount`);
add_suggestion(`Line Items[${i}]/Item Amount`, expected.toFixed(2), 'Quantity × Unit Price');
}
});
if (problems.length) {
set_error(`Item Amount does not match Quantity × Unit Price on ${problems.join('; ')}`);
}
Two things in it are easy to get wrong:
- Rows are counted from 0 in code. A cell path, in
highlight_fields,add_suggestion,get_fieldorset_field, takes the row index, counted from 0, which is also the position in the listget_column_valuesreturns. The reviewer sees the same row numbered from 1:Line Items[1]/Item Amountin code is the row the viewer labels[2], and the error list shows it asLine Items[2] → Item Amount. - One message per rule. Each call to
set_errorreplaces the one before, so collect the problems and report them once, as above;highlight_fieldsandadd_suggestionadd up, so every failing line keeps its own highlight and suggestion.
Example: check a column's total against a field
The Item Amount column must add up to Total. When it does not, Total is highlighted and
the sum is offered as its suggestion. An empty Total is reported rather than passed: without it
there is nothing to check. Scope: Line Items / Item Amount and Total.
// The Item Amount column must add up to Total.
const amounts = get_column_values('Line Items/Item Amount');
const sum = amounts.reduce((total, v) => total + (parse_number(v) ?? 0), 0);
const total = parse_number(get_field('Total'));
if (total === null) {
set_error('Total is missing, so the line items cannot be checked against it');
highlight_fields('Total');
} else if (Math.abs(sum - total) > 0.01) {
set_error(`The Item Amounts add up to ${sum.toFixed(2)}, but Total is ${total.toFixed(2)}`);
highlight_fields('Total');
add_suggestion('Total', sum.toFixed(2), 'Sum of Item Amount');
}
Both checks can also be built without code: Arithmetic Validation in per-row mode for the first, and with a column sum for the second. The generated rules highlight and suggest the same way, and stay editable in the visual builder.
A field's id is shown, with a copy button, in its Properties dialog (open a field's menu in the Transaction Viewer or the extraction schema and choose Properties). Ids never change when a field is renamed, which is why a rule that has to survive a rename should use them.
Field-Level API
Field-level rules operate on a single field value. Use them for format checks, normalization, and single-value validation.
Context Variables
| Variable | Type | Description |
|---|---|---|
value | any | Current field value |
original_text | string | The text on the page the value came from, before normalization ("03/04/26" for a date stored as "2026-04-03"). Read-only. The value itself when no page text is stored |
has_region | boolean | Whether the value has a region on the page |
field_name | string | Display name of the field |
field_id | string | Stable field identifier (survives renames) |
field_type | string | Data type (string, number, date, etc.) |
field_settings | object | Field-specific settings from project configuration |
choices | string[] | null | Allowed values, for a Choice field. null when the field is free text |
allow_multiple | boolean | Whether a Choice field accepts more than one value |
default_choice | string | null | The default configured for a Choice field |
apply_default_if_empty | boolean | Whether that default applies when extraction found nothing |
document_id | string | Parent document ID |
document_class_id | string | Parent document class ID |
transaction_metadata | object | Read view of transaction.metadata (also via get_transaction_metadata()) |
document_metadata | object | Read view of the parent document's metadata (also via get_document_metadata()) |
set_value(new_value)
Replace the field value. Primarily used in normalization rules.
// Normalize a phone number
const digits = value.replace(/\D/g, '');
if (digits.length === 10) {
set_value(`(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`);
set_success('Phone number formatted');
}
add_suggestion(value, label?, source?)
Suggest an alternative value for the current field. Suggestions appear as a dropdown in the Data Panel.
if (value !== 'USD' && value !== 'EUR') {
add_suggestion('USD', 'US Dollar', 'Currency Rule');
add_suggestion('EUR', 'Euro', 'Currency Rule');
set_warning('Currency should be USD or EUR');
}
Full Example: Normalize SSN
if (value) {
const digits = value.replace(/\D/g, '');
if (digits.length === 9) {
set_value(`${digits.slice(0,3)}-${digits.slice(3,5)}-${digits.slice(5)}`);
set_success('SSN normalized');
} else {
set_error('SSN must be 9 digits');
}
}
Document-Level API
Document-level rules see all fields in a single document. Use them for cross-field validation and document-wide checks.
Context Variables
| Variable | Type | Description |
|---|---|---|
fields | object | Map of field_id → { value, name, field_type, confidence, has_region } |
document_type | string | Document class name |
document_id | string | Document identifier |
field_name_to_id | object | Map of field display name → field ID |
scoped_field_ids | string[] | null | Field IDs this rule is scoped to (null = all fields) |
__row_index | number | null | Which row is running, for a per-row rule. null everywhere else, including a rule that loops over the rows itself |
transaction_metadata | object | Read view of transaction.metadata |
document_metadata | object | Read view of this document's metadata |
document_class_id is not available heredocument_class_id exists at field level and inside each entry of documents
at transaction level, but not in a document-level rule. Reading it throws at
run time. The save-time check inspects the functions your code calls, not the
variables it reads, so it will not catch this for you: use document_type (the
class name) instead.
get_field(field_name)
Get the value of a field by its display name.
const total = get_field('Total');
const subtotal = get_field('Subtotal');
has_region(field_name)
Whether a field's value has a region on the page: true or false. It names a field the way
get_field() does, table cells included. Coordinates are not available to rules.
if (get_field('PO Number') && !has_region('PO Number')) set_warning('PO Number is not on the page');
get_column_values(table_column)
Read a whole column of a table: one value per row, in row order, null for a row with no value.
The column is named 'Table/Column'. See Working with table cells
for two complete examples.
const amounts = get_column_values('Line Items/Item Amount'); // ['10.00', '4.00', null]
get_original_text(field_name)
Read the text on the page a field's value came from, before normalization. The field is named
exactly as for get_field(), table cells included. It is read-only: set_field() writes the
value, and nothing writes the page text. A field with no stored page text (one extracted before
it was kept, or a value typed with no region) reads as its value.
// The date was normalized to 2026-04-03; was it written day-first on the page?
const printed = get_original_text('Invoice Date'); // '03/04/26'
set_field(field_name, value)
Set the value of a field by its display name. Used in normalization rules.
set_field('Full Name', `${get_field('First Name')} ${get_field('Last Name')}`);
set_field() updates a field; it cannot create one. The target must already exist on the
document or be declared in the project's extraction schema — a declared field that extraction
left empty is filled normally. Any other name (a typo, or a label that belongs to no field)
fails the rule with an error, because a value stored under a name the schema does not know
would be invisible to the viewer, exports and catalog lookups. To record something the schema
does not model, use set_transaction_metadata() instead.
get_scoped_fields()
Returns the fields this rule is scoped to (or all fields if unscoped). Returns an object of { field_id: { value, name, field_type, confidence } }.
const scoped = get_scoped_fields();
for (const [id, field] of Object.entries(scoped)) {
if (!field.value) {
set_error(`${field.name} is empty`);
}
}
highlight_fields(...field_names)
Nominate specific fields to be highlighted red in the viewer when the rule reports an error or warning. If not called, all fields in the rule's scope are highlighted.
highlight_fields('Invoice Number', 'Total Amount');
set_error('Invoice total does not match line items');
To highlight one cell of a table, give its path, with the row index counted from 0:
highlight_fields('Line Items[1]/Item Amount') is the second row. The reviewer sees it as
Line Items[2] → Item Amount, numbered from 1. That display form is also accepted as an
argument, so rules written with it keep working.
add_suggestion(field_name, value, label?, source?)
Suggest a value for a field, which the reviewer can accept with one click. The field is named
the way get_field() names it, so one table cell takes its row index, counted from 0:
add_suggestion('Line Items[1]/Item Amount', '4.50') is the second row. A bare column name
would put the suggestion on every cell of the column.
const expected = parse_number(get_field('Subtotal')) + parse_number(get_field('Tax'));
add_suggestion('Total', expected.toFixed(2), `Calculated: ${expected.toFixed(2)}`, 'Arithmetic Check');
Full Example: Validate Invoice Total
const amounts = get_column_values('Line Items/Amount');
const sum = amounts.reduce((acc, v) => acc + (parse_number(v) ?? 0), 0);
const total = parse_number(get_field('Total'));
if (total === null) {
highlight_fields('Total');
set_error('{field:Total} is missing');
} else if (Math.abs(sum - total) > 0.01) {
highlight_fields('Total');
add_suggestion('Total', sum.toFixed(2), 'Sum of the line items');
set_error(`Line items sum (${sum.toFixed(2)}) doesn't match {field:Total} (${total.toFixed(2)})`);
} else {
set_success('Invoice total validated');
}
Transaction-Level API
Transaction-level rules see all documents in a transaction. Use them for cross-document validation.
Context Variables
| Variable | Type | Description |
|---|---|---|
documents | array | Array of document objects (see shape below) |
class_name_to_id | object | Map of document class name → class ID |
transaction_metadata | object | Read view of transaction.metadata |
Each element in the documents array has the following properties:
| Property | Type | Description |
|---|---|---|
document_id | string | Unique document identifier |
document_type | string | Document class display name (e.g. "Invoice") |
document_class_id | string | Stable document class ID (survives renames) |
fields | object | Map of field_id → { value, name, field_type, confidence, has_region } |
field_name_to_id | object | Map of field display name → field ID |
page_count | number | Number of pages in this document |
You can iterate over documents directly or use the helper functions below.
get_document(type_name)
Find the first document matching a type name or class ID.
const invoice = get_document('Invoice');
const po = get_document('Purchase Order');
get_all_documents(type_name)
Find all documents matching a type name or class ID.
const receipts = get_all_documents('Receipt');
get_field(document_type, field_name)
Get a field value from a specific document type.
const invoiceTotal = get_field('Invoice', 'Total');
const poAmount = get_field('Purchase Order', 'Amount');
get_original_text(document_type, field_name)
Read the text on the page a field's value came from in one document, before normalization.
Addressed as get_field() is at this level. Read-only.
has_region(document_type, field_name) takes the same arguments and answers whether that value
has a region on the page.
set_field(document_type, field_name, value)
Set a field value in a specific document type. As at document level, the field must already exist on that document or be declared for its class — rules update fields, they never create them.
set_field('Summary', 'Total Amount', invoiceTotal + poAmount);
The value is saved into that document, in the Validate step and also while a reviewer edits: an edit to one document can run a transaction rule that changes a field in another document of the transaction. The transaction rules after it read the new value, the field's own rules check it, and the document rules that read the field run again. The field's confirmation is left as it was, unless a rule confirms it. Nothing in the Transaction Viewer points the change out, so when a change needs the reviewer's attention, have a rule raise an error or a warning on it.
set_document_error(document, message) / set_document_warning(document, message)
Pin a message to one document: it appears in that document's Data Panel and turns the
document red (or amber) in the Thumbnails panel. document is the document id or its 0-based
index in documents.
set_error() raises the issue on the transaction (a banner in the transaction list and
Thumbnails panel) but on its own highlights no document. The two are complementary: state the
problem once at transaction level, then flag each document that caused it.
const invoice = get_document('Invoice');
const po = get_document('Purchase Order');
if (invoice && po && get_field('Invoice', 'Total') !== get_field('Purchase Order', 'Amount')) {
set_error('Invoice total does not match the purchase order');
set_document_error(invoice.document_id, 'Total does not match the linked purchase order');
set_document_error(po.document_id, 'Amount does not match the linked invoice');
}
highlight_fields(...field_names)
Same as document-level: nominate fields to highlight on error/warning.
add_suggestion(field_name, value, label?, source?, document_id?)
Suggest a value for a field, optionally scoped to a specific document by ID.
add_suggestion('Total', 1000, '1000.00', 'Cross-doc Check', invoice.document_id);
Full Example: Validate Passport Matches Application
const application = get_document('Application');
const passport = get_document('Passport');
if (application && passport) {
const appNum = get_field('Application', 'Passport Number');
const passNum = get_field('Passport', 'Passport Number');
if (appNum !== passNum) {
highlight_fields('Passport Number');
set_error('Passport number mismatch between {document:Application} and {document:Passport}');
} else {
set_success('Passport numbers match');
}
} else {
set_warning('Missing required documents for validation');
}
Full Example: Validate Document Composition
This example validates that a transaction contains the expected document types, counts, and page counts. This is similar to what the Transaction Integrity Check preset generates, but written as a code rule for full control.
// Count documents by type
const docCounts = {};
for (const doc of documents) {
const t = doc.document_type;
if (!docCounts[t]) docCounts[t] = 0;
docCounts[t]++;
}
const errors = [];
const warnings = [];
// Required: exactly 1 Invoice
if (!docCounts['Invoice']) {
errors.push('Missing required document: {document:Invoice}');
} else if (docCounts['Invoice'] > 1) {
errors.push('{document:Invoice}: expected 1, found ' + docCounts['Invoice']);
}
// Required: at least 1 Supporting Document, max 5
const supportCount = docCounts['Supporting Document'] || 0;
if (supportCount === 0) {
errors.push('Missing required document: {document:Supporting Document}');
} else if (supportCount > 5) {
errors.push('{document:Supporting Document}: max 5 allowed, found ' + supportCount);
}
// Optional: Contract (validate page count if present)
if (docCounts['Contract']) {
for (const doc of documents) {
if (doc.document_type === 'Contract' && doc.page_count < 2) {
warnings.push('{document:Contract}: expected at least 2 pages, has ' + doc.page_count);
}
}
}
// Warn on unexpected document types
const expected = new Set(['Invoice', 'Supporting Document', 'Contract']);
for (const t of Object.keys(docCounts)) {
if (!expected.has(t)) {
warnings.push('Unexpected document type: {document:' + t + '} (' + docCounts[t] + ' found)');
}
}
// Report
if (errors.length > 0) {
set_error(errors.join('\n'));
} else if (warnings.length > 0) {
set_warning(warnings.join('\n'));
} else {
set_success('Transaction composition validated');
}
The Transaction Integrity Check preset provides a visual builder for configuring document composition rules without writing code. It supports required/optional/conditional/not-allowed requirements, document count constraints, page count constraints, substitute document types (OR logic), and unknown document detection. The preset generates code using the same API shown above, so you can use Edit as code at any time to make adjustments.
Security
Rules execute inside a V8 isolate (via isolated-vm) with strict resource limits:
| Constraint | Limit |
|---|---|
| Execution timeout | 5 seconds per rule, including time spent waiting for fetch() |
| Memory | 128 MB per isolate |
| Filesystem access | None |
| Network access | Only via fetch() with project-level domain allowlist |
| Node.js APIs | None (require, process, Buffer, etc. are unavailable) |
HTTP Allowlist
To enable fetch() in rules, configure the HTTP allowlist in your project settings:
- Go to Project Settings → Business Rules → HTTP Allowlist
- Enable the allowlist
- Add allowed domains (e.g.
api.example.com) - Set max requests per rule (default: 5) and request timeout (default: 5000ms)
The rule's own execution timeout includes the time it waits for its requests, so a rule that makes several slow calls can run out of time before any single request does. When one validation runs many rules that call fetch, for example a per-row rule on a long table, their requests together may wait at most 30 seconds; after that, further calls in that run fail.
Only http: and https: protocols are allowed. Subdomains of allowed domains are automatically permitted.
A request is refused, with the error Destination not allowed, when the domain resolves to an address on the server's own network: loopback, the cloud metadata service and, on our cloud service, private ranges. Self-hosted installations can reach private ranges.
Redirects are followed up to 5 times, and only to an allowed domain. A redirect to another host drops the Authorization and Cookie headers. A response larger than 5 MB fails the call.
Tips
- Use
field_namefor display,field_idfor stability. Field names can be renamed by users; field IDs are immutable. The helper functionsget_field()andset_field()accept display names and resolve them to IDs internally. - Scope your rules. When creating document-level or transaction-level rules, set the field scope to limit which fields the rule applies to. This avoids running expensive rules on every field change.
- Return early on missing data. Always check for
null/undefinedvalues before operating on them, since extracted fields may be empty. - Use
highlight_fields()for focused feedback. Without it, all scoped fields are highlighted on error, which can be noisy. - Use
{field:...}and{document:...}tags in messages. These render as clickable badges in the Transaction Viewer, making it easy for users to jump to the relevant field or document. See Clickable Field & Document Tags. - Normalization rules run first. Field-level normalization rules execute during extraction, before validation rules. This means validation rules see already-normalized values.
- Carry reference data on
transaction.metadata. Submit reference values (expected vendor, PO record, policy flags) alongside the document at upload time and rules can compare against them withget_transaction_metadata(). The metadata-driven presets give you a no-code path for the common patterns; rules can alsoset_transaction_metadata(key, value)to persist enrichment back to the transaction for downstream steps.