Document Validation
GoRoute's full validation stack checks a document in several layers, in a fixed order. It runs
on deep validation (POST /api/v1/invoices/validate/deep), the CSV import preview, the CSV
import-and-send job and GoRoute's readiness assessments. Other validation calls run lighter
checks โ see the other validation endpoints.
Validation Layersโ
Document โ XSD โ STRUCTURE โ BUSINESS โ EDEC โ SCHEMATRON โ SCHEMATRON_CIUS
Each finding names the layer that produced it in its layer field. The first four layers stop
at the first one that fails, so a document refused by the UBL 2.1 schema shows only schema
findings; fix them and validate again to see the next layer. (A readiness assessment reports
every layer at once.) The two Schematron layers run in full.
layer | What it checks |
|---|---|
XSD | The official UBL 2.1 schema: UBL-Invoice-2.1.xsd for an Invoice, UBL-CreditNote-2.1.xsd for a Credit Note. Element names, order and nesting, required elements, data types and namespaces. Every violation is fatal, with identifier UBL-XSD. A document that is not well-formed XML is XML_PARSE_ERROR; a root element that is not a UBL Invoice or CreditNote is also UBL-XSD |
STRUCTURE | GoRoute's own fast checks of the Peppol BIS 3.0 essentials: mandatory elements, identifiers and their schemes, code and date formats, each under the BR- code it mirrors. Every finding is fatal. Not the official rules โ the Schematron layer is |
BUSINESS | Calculation and business rules on the invoice data, under GoRoute's own codes such as BUS_034. Runs when GoRoute has the invoice data, not only its XML (deep validation and CSV import) |
EDEC | The seller's and buyer's Peppol ID schemes against the Peppol code lists, as EDEC-ICD-<scheme>. Runs when GoRoute has the invoice data |
SCHEMATRON | The official CEN EN16931 and Peppol BIS 3.0 Schematron rules, or the PINT pack for a PINT document. The authoritative check: any error or fatal finding refuses the document; a warning does not |
SCHEMATRON_CIUS | National CIUS rules for the receiver's country. Runs no rules today โ see Country CIUS below |
A schema finding from deep validation has this shape (the two values in angle brackets are written by the schema validator for your document):
{
"layer": "XSD",
"identifier": "UBL-XSD",
"identifier_type": "rule_id",
"severity": "fatal",
"message": "<the schema validator's message, naming the element>",
"xpath": "<where in the document>",
"field": null,
"rule_id": null,
"assert_id": null
}
The CSV import preview reports the same layers per invoice as xsd_valid, structure_valid,
business_valid, edec_valid, schematron_valid and cius_valid.
Schematron rules (SCHEMATRON)โ
Validates against EN16931 European standard and Peppol BIS 3.0:
EN16931 (Base)
โ
โโโ Peppol BIS 3.0 (Peppol-specific rules)
โ
โโโ Country CIUS (National requirements)
Common Schematron Errors:
| Rule | Description | Severity |
|---|---|---|
| BR-01 | Invoice must have invoice number | Error |
| BR-02 | Invoice must have issue date | Error |
| BR-16 | Line extension amount must be rounded to 2 decimals | Error |
| PEPPOL-EN16931-R001 | BIS profile must be specified | Error |
| PEPPOL-EN16931-R002 | Customization ID must be valid | Error |
Country CIUS (SCHEMATRON_CIUS) โ not implementedโ
This layer exists in the code path and does nothing: it is a stub that finds no configured pack and returns no findings. XRechnung, FatturaPA, Factur-X and NLCIUS Schematrons are not compiled into the GoRoute validator and are not run. Documents for Germany, Italy, France and the Netherlands are validated against CEN EN16931 + Peppol BIS 3.0 only.
The national requirements below are real requirements of those jurisdictions โ they are listed so you know what your document must satisfy โ but GoRoute will not catch a breach of them for you. Validate against national tooling as well if you need that assurance.
| Country | National CIUS | Key requirement โ not checked by GoRoute |
|---|---|---|
| ๐ฉ๐ช Germany | XRechnung | Leitweg-ID required for B2G |
| ๐ฎ๐น Italy | FatturaPA | Codice Destinatario required |
| ๐ซ๐ท France | Factur-X | Chorus Pro requirements |
| ๐ณ๐ฑ Netherlands | NLCIUS | KVK number format |
What is selected per jurisdiction is the PINT pack, chosen from the document's
cbc:CustomizationID โ see
Schematron packs GoRoute actually runs.
Pre-Validation APIโ
POST /api/v1/documents/validate validates a UBL document without sending it. It takes the
same JSON shape as the send endpoint โ the XML travels as a string in the document
field โ and requires the invoices:read permission.
import requests
def validate_document(xml_content: str) -> dict:
"""Validate a UBL document without sending it."""
response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/documents/validate",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={"document": xml_content},
)
return response.json()
result = validate_document(invoice_xml)
if result["valid"]:
print("Document is valid")
else:
print(f"Validation failed: {result['error_count']} error(s)")
for issue in result["issues"]:
print(f" [{issue['severity']}] {issue['code']}: {issue['message']}")
Request bodyโ
| Field | Type | Required | Meaning |
|---|---|---|---|
document | string | Yes | The UBL document as an XML string |
document_type | string | No | Defaults to the Peppol BIS 3.0 Invoice document type identifier |
profile | string | No | Validation profile, e.g. peppol-bis3 or en16931 |
Validation responseโ
{
"valid": false,
"document_type": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice",
"profile": "peppol-bis3",
"error_count": 1,
"warning_count": 1,
"issues": [
{
"severity": "error",
"code": "BR-16",
"message": "Amount MUST be rounded to maximum 2 decimals",
"location": "/Invoice/cac:InvoiceLine[1]/cbc:LineExtensionAmount",
"rule_id": "BR-16"
}
],
"sender_id": "9959:123456789",
"receiver_id": "9959:987654321",
"document_id": "INV-2026-001",
"issue_date": "2026-08-05"
}
Errors and warnings arrive in a single flat issues array, distinguished by
severity. There is no nested validation_layers object and no per-layer breakdown on
this endpoint.
| Field | Type | Meaning |
|---|---|---|
valid | boolean | Whether the document passed |
document_type | string | The document type identifier applied |
profile | string or null | The validation profile applied |
error_count | integer | Number of error issues |
warning_count | integer | Number of warning issues |
issues | array | Each with severity, code, message, and optionally location and rule_id |
sender_id | string or null | Parsed from the document, if it parsed |
receiver_id | string or null | Parsed from the document, if it parsed |
document_id | string or null | Parsed from the document, if it parsed |
issue_date | string or null | Parsed from the document, if it parsed |
The other validation endpointsโ
Six endpoints check a document before it goes anywhere, and they take different inputs and give different answers. Pick by what you are holding and what you want back:
| You have | Endpoint | Notes |
|---|---|---|
| UBL XML | POST /api/v1/documents/validate | The endpoint above |
| A canonical invoice (JSON) | POST /api/v1/invoices/validate | Fast structural and business-rule check; returns a compliance score and country findings such as COUNTRY_AU_001 |
| A canonical invoice, and you want Schematron | POST /api/v1/invoices/validate/deep | Full CEN EN16931 + Peppol BIS, or the PINT pack for a PINT document |
| A canonical invoice, and you want to see the XML we would send | POST /api/v1/invoices/preview-xml | Validates, then returns the rendered Peppol UBL itself instead of a verdict โ see Preview the XML before you send it |
| A canonical invoice, and you want a judgement rather than a rule list | POST /api/v1/intelligence/score | A 0-to-1 confidence score, a risk level and a plain recommendation. Not a rules verdict โ see Score an invoice |
| A canonical invoice you want corrected for you | POST /api/v1/intelligence/auto-fix | Returns a corrected copy and the list of what it changed. Read the currency caution first โ see Have an invoice corrected |
POST /api/v1/invoices has no dry runPOST /api/v1/invoices always sends. Earlier revisions of the API reference showed a
validate_only flag on it, at the top level and inside an options object next to
send_immediately. The flag was never read, so the invoice was sent (and, for an Omani
seller, reported to the Tax Authority). It is now refused: validate_only: true, in either
place, answers HTTP 422 with error_code VALIDATE_ONLY_NOT_SUPPORTED, and nothing
is created, stored or sent. To check an invoice without sending it, POST the same body to
POST /api/v1/invoices/validate (or validate/deep, or preview-xml), which never send.
Never set validate_only expecting a dry run: use POST /api/v1/invoices/validate.
The same revisions taught two more keys in that options object, and neither was read
either. Both are now refused the same way, at the top level or inside options, before
anything is created, stored or sent:
send_immediatelyother thantrueanswers HTTP 422 witherror_codeSEND_IMMEDIATELY_NOT_SUPPORTED.POST /api/v1/invoicesalways sends at once; there is no "send later" on it. To keep an invoice and send it later, create a draft withPOST /api/v1/draftsand send it withPOST /api/v1/drafts/{draft_id}/send(a draft is sent with totals calculated from its lines; the drafts page lists what else differs).send_immediately: true, or leaving it out, sends as before.idempotency_keyin the body answers HTTP 422 witherror_codeIDEMPOTENCY_KEY_IN_BODY_NOT_SUPPORTED. It never stopped a duplicate: the key is read only from theIdempotency-Keyrequest header. Send it there. A bodyidempotency_keyequal to theIdempotency-Keyheader is accepted, because the header deduplicates that request.
Anything else in an options object is still ignored.
Preview the XML before you send itโ
POST /api/v1/invoices/preview-xml renders the exact Peppol UBL that the platform would
transmit for your invoice, and hands it back to you instead of sending it. Nothing is
transmitted, nothing is billed, no transaction record is created and no certificate is
touched. When you are debugging what we build out of your JSON, this is the call that
answers the question directly.
It takes the same request body as the send call โ an invoice object and the same
options, including totals_authority โ and it requires the invoices:read permission,
which is a lighter permission than the one needed to send.
curl -X POST "https://app.goroute.ai/peppol-api/api/v1/invoices/preview-xml" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-H "Accept: application/xml" \
-d '{
"invoice": { "...": "the same canonical invoice you would send" },
"totals_authority": "SYSTEM"
}'
What comes backโ
On success the response is raw application/xml โ the UBL document itself, not JSON
with the XML inside a field. It carries a filename for it:
HTTP/1.1 200 OK
Content-Type: application/xml
Content-Disposition: inline; filename="INV-2026-001.xml"
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
...
</Invoice>
The filename is your own invoice number with .xml appended.
The invoice is validated first, and an invoice that does not pass is never rendered. In that case you get 422 with the field-level errors and the compliance score, in the same shape the other invoice-validation calls use:
{
"message": "Invoice validation failed",
"errors": [
{
"field": "buyer.name",
"code": "BUS_008",
"severity": "error",
"message": "Buyer name is required",
"technical_code": "BUS_008",
"suggested_fix": null,
"auto_fix_available": false
}
],
"score": 85,
"request_id": "โฆ"
}
If totals_authority is SYSTEM โ which is the default โ the calculated totals are applied
to the invoice before it is rendered, exactly as the send path does it. So the preview shows
you the totals that would actually travel, not the ones you posted.
Two things it does not doโ
- It does not show you a buyer-specific variant. The preview renders with the default
profile resolution, which follows the buyer's country. It will not show you the XRechnung
customisation a particular German public-sector buyer would be sent, even if you set
document_formatin the body; that field is honoured by the send call, not by this one. - It is not the delivery decision. Seeing the XML tells you what would be built. It does not tell you whether the buyer is reachable on the network, or whether the document would go over Peppol or by email โ see Sending an Invoice and Routing for that.
Will this invoice go through, and what can be fixed for meโ
The endpoints above answer "does this break a rule". Two further calls answer a different question: how likely is this invoice to be accepted, and can GoRoute correct the obvious problems for you. Both take a canonical invoice in GoRoute's JSON format.
Score an invoiceโ
POST /api/v1/intelligence/score โ requires the reports:read permission. That is not
the permission the other validation calls need, and it is worth knowing before you build
against it: an API key with read or admin scope holds reports:read; a key scoped only
to sending does not.
Send the invoice under an invoice key:
curl -X POST https://app.goroute.ai/peppol-api/api/v1/intelligence/score \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"invoice": { "...": "the same canonical invoice you would send" }}'
What comes back:
| Field | Meaning |
|---|---|
overall_score | A number from 0.0 to 1.0. How it is made up is below. |
confidence | The four parts the score is built from: data_completeness, tax_confidence, delivery_probability, compliance_score, each 0.0 to 1.0. |
risk_level | low, medium, high or critical. |
recommendation | A short sentence in plain English, chosen from five by the overall_score. The text carries a leading symbol: โ
Ready to send - high confidence (0.95 and above), โ
Ready to send - good confidence (0.85), โ ๏ธ Review recommended before sending (0.70), โ ๏ธ Several issues need attention (0.50), โ Significant issues - not recommended to send (below 0.50). |
is_sendable | true or false โ see the caution below. |
risk_factors | One entry per problem found, each with the field, its risk, a reason, and auto_fixable saying whether the next call can correct it. |
How overall_score is built. It is a fixed weighting of the four parts, so you can see
why a score moved:
| Part | Weight |
|---|---|
delivery_probability | 0.30 |
data_completeness | 0.25 |
tax_confidence | 0.25 |
compliance_score | 0.20 |
is_sendable is not a validation verdictis_sendable is false in only two situations: the risk level is critical, or the
invoice sits below a minimum compliance level that this call works out for itself. An
invoice can be is_sendable: true and still be rejected by the official Schematron rules,
because this call does not run them.
Use it as a triage signal โ which invoices to look at first โ and use
POST /api/v1/invoices/validate/deep when you need to know whether a document actually
passes.
Have an invoice correctedโ
POST /api/v1/intelligence/auto-fix โ requires the invoices:send permission. It
returns a corrected copy of your invoice and a list of every change it made. It does not
send anything and it does not alter anything stored; the corrected invoice comes back to
you to do as you like with.
The invoice goes in the body on its own โ not wrapped in an invoice key, unlike the scoring
call above โ and the optional fixes parameter goes in the query string:
# Apply only the fixes you name
curl -X POST "https://app.goroute.ai/peppol-api/api/v1/intelligence/auto-fix?fixes=recalculate_totals&fixes=fix_rounding" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{ "...": "the canonical invoice, at the top level" }'
The reply has fixed_invoice (the corrected copy), applied_fixes (one entry per change,
each naming the action, the field, the old_value and the new_value) and fix_count.
Five fixes exist, and you name them by the values in the first column:
fixes value | What it does |
|---|---|
recalculate_totals | Recalculates the invoice totals where they do not add up. |
apply_default_currency | Sets currency to EUR when the invoice has none. Read the caution below. |
fix_country_case | Upper-cases the seller's and buyer's country codes. |
fix_rounding | Rounds net_amount, tax_amount and payable_amount to two decimals. |
apply_default_unit | Sets a line's unit_code to EA (each) when the line has none. |
fixes out and it applies all five โ including the euroWhen you do not pass fixes, every fix above is applied. One of them writes
currency: "EUR" into any invoice that has no currency. It is a fixed value: it does not
look at your organisation's country or at its own default currency.
For a seller in Oman, whose currency is the rial, that is a wrong currency applied without
being asked for โ and because it happens silently, the only sign is the
apply_default_currency entry in applied_fixes.
Two ways to stay safe: always set currency on the invoice yourself, or name the fixes you
want in the query string and leave apply_default_currency out. Always read applied_fixes
before you send the result.
We have asked GoRoute engineering to decide whether this default should stand.
Neither call replaces validation. Score the invoice to decide where to look, auto-fix to clear the mechanical problems, then validate before you send.
Error Handlingโ
Error Severityโ
| Severity | Action | Can Send? |
|---|---|---|
error | Must fix before sending | โ No |
warning | Review recommended | โ Yes |
info | Informational only | โ Yes |
Common Errors and Fixesโ
BR-16: Rounding Errorโ
# โ Wrong: Too many decimals
<cbc:LineExtensionAmount currencyID="EUR">100.123</cbc:LineExtensionAmount>
# โ
Correct: Maximum 2 decimals
<cbc:LineExtensionAmount currencyID="EUR">100.12</cbc:LineExtensionAmount>
BR-CO-10: Tax Total Mismatchโ
# โ Wrong: Tax total doesn't match calculated
line_tax = 21.00 # (100.00 * 21%)
invoice_tax_total = 20.00 # Incorrect
# โ
Correct: Values match
line_tax = 21.00
invoice_tax_total = 21.00