Skip to content

Peppol BIS validation API

Check Peppol BIS Billing 3.0 invoices by REST API against selected rules, with returned findings explained. Klarbon does not send invoices.

This capability is in beta: it works and is tested, and we may still change details.

On this page

The Klarbon Peppol BIS validation API checks a Peppol BIS Billing 3.0 invoice or credit note against the selected Peppol and EN 16931 rules with one REST call, and explains returned findings in plain words. Use it before you hand a file to your access point. Klarbon is not an access point: it does not send, receive or register anything on the Peppol network.

What the API does

  • POST /api/v1/check selects rules from the file. To select Peppol explicitly, use ruleset=peppol-bis3-invoice for an invoice or ruleset=peppol-bis3-creditnote for a credit note.
  • Each finding has a rule id, a severity, the location in the XML and a message.
  • The same check runs in the browser tool, so a person can look at a failing file too.

What it does not do

Most searches for a Peppol API mean sending invoices. For that you need an access point; Klarbon checks the file before it goes there. Validation is in beta, and checks count towards your plan's daily checks.

Quick start

Each snippet is the same call. The end-to-end test runs these snippets against a local Klarbon server and the real validation engine.

Create a key on your account page and put it in KLARBON_API_KEY. Never put it in the address.

POST /api/v1/check

curl + jq
curl -sS https://klarbon.app/api/v1/check \
  -H "Authorization: Bearer $KLARBON_API_KEY" \
  -F "file=@invoice.xml;type=application/xml" \
  -F ruleset=peppol-bis3-invoice \
  -o result.json
jq -e '.ok' result.json > /dev/null || { jq -r '.error + ": " + .message' result.json; exit 1; }
jq '{valid: .report.valid, findings: [.report.findings[] | {ruleId, severity, message}]}' result.json
TypeScript, Node.js 22.18+
import { readFile, writeFile } from "node:fs/promises"

type KlarbonFile = { content: string; filename: string; mime: string; bytes: number }
type Failure = { ok: false; error: string; message: string }
type Checked = { ok: true; report: { valid: boolean; findings: { ruleId: string; severity: string; message: string }[] } }

const form = new FormData()
form.append("file", new Blob([await readFile("invoice.xml")], { type: "application/xml" }), "invoice.xml")
form.append("ruleset", "peppol-bis3-invoice")

