Validations
How validation rules and custom validations appear in API responses and webhooks
A document is checked two ways, and both reach you through the same fields.
Validation rules are declarative checks configured on the model — an amount that must be positive, a date that must not be empty, a line-item total that must match the invoice total. They evaluate as part of extraction and cost nothing.
Custom validations are the code you supply: validation scripts that run in a sandbox after extraction, and validator agents that submit a verdict. They are dispatched as a validation attempt, are billed per dispatch, and can re-derive a value when a check is configured to RESEND.
| Validation rules | Custom validations | |
|---|---|---|
| Identified by | the rule's id | the script's key |
Cell entry source | RULE | SCRIPT or AGENT |
| Document-level list | validations.rules | validations.checks |
| Scopes | FIELD, COLUMN, CROSS_FIELD, ROW, DOCUMENT | the whole document |
| Attempt lifecycle | none — evaluation is synchronous | DOCUMENT_VALIDATION_STARTED, then a settle |
| Cost | free | billed per dispatch |
Both contribute to the document's single validation status and to validationFailCount.
Rule results
Every rule result — on a cell, in a document-level list, or in a webhook payload — carries the same identity fields:
| Field | Type | Description |
|---|---|---|
check | string | The rule's id. A cell entry on the nested endpoints calls it checkKey, and a nested rule group calls it ruleId. |
name | string | The rule's name, or a generated label when it has none. Never empty. |
scope | FIELD | COLUMN | CROSS_FIELD | ROW | DOCUMENT | null | Where the rule applies. null when the rule no longer exists. |
operator | string | null | IS_EMPTY, IS_NOT_EMPTY, EQUALS, DOES_NOT_EQUAL, CONTAINS, DOES_NOT_CONTAIN, STARTS_WITH, ENDS_WITH, IS_GREATER_THAN, IS_LESS_THAN or IS_BETWEEN. |
passed | boolean | Whether the rule held. |
expected | string | null | What the rule required, rendered for reading: > 0, non-empty value, contains "PO-", between 1 and 99. |
actual | string | null | The value the rule saw. |
message | string | null | Human-readable failure detail. null on a passing result. |
How name is built
A rule's own name is used when it has one. Otherwise the label is derived from the rule's operator, its configuration and the names of the columns it references:
| Rule | Generated name |
|---|---|
| Field rule, amount greater than zero | Total Amount > 0 |
| Field rule, date must be filled | Invoice Date is not empty |
| Cross-field rule | Net Amount vs Gross Amount |
| Row rule comparing computed to extracted | Quantity * Unit Price = Line Total |
| Document rule over a child table | SUM(Amount) |
Labels use column display names, not slugs, so they read the same way they do in the app.
How message is built
message is null while a rule passes. A failing rule reports <name>: expected <expected>, got <actual>:
Total Amount > 0: expected > 0, got -40When only one side is known the message carries that side — <name>: expected <expected> or <name>: got <actual> — and <name>: failed when neither is. The message is composed when the response is serialized. It is not stored, and it is not configurable per rule; to change what it says, rename the rule.
Rules that no longer exist
A result whose rule was deleted since it was evaluated is still returned. scope and operator are null, and name falls back to a label derived from the stored result: the operator's own label (Is greater than) for field and column results, and Validation for cross-field, row and document results, which store no operator.
Rule ids
check is the rule's id, and it survives edits to the rule's name, operator and configuration. Changing a rule's type in the app replaces the rule: the old id stops appearing and a new one takes its place.
Rules are not versioned. Evaluation reads the rules as they are defined at that moment, so a document keeps the verdict of its last evaluation until it is extracted again or revalidated.
The document's validation status
One status answers "is this document validated?" for both kinds of validation. It is validations.status on the data endpoints, validationStatus on Document Detail, and data.status on DOCUMENT_VALIDATED.
| Value | Meaning |
|---|---|
failed | A rule failed, or a custom check reported findings. |
pending | A custom check is queued or running, or an extraction job that has just completed is still evaluating the model's rules. See when the rules lane reports pending. |
error | A custom check could not run, or rule evaluation failed for the document. |
passed | Everything recorded for the document passed. |
null | Nothing is recorded for the document: no rule verdict and no custom validation. A model with neither kind of check, or a document extracted before its model had any rule. |
Precedence is failed > pending > error > passed: a document with one failing rule and one still-running script reports failed.
DOCUMENT_VALIDATED only ever carries a settled status, so pending and null never appear in a webhook payload.
When rules are evaluated
- During extraction. On a model with at least one rule, evaluation finishes before
DOCUMENT_EXTRACTEDis published, so a read triggered by that event sees the document's final rule results. A model with no rules has nothing to evaluate, and the event is published as soon as the extraction completes. - The previous run's rule results are cleared as the extraction job completes, so a re-extraction never shows the earlier run's verdict. Between that moment and the end of evaluation — the same job, moments later — the status is
pending. - Documents routed to the extraction agent — Excel files, and models configured for agent or dynamic-mapping extraction — hold to the same ordering: the agent's run evaluates the model's rules before
DOCUMENT_EXTRACTEDis published, and the document settles afterwards. These documents keep the previous run's rule results until the new evaluation replaces them, so the rules lane never reportspendingfor them. - The synchronous Process File endpoint evaluates rules before it responds, so its response already carries them.
POST …/revalidatere-evaluates every rule on every call, free of charge, on top of the custom lanes it was asked for.
Rule evaluation is synchronous and has no validation attempt. No DOCUMENT_VALIDATION_STARTED fires for rules, and validationAttemptId is null for a document whose only validations are rules.
When evaluation fails for a document, DOCUMENT_EXTRACTED is published first and DOCUMENT_VALIDATION_FAILED follows it. The document's status is error from the moment DOCUMENT_EXTRACTED is published, and its rule results are empty.
When the rules lane reports pending
The rules lane reports pending only inside the window above, and only on a model that has rules. Three states that look similar are not pending:
- A model with no validation rules. Its documents never take
pendingfrom the rules lane; their status is the custom aggregate alone. - A document extracted before its model had any rule. It holds no rule verdict at all: its status comes from its custom validations, and is
nullwhen it has none. Revalidate it to evaluate the current rules against it. - A document whose rule evaluation failed. Its status is
error, and its rule results are empty.
The window is bounded. A document that has been pending longer than 15 minutes — the validation watchdog's check timeout — reads as error on every surface, so nothing stays pending indefinitely.
pending from a custom validation means something different: a validation script or a validator agent run is still in flight.
Where results appear
| Endpoint | Per-cell entries | Document-level rule results |
|---|---|---|
| Document Data (Flat) and its bulk form | validations[] on each cell | validations.rules[] — one entry per rule, every scope, each with its failures[] |
| Get File Data, Get Files Data, Process File | validations[] on each cell | validations.rules.{crossField,row,document} |
| Document Detail | — | validations.{crossField,row,document}, re-evaluated live |
DOCUMENT_VALIDATED | — | data.rules[], one pass/fail entry per rule |
Field and column rule results live on the cell they were evaluated against. Cross-field, row and document results are document-level. The flat rules[] list folds all five scopes into one list, so it is the single place to read every rule outcome for a document:
{
"validations": {
"status": "failed",
"failCount": 2,
"checks": [],
"rules": [
{
"check": "vrule_7h2",
"name": "Total Amount > 0",
"scope": "FIELD",
"operator": "IS_GREATER_THAN",
"passed": false,
"message": "Total Amount > 0: expected > 0, got -40",
"failures": [
{
"table": "invoices",
"rowId": "row_h1",
"column": "total_amount",
"expected": "> 0",
"actual": "-40"
}
]
},
{
"check": "vrule_9k1",
"name": "Net Amount vs Gross Amount",
"scope": "CROSS_FIELD",
"operator": "EQUALS",
"passed": false,
"message": "Net Amount vs Gross Amount: expected 1250.00, got 1180.00",
"expected": "1250.00",
"actual": "1180.00",
"failures": []
}
]
}
}expected and actual sit on the entry itself for cross-field and document rules, which produce one result per document. Field, column and row rules produce one result per cell or row, so their evidence is in failures[] instead, and passing rules have an empty failures[].