Validating a document
One endpoint, one document per request, one explicitly named target. What the request must carry, what the report contains, and why an invalid invoice is a 200.
The endpoint
POST /api/v1/validatemultipart/form-data, exactly two fields, exactly one file. A second file, a repeated field or an unexpected field is refused rather than ignored — silently dropping part of a request is how a caller comes to believe something was validated when it was not.
| Field | Required | What it is |
|---|---|---|
| target | Yes | A public target id. There is no default and no detection: see the catalogue. |
| file | Yes | The document. XML or PDF, decided from the bytes rather than from the filename or the declared content type. |
curl https://edocumenttools.com/api/v1/validate \
-H "Authorization: Bearer $EDOCUMENT_API_KEY" \
-F "target=en16931-1.3.16-ubl-2.1" \
-F "file=@invoice.xml"import { readFile } from 'node:fs/promises';
const key = process.env.EDOCUMENT_API_KEY;
if (!key) throw new Error('EDOCUMENT_API_KEY is not set');
const bytes = await readFile('invoice.xml');
const form = new FormData();
form.set('target', 'en16931-1.3.16-ubl-2.1');
form.set('file', new File([bytes], 'invoice.xml', { type: 'application/xml' }));
const response = await fetch('https://edocumenttools.com/api/v1/validate', {
method: 'POST',
headers: { Authorization: `Bearer ${key}` },
body: form,
});
// A 4xx or 5xx is about the request, never about the invoice.
if (!response.ok) {
const problem = await response.json();
throw new Error(`${problem.code} (request ${problem.requestId}): ${problem.title}`);
}
const report = await response.json();
if (report.result.valid) {
console.log('Valid against', report.target.id, report.target.version);
} else {
for (const finding of report.result.findings) {
console.log(`${finding.severity} ${finding.ruleId ?? ''} ${finding.message}`);
}
}HTTP status describes the request, not the invoice
This is the single most important thing on this page. A document that fails validation is answered with 200 OK and a report saying so. The API was asked to run a rule set against a document, it did, and the answer is the finding list.
A 4xx or 5xx means the API could not do that: the key was wrong, the target does not exist, the upload was too large, the service was busy. Treating a 4xx as "invalid invoice" would make every integration report a transport failure as a document problem, and — worse — would make retrying look reasonable when it is not.
if (!response.ok) {
// The request failed. Nothing has been concluded about the document.
throw new Error(problem.code);
}
const report = await response.json();
if (!report.result.valid) {
// The document was validated, and it did not pass.
}The report
One finding shown, and the presentation-only message object each check also carries is omitted; the XPath in location is abbreviated here because a real one is long. Everything else is exactly what the API returns.
{
"apiVersion": "v1",
"requestId": "req_5f2a91c4d0b7486e9a3c18bd7e04f562",
"target": {
"id": "en16931-1.3.16-ubl-2.1",
"family": "generic",
"version": "1.3.16"
},
"input": {
"kind": "xml",
"sizeBytes": 8241
},
"result": {
"valid": false,
"partial": false,
"detectedProfile": null,
"ruleSetVersions": {
"ubl-2.1-xsd": "2.1",
"en16931-1.3.16-ubl-sef": "1.3.16"
},
"checks": [
{
"layer": "XSD",
"label": "XML Schema validation",
"status": "PASSED",
"detail": "Structure and datatypes conform to OASIS UBL 2.1.",
"ruleSetId": "ubl-2.1-xsd",
"ruleSetVersion": "2.1",
"errorCount": 0,
"warningCount": 0,
"durationMs": 208
},
{
"layer": "EN16931",
"label": "EN 16931 business rules",
"status": "FAILED",
"detail": "1 rule fired from EN 16931 1.3.16 (UBL).",
"ruleSetId": "en16931-1.3.16-ubl-sef",
"ruleSetVersion": "1.3.16",
"errorCount": 1,
"warningCount": 0,
"durationMs": 259
}
],
"findings": [
{
"ruleId": "BR-CO-10",
"severity": "error",
"message": "[BR-CO-10]-Sum of Invoice line net amount (BT-106) = Σ Invoice line net amount (BT-131).",
"location": "/*:Invoice[namespace-uri()='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'][1]/*:LegalMonetaryTotal[...]",
"source": "EN 16931 1.3.16 (UBL)",
"explanation": null,
"remediation": null,
"sourceFlag": "fatal"
}
],
"findingCount": 1,
"returnedFindingCount": 1,
"findingsTruncated": false,
"durationMs": 471
}
}The verdict fields
result.valid— true only when every requested layer ran and passed. A report containing anUNSUPPORTEDlayer is never valid, whatever the other layers say.result.partial— true when at least one layer produced no verdict. This is the field that distinguishes "checked and clean" from "not fully checked", and it is why readingvalidalone is not enough.result.findingCount— every finding the validation produced.returnedFindingCountis how many are in this response, andfindingsTruncatedsays whether the two differ. A document with thousands of findings is capped rather than streamed.
Layers
result.checks is one entry per layer, in execution order, each with its own status, its own counts, and the rule package and version that produced it.
| Layer | What it checks |
|---|---|
| XSD | XML Schema validation against the pinned schema package. |
| EN16931 | The European semantic and business rules. |
| COUNTRY_PROFILE | National or profile rules, such as the French BR-FR set. |
Before any of these run, the bytes have to be well-formed and the syntax has to be recognised. Those are not reported as layers: a document that fails them never reaches a rule set, so it is answered with document_unreadable or target_input_mismatch rather than with a report containing a failed check.
Which layers a target runs is a property of that target, and the catalogue states it. A layer that did not run says so; it never inherits the previous layer's success.
| Status | Meaning |
|---|---|
| PASSED | The layer ran and found nothing at error severity. |
| FAILED | The layer ran and produced at least one error. |
| WARNING | The layer ran and produced warnings but no errors. |
| NOT_RUN | The layer was not reached, usually because an earlier one failed. |
| SKIPPED | The layer does not apply to this target. |
| UNSUPPORTED | No executable artefact exists for this combination. Never treated as a pass. |
Findings
ruleId— the official identifier exactly as the rule package states it, such asBR-CO-10. This is the stable contract. Branch on it.severity—error,warningorinfo. Lower-case.message— the rule package's own text, unmodified and in English. Useful to show a human; not something to match on, because an upstream release can reword it.location— where in the document the rule fired, when the engine provides one.source— which rule package produced the finding, including its version. This is what lets you attribute a finding to a release.
Request identifiers
Every response carries requestId, in the body and in the X-Request-Id header, and every problem document carries the same value. It is the one thing that ties a call you made to a line in this deployment's operational log.
Log it on your side, and quote it when you report a problem. Without it a report of "a validation failed yesterday" cannot be located; with it, it can. There is no status dashboard or ticketing system behind this — the contact form is the channel.
What happens to the document
It is processed in memory. No temporary file is written at any point, nothing is retained, and no document content, filename, party name, amount or finding text is written to a log or a database. What is recorded is a per-day count of requests and bytes against your project. The privacy policy sets this out in full.