# API reference

Source: https://xmlvalid.com/docs/api

> Validate XML from scripts and CI with the same engine as the validator, running on our servers.

## Getting started

Make a key under [Dashboard → API keys](https://xmlvalid.com/dashboard/api) (an account with a confirmed email), and send it with every request:

```http
Authorization: Bearer xv_…
```

For GitHub Actions, GitLab CI, pre-commit and the command line, the [xmlvalid client](https://xmlvalid.com/docs/ci) does the requests and polling for you. The API is also described as [OpenAPI 3.1](https://xmlvalid.com/docs/api/openapi.yaml).

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:

```bash
# a file (multipart)
curl https://xmlvalid.com/api/v1/validations -H "Authorization: Bearer $KEY" \
  -F document=@invoice.xml -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](#pdf), 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](https://xmlvalid.com/schemas) by its slug (`ubl-2.1-invoice`), or one of your [saved schemas](https://xmlvalid.com/dashboard/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. `auto` picks 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.detected` is 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; with `url`, 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 (`.rng` or 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 `xslt2` or `xslt3`), up to 1 MB with the files it includes. For includes, repeat `rules_file[]` with the relative path of each file in `rules_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 type `schematron` and the rule's id in `rule`; rules that don't compile give the outcome `schema` with 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](#polling).

**`name`**

For a document sent as the body: its file name, shown in your history when you keep file names.

## The result

```json
{
  "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" }
}
```

**`status`**

`queued`, `running`, `done`, `failed` (our fault: `error` says what happened) or `cancelled`.

**`result.outcome`**

`valid`, `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) or `failed`. `result.valid` is true for the first two.

**`errors`, `warnings`**

The first 500 of each, with line and column; `errors_total` counts them all. `type` is `syntax`, `xsd`, `relaxng`, `dtd`, `schema`, `io`, or `schematron` for a business rule (of a catalog schema such as `peppol-bis-3-invoice`, or your own), `archive` for a problem with an EPUB archive itself, or `pdf` for the PDF around an invoice; for `schematron`, `rule` names it (`BR-CO-15`), otherwise it is null. `help_url` links a common message to [its explanation](https://xmlvalid.com/errors), or is null; `rule_url` links a rule of a catalog schema to [its page](https://xmlvalid.com/rules), or is null; `message_en` gives 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`:

```bash
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](https://xmlvalid.com/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:

```http
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`](#monitors) 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
// 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);
```

```javascript
// 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](https://xmlvalid.com/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:

```json
{ "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:

```json
{ "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_rules` or `invalid_request` (with `fields`).

**429**

`quota_exceeded` for the month, or `rate_limited` per minute (see `Retry-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.
