JavaScript and TypeScript SDK

@xmlvalid/sdk: validate XML and e-invoices from Node, Bun, Deno or Cloudflare Workers, against the catalog, your own schemas and Schematron business rules.

Install and set up

npm install @xmlvalid/sdk      # or: pnpm add, yarn add, bun add

It has no dependencies: requests go through the platform's fetch. It runs on Node 20+, Bun, Deno and Cloudflare Workers, ships ESM and CommonJS, and has TypeScript types. 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:

import { Xmlvalid } from '@xmlvalid/sdk';          // or: const { Xmlvalid } = require('@xmlvalid/sdk')

const xmlvalid = new Xmlvalid();                    // XMLVALID_API_KEY
const other = new Xmlvalid({ apiKey: env.XMLVALID_API_KEY });   // Workers, Deno: pass it

The key belongs on a server. In a browser app, send the file to your own backend and validate there (see uploads).

Documents

validate() sends a document, waits for the result and returns a Validation. The first argument is the document:

await xmlvalid.validate('invoice.xml');                           // a file path, streamed from disk
await xmlvalid.validate(file);                                    // a File or Blob (keeps a File's name)
await xmlvalid.validate(bytes, { name: 'invoice.xml' });          // a Uint8Array, Buffer or ArrayBuffer
await xmlvalid.validate(stream, { name: 'invoice.xml' });         // a ReadableStream
await xmlvalid.validate({ text: xml });                           // a string
await 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. A path is read with fs.openAsBlob, so it needs Node, Bun or Deno; in Workers and browsers pass a File, bytes or a stream.

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:

await xmlvalid.validate('order.xml', { schema: 'ubl-2.1-order' });
await 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 (const schema of await xmlvalid.schemas()) {
    console.log(schema.schema, schema.name, schema.saved ? '(saved)' : '');
}

Your own schema file

A single XSD, DTD or RelaxNG file (.rng or compact .rnc), as a path or a Blob, goes with the request; its language is read from its content:

