Validating Extracted Data
Check the data Docutray extracts with JSON Schema and business rules written in the validation DSL — syntax, conditional and list rules, calculated totals, full examples, issue codes, and how to manage rules over REST and MCP.
Every document type can check the data Docutray extracts from it. Validation answers questions like do the line items add up to the subtotal?, is the due date after the issue date? or did every candidate's vote count match its written form?, and returns a list of issues you can act on: send the document to manual review, reject it, or accept it as is.
This guide covers what Docutray validates, how to write rules, where the results appear, and how to manage rules from the API. The full list of operators is in the operator reference.
How validation works
A document type has two validation layers. Both run on the extracted data of every conversion:
| Layer | Stored in | Checks | Issue source |
|---|---|---|---|
| JSON Schema | jsonSchema | The shape of the data: types, required fields, formats, enums, numeric limits. | schema |
| Business rules | dslRules | Domain logic written in a small text language (the DSL): cross-field comparisons, totals, dates, conditional and per-item rules. | rule |
The JSON Schema is the same schema that guides extraction. The DSL is optional and adds the checks a schema cannot express. Both layers always run, and their issues are reported together.
{
"type": "object",
"required": ["invoice_number"],
"properties": {
"invoice_number": { "type": "string" },
"subtotal": { "type": "number" },
"tax": { "type": "number" },
"total": { "type": "number" }
}
}# The total must be the subtotal plus tax
define expected sum field.subtotal field.tax
field.total equalsWithPrecision(calculated.expected, 2){ "subtotal": 1000, "tax": 190, "total": 1290 }{
"valid": false,
"errors": [
{ "field": "invoice_number", "code": "REQUIRED_FIELD", "source": "schema", "message": "El campo 'invoice_number' es obligatorio" },
{ "field": "total", "code": "VALIDATION_ERROR", "source": "rule", "message": "El campo 'total' debe ser igual a '1190' con 2 decimales de precisión, pero es '1290'" }
]
}Throughout this guide, a Result block shows valid and each issue's field,
code and message, plus other properties where they help. Issue messages are
written in Spanish. Every example on this page runs in Docutray's test suite, so
the results are what the API returns.
Validation is informational
Validation never changes the outcome of a conversion. A conversion whose data
breaks every rule still finishes with status SUCCESS and returns its data;
validation.valid is false and the issues explain why. Decide in your own code
what an invalid document means for your process.
Where the result appears
| Where | Field | Shape |
|---|---|---|
Synchronous conversion, POST /api/convert | validation | Detailed result |
Conversion status, GET /api/convert-async/status/{id} (status SUCCESS) | validation | Detailed result |
CONVERSION_COMPLETED webhook | validation | Detailed result |
MCP convert_document and get_conversion_status | validation | Detailed result, shortened when very long |
Steps status and STEP_COMPLETED webhook | validation | Summary: errors and warnings with count and messages |
On demand, POST /api/document-types/{id}/validate and MCP validate_document_data | the whole response | valid, issues and the errors/warnings summaries |
The detailed result in conversions has three possible values:
// Validation ran
{ "valid": false, "errors": [ /* Issue */ ], "warnings": [] }
// Validation was skipped: the document type has no JSON Schema and no rules
null
// Validation could not be completed, or no validation record exists
{ "status": "error" }{ "status": "error" } never includes internal details. It means the data could
not be checked, or that no validation record exists for the conversion; it does
not mean the data is invalid. You can check it again later with
/validate. See
Convert Documents and
Conversion Events for full payloads, and
Steps Operations for the summary format.
Writing rules
Rules are plain text: one rule per line. Lines starting with #, and everything
after a # outside quotes, are comments.
# <field> <operator> <value>
field.total greaterThan 0
field.currency in("CLP", "USD")
field.due_date dateAfter field.issue_date # compare two fieldsA simple rule names a field, an operator and, for most operators, a value. The value can follow the operator with a space or go in parentheses; operators with more than one parameter always use parentheses:
field.total greaterThan 0
field.total greaterThan(0)
field.tax_rate between(0, 0.19)
field.subtotal equalsWithPrecision(field.items_total, 2)The common comparisons also have symbol forms:
| Symbol | Operator |
|---|---|
= or == | equals |
!= | notEquals |
> | greaterThan |
>= | greaterOrEqual |
< | lessThan |
<= | lessOrEqual |
Field references
| Reference | Meaning |
|---|---|
field.total | The property total at the root of the extracted data. |
field.supplier.tax_id | A nested property. |
field.items[0].amount | amount in the first element of the items list. |
field.items[*].amount | amount in every element of items. |
calculated.subtotal | A value computed earlier with define. |
Property names can contain letters with accents, digits, _ and -
(field.razón_social, field.tax-id). Paths are always relative to the root of
the extracted data, which is the data object of a conversion.
An issue's field is the concrete path of the value that failed, without the
field. prefix: items[1].amount, not items[*].amount. A root-level issue has
an empty field.
Values
| Value | Examples |
|---|---|
| Numbers | 0, -5, 1190.5, 1e6 |
| Text, in double or single quotes | "CLP", 'F-', "line\nbreak" |
| Booleans and null | true, false, null |
| Regular expressions | /^F-\d+$/i |
| Field references | field.amount_due, calculated.total |
| Lists, as parameters | in("CLP", "USD") |
A word without quotes that is not a field reference is read as text, so
format email and format "email" are the same. Text in quotes is always
literal, even when it looks like a reference: field.code equals "field.x"
compares with the text field.x.
Missing values
Most operators do not run when the field is missing or null, and the rule
passes. To require a value, add required (missing, null or "" fail) or
exists (missing or null fail). notExists requires the opposite.
field.total greaterThan 0
field.invoice_number required
field.discount greaterThan 0{ "total": 1190, "discount": null }{
"valid": false,
"errors": [
{ "field": "invoice_number", "code": "REQUIRED_FIELD", "message": "El campo 'invoice_number' es obligatorio" }
]
}A reference used as a parameter must exist, though. Comparing with a field
that is not in the document fails with FIELD_REFERENCE_NOT_FOUND, so a missing
value is never compared as if it were 0:
field.total equals field.amount_due{ "total": 1190 }{
"valid": false,
"errors": [
{ "field": "total", "code": "FIELD_REFERENCE_NOT_FOUND", "message": "Error ejecutando operador equals: No existe el campo referenciado 'field.amount_due'" }
]
}Lists: [*] and [0]
A rule on [*] checks every element and reports one issue per element that fails,
with its index:
field.items[*].quantity greaterThan 0
field.items[0].description required{
"items": [
{ "description": "Consulting", "quantity": 2 },
{ "description": "Travel", "quantity": 0 },
{ "description": "Support", "quantity": -1 }
]
}{
"valid": false,
"errors": [
{ "field": "items[1].quantity", "code": "INVALID_RANGE", "message": "El campo 'items[1].quantity' debe ser mayor que 0" },
{ "field": "items[2].quantity", "code": "INVALID_RANGE", "message": "El campo 'items[2].quantity' debe ser mayor que 0" }
]
}Paired values in the same list
When both sides of a rule use [*] on the same list, they are paired by
index: element 0 is compared with element 0, element 1 with element 1, and so on.
This is how you check two properties of each element against each other:
# Each candidate's count in digits must match the count written in words
field.candidates[*].votes equals(field.candidates[*].votes_in_words){
"candidates": [
{ "name": "Candidate 1", "votes": 120, "votes_in_words": 120 },
{ "name": "Candidate 2", "votes": 85, "votes_in_words": 58 }
]
}{
"valid": false,
"errors": [
{ "field": "candidates[1].votes", "code": "VALIDATION_ERROR", "message": "El campo 'candidates[1].votes' debe ser igual a '58', pero es '85'" }
]
}A [*] reference to a different list is not paired: it matches every element
of that list, so a comparison does not have a single value to compare with. Each
element reports RULE_EXECUTION_ERROR; the other rules still run and report their
own issues:
field.ordered[*].quantity equals(field.delivered[*].quantity)
field.total greaterThan 0{
"total": 0,
"ordered": [{ "quantity": 10 }, { "quantity": 5 }],
"delivered": [{ "quantity": 10 }, { "quantity": 5 }]
}{
"valid": false,
"errors": [
{ "field": "ordered[0].quantity", "code": "RULE_EXECUTION_ERROR", "message": "Error ejecutando operador equals: La referencia 'field.delivered[*].quantity' debe resolver a un solo valor en esta regla" },
{ "field": "ordered[1].quantity", "code": "RULE_EXECUTION_ERROR", "message": "Error ejecutando operador equals: La referencia 'field.delivered[*].quantity' debe resolver a un solo valor en esta regla" },
{ "field": "total", "code": "INVALID_RANGE", "message": "El campo 'total' debe ser mayor que 0" }
]
}Compare across lists with a fixed index ([0]), with forEach and a fixed
reference, or with totals computed with define.
Conditional rules: when … then … otherwise
when applies a block of rules only if a condition holds, and otherwise applies
another block if it does not. The condition is written like a rule; the blocks go
in braces and can hold any rules, including other when and forEach blocks.
otherwise can start on the same line as the closing brace or on the next one.
when field.statement_type equals "domestic" then {
field.currency equals "CLP"
} otherwise {
field.currency equals "USD"
}
when field.discount greaterThan 0 then {
field.discount_reason required
}{ "statement_type": "international", "currency": "CLP", "discount": 5000 }{
"valid": false,
"errors": [
{ "field": "currency", "code": "VALIDATION_ERROR", "message": "El campo 'currency' debe ser igual a 'USD', pero es 'CLP'" },
{ "field": "discount_reason", "code": "REQUIRED_FIELD", "message": "El campo 'discount_reason' es obligatorio" }
]
}A condition on a missing or null field is not met, so otherwise runs. A
condition on [*] is met when any element satisfies it.
Per-element rules: forEach … as
forEach applies a block to each element of a list. Inside the block, the alias
names the current element; without as, the alias is item. References that
start with field. still point to the root of the document.
forEach field.lines[*] as line then {
line.amount greaterThan 0
line.date dateBeforeOrEqual field.issue_date
when line.type equals "product" then {
line.sku required
}
}{
"issue_date": "2024-05-31",
"lines": [
{ "type": "product", "sku": "A-1", "amount": 1000, "date": "2024-05-02" },
{ "type": "product", "amount": 500, "date": "2024-06-01" },
{ "type": "service", "amount": 0, "date": "2024-05-10" }
]
}{
"valid": false,
"errors": [
{ "field": "lines[1].date", "code": "VALIDATION_ERROR", "message": "El campo 'lines[1].date' debe ser anterior o igual a '2024-05-31'" },
{ "field": "lines[1].sku", "code": "REQUIRED_FIELD", "message": "El campo 'lines[1].sku' es obligatorio" },
{ "field": "lines[2].amount", "code": "INVALID_RANGE", "message": "El campo 'lines[2].amount' debe ser mayor que 0" }
]
}forEach requires the list to exist: a missing list, or a value that is not a
list, fails with INVALID_TYPE on the list's path.
Calculated values with define
define <name> <expression> computes a value once and stores it as
calculated.<name>, so later rules can compare it with fields or other calculated
values. The expression is one of:
| Expression | Result |
|---|---|
sum or add | The sum of every number in the operands. |
subtract | The first operand minus the following ones; each operand is added up first. |
average, min, max | The average, smallest or largest number in the operands. |
count | The number of elements in a list, optionally where <property> <comparison> <number>. |
concat | The operands joined as text. |
| A field or a literal | The value itself, such as define declared field.total or define limit 1000. |
Operands are fields, [*] wildcards over lists, calculated values or literals,
separated by spaces or written in parentheses with commas: sum field.a field.b
and add(field.a, field.b) are equivalent.
define itemsTotal sum field.items[*].amount
define paid sum field.payments[*].amount
define balance subtract calculated.itemsTotal calculated.paid
define reference concat field.series "-" field.folio
calculated.itemsTotal equalsWithPrecision(field.subtotal, 2)
calculated.balance equals field.balance_due
calculated.reference equals "F-1001"{
"series": "F",
"folio": 1001,
"subtotal": 1500,
"balance_due": 1000,
"items": [{ "amount": 1000 }, { "amount": 500 }],
"payments": [{ "amount": 300 }, { "amount": 300 }]
}{
"valid": false,
"errors": [
{ "field": "calculated.balance", "code": "VALIDATION_ERROR", "message": "El campo 'calculated.balance' debe ser igual a '1000', pero es '900'" }
]
}Keep in mind:
- Rules run in order. Define a value before the rules that use it. A
calculatedvalue used before itsdefine, or never defined at all (often a typo), is an error, as shown below. - There are no arithmetic expressions.
define x field.a * field.bis a syntax error; there is no multiplication or division. Extract per-line totals instead of recomputing them from quantity and price. - A failed calculation is an issue on
define. A missing field or a value that is not a number makesdefinereport an issue withfielddefineand the variable name asvalue, and the variable is left undefined. Later rules that use it, as their subject or as a parameter, fail withFIELD_REFERENCE_NOT_FOUND. - A defined value can still be empty.
define low min field.items[*].amountover an empty list storesnull. A rule whose subject is that value is skipped, like any missing value.
Undefined calculated values
A reference to calculated.<name> without an earlier define <name> is usually a
typo. Saving or testing such rules through the API or MCP is rejected with the
line and column of each reference; see managing rules. For
these rules:
define total sum field.items[*].amount
calculated.totl equals field.subtotal
field.subtotal equals calculated.totlthe API returns 400:
{
"error": "Reglas DSL inválidas: La variable calculada 'totl' no está definida; agregue 'define totl …' antes de usarla en línea 2, columna 1; La variable calculada 'totl' no está definida; agregue 'define totl …' antes de usarla en línea 3, columna 23",
"errors": [
{ "line": 2, "column": 1, "message": "La variable calculada 'totl' no está definida; agregue 'define totl …' antes de usarla" },
{ "line": 3, "column": 23, "message": "La variable calculada 'totl' no está definida; agregue 'define totl …' antes de usarla" }
]
}Rules saved before this check, and rules saved from the web app, report the
problem when they run: each rule that uses the undefined value fails with
FIELD_REFERENCE_NOT_FOUND instead of passing.
define total sum field.items[*].amount
calculated.totl equals field.subtotal
field.subtotal equals calculated.totl{ "subtotal": 1500, "items": [{ "amount": 1000 }, { "amount": 500 }] }{
"valid": false,
"errors": [
{ "field": "calculated.totl", "code": "FIELD_REFERENCE_NOT_FOUND", "message": "Error ejecutando operador equals: No existe el campo referenciado 'calculated.totl'" },
{ "field": "subtotal", "code": "FIELD_REFERENCE_NOT_FOUND", "message": "Error ejecutando operador equals: No existe el campo referenciado 'calculated.totl'" }
]
}A define inside a when or forEach block counts as earlier for every rule
that follows it in the text, even if the block did not run for this document; in
that case the rule fails with FIELD_REFERENCE_NOT_FOUND when it runs.
Numbers and precision
Comparisons read numbers written as numbers or as text, including the Spanish
format with . for thousands and , for decimals:
| Text | Number |
|---|---|
"1190.5" | 1190.5 |
"1.234,56" | 1234.56 |
"1.234.567" | 1234567 |
"-1,5" | -1.5 |
"1.234" | 1.234 (a single group of three digits is read as decimals) |
"1,234.56" | not a number |
Write numbers in your extraction schema as JSON numbers whenever you can. For
totals that carry rounding, use equalsWithPrecision instead of equals.
field.total equals 1234.56
field.items_total equalsWithPrecision(field.total, 2)
field.units greaterThan 1000000{ "total": "1.234,56", "items_total": 1234.564, "units": "1.234.567" }{ "valid": true, "errors": [] }Dates
Date operators compare calendar days and accept YYYY-MM-DD, DD/MM/YYYY,
DD-MM-YYYY and ISO 8601 date-times. A date-time is converted to its UTC day
first, so 2024-03-15T23:30:00-03:00 is March 16.
field.due_date dateAfter field.issue_date
field.delivery_date dateEquals field.issue_date
field.signed_at dateEquals "2024-03-15"{
"issue_date": "2024-03-15",
"due_date": "14/04/2024",
"delivery_date": "15-03-2024",
"signed_at": "2024-03-15T23:30:00-03:00"
}{
"valid": false,
"errors": [
{ "field": "signed_at", "code": "VALIDATION_ERROR", "message": "El campo 'signed_at' debe ser igual a '2024-03-15'" }
]
}Complete examples
Invoice: line items add up to the totals
# Header
field.invoice_number required
field.issue_date format date
field.due_date dateAfterOrEqual field.issue_date
field.currency in("CLP", "USD")
# Line items
field.items minLength 1
forEach field.items[*] as item then {
item.description required
item.quantity greaterThan 0
item.amount greaterOrEqual 0
}
# Totals
define itemsTotal sum field.items[*].amount
define expectedTotal sum field.subtotal field.tax
calculated.itemsTotal equalsWithPrecision(field.subtotal, 2)
field.total equalsWithPrecision(calculated.expectedTotal, 2){
"invoice_number": "F-2024-00187",
"issue_date": "2024-09-02",
"due_date": "2024-10-02",
"currency": "CLP",
"items": [
{ "description": "Consulting services", "quantity": 10, "amount": 1000000 },
{ "description": "Travel expenses", "quantity": 1, "amount": 85000 }
],
"subtotal": 1080000,
"tax": 205200,
"total": 1285200
}{
"valid": false,
"errors": [
{ "field": "calculated.itemsTotal", "code": "VALIDATION_ERROR", "message": "El campo 'calculated.itemsTotal' debe ser igual a '1080000' con 2 decimales de precisión, pero es '1085000'" }
]
}Credit card statement: balances and the billing period
field.holder required
field.statement_type in("domestic", "international")
when field.statement_type equals "domestic" then {
field.currency equals "CLP"
} otherwise {
field.currency equals "USD"
}
# Credit limits
field.used_credit lessOrEqual field.credit_limit
define available subtract field.credit_limit field.used_credit
calculated.available equalsWithPrecision(field.available_credit, 2)
# Every transaction belongs to the billing period
forEach field.transactions[*] as tx then {
tx.date dateBetween(field.period_start, field.period_end)
}
# The billed amount is the previous balance plus this period's charges
define charges sum field.transactions[*].amount
define billed sum field.previous_balance calculated.charges
calculated.billed equalsWithPrecision(field.total_billed, 2){
"holder": "Account Holder 1",
"statement_type": "domestic",
"currency": "CLP",
"credit_limit": 1000000,
"used_credit": 350000,
"available_credit": 650000,
"period_start": "2024-06-01",
"period_end": "2024-06-30",
"previous_balance": 120000,
"transactions": [
{ "date": "05/06/2024", "description": "Supermarket", "amount": 80000 },
{ "date": "2024-07-02", "description": "Airline", "amount": 150000 }
],
"total_billed": 350000
}{
"valid": false,
"errors": [
{ "field": "transactions[1].date", "code": "VALIDATION_ERROR", "message": "El campo 'transactions[1].date' debe estar entre '2024-06-01' y '2024-06-30'" }
]
}Voting record: paired counts and totals
A tally sheet records each count in digits and in words, and the extraction schema stores both. The rules compare each pair and check that the counts add up.
# Each candidate's count in digits matches the count in words
forEach field.votes.candidates[*] as candidate then {
when candidate.in_words exists then {
candidate.in_digits equals candidate.in_words
}
}
# Blank and null votes, paired the same way
field.votes.blank.in_digits equals(field.votes.blank.in_words)
field.votes.null.in_digits equals(field.votes.null.in_words)
# Exactly 4 candidates
define candidateCount count field.votes.candidates[*]
calculated.candidateCount equals 4
# Candidates + blank + null = declared total = ballots in the box
define candidateVotes sum field.votes.candidates[*].in_digits
define counted sum calculated.candidateVotes field.votes.blank.in_digits field.votes.null.in_digits
calculated.counted equals field.votes.total.in_digits
field.votes.total.in_digits equals field.ballots_in_box{
"ballots_in_box": 300,
"votes": {
"candidates": [
{ "name": "Candidate 1", "in_digits": 120, "in_words": 120 },
{ "name": "Candidate 2", "in_digits": 85, "in_words": 58 },
{ "name": "Candidate 3", "in_digits": 40, "in_words": 40 },
{ "name": "Candidate 4", "in_digits": 30 }
],
"blank": { "in_digits": 10, "in_words": 10 },
"null": { "in_digits": 15, "in_words": 15 },
"total": { "in_digits": 300, "in_words": 300 }
}
}{
"valid": false,
"errors": [
{ "field": "votes.candidates[1].in_digits", "code": "VALIDATION_ERROR", "message": "El campo 'votes.candidates[1].in_digits' debe ser igual a '58', pero es '85'" }
]
}The sum of candidates, blank and null votes is 300 and matches the declared total, so only the mismatched pair is reported. The fourth candidate has no count in words, so its pair check is skipped.
Reading the results
Each issue has this shape:
{
"field": "items[1].quantity",
"value": 0,
"rule": "greaterThan",
"code": "INVALID_RANGE",
"message": "El campo 'items[1].quantity' debe ser mayor que 0",
"severity": "error",
"source": "rule"
}| Property | Description |
|---|---|
field | Path of the value that failed, without the field. prefix; an empty string for the root of the document. Calculated values appear as calculated.<name> and calculation failures as define. |
value | The value that failed, when there is one. |
rule | The operator or JSON Schema keyword that failed, such as greaterThan or required. |
code | A stable code for the kind of problem. Use it in your code instead of the message. |
message | A description in Spanish, meant for people. It can change between versions. |
severity | error or warning. Rules and schemas report errors; warnings is kept for compatibility and is normally empty. |
source | schema for the JSON Schema layer, rule for the DSL. |
details | Extra context when available, such as the JSON Schema keyword and its parameters. Optional. |
A document is valid when it has no errors. The validator reports up to 100 errors per document.
Issue codes
| Code | Raised by |
|---|---|
REQUIRED_FIELD | A required property in the JSON Schema; required and exists. |
INVALID_TYPE | A JSON Schema type; an operator that received the wrong kind of value, such as a text that is not a number or an invalid date; forEach over something that is not a list. |
INVALID_FORMAT | A JSON Schema format; format. |
INVALID_PATTERN | A JSON Schema pattern; pattern. |
INVALID_ENUM | A JSON Schema enum or const; in. |
INVALID_RANGE | JSON Schema numeric limits; greaterThan, lessThan, greaterOrEqual, lessOrEqual, between and range. |
INVALID_LENGTH | JSON Schema length and size limits; length, minLength and maxLength. |
ADDITIONAL_PROPERTIES | A property the JSON Schema does not allow (additionalProperties: false). |
FIELD_REFERENCE_NOT_FOUND | A rule or define refers to a field that is not in the document, or to a calculated value that was never defined. |
RULE_EXECUTION_ERROR | A rule could not be evaluated as written: a comparison against a [*] reference with several values, a pattern that is not valid, uses syntax that RE2 does not support, or is over its limits (see the operator reference), or a field path that is not valid. The message says which. An internal failure of Docutray is never reported as an issue: conversions report validation as { "status": "error" }, and /validate responds 500. |
VALIDATION_ERROR | Any other failed rule: equals, notEquals, equalsWithPrecision, text and date operators, notIn, notExists, contains, a failed define, or another JSON Schema keyword. |
Managing rules
Rules belong to a document type and are stored in its dslRules field as text.
They are limited to 64 KiB of UTF-8 text. Rules are checked whenever they are
saved or tested through the API or MCP: syntax errors and calculated values used
without an earlier define are rejected with the line, column and a message in
Spanish for each problem, and nothing is saved.
REST API
GET /api/document-types/{id} returns dslRules, or null when the type has no
rules. Send dslRules when you create a type with POST /api/document-types or
update one with PUT /api/document-types/{id}:
curl -X PUT https://app.docutray.com/api/document-types/YOUR_DOCUMENT_TYPE_ID \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"dslRules": "field.total greaterThan 0\nfield.due_date dateAfter field.issue_date"
}'In PUT, omitting dslRules keeps the saved rules, and null or "" removes
them. When you omit them, the saved rules are checked again for syntax only: if
they no longer compile, the update fails with 400 until you send new rules.
Saved rules with an undefined calculated value do not block other edits.
Updating a type stores its previous configuration as a version. Invalid rules
return 400:
{
"error": "Reglas DSL inválidas: Se esperaba un operador registrado. Encontrado: greaterThen en línea 1, columna 13",
"errors": [
{ "line": 1, "column": 13, "message": "Se esperaba un operador registrado. Encontrado: greaterThen" }
]
}Test rules without saving them
POST /api/document-types/{id}/validate checks a JSON document against the type
without running OCR. Send the document as the whole body to use the saved rules,
or wrap it as { "data", "dslRules" } to try other rules for this call only. The
JSON Schema still runs; null or "" tests without rules.
curl -X POST https://app.docutray.com/api/document-types/YOUR_DOCUMENT_TYPE_ID/validate \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": { "subtotal": 1000, "tax": 190, "total": 1290 },
"dslRules": "define expected sum field.subtotal field.tax\nfield.total equalsWithPrecision(calculated.expected, 2)"
}'{
"valid": false,
"issues": [
{
"field": "total",
"value": 1290,
"rule": "equalsWithPrecision",
"message": "El campo 'total' debe ser igual a '1190' con 2 decimales de precisión, pero es '1290'",
"code": "VALIDATION_ERROR",
"severity": "error",
"source": "rule"
}
],
"errors": {
"count": 1,
"messages": ["El campo 'total' debe ser igual a '1190' con 2 decimales de precisión, pero es '1290'"]
},
"warnings": { "count": 0, "messages": [] }
}issues lists errors first and then warnings; errors and warnings keep the
summary format of earlier versions. The response is 200 even when the document
has errors. A body with both data and dslRules at its root is always read as a
test request, and that envelope accepts no other fields. See
Document Types and the
API reference for the full contract.
MCP
The Docutray MCP server exposes the same cycle to AI agents:
get_document_typereturns the type'sjson_schemaanddslRules.validate_document_datatestsdataagainst the saved rules, or againstdslRuleswhen given, without saving anything:{ "document_type": "invoice", "data": { "total": 0 }, "dslRules": "field.total greaterThan 0" }.update_document_type_validation_rulessaves the rules once they work:{ "codeType": "invoice", "dslRules": "field.total greaterThan 0" }. It needs thedocutray:document_types:writepermission, saves a version, and returnschanged: falsewhen the rules are identical.
SDKs and CLI
The Node.js and Python SDKs and the CLI do not expose dslRules or the rule test
yet. Until they do, manage rules with the REST API or MCP. Their existing
validate methods keep working and return the errors and warnings summaries.
Related pages
Create Document Type
Step-by-step guide to create custom document types using the AI-powered creation wizard
Validation Operators
Reference for every operator in the Docutray validation DSL — presence, comparison, numeric precision, text, lists, dates and calculated values — with a runnable example for each.