DocuTray

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:

LayerStored inChecksIssue source
JSON SchemajsonSchemaThe shape of the data: types, required fields, formats, enums, numeric limits.schema
Business rulesdslRulesDomain 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.

JSON Schema
{
  "type": "object",
  "required": ["invoice_number"],
  "properties": {
    "invoice_number": { "type": "string" },
    "subtotal": { "type": "number" },
    "tax": { "type": "number" },
    "total": { "type": "number" }
  }
}
Rules
# The total must be the subtotal plus tax
define expected sum field.subtotal field.tax
field.total equalsWithPrecision(calculated.expected, 2)
Document
{ "subtotal": 1000, "tax": 190, "total": 1290 }
Result
{
  "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

WhereFieldShape
Synchronous conversion, POST /api/convertvalidationDetailed result
Conversion status, GET /api/convert-async/status/{id} (status SUCCESS)validationDetailed result
CONVERSION_COMPLETED webhookvalidationDetailed result
MCP convert_document and get_conversion_statusvalidationDetailed result, shortened when very long
Steps status and STEP_COMPLETED webhookvalidationSummary: errors and warnings with count and messages
On demand, POST /api/document-types/{id}/validate and MCP validate_document_datathe whole responsevalid, 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 fields

A 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:

SymbolOperator
= or ==equals
!=notEquals
>greaterThan
>=greaterOrEqual
<lessThan
<=lessOrEqual

Field references

ReferenceMeaning
field.totalThe property total at the root of the extracted data.
field.supplier.tax_idA nested property.
field.items[0].amountamount in the first element of the items list.
field.items[*].amountamount in every element of items.
calculated.subtotalA 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

ValueExamples
Numbers0, -5, 1190.5, 1e6
Text, in double or single quotes"CLP", 'F-', "line\nbreak"
Booleans and nulltrue, false, null
Regular expressions/^F-\d+$/i
Field referencesfield.amount_due, calculated.total
Lists, as parametersin("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.

Rules
field.total greaterThan 0
field.invoice_number required
field.discount greaterThan 0
Document
{ "total": 1190, "discount": null }
Result
{
  "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:

Rules
field.total equals field.amount_due
Document
{ "total": 1190 }
Result
{
  "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:

Rules
field.items[*].quantity greaterThan 0
field.items[0].description required
Document
{
  "items": [
    { "description": "Consulting", "quantity": 2 },
    { "description": "Travel", "quantity": 0 },
    { "description": "Support", "quantity": -1 }
  ]
}
Result
{
  "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:

Rules
# Each candidate's count in digits must match the count written in words
field.candidates[*].votes equals(field.candidates[*].votes_in_words)
Document
{
  "candidates": [
    { "name": "Candidate 1", "votes": 120, "votes_in_words": 120 },
    { "name": "Candidate 2", "votes": 85, "votes_in_words": 58 }
  ]
}
Result
{
  "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:

Rules
field.ordered[*].quantity equals(field.delivered[*].quantity)
field.total greaterThan 0
Document
{
  "total": 0,
  "ordered": [{ "quantity": 10 }, { "quantity": 5 }],
  "delivered": [{ "quantity": 10 }, { "quantity": 5 }]
}
Result
{
  "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.

Rules
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
}
Document
{ "statement_type": "international", "currency": "CLP", "discount": 5000 }
Result
{
  "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.

Rules
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
  }
}
Document
{
  "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" }
  ]
}
Result
{
  "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:

ExpressionResult
sum or addThe sum of every number in the operands.
subtractThe first operand minus the following ones; each operand is added up first.
average, min, maxThe average, smallest or largest number in the operands.
countThe number of elements in a list, optionally where <property> <comparison> <number>.
concatThe operands joined as text.
A field or a literalThe 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.

Rules
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"
Document
{
  "series": "F",
  "folio": 1001,
  "subtotal": 1500,
  "balance_due": 1000,
  "items": [{ "amount": 1000 }, { "amount": 500 }],
  "payments": [{ "amount": 300 }, { "amount": 300 }]
}
Result
{
  "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 calculated value used before its define, or never defined at all (often a typo), is an error, as shown below.
  • There are no arithmetic expressions. define x field.a * field.b is 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 makes define report an issue with field define and the variable name as value, and the variable is left undefined. Later rules that use it, as their subject or as a parameter, fail with FIELD_REFERENCE_NOT_FOUND.
  • A defined value can still be empty. define low min field.items[*].amount over an empty list stores null. 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.totl

the 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.

Rules
define total sum field.items[*].amount
calculated.totl equals field.subtotal
field.subtotal equals calculated.totl
Document
{ "subtotal": 1500, "items": [{ "amount": 1000 }, { "amount": 500 }] }
Result
{
  "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:

TextNumber
"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.

Rules
field.total equals 1234.56
field.items_total equalsWithPrecision(field.total, 2)
field.units greaterThan 1000000
Document
{ "total": "1.234,56", "items_total": 1234.564, "units": "1.234.567" }
Result
{ "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.

Rules
field.due_date dateAfter field.issue_date
field.delivery_date dateEquals field.issue_date
field.signed_at dateEquals "2024-03-15"
Document
{
  "issue_date": "2024-03-15",
  "due_date": "14/04/2024",
  "delivery_date": "15-03-2024",
  "signed_at": "2024-03-15T23:30:00-03:00"
}
Result
{
  "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

Rules
# 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)
Document
{
  "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
}
Result
{
  "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

Rules
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)
Document
{
  "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
}
Result
{
  "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.

Rules
# 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
Document
{
  "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 }
  }
}
Result
{
  "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"
}
PropertyDescription
fieldPath 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.
valueThe value that failed, when there is one.
ruleThe operator or JSON Schema keyword that failed, such as greaterThan or required.
codeA stable code for the kind of problem. Use it in your code instead of the message.
messageA description in Spanish, meant for people. It can change between versions.
severityerror or warning. Rules and schemas report errors; warnings is kept for compatibility and is normally empty.
sourceschema for the JSON Schema layer, rule for the DSL.
detailsExtra 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

CodeRaised by
REQUIRED_FIELDA required property in the JSON Schema; required and exists.
INVALID_TYPEA 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_FORMATA JSON Schema format; format.
INVALID_PATTERNA JSON Schema pattern; pattern.
INVALID_ENUMA JSON Schema enum or const; in.
INVALID_RANGEJSON Schema numeric limits; greaterThan, lessThan, greaterOrEqual, lessOrEqual, between and range.
INVALID_LENGTHJSON Schema length and size limits; length, minLength and maxLength.
ADDITIONAL_PROPERTIESA property the JSON Schema does not allow (additionalProperties: false).
FIELD_REFERENCE_NOT_FOUNDA rule or define refers to a field that is not in the document, or to a calculated value that was never defined.
RULE_EXECUTION_ERRORA 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_ERRORAny 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:

  1. get_document_type returns the type's json_schema and dslRules.
  2. validate_document_data tests data against the saved rules, or against dslRules when given, without saving anything: { "document_type": "invoice", "data": { "total": 0 }, "dslRules": "field.total greaterThan 0" }.
  3. update_document_type_validation_rules saves the rules once they work: { "codeType": "invoice", "dslRules": "field.total greaterThan 0" }. It needs the docutray:document_types:write permission, saves a version, and returns changed: false when 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.

On this page