await xmlvalid.validate('order.xml', { schemaFile: '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:

await 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

await 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:

const validation = await xmlvalid.validate('invoice.xml', { schema: 'peppol-bis-3-invoice' });

for (const error of validation.errors) {
    if (error.type === 'schematron') {
        console.log(`${error.rule} on line ${error.line}: ${error.message}`);
        console.log(`  What it checks and how to fix it: ${error.ruleUrl}`);
    }
}
// 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.messageEn. String(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:

await xmlvalid.validate('invoice.xml', {
    schema: 'peppol-bis-3-invoice',     // its own rules run first
    rules: 'rules/our-checks.sch',      // a path or a Blob
});

Rules split over several files (sch:include, sch:extends, xsl:include) are passed as an object 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:

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

const validation = await xmlvalid.validate('invoice.xml', { schema: 'xrechnung-3-ubl' });

if (validation.isValid) {
    // 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:

const validation = await 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.

const validation = await xmlvalid.validate('facture-2026-0412.pdf');

validation.result?.pdf;   // { attachment: 'factur-x.xml', guideline: 'urn:cen.eu:en16931:2017', profile: … }
const pdfProblems = validation.errors.filter((error) => 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).

const validation = await xmlvalid.validate('march-invoices.zip', { schema: 'auto' });

const { passed, total } = validation.result?.documents ?? {};
console.log(`${passed} of ${total} passed`);
for (const item of validation.result?.items ?? []) {
    if (!item.valid) {
        console.log(`${item.name} (${item.schema}): ${item.errorsTotal} errors`);
        for (const error of item.errors) console.log('  ' + error);   // the first 20 of each
    }
}

Large files and long validations

A path is read from disk as it is sent, and a ReadableStream is sent as it comes, so a large document is never held in memory. 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

import { ValidationTimeoutError } from '@xmlvalid/sdk';

try {
    const validation = await xmlvalid.validate('export.xml', { schema: 'my-12', timeout: 120 });
} catch (error) {
    if (!(error instanceof ValidationTimeoutError)) throw error;
    const id = error.validation.id;   // still running on the server; fetch it later
}

The default timeout is 15 minutes; timeout is in seconds. A timeout only stops the waiting: the validation goes on, and its result is kept for 24 hours. signal takes an AbortSignal to stop the requests and the waiting, for example AbortSignal.timeout(30_000).

Queue it and come back

In a request handler or a serverless function with a short time limit, submit() answers at once with the validation's id. Store it, and fetch the result later:

const queued = await xmlvalid.submit('export.xml', { schema: 'my-12' });
await db.jobs.update(jobId, { validationId: queued.id });   // status: queued

// later, in another request or job
const current = await xmlvalid.validation(job.validationId);
if (current.isFinished) {
    // done, failed or cancelled
} else {
    console.log(current.status, `${current.progress}%`);   // running 40%
}

// or wait until it is done
const done = await xmlvalid.waitFor(job.validationId, { timeout: 600 });

// or give up on it
await xmlvalid.cancel(job.validationId);

On Pro, a webhook tells you when it has finished, so nothing has to poll.

Uploads in a web app

A File from a form goes straight in. With a framework that gives you Web Request objects (Next.js route handlers, Hono, Remix, SvelteKit, Workers):

// app/api/invoices/route.ts (Next.js)
import { Xmlvalid } from '@xmlvalid/sdk';

const xmlvalid = new Xmlvalid();

export async function POST(request: Request) {
    const form = await request.formData();
    const file = form.get('invoice');
    if (!(file instanceof File)) return Response.json({ error: 'No file' }, { status: 422 });

    const validation = await xmlvalid.validate(file, { schema: 'auto', timeout: 25 });
    return Response.json({
        valid: validation.isValid,
        errors: validation.errors.slice(0, 10).map(String),
    });
}

With Express and multer, pass the file's path (disk storage) or req.file.buffer with name: req.file.originalname (memory storage).

Reading the result

validation.status;                    // done, failed (on xmlvalid's side) or cancelled
validation.isValid;                   // true for the outcomes valid and warnings
validation.result?.outcome;           // valid, warnings, invalid, schema, input or failed
validation.result?.errorsTotal;       // all of them
validation.result?.errorsTruncated;   // true when more than the listed 500
validation.result?.bytes; validation.result?.durationMs;
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

for (const error of 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 null
    error.messageEn;  // the English text of a rule written in another language
    error.ruleUrl;    // the rule's page on xmlvalid.com
    error.helpUrl;    // 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.

Reports and links

const html = await xmlvalid.report(validation);          // the HTML report: verdict, schema, every finding
await writeFile('report.html', html);                    // open it and print to PDF

const link = await xmlvalid.share([validation, other]);  // Pro: one public page for several
console.log(link.url, 'until', link.expiresAt);          // 30 days

const usage = await xmlvalid.usage();
console.log(`${usage.used} of ${usage.limit} this month, ${usage.remaining} left`);

Errors and retries

A finding in the document is not an error: it is in the result. Errors are thrown for requests that could not be done. All extend XmlvalidError; refusals by the API are ApiErrors with statusCode, errorCode and, for 422, fields:

ErrorWhen
AuthenticationError401: no key, or a revoked one
PlanLimitError403: not in your plan (report links on Free)
NotFoundError404: no such validation, or it expired after 24 hours
NotFinishedError409: the report of a validation still running
DocumentTooLargeError413: over your plan's size limit
InvalidRequestError422: an unknown schema, a bad URL, a schema file that isn't one
QuotaExceededError429: this month's validations are used up
RateLimitedError429: too many requests a minute, still so after the retries; retryAfter
ServerError5xx
TransportErrorxmlvalid could not be reached, or answered with something unreadable
ValidationTimeoutErrorStill running after the timeout; error.validation has it
import { InvalidRequestError, QuotaExceededError } from '@xmlvalid/sdk';

try {
    await xmlvalid.validate('invoice.xml', { schema: slug });
} catch (error) {
    if (error instanceof InvalidRequestError) console.log(error.errorCode, error.fields);   // unknown_schema
    else if (error instanceof QuotaExceededError) { /* wait for next month, or upgrade */ }
    else throw error;
}

A request refused with 429 rate_limited is sent again after the Retry-After the API gives, up to 3 times (maxRetries). A ReadableStream can't be sent twice, so it is not retried. An aborted signal rejects with the platform's AbortError, as fetch does.

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; it uses Web Crypto, so it works on every runtime. With Express:

import express from 'express';
import { SignatureVerificationError, WebhookEvent, verifyWebhook } from '@xmlvalid/sdk';

const app = express();

app.post('/xmlvalid/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
    let event;
    try {
        event = await verifyWebhook(req.body, req.get('Xmlvalid-Signature'), process.env.XMLVALID_WEBHOOK_SECRET!);
    } catch (error) {
        if (error instanceof SignatureVerificationError) return res.sendStatus(403);
        throw error;
    }

    if (event.type === WebhookEvent.VALIDATION_COMPLETED) {
        const validation = event.validation;   // the same object validation() returns
    } else if (event.type === WebhookEvent.MONITOR_FAILED) {
        const { monitor, check } = event.data;
    }
    res.sendStatus(204);
});

With a Web Request, pass await request.text() and request.headers.get('Xmlvalid-Signature'). 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 a fetch of your own to answer without the network:

const xmlvalid = new Xmlvalid({
    apiKey: 'xv_test',
    fetch: async () => Response.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' },
        ] },
    }),
});

MSW, nock (with Node's fetch) and similar tools work as well, since the client only calls fetch.

Options and limits

new Xmlvalid({
    apiKey: 'xv_…',                  // default: XMLVALID_API_KEY
    baseUrl: '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)
    maxRetries: 3,                   // retries of a 429 rate_limited
    fetch: myFetch,                  // proxies, tests
});
FreePro
API validations a month10010,000
Largest document10 MB1 GB
Requests a minute10120
Saved schemas25200
Rules kept with a saved schema, webhooks, report linksNoYes

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.