Dispatch Validation Rules
Check each record of a dispatch at intake, show the reviewer the figures behind a failed check, and decide whether a failure holds the dispatch for approval or refuses it.
A validation rule is a check that REFRACT Dispatch runs on every record of a dispatch when the dispatch arrives, before anything is rendered or sent. A record that fails a rule gets an issue on the intake receipt and in Dispatch Review, with the figures the rule names, so the approver can see why it failed.
Rules live in distribution.validationRules, in the template or in the request. A request that sends validationRules replaces the template's list whole; see Template Distribution Settings.
Rule Fields
| Field | Required | Description |
|---|---|---|
id | Yes | The rule's name. Stored with each issue as ruleId. |
condition | Yes | An expression that must be true for the record to pass. See Expressions. |
severity | No | warning or error. error is the default, so a rule with no severity refuses the whole dispatch when any record fails it. See Warnings, Errors, and Approval. |
message | No | What the reader is told when the rule fails. Left out, Rule "<id>" failed. |
fieldKey | No | The field the failure is reported against, such as totalAmountDue. Left out, the rule's id. |
amounts | No | A list of { "label", "value" }, each value an expression. The figures are worked out on the record and shown beside the message. |
forEach | No | A list in the record to check entry by entry, such as a statement's currencies. Each entry is checked on its own, and the expressions read that entry's fields. When the list is missing, empty, or not a list, the whole record is checked once. |
forEachLabel | No | A field of the entry that names it at the start of the message, such as USD: . Used only with forEach, and only when an entry of the list is being checked. |
A rule that can never run refuses the dispatch with 422 and the issue INVALID_VALIDATION_RULE before any record is checked. That happens when a rule has no id or no condition; a condition or an amount does not parse or uses an unknown function; severity is not warning or error; message, forEach, or forEachLabel is not text; or amounts is not a list of { "label", "value" }. The issue names the rule and the reason, for example:
Validation rule "invoice_lines_sum_to_total" is invalid: its condition cannot be evaluated: Unknown built-in function: 'ceil'Expressions
Expressions take >, <, >=, <=, ==, !=, &&, ||, !, implies, +, -, *, /, %, null, dotted paths such as remittance.totalPayment, and the functions abs(x), min(a, b), max(a, b), round(x), len(x), today(), and sum(path). These are the same expressions as workflow validation rules. An expression is at most 4,096 characters.
sum(lines.amount)adds a field up across a list. A blank ornullvalue counts as 0. Text that is not a number makes the sum fail, so the rule fails.- In arithmetic, a
nullvalue counts as 0. - In a comparison, a missing value is never
<,>,<=, or>=anything, sototalAmountDue <= 5000fails when the total is missing. == nulland!= nulltreat a blank value asnull.- Money comparisons work in cents, as in
round(sum(lines.amount) * 100) == round(totalAmountDue * 100), so that fractions of a cent left over from arithmetic do not fail the rule.
Rules and amounts are not limited in number, but the template settings, rules included, are stored with the dispatch. Settings that are too long refuse the dispatch with 400 BATCH_RECORD_TOO_LARGE.
What a Failed Rule Shows
Each failure is stored in the dispatch's validation results as an issue:
{
"field": "totalAmountDue",
"code": "VALIDATION_RULE_FAILED",
"message": "The invoice lines do not add up to the total due.",
"severity": "warning",
"ruleId": "invoice_lines_sum_to_total",
"amounts": [
{ "label": "Lines total", "value": 1130 },
{ "label": "Total due", "value": 1180 },
{ "label": "Difference", "value": -50 }
]
}Each amount's value is a number rounded to the cent, with no currency. A figure that cannot be worked out is null.
On the intake receipt, the record's row shows its status and the message, followed by each amount as its label and figure. Figures print with two decimals and no currency symbol, whatever the template's locale, and a missing figure prints "not given":
2 WARNING 1 The invoice lines do not add up to the total due. Lines total 1,130.00, Total due 1,180.00, Difference -50.00.In Dispatch Review, records with errors are listed first, then records with warnings, each headed "Record n" with its record label, or its validationFields values when the template sets none. Each issue shows ✕ for an error or ⚠ for a warning, then the message, then the amounts as a grid of labels and figures. Figures use the viewer's browser locale, with two decimals and no currency symbol; a missing figure shows "Not given". A rule that can never run is shown once, as "This dispatch", with "No record was checked."
Warnings, Errors, and Approval
| Rule | Settings | What happens |
|---|---|---|
warning fails | requireApproval on, the default | 202, readiness WARNINGS. The dispatch waits in pending-approval, and the approvers are emailed. |
warning fails | requireApproval: false | Still held: 202, readiness WARNINGS, pending-approval. The receipt says "Held for approval", and the audit chain records that the warnings held it. |
warning fails | requireApproval: false and warningsForceApproval: false | Queued automatically: 202, the receipt reads "DISPATCH QUEUED FOR PROCESSING", and no approval email is sent. The audit chain records an automatic approval, with the number of records with warnings. |
error fails | Any | 422, readiness INVALID. The whole dispatch is refused, the records that passed included: nothing is queued and no approver is emailed. The body is the intake receipt, headed "DISPATCH REFUSED". |
An approver can approve a dispatch with warnings. A dispatch with an invalid record cannot be approved; correct the records and submit a new dispatch.
Worked Examples
Each example is a two-record dispatch: the first record passes and the second fails. Only the fields the rule reads are shown; the records also carry the rest of the template's fields, such as the customer's name and email.
Invoice lines add up to the total
For the shipped invoice-standard template, where each line's amount already includes its tax:
{
"id": "invoice_lines_sum_to_total",
"severity": "warning",
"condition": "round(sum(lines.amount) * 100) == round(totalAmountDue * 100)",
"message": "The invoice lines do not add up to the total due.",
"fieldKey": "totalAmountDue",
"amounts": [
{ "label": "Lines total", "value": "sum(lines.amount)" },
{ "label": "Total due", "value": "totalAmountDue" },
{ "label": "Difference", "value": "sum(lines.amount) - totalAmountDue" }
]
}[
{
"invoiceNumber": "INV-1001",
"totalAmountDue": 1130,
"lines": [
{ "lineNumber": 1, "description": "Annual software licence", "qty": 2, "unitPrice": 300, "tax": 78, "amount": 678 },
{ "lineNumber": 2, "description": "Implementation services", "qty": 1, "unitPrice": 400, "tax": 52, "amount": 452 }
]
},
{
"invoiceNumber": "INV-1002",
"totalAmountDue": 1180,
"lines": [
{ "lineNumber": 1, "description": "Annual software licence", "qty": 2, "unitPrice": 300, "tax": 78, "amount": 678 },
{ "lineNumber": 2, "description": "Implementation services", "qty": 1, "unitPrice": 400, "tax": 52, "amount": 452 }
]
}
]The answer is 202 with readiness WARNINGS. INV-1001 is valid. INV-1002 has a warning against totalAmountDue: Lines total 1,130.00, Total due 1,180.00, Difference -50.00.
Tax equals the rate times the taxable amount
Same template. The taxable amount is subtotal:
{
"id": "invoice_tax_matches_rate",
"severity": "warning",
"condition": "round(subtotal * taxRate * 100) == round(taxAmount * 100)",
"message": "The tax is not the tax rate times the subtotal.",
"fieldKey": "taxAmount",
"amounts": [
{ "label": "Subtotal", "value": "subtotal" },
{ "label": "Tax rate", "value": "taxRate" },
{ "label": "Expected tax", "value": "subtotal * taxRate" },
{ "label": "Tax", "value": "taxAmount" }
]
}[
{ "invoiceNumber": "INV-1001", "subtotal": 1000, "taxRate": 0.13, "taxAmount": 130 },
{ "invoiceNumber": "INV-1003", "subtotal": 1000, "taxRate": 0.13, "taxAmount": 120 }
]INV-1001 is valid. INV-1003 has a warning against taxAmount: Subtotal 1,000.00, Tax rate 0.13, Expected tax 130.00, Tax 120.00. The receipt prints every figure with two decimals, the rate included.
Payment lines add up to the total payment
For the shipped vend-remit-standard template, whose rule this is. Its keys are camelCase: company, vendor, remittance, paymentInfo, and the lists paymentDetails[] and openItems[]. remittance.totalPayment is a dotted path into the record, and the dispatch summary can read the same path:
{
"validationRules": [
{
"id": "remittance_lines_add_up",
"severity": "warning",
"condition": "round(sum(paymentDetails.paymentApplied) * 100) == round(remittance.totalPayment * 100)",
"message": "The payments on the lines do not add up to the total payment.",
"fieldKey": "remittance.totalPayment",
"amounts": [
{ "label": "Lines", "value": "sum(paymentDetails.paymentApplied)" },
{ "label": "Total payment", "value": "remittance.totalPayment" },
{ "label": "Difference", "value": "sum(paymentDetails.paymentApplied) - remittance.totalPayment" }
]
}
],
"batchSummary": { "field": "remittance.totalPayment", "operation": "sum", "format": "currency", "label": "Total paid" }
}[
{
"vendor": { "accountNo": "V10000", "name": "Fabrikam, Inc.", "email": "ap@fabrikam.example" },
"remittance": { "number": "REM-1001", "paymentDate": "2026-10-30", "totalPayment": 2000 },
"paymentDetails": [
{ "invoiceNo": "INV-5001", "paymentApplied": 1200 },
{ "invoiceNo": "INV-5002", "paymentApplied": 800 }
]
},
{
"vendor": { "accountNo": "V20000", "name": "Northwind Traders", "email": "ap@northwind.example" },
"remittance": { "number": "REM-1002", "paymentDate": "2026-10-30", "totalPayment": 1900 },
"paymentDetails": [
{ "invoiceNo": "INV-6001", "paymentApplied": 1500 },
{ "invoiceNo": "INV-6002", "paymentApplied": 350 }
]
}
]REM-1001 is valid. REM-1002 has a warning against remittance.totalPayment: Lines 1,850.00, Total payment 1,900.00, Difference -50.00. The dispatch summary reads "Total paid" with $3,900.00: the request sent no currency, so it is in US dollars, the default for en-US. The shipped template's own summary also sets "currencyField": "vendor.currency", so a dispatch of vendors paid in different currencies gets one total per currency; see Totals per Currency.
Reference: the statement template's rules
The shipped account-statement-standard template carries three warning rules. All three use forEach so that a statement in several currencies is checked one currency at a time:
[
{
"id": "statement_balance_reconciles",
"severity": "warning",
"forEach": "currencies",
"forEachLabel": "currency",
"condition": "round((openingBalance + sum(transactions.debit) - sum(transactions.credit)) * 100) == round(balanceDue * 100)",
"message": "Opening balance plus debits less credits does not equal the balance due.",
"fieldKey": "balanceDue",
"amounts": [
{ "label": "Opening balance", "value": "openingBalance" },
{ "label": "Debits", "value": "sum(transactions.debit)" },
{ "label": "Credits", "value": "sum(transactions.credit)" },
{ "label": "Calculated", "value": "openingBalance + sum(transactions.debit) - sum(transactions.credit)" },
{ "label": "Balance due", "value": "balanceDue" },
{ "label": "Difference", "value": "openingBalance + sum(transactions.debit) - sum(transactions.credit) - balanceDue" }
]
},
{
"id": "aging_sums_to_balance",
"severity": "warning",
"forEach": "currencies",
"forEachLabel": "currency",
"condition": "round(sum(aging.amount) * 100) == round(balanceDue * 100)",
"message": "The aging bands do not add up to the balance due.",
"fieldKey": "aging",
"amounts": [
{ "label": "Aging total", "value": "sum(aging.amount)" },
{ "label": "Balance due", "value": "balanceDue" },
{ "label": "Difference", "value": "sum(aging.amount) - balanceDue" }
]
},
{
"id": "overdue_total_matches_entries",
"severity": "warning",
"forEach": "currencies",
"forEachLabel": "currency",
"condition": "(len(overdue) == 0 && overdueTotal == null) || round(sum(overdue.remainingAmount) * 100) == round(overdueTotal * 100)",
"message": "The overdue entries' remaining amounts do not add up to the overdue total.",
"fieldKey": "overdueTotal",
"amounts": [
{ "label": "Overdue entries", "value": "sum(overdue.remainingAmount)" },
{ "label": "Overdue total", "value": "overdueTotal" },
{ "label": "Difference", "value": "sum(overdue.remainingAmount) - overdueTotal" }
]
}
]The overdue rule passes a statement with no overdue entries and no overdue total.
- A statement with
currencies, such as one with a CAD entry that reconciles and a USD entry with opening balance 500, debits 400, credits 50, balance due 800, and aging bands of 600 and 150: the CAD entry passes, and the USD entry fails both rules. The messages start with the entry'scurrency: "USD: Opening balance plus debits less credits does not equal the balance due." with Calculated 850.00, Balance due 800.00, Difference 50.00, and "USD: The aging bands do not add up to the balance due." with Aging total 750.00, Balance due 800.00, Difference -50.00. - A statement in one currency, with its figures at the record's root and no
currencieslist, or an empty one: the record is checked once, and the message has no prefix.
REFRACT Dispatch
Distribution settings, approvers, the dispatch summary, and the dispatch lifecycle.
REFRACT Dispatch
REFRACT Dispatch is a governed engine for generating documents in bulk. Submit a payload with hundreds of records, get a validation report, obtain approval, and let the Processor render and deliver each document independently - with a per-record audit trail and REFRACT Delivery integration.
REFRACT Dispatch API Reference
HTTP endpoint reference for dispatch management - get dispatch detail, documents, validation results and audit chain, approve or reject, and cancel.