Getting started
Make a key under Dashboard → API keys (an account with a confirmed email), and send it with every request:
Authorization: Bearer xv_…
For GitHub Actions, GitLab CI, pre-commit and the command line, the xmlvalid client does the requests and polling for you. The API is also described as OpenAPI 3.1.
The base URL is https://xmlvalid.com/api/v1. Requests and answers are JSON, except the document itself, which you send as a file or as the request body.
Validate a document
POST /validations takes the document in one of three ways:
# a file (multipart)
curl https://xmlvalid.com/api/v1/validations -H "Authorization: Bearer $KEY" \
-F [email protected] -F schema=ubl-2.1-invoice
# a public URL, downloaded by our server
curl https://xmlvalid.com/api/v1/validations -H "Authorization: Bearer $KEY" \
-F url=https://example.com/feed.atom -F schema=atom-1.0
# the XML as the request body
curl "https://xmlvalid.com/api/v1/validations?schema=sitemap-0.9&name=sitemap.xml" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/xml" \
--data-binary @sitemap.xml
Gzip-compressed documents are decompressed on the fly. The schema is optional; without one the document is checked for well-formedness only.
Several documents at once
Send a .zip as document or the body and every file in it with an XML extension (.xml, .svg, .xhtml…) is validated against the request's schema and rules, and every PDF invoice as described below, up to 1,000 documents; folders are kept in the names. An .epub is checked file by file, each against its own catalog schema, as in the validator: container.xml, the package document, the NCX, and the XHTML, SVG and SMIL files of the manifest for well-formedness, plus what is wrong with the archive itself. Each document checked counts as one API validation, and an archive the rest of the month's quota doesn't cover is refused before anything runs. The result then has documents (total, passed, failed, skipped) and items: one result per document with its name and schema, at most 20 errors and 20 warnings each (all counted). Documents over your plan's file limit, and any left after 15 minutes, have the outcome skipped and a note. Archives can't be fetched by url.
PDF invoices
A Factur-X, ZUGFeRD or XRechnung PDF sent as document, the body or a url (and one a URL monitor watches) has its XML invoice taken out (factur-x.xml, zugferd-invoice.xml, xrechnung.xml) and validated against schema or, without one, the catalog profile its specification identifier (BT-24) names, with that profile's business rules. The PDF around it is checked against what Factur-X and ZUGFeRD ask of it: the attachment's name, /AFRelationship and MIME type, its place in the catalog's /AF, and XMP metadata that declares PDF/A-3 and names the attachment and the same profile. Those findings have the type pdf and a code such as PDF-AF in rule; the result's pdf names the attachment, the identifier and the profile. It is not a PDF/A conformance check (fonts, colour, structure): use a PDF/A validator such as veraPDF for that. A UBL invoice in a PDF is validated with its UBL profile (Peppol BIS, XRechnung, EN 16931) and noted as PDF-UBL, since Factur-X and ZUGFeRD carry CII. PDFs up to 100 MB; encrypted PDFs and ZUGFeRD 1.0 are not supported.
schema- A schema from the public catalog by its slug (
ubl-2.1-invoice), or one of your saved schemas (my-12). Saved schemas keep all their include files, so this is how to use a schema that spans several files; on Pro, Schematron rules kept with a saved schema run after it.autopicks the catalog profile an e-invoice names: its specification identifier (BT-24) and business process (BT-23) for UBL and CII (Peppol BIS, XRechnung, EN 16931, the French profiles), or its document element and namespace for other formats (FatturaPA, Facturae, KSeF FA(3), CFDI, NF-e, ISDOC, Finvoice);schema.detectedis then true, and a document it doesn't recognise is checked for well-formedness only. In a.zip, each file is read on its own; withurl, the start of the download is read before validation begins, and a gzipped document is read inflated. schema_file- A single XSD, DTD or RelaxNG (
.rngor compact.rnc) file sent with the request. Its language is read from its content. rules_file- Your own Schematron rules: an ISO Schematron file (query binding
xslt2orxslt3), up to 1 MB with the files it includes. For includes, repeatrules_file[]with the relative path of each file inrules_path[]; the main file is the schema none of the others includes. They run after the schema check, and after a catalog schema's own rules, on a document that passed it and is at most 50 MB. Findings have the typeschematronand the rule's id inrule; rules that don't compile give the outcomeschemawith the compiler's message. The same rules are compiled once and kept for a week. doctype=true- Validate against the DTD the document declares in its own
<!DOCTYPE>(its internal subset; external files are not fetched). wait- Seconds to wait for the result, 0–60 (default 10). See waiting and polling.
name- For a document sent as the body: its file name, shown in your history when you keep file names.
The result
{
"id": "val_01jb…",
"status": "done",
"progress": 100,
"document": { "source": "upload", "name": "invoice.xml", "size": 18342 },
"schema": { "kind": "xsd", "catalog": "ubl-2.1-invoice", "name": "UBL 2.1 Invoice" },
"result": {
"valid": false,
"outcome": "invalid",
"errors_total": 1,
"warnings_total": 0,
"errors_truncated": false,
"errors": [
{ "line": 14, "column": 0, "level": "error", "type": "xsd", "rule": null,
"message": "Element 'cbc:IssueDate': '2026-13-01' is not a valid value of the atomic type 'xs:date'.", "message_en": null,
"help_url": "https://xmlvalid.com/errors/not-a-valid-value-of-the-atomic-type", "rule_url": null }
],
"warnings": [],
"bytes": 18342,
"duration_ms": 41
},
"error": null,
"links": { "self": "https://xmlvalid.com/api/v1/validations/val_01jb…", "report": "https://xmlvalid.com/api/v1/validations/val_01jb…/report" }
}
statusqueued,running,done,failed(our fault:errorsays what happened) orcancelled.result.outcomevalid,warnings(valid, with warnings),invalid,schema(the schema itself has errors or can't be loaded),input(the document can't be read or downloaded) orfailed.result.validis true for the first two.errors,warnings- The first 500 of each, with line and column;
errors_totalcounts them all.typeissyntax,xsd,relaxng,dtd,schema,io, orschematronfor a business rule (of a catalog schema such aspeppol-bis-3-invoice, or your own),archivefor a problem with an EPUB archive itself, orpdffor the PDF around an invoice; forschematron,rulenames it (BR-CO-15), otherwise it is null.help_urllinks a common message to its explanation, or is null;rule_urllinks a rule of a catalog schema to its page, or is null;message_engives the message in English for a catalog rule its publisher wrote in another language (the Croatian HR-BR-*), or is null.
Waiting and polling
Validations run on a queue. The request waits up to wait seconds: if the validation finishes by then the answer is 200 with the result, otherwise 202 with the status so far and a Location header. Fetch it until status is no longer queued or running:
curl https://xmlvalid.com/api/v1/validations/val_01jb… -H "Authorization: Bearer $KEY"
For large files, send wait=0 and poll every few seconds. DELETE /validations/{id} cancels a validation that is still queued or running.
GET /validations/{id}/report returns a finished result as an HTML page, the report the validator's Report button makes: the verdict, the schema, and every listed finding with its line, the rule's page and the explanation of the message. Open it in a browser and print it to save a PDF; before the validation has finished it answers 409 not_finished.
On the Pro plan, POST /reports with {"validations": ["val_…", "val_…"]} stores the report of those finished validations behind a public link and answers 201 with {"url": "…", "expires_at": "…"}: one link for a whole CI run, to put in a pull request comment or a ticket. Anyone with the link can open the report for 30 days, without an API key. It keeps the verdicts and the findings, which can quote values from the documents; the documents are not kept. Links are listed under Report links in the dashboard, up to 200 at a time; on Free, or past that number, the call answers 403 plan_limit.
Webhooks
On the Pro plan you can add up to 5 https endpoints under Dashboard → Webhooks instead of polling. When an API validation finishes (done, failed or cancelled), each endpoint gets a POST with the same object GET /validations/{id} returns:
POST /hooks/xmlvalid
Content-Type: application/json
Xmlvalid-Event: validation.completed
Xmlvalid-Delivery: evt_01jb…
Xmlvalid-Signature: t=1792152000,v1=5f8c…
{ "id": "evt_01jb…", "type": "validation.completed", "created_at": "…", "data": { "id": "val_01jb…", "status": "done", "result": { … } } }
Monitors send monitor.failed and monitor.recovered to the same endpoints. Answer with any 2xx within 10 seconds. Anything else, a timeout or a redirect counts as a failure and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. A retry keeps the event id, so use it to skip events you have already handled. After 20 failed deliveries in a row the endpoint is switched off until you enable it again. A new endpoint, and the Send test button, send a ping event.
Check the signature. v1 is the HMAC-SHA256 of t, a dot and the raw body, keyed with the endpoint's secret (whsec_…). Compare in constant time and refuse old timestamps:
// PHP
[$t, $v1] = sscanf($_SERVER['HTTP_XMLVALID_SIGNATURE'], 't=%d,v1=%s');
$body = file_get_contents('php://input');
$ok = abs(time() - $t) < 300
&& hash_equals(hash_hmac('sha256', "$t.$body", $secret), $v1);
// Node.js
const [, t, v1] = req.headers['xmlvalid-signature'].match(/t=(\d+),v1=([0-9a-f]+)/);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const ok = Math.abs(Date.now() / 1000 - t) < 300 && v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
URL monitors
A monitor validates a document at a public URL on a schedule (a product feed, a sitemap, a partner's export) and tells you when it starts failing and when it passes again. Set them up under Dashboard → Monitors (Pro, up to 10): the URL, a schema (catalog, saved, the document's DOCTYPE, or the e-invoice profile the document names, read at each check) and how often: every hour, every 6 hours or once a day. Checks don't count towards your API validations, and they use your plan's document size limit.
A check that can't download or read the document counts as failing. You get one email (unless you turn it off for that monitor) and one webhook event per change, not one per failed check:
{ "id": "evt_01jb…", "type": "monitor.failed", "created_at": "…",
"data": {
"monitor": { "id": 7, "name": "Product feed", "url": "https://example.com/feed.xml",
"schema": "atom-1.0", "interval_minutes": 60, "status": "failing" },
"check": { "result": "invalid", "valid": false, "errors_total": 3, "warnings_total": 0,
"message": "line 12: Element 'updated': '2026-13-01' is not a valid value…",
"bytes": 48213, "duration_ms": 37, "checked_at": "…" }
} }
monitor.recovered has the same shape. Webhook events are signed and retried as described above. Each monitor keeps 30 days of checks with their first error message.
Other endpoints
GET /schemas- Your saved schemas and the public catalog, with the value to pass as
schema. GET /usage- Your plan, validations used this month and the limits.
Plans and limits
| Free | Pro | |
|---|---|---|
| API validations per month | 100 | 10,000 |
| Largest document | 10 MB | 1 GB |
| Requests per minute | 10 | 120 |
| API keys | 1 | 10 |
| Webhook endpoints | – | 5 |
| URL monitors | – | 10 |
Every POST /validations counts, whatever its outcome; an archive counts each document it checks. Months follow UTC. Answers carry X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset. Validating in the browser is not counted and stays free up to 1 GB.
Errors
Requests that can't be served answer with an HTTP error and a JSON body:
{ "error": { "code": "quota_exceeded", "message": "You have used this month's 100 API validations on the Free plan." } }
- 401
unauthenticated - No key, a revoked key, or an account without a confirmed email.
- 413
too_large - The document is over your plan's size limit.
- 422
missing_document,invalid_url,unknown_schema,invalid_schema,invalid_rulesorinvalid_request(withfields).- 429
quota_exceededfor the month, orrate_limitedper minute (seeRetry-After).
Your data
Documents and schema files you send are deleted as soon as the validation finishes. The result, with its error messages, is kept for 24 hours so you can fetch it, then deleted. The validation is added to your history in the dashboard like a browser one: facts about the run, and file names only if you turned that on. Documents fetched by URL are downloaded from public addresses only.
The webhook delivery log in the dashboard is kept for 30 days: event, endpoint, response status and timing. The bodies sent, which contain the results, are kept for 24 hours so a delivery can be resent, then deleted. We never store what your endpoint answers.