const response = await fetch("https://klarbon.app/api/v1/check", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KLARBON_API_KEY}`,
  },
  body: form,
})
const result = (await response.json()) as Checked | Failure
if (!result.ok) throw new Error(`${response.status} ${result.error}: ${result.message}`)
console.log(result.report.valid ? "valid" : "not valid")
for (const finding of result.report.findings) console.log(finding.severity, finding.ruleId, finding.message)
Python 3.9+, requests
import os

import requests

with open("invoice.xml", "rb") as upload:
    response = requests.post(
        "https://klarbon.app/api/v1/check",
        headers={
            "Authorization": f"Bearer {os.environ['KLARBON_API_KEY']}",
        },
        files={"file": ("invoice.xml", upload, "application/xml")},
        data={"ruleset": "peppol-bis3-invoice"},
        timeout=120,
    )
result = response.json()
if not result["ok"]:
    raise SystemExit(f"{response.status_code} {result['error']}: {result['message']}")
print("valid" if result["report"]["valid"] else "not valid")
for finding in result["report"]["findings"]:
    print(finding["severity"], finding["ruleId"], finding["message"])
PHP 8+, curl
<?php
$curl = curl_init('https://klarbon.app/api/v1/check');
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 120,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('KLARBON_API_KEY'),
    ],
    CURLOPT_POSTFIELDS => [
        'file' => new CURLFile('invoice.xml', 'application/xml', 'invoice.xml'),
        'ruleset' => 'peppol-bis3-invoice',
    ],
]);
$body = curl_exec($curl);
if ($body === false) {
    fwrite(STDERR, curl_error($curl) . "\n");
    exit(1);
}
$result = json_decode($body, true);
if (!$result['ok']) {
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    fwrite(STDERR, "$status {$result['error']}: {$result['message']}\n");
    exit(1);
}
echo $result['report']['valid'] ? "valid\n" : "not valid\n";
foreach ($result['report']['findings'] as $finding) {
    echo "{$finding['severity']} {$finding['ruleId']} {$finding['message']}\n";
}
C#, .NET 10
using System.Net.Http.Headers;
using System.Text.Json;

using var http = new HttpClient();
var key = Environment.GetEnvironmentVariable("KLARBON_API_KEY");
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);

var upload = new ByteArrayContent(await File.ReadAllBytesAsync("invoice.xml"));
upload.Headers.ContentType = new MediaTypeHeaderValue("application/xml");

using var request = new HttpRequestMessage(HttpMethod.Post, "https://klarbon.app/api/v1/check?ruleset=peppol-bis3-invoice") { Content = upload };
using var response = await http.SendAsync(request);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var result = json.RootElement;
if (!result.GetProperty("ok").GetBoolean())
{
    Console.Error.WriteLine($"{(int)response.StatusCode} {result.GetProperty("error")}: {result.GetProperty("message")}");
    return 1;
}
var report = result.GetProperty("report");
Console.WriteLine(report.GetProperty("valid").GetBoolean() ? "valid" : "not valid");
foreach (var finding in report.GetProperty("findings").EnumerateArray())
    Console.WriteLine($"{finding.GetProperty("severity")} {finding.GetProperty("ruleId")} {finding.GetProperty("message")}");
return 0;

What comes back

The answer to the curl call above, recorded from that run with Klarbon synthetic fixture, CC0-1.0.

{
  "ok": true,
  "format": "peppol-bis-billing",
  "report": {
    "detected": {
      "syntax": "ubl",
      "customizationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0",
      "profile": null,
      "container": "xml"
    },
    "ruleset": {
      "id": "peppol-bis3-invoice",
      "name": "Peppol BIS Billing 3.0 (invoice)",
      "version": "2026.5"
    },
    "valid": false,
    "engineVersion": "1.0.0",
    "durationMs": 26,
    "findings": [
      {
        "ruleId": "PEPPOL-COMMON-R043",
        "severity": "fatal",
        "message": "[PEPPOL-COMMON-R043]-Belgian enterprise number MUST be stated in the correct format.",
        "location": "/*:Invoice[namespace-uri()='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'][1]/cac:AccountingSupplierParty[1]/cac:Party[1]/cbc:EndpointID[1]",
        "test": "matches(normalize-space(), '^[0-9]10$') and u:mod97-0208(normalize-space())",
        "title": "PEPPOL-COMMON-R043",
        "what": "[PEPPOL-COMMON-R043]-Belgian enterprise number MUST be stated in the correct format.",
        "fix": "",
        "link": null,
        "officialUrl": null
      },
      {
        "ruleId": "PEPPOL-COMMON-R043",
        "severity": "fatal",
        "message": "[PEPPOL-COMMON-R043]-Belgian enterprise number MUST be stated in the correct format.",
        "location": "/*:Invoice[namespace-uri()='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'][1]/cac:AccountingCustomerParty[1]/cac:Party[1]/cbc:EndpointID[1]",
        "test": "matches(normalize-space(), '^[0-9]10$') and u:mod97-0208(normalize-space())",
        "title": "PEPPOL-COMMON-R043",
        "what": "[PEPPOL-COMMON-R043]-Belgian enterprise number MUST be stated in the correct format.",
        "fix": "",
        "link": null,
        "officialUrl": null
      }
    ]
  },
  "invoice": {
    "kind": "invoice",
    "typeCode": "380",
    "number": "TEST-UBL-001",
    "issueDate": "2026-11-05",
    "dueDate": "2026-12-05",
    "currency": "EUR",
    "buyerReference": "TEST-BUYER",
    "processId": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
    "specificationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0",
    "notes": [
      {
        "text": "Fictional test data only. Dummy tax ids. Not for payment."
      }
    ],
    "precedingInvoices": [],
    "seller": {
      "name": "Fictional supplier - test only",
      "tradingName": "Fictional supplier - test only",
      "identifiers": [],
      "legalRegistration": {
        "value": "0000000000"
      },
      "vatId": "BE0000000000",
      "electronicAddress": {
        "value": "0000000000",
        "scheme": "0208"
      },
      "address": {
        "line1": "Test street 1",
        "city": "Fictional city",
        "postCode": "1000",
        "country": "BE"
      }
    },
    "buyer": {
      "name": "Fictional buyer - test only",
      "tradingName": "Fictional buyer - test only",
      "identifiers": [],
      "legalRegistration": {
        "value": "0000000000"
      },
      "vatId": "BE0000000000",
      "electronicAddress": {
        "value": "0000000000",
        "scheme": "0208"
      },
      "address": {
        "line1": "Test street 2",
        "city": "Fictional city",
        "postCode": "1000",
        "country": "BE"
      }
    },
    "payment": {
      "meansCode": "30",
      "remittanceInformation": "TEST-UBL-001",
      "creditTransfers": [
        {
          "iban": "TEST-ACCOUNT-NOT-PAYABLE"
        }
      ]
    },
    "allowances": [],
    "charges": [],
    "totals": {
      "lineTotal": "100.00",
      "taxExclusive": "100.00",
      "tax": "21.00",
      "taxInclusive": "121.00",
      "payable": "121.00"
    },
    "vatBreakdown": [
      {
        "category": "S",
        "rate": "21",
        "taxableAmount": "100.00",
        "taxAmount": "21.00"
      }
    ],
    "attachments": [],
    "lines": [
      {
        "id": "1",
        "quantity": "1",
        "unit": "C62",
        "netAmount": "100.00",
        "allowances": [],
        "charges": [],
        "price": {
          "amount": "100.00"
        },
        "vat": {
          "category": "S",
          "rate": "21"
        },
        "item": {
          "name": "Fictional service",
          "classifications": [],
          "attributes": []
        }
      }
    ],
    "nationalTerms": [],
    "source": {
      "format": "peppol-bis-billing",
      "syntax": "ubl",
      "customizationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
    }
  },
  "usage": {
    "used": 6,
    "limit": 20
  }
}

What it costs

Prepaid API packs, bought by card on your account page: 1,000 calls for €39.00, 5,000 calls for €129.00, 25,000 calls for €449.00, 100,000 calls for €990.00, VAT included, never expiring. Your first key brings 25 free calls once per email address, excluding disposable addresses. Paid calls spend the API balance first, then the same credits as the site. A call that fails costs nothing.

Call Cost
POST /open Free, fair use 1,000 a day per account
POST /check Free within your plan's daily checks (20 a day on the free plan)
POST /convert, POST /make 1 call, only when the written file validates
POST /bank/convert 1 call per started 500 transactions, only when converted

Limits

  • Files up to 20 MB, PDFs up to 50 pages.
  • 60 requests a minute per key, 120 per account. A per-minute rate-limit refusal carries Retry-After.
  • Up to 10 keys per account. Idempotency-Key is honoured for 24 hours, so a retry never charges twice.

Errors

Every error is { ok: false, error, message }: a stable code and one plain sentence.

Status Code When
401 auth No key, or an unknown or revoked key.
402 no_credits The combined API balance and credits cannot cover this call. Nothing was charged; topUpUrl says where to buy.
413 too_large Over 20 MB or 50 PDF pages.
415 unsupported A file format the route does not take.
422 invalid The file could not be read, or the written file did not validate. Not charged.
429 limit A rate limit or the day's free calls.
503 engine_unavailable Validation is briefly unavailable. Not charged.

Privacy

Files are processed for the request, without a document archive. Klarbon's application logs carry no file name, no content and no key. The full key is not stored, only an HMAC of it and a short display prefix.

Next steps

Frequently asked questions

Does Klarbon send invoices over Peppol?

No. Klarbon is not a Peppol access point. It checks the file; your access point sends it.

Which rules does the check use?

The Peppol BIS Billing 3.0 rule set with its EN 16931 base. The report names the rule set and its version.

Is a valid result a guarantee of delivery?

No. It means the file passed the selected checks. The receiving access point and the buyer may have further requirements.

Does validation cost credits?

No. Checks are free within your plan's daily limit; see the pricing page for the current numbers.

Sources

Updated 6 November 2026