Skip to main content

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.

Pick the level before you write

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');
Field-level messages and field names

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');
One argument only

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.

OptionTypeEffect
trimbooleanRemove leading/trailing whitespace
lowercasebooleanConvert to lowercase
uppercasebooleanConvert to uppercase
removeSpacesbooleanCollapse all whitespace runs to a single space
removeSpecialCharsbooleanStrip everything except letters (in any script), digits, and spaces
foldDiacriticsbooleanLower-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
FunctionAvailable atDescription
get_transaction_metadata(key?)field, document, transactionRead transaction.metadata. No arg returns the whole object.
set_transaction_metadata(key, value)field, document, transactionWrite a key into transaction.metadata. Persisted after the run.
get_document_metadata(key?)field, documentRead the current document's metadata.
set_document_metadata(key, value)field, documentWrite a key into the current document's metadata.
get_document_metadata(doc, key?)transactionRead one document's metadata, by id or 0-based index.
set_document_metadata(doc, key, value)transactionWrite 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:

TagAppearanceClick action
{field:Invoice Number}Blue badgeSelects the field in the Data Panel
{document:2. Passport}Purple badgeOpens the 2nd document in the transaction
{document:Passport}Purple badgeOpens 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}}`
);
Always tag documents by position

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.

FieldHow the code refers to it
A general field (belongs to no class)get_field('Currency')
A field of the document's classget_field('PO Number')
A field inside a groupget_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_field or set_field, takes the row index, counted from 0, which is also the position in the list get_column_values returns. The reviewer sees the same row numbered from 1: Line Items[1]/Item Amount in code is the row the viewer labels [2], and the error list shows it as Line Items[2] → Item Amount.
  • One message per rule. Each call to set_error replaces the one before, so collect the problems and report them once, as above; highlight_fields and add_suggestion add 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​

VariableTypeDescription
valueanyCurrent field value
original_textstringThe 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_regionbooleanWhether the value has a region on the page
field_namestringDisplay name of the field
field_idstringStable field identifier (survives renames)
field_typestringData type (string, number, date, etc.)
field_settingsobjectField-specific settings from project configuration
choicesstring[] | nullAllowed values, for a Choice field. null when the field is free text
allow_multiplebooleanWhether a Choice field accepts more than one value
default_choicestring | nullThe default configured for a Choice field
apply_default_if_emptybooleanWhether that default applies when extraction found nothing
document_idstringParent document ID
document_class_idstringParent document class ID
transaction_metadataobjectRead view of transaction.metadata (also via get_transaction_metadata())
document_metadataobjectRead 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​

VariableTypeDescription
fieldsobjectMap of field_id → { value, name, field_type, confidence, has_region }
document_typestringDocument class name
document_idstringDocument identifier
field_name_to_idobjectMap of field display name → field ID
scoped_field_idsstring[] | nullField IDs this rule is scoped to (null = all fields)
__row_indexnumber | nullWhich row is running, for a per-row rule. null everywhere else, including a rule that loops over the rows itself
transaction_metadataobjectRead view of transaction.metadata
document_metadataobjectRead view of this document's metadata
document_class_id is not available here

document_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​

VariableTypeDescription
documentsarrayArray of document objects (see shape below)
class_name_to_idobjectMap of document class name → class ID
transaction_metadataobjectRead view of transaction.metadata

Each element in the documents array has the following properties:

PropertyTypeDescription
document_idstringUnique document identifier
document_typestringDocument class display name (e.g. "Invoice")
document_class_idstringStable document class ID (survives renames)
fieldsobjectMap of field_id → { value, name, field_type, confidence, has_region }
field_name_to_idobjectMap of field display name → field ID
page_countnumberNumber 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');
}
Transaction Integrity Check Preset

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:

ConstraintLimit
Execution timeout5 seconds per rule, including time spent waiting for fetch()
Memory128 MB per isolate
Filesystem accessNone
Network accessOnly via fetch() with project-level domain allowlist
Node.js APIsNone (require, process, Buffer, etc. are unavailable)

HTTP Allowlist​

To enable fetch() in rules, configure the HTTP allowlist in your project settings:

  1. Go to Project Settings → Business Rules → HTTP Allowlist
  2. Enable the allowlist
  3. Add allowed domains (e.g. api.example.com)
  4. 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_name for display, field_id for stability. Field names can be renamed by users; field IDs are immutable. The helper functions get_field() and set_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/undefined values 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 with get_transaction_metadata(). The metadata-driven presets give you a no-code path for the common patterns; rules can also set_transaction_metadata(key, value) to persist enrichment back to the transaction for downstream steps.