Skip to content

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

http
POST /api/v1/validate

multipart/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.

FieldRequiredWhat 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.
bash
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"
javascript
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.

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

json
{
  "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 an UNSUPPORTED layer 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 reading valid alone is not enough.
  • result.findingCount — every finding the validation produced. returnedFindingCount is how many are in this response, and findingsTruncated says 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.

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

StatusMeaning
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 as BR-CO-10. This is the stable contract. Branch on it.
  • severity — error, warning or info. 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.