Install and set up
pip install xmlvalid # or: uv add xmlvalid, poetry add xmlvalid
Its one dependency is httpx. Create an API key under Dashboard → API keys and put it in XMLVALID_API_KEY; the client reads it from there when you don't pass one:
from xmlvalid import Client
xmlvalid = Client() # XMLVALID_API_KEY
xmlvalid = Client("xv_…") # or explicitly
with Client() as xmlvalid: # closes its connection pool at the end
...
A client is thread-safe and meant to be shared. The package is typed (py.typed): editors and mypy know every field.
Documents
validate() sends a document, waits for the result and returns a Validation. The first argument is the document:
from pathlib import Path
xmlvalid.validate("invoice.xml") # a path, streamed from disk
xmlvalid.validate(Path("exports") / "invoice.xml") # a Path
xmlvalid.validate(data, name="invoice.xml") # bytes
xmlvalid.validate(request.files["invoice"].stream) # a binary file object (an upload)
xmlvalid.validate(text="<Invoice>…</Invoice>") # a string
xmlvalid.validate(url="https://example.com/feed.xml") # a public URL that xmlvalid downloads
Besides XML, a document can be gzipped (.gz), a .zip of many documents, an EPUB or a Factur-X / ZUGFeRD PDF. name= sets the name shown in results, reports and your history; for a path or a named file object it defaults to the file's name.
Schemas
Without a schema, a document is checked for well-formedness only. Choose one of these:
A catalog schema
The catalog has the official schemas, with all their include files. Pass a schema's slug:
xmlvalid.validate("order.xml", schema="ubl-2.1-order")
xmlvalid.validate("sitemap.xml", schema="sitemap-0.9")
The slug is on each schema's page, or list them (your saved schemas first, then the catalog):
for schema in xmlvalid.schemas():
print(schema.schema, schema.name, "(saved)" if schema.saved else "")
Your own schema file
A single XSD, DTD or RelaxNG file (.rng or compact .rnc) goes with the request; its language is read from its content:
xmlvalid.validate("order.xml", schema_file="schemas/order.xsd")
A schema that spans several files
When a schema has xs:include or xs:import (or RelaxNG include), save it once under Dashboard → Schemas: upload all its files, or its folder, and mark the main one. Relative paths such as ../common/types.xsd resolve as they do on disk. The saved schema gets an id such as my-12, used like a catalog slug:
xmlvalid.validate("order.xml", schema="my-12")
The same id works in the browser validator and in URL monitors. Free accounts keep up to 25 saved schemas, Pro up to 200.
The document's own DTD
xmlvalid.validate("page.xml", doctype=True)
This checks the document against the DTD in its own <!DOCTYPE> (the internal subset; external DTD files are not fetched).
Business rules (Schematron)
A schema says which elements may appear; business rules say whether the content makes sense: totals that add up, mandatory fields, code lists, dates. They are written in Schematron and run after the schema check, only on a document that passed it.
Rules that come with a catalog profile
The e-invoice profiles run their official rules for you: en16931-ubl-invoice the EN 16931 rules, peppol-bis-3-invoice EN 16931 and Peppol's, xrechnung-3-ubl EN 16931 and XRechnung's, france-ctc-ubl the French BR-FR rules, and so on. Each finding names its rule:
validation = xmlvalid.validate("invoice.xml", schema="peppol-bis-3-invoice")
for error in validation.errors:
if error.type == "schematron":
print(f"{error.rule} on line {error.line}: {error.message}")
print(f" What it checks and how to fix it: {error.rule_url}")
# BR-CO-15 on line 212: Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + …
Some rule sets are written in another language: the Croatian rules of hr-eracun-invoice report in Croatian, with the English text in error.message_en. str(error) uses the English text when there is one.
Your own rules
An ISO Schematron file (query binding xslt2 or xslt3) runs after the schema, with a catalog schema, your own schema file or no schema at all:
xmlvalid.validate(
"invoice.xml",
schema="peppol-bis-3-invoice", # its own rules run first
rules="rules/our-checks.sch",
)
Rules split over several files (sch:include, sch:extends, xsl:include) are passed as a dict of relative path to file. The paths are the ones the files use to include each other; the main file is the one no other file includes:
xmlvalid.validate(
"invoice.xml",
rules={
"main.sch": "rules/main.sch",
"parts/totals.sch": "rules/parts/totals.sch",
"parts/parties.sch": "rules/parts/parties.sch",
},
)
Rules are compiled on the first request and kept for a week, so later requests with the same files start at once. Rules that don't compile give the outcome schema with the compiler's message. On Pro, rules you use all the time can be kept with a saved schema instead, and then run wherever it is used.
E-invoices
One format
When you know the format, name its profile: it runs the schema and the business rules in one go.
validation = xmlvalid.validate("invoice.xml", schema="xrechnung-3-ubl")
if validation.is_valid:
... # passes the XRechnung schema and rules; warnings are in validation.warnings
The country guides list the right profile for each mandate: Germany, France, Belgium, Poland, Italy, Spain and more.
Invoices of mixed formats
auto reads which profile each invoice names (its specification identifier BT-24, or the document element and namespace for national formats such as FatturaPA, KSeF or CFDI) and validates against it:
validation = xmlvalid.validate(path, schema="auto")
validation.schema["catalog"] # "peppol-bis-3-invoice", the profile it picked
validation.schema["detected"] # True; False if it didn't recognise the document
A document auto doesn't recognise is checked for well-formedness only, so look at detected before trusting a pass.
Factur-X, ZUGFeRD and XRechnung PDFs
Send the PDF itself. The XML invoice inside it is taken out and validated against the profile it names (or the schema you pass), and the PDF is checked for what Factur-X asks of it: the attachment, its relationship and the XMP metadata.
validation = xmlvalid.validate("facture-2026-0412.pdf")
validation.result.pdf # {"attachment": "factur-x.xml", "guideline": "urn:cen.eu:en16931:2017", "profile": …}
pdf_problems = [error for error in validation.errors if error.type == "pdf"] # PDF-AF, PDF-XMP, …
It is not a PDF/A conformance check (fonts, colours, structure); use veraPDF for that.
A batch
Put the invoices in a .zip: every XML file and PDF invoice in it is validated, up to 1,000, each against the schema you pass (auto works per file).
validation = xmlvalid.validate("march-invoices.zip", schema="auto")
documents = validation.result.documents
print(f"{documents['passed']} of {documents['total']} passed")
for item in validation.result.items:
if not item.valid:
print(f"{item.name} ({item.schema}): {item.errors_total} errors")
for error in item.errors: # the first 20 of each
print(" ", error)
Building the archive in memory works too: zipfile.ZipFile(io.BytesIO(), "w"), then pass the buffer's bytes with name="batch.zip".
Large files and long validations
A path or a file object is streamed as it is sent, in 64 KB chunks, never read into memory whole. The validation itself runs on a queue: validate() holds the first request up to 10 seconds (wait), then polls, at 1 second, then less often, up to every 5 seconds.
Waiting longer, or less
from xmlvalid import ValidationTimeoutError
try:
validation = xmlvalid.validate("export.xml", schema="my-12", timeout=120)
except ValidationTimeoutError as e:
validation_id = e.validation.id # still running on the server; fetch it later
The default timeout is 15 minutes. A timeout only stops the waiting: the validation goes on, and its result is kept for 24 hours. ValidationTimeoutError is also a TimeoutError.
Queue it and come back
In a web request or a task you don't want to block, submit() answers at once with the validation's id. Store it, and fetch the result later:
validation = xmlvalid.submit("export.xml", schema="my-12")
task.validation_id = validation.id # status: queued
# later, in another request or task
validation = xmlvalid.validation(task.validation_id)
if validation.is_finished:
... # done, failed or cancelled
else:
print(validation.status, f"{validation.progress}%") # running 40%
# or block until it is done
validation = xmlvalid.wait_for(task.validation_id, timeout=600)
# or give up on it
xmlvalid.cancel(task.validation_id)
With Celery, RQ or Dramatiq, submit in the request and wait in the task. On Pro, a webhook tells you when it has finished, so nothing has to poll.
Async
AsyncClient has the same methods, awaited, and works with asyncio and trio:
import asyncio
from xmlvalid import AsyncClient
async def main() -> None:
async with AsyncClient() as xmlvalid:
paths = ["a.xml", "b.xml", "c.xml"]
results = await asyncio.gather(*(xmlvalid.validate(p, schema="auto") for p in paths))
for path, validation in zip(paths, results):
print(path, validation.result.outcome if validation.result else validation.status)
asyncio.run(main())
Keep the number of documents in flight within your plan's requests a minute; a short burst over it is retried for you.
Reading the result
validation.status # done, failed (on xmlvalid's side) or cancelled
validation.is_valid # True for the outcomes valid and warnings
validation.result.outcome # valid, warnings, invalid, schema, input or failed
validation.result.errors_total # all of them
validation.result.errors_truncated # True when more than the listed 500
validation.result.bytes, validation.result.duration_ms
validation.schema # {"kind": "xsd", "catalog": "peppol-bis-3-invoice", …}
validation.error # what went wrong when the status is failed
validation.raw # the API's JSON, as a dict
for error in validation.errors: # and validation.warnings
error.line # 0 when about the whole document
error.column # 0 when unknown
error.level # fatal, error or warning
error.type # syntax, xsd, relaxng, dtd, schematron, pdf, …
error.message
error.rule # "BR-CO-15" for a business rule, else None
error.message_en # the English text of a rule written in another language
error.rule_url # the rule's page on xmlvalid.com
error.help_url # what this kind of message means and how to fix it
The outcomes: invalid means the document broke the schema or the rules; schema that the schema itself could not be used (an XSD that doesn't compile, a missing include); input that the document could not be read (not XML, a broken archive); failed that something broke on our side. All objects are frozen dataclasses.
Reports and links
html = xmlvalid.report(validation) # the HTML report: verdict, schema, every finding
Path("report.html").write_text(html) # open it and print to PDF
link = xmlvalid.share([validation, other]) # Pro: one public page for several
print(link.url, "until", link.expires_at.date()) # 30 days
usage = xmlvalid.usage()
print(f"{usage.used} of {usage.limit} this month, {usage.remaining} left")
Errors and retries
A finding in the document is not an exception: it is in the result. Exceptions are for requests that could not be done. All extend xmlvalid.XmlvalidError; refusals by the API are ApiErrors with status_code, error_code and, for 422, fields:
| Exception | When |
|---|---|
AuthenticationError | 401: no key, or a revoked one |
PlanLimitError | 403: not in your plan (report links on Free) |
NotFoundError | 404: no such validation, or it expired after 24 hours |
NotFinishedError | 409: the report of a validation still running |
DocumentTooLargeError | 413: over your plan's size limit |
InvalidRequestError | 422: an unknown schema, a bad URL, a schema file that isn't one |
QuotaExceededError | 429: this month's validations are used up |
RateLimitedError | 429: too many requests a minute, still so after the retries; retry_after |
ServerError | 5xx |
TransportError | xmlvalid could not be reached, or answered with something unreadable |
ValidationTimeoutError | Still running after the timeout; e.validation has it |
from xmlvalid import InvalidRequestError, QuotaExceededError
try:
validation = xmlvalid.validate("invoice.xml", schema=slug)
except InvalidRequestError as e:
print(e.error_code, e.fields) # unknown_schema {"schema": ["…"]}
except QuotaExceededError:
... # wait for next month, or upgrade
A request refused with 429 rate_limited is sent again after the Retry-After the API gives, up to 3 times (max_retries). A document from a non-seekable stream can't be sent twice, so it is not retried.
Webhooks
On Pro, add an endpoint under Dashboard → Webhooks. xmlvalid then posts an event when an API validation finishes (validation.completed), when a URL monitor starts failing or passes again (monitor.failed, monitor.recovered), and a ping when you test the endpoint. Check its signature with the endpoint's secret and the raw body. With FastAPI:
import os
from fastapi import FastAPI, HTTPException, Request, Response
from xmlvalid import Event, SignatureVerificationError, webhooks
app = FastAPI()
@app.post("/xmlvalid/webhook")
async def xmlvalid_webhook(request: Request) -> Response:
try:
event = webhooks.verify(
await request.body(),
request.headers.get("Xmlvalid-Signature"),
os.environ["XMLVALID_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
raise HTTPException(status_code=403)
if event.type == Event.VALIDATION_COMPLETED:
validation = event.validation # the same object validation() returns
elif event.type == Event.MONITOR_FAILED:
monitor, check = event.data["monitor"], event.data["check"]
return Response(status_code=204)
In Django use request.body and request.headers["Xmlvalid-Signature"], in Flask request.get_data(). A signature older than 5 minutes is refused, against replays (tolerance=, in seconds). A delivery that fails is retried with the same event.id, so keep the ids you have handled.
Testing
Pass an httpx client with a mock transport to answer without the network:
import httpx
from xmlvalid import Client
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"id": "val_test", "status": "done",
"result": {"valid": False, "outcome": "invalid", "errors_total": 1, "errors": [
{"line": 12, "level": "error", "type": "schematron", "rule": "BR-CO-15", "message": "Totals"},
]},
})
xmlvalid = Client("xv_test", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
For AsyncClient, pass httpx.AsyncClient(transport=httpx.MockTransport(handler)). respx works as well.
Options and limits
Client(
api_key="xv_…", # default: XMLVALID_API_KEY
base_url="https://xmlvalid.com/api/v1",
timeout=900, # seconds validate() waits in all
wait=10, # seconds the API holds a new validation's request (0-60)
max_retries=3, # retries of a 429 rate_limited
http_client=httpx.Client(...), # proxies, certificates, a mock transport
)
| Free | Pro | |
|---|---|---|
| API validations a month | 100 | 10,000 |
| Largest document | 10 MB | 1 GB |
| Requests a minute | 10 | 120 |
| Saved schemas | 25 | 200 |
| Rules kept with a saved schema, webhooks, report links | No | Yes |
A document in a .zip counts as one validation each. Schematron rules run on documents up to 50 MB, PDFs up to 100 MB, archives up to 1,000 documents.
The source, the changelog and the issue tracker are on GitHub. The HTTP API underneath is described in the API reference.