Skip to content

E-invoice MCP server

Let an assistant open, check and convert e-invoices and bank statements through Klarbon's MCP server, with the same keys, API balance and limits as REST.

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

On this page

Klarbon's MCP server lets an AI assistant or agent open, check and convert e-invoices and bank statements through the Model Context Protocol. It speaks Streamable HTTP at /mcp, uses the same API keys, API balance and limits as the REST API, and does not build a document archive. Listing formats and opening an invoice work without a key.

What the server offers

  • list_formats and open_invoice, with no key needed.
  • check_invoice, convert_invoice and convert_bank_statement, with a key as a bearer token.
  • The file tools take base64 or a public https address and return the same structured results as REST. list_formats takes no file.

What it does not do

It does not send invoices or connect to banks, and it adds no model of its own: your assistant decides when to call a tool. Checking is in beta. Directory listings follow at launch.

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 /mcp, tools/call check_invoice

curl + jq
jq -n --arg file "$(base64 < invoice.xml | tr -d '\n')" \
  '{jsonrpc: "2.0", id: 1, method: "tools/call", params: {name: "check_invoice", arguments: {filename: "invoice.xml", file_base64: $file}}}' \
  | curl -sS https://klarbon.app/mcp \
    -H "Authorization: Bearer $KLARBON_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d @- -o reply.json
jq -e '.result.isError | not' reply.json > /dev/null || { jq -r '.result.content[0].text // .error.message' reply.json; exit 1; }
jq '{valid: .result.structuredContent.report.valid, findings: [.result.structuredContent.report.findings[] | .ruleId]}' reply.json
TypeScript, Node.js 22.18+
import { readFile } from "node:fs/promises"

type Report = { valid: boolean; findings: { ruleId: string; severity: string; message: string }[] }
type Reply = {
  result?: { isError?: boolean; content: { type: string; text?: string }[]; structuredContent?: { report: Report } }
  error?: { code: number; message: string }
}

const response = await fetch("https://klarbon.app/mcp", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KLARBON_API_KEY}`,
    "Content-Type": "application/json",
    Accept: "application/json, text/event-stream",
  },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: {
      name: "check_invoice",
      arguments: { filename: "invoice.xml", file_base64: (await readFile("invoice.xml")).toString("base64") },
    },
  }),
})
const reply = (await response.json()) as Reply
if (reply.error) throw new Error(reply.error.message)
if (!reply.result || reply.result.isError) throw new Error(reply.result?.content[0]?.text ?? "tool call failed")
const report = reply.result.structuredContent?.report
console.log(report?.valid ? "valid" : "not valid")
for (const finding of report?.findings ?? []) console.log(finding.severity, finding.ruleId, finding.message)
Python 3.9+, requests
import base64
import os

import requests

with open("invoice.xml", "rb") as upload:
    content = base64.b64encode(upload.read()).decode("ascii")
response = requests.post(
    "https://klarbon.app/mcp",
    headers={
        "Authorization": f"Bearer {os.environ['KLARBON_API_KEY']}",
        "Accept": "application/json, text/event-stream",
    },
    json={
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {"name": "check_invoice", "arguments": {"filename": "invoice.xml", "file_base64": content}},
    },
    timeout=120,
)
reply = response.json()
if "error" in reply:
    raise SystemExit(reply["error"]["message"])
if reply["result"].get("isError"):
    raise SystemExit(reply["result"]["content"][0]["text"])
report = reply["result"]["structuredContent"]["report"]
print("valid" if report["valid"] else "not valid")
for finding in report["findings"]:
    print(finding["severity"], finding["ruleId"], finding["message"])
PHP 8+, curl
<?php
$message = [
    'jsonrpc' => '2.0',
    'id' => 1,
    'method' => 'tools/call',
    'params' => [
        'name' => 'check_invoice',
        'arguments' => ['filename' => 'invoice.xml', 'file_base64' => base64_encode(file_get_contents('invoice.xml'))],
    ],
];
$curl = curl_init('https://klarbon.app/mcp');
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 120,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('KLARBON_API_KEY'),
        'Content-Type: application/json',
        'Accept: application/json, text/event-stream',
    ],
    CURLOPT_POSTFIELDS => json_encode($message),
]);
$body = curl_exec($curl);
if ($body === false) {
    fwrite(STDERR, curl_error($curl) . "\n");
    exit(1);
}
$reply = json_decode($body, true);
if (isset($reply['error']) || !empty($reply['result']['isError'])) {
    fwrite(STDERR, ($reply['error']['message'] ?? $reply['result']['content'][0]['text']) . "\n");
    exit(1);
}
$report = $reply['result']['structuredContent']['report'];
echo $report['valid'] ? "valid\n" : "not valid\n";
foreach ($report['findings'] as $finding) {
    echo "{$finding['severity']} {$finding['ruleId']} {$finding['message']}\n";
}
C#, .NET 10
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
var key = Environment.GetEnvironmentVariable("KLARBON_API_KEY");
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
http.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/event-stream");

var message = new
{
    jsonrpc = "2.0",
    id = 1,
    method = "tools/call",
    @params = new
    {
        name = "check_invoice",
        arguments = new
        {
            filename = "invoice.xml",
            file_base64 = Convert.ToBase64String(await File.ReadAllBytesAsync("invoice.xml")),
        },
    },
};
using var response = await http.PostAsJsonAsync("https://klarbon.app/mcp", message);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var reply = json.RootElement;
if (reply.TryGetProperty("error", out var error))
{
    Console.Error.WriteLine(error.GetProperty("message"));
    return 1;
}
var result = reply.GetProperty("result");
if (result.TryGetProperty("isError", out var isError) && isError.GetBoolean())
{
    Console.Error.WriteLine(result.GetProperty("content")[0].GetProperty("text"));
    return 1;
}
var report = result.GetProperty("structuredContent").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 KoSIT XRechnung test suite, business case 01.01a (UBL), Apache-2.0.

Shortened for the page: result.content[0].text: cut

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"ok\": true,\n  \"format\": \"xrechnung\",\n  \"report\": {\n    \"detected\": {\n      \"syntax\": \"ubl\",\n      \"customizationId\": \"urn:cen.eu:en16931:2017#compliant#urn:..."
      }
    ],
    "structuredContent": {
      "ok": true,
      "format": "xrechnung",
      "report": {
        "detected": {
          "syntax": "ubl",
          "customizationId": "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0",
          "profile": null,
          "container": "xml"
        },
        "ruleset": {
          "id": "xrechnung-3.0-ubl",
          "name": "XRechnung 3.0 (UBL)",
          "version": "3.0.2"
        },
        "valid": true,
        "engineVersion": "1.0.0",
        "durationMs": 20,
        "findings": [
          {
            "ruleId": "BR-DE-TMP-32",
            "severity": "info",
            "message": "[BR-DE-TMP-32] Eine Rechnung sollte zur Angabe des Liefer-/Leistungsdatums entweder BT-72 \"Actual delivery date\", BG-14 \"Invoicing period\" oder in jeder Rechnungsposition BG-26 \"Invoice line period\" enthalten.",
            "location": "/*:Invoice[namespace-uri()='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'][1]",
            "test": "cac:Delivery/cbc:ActualDeliveryDate or cac:InvoicePeriod or (every $line in (cac:InvoiceLine | cac:CreditNoteLine) satisfies $line/cac:InvoicePeriod)",
            "title": "BR-DE-TMP-32",
            "what": "[BR-DE-TMP-32] Eine Rechnung sollte zur Angabe des Liefer-/Leistungsdatums entweder BT-72 \"Actual delivery date\", BG-14 \"Invoicing period\" oder in jeder Rechnungsposition BG-26 \"Invoice line period\" enthalten.",
            "fix": "",
            "link": null,
            "officialUrl": null
          }
        ]
      },
      "invoice": {
        "kind": "invoice",
        "typeCode": "380",
        "number": "123456XX",
        "issueDate": "2016-04-04",
        "currency": "EUR",
        "buyerReference": "04011000-12345-03",
        "paymentTerms": "Zahlbar sofort ohne Abzug.",
        "processId": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
        "specificationId": "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0",
        "notes": [
          {
            "text": "Es gelten unsere Allgem. Geschäftsbedingungen, die Sie unter […] finden.",
            "subjectCode": "ADU"
          }
        ],
        "precedingInvoices": [],
        "seller": {
          "name": "[Seller name]",
          "tradingName": "[Seller trading name]",
          "identifiers": [],
          "legalRegistration": {
            "value": "[HRA-Eintrag]"
          },
          "vatId": "DE 123456789",
          "legalInformation": "123/456/7890, HRA-Eintrag in […]",
          "electronicAddress": {
            "value": "seller@email.de",
            "scheme": "EM"
          },
          "address": {
            "line1": "[Seller address line 1]",
            "city": "[Seller city]",
            "postCode": "12345",
            "country": "DE"
          },
          "contact": {
            "name": "nicht vorhanden",
            "telephone": "+49 1234-5678",
            "email": "seller@email.de"
          }
        },
        "buyer": {
          "name": "[Buyer name]",
          "identifiers": [
            {
              "value": "[Buyer identifier]"
            }
          ],
          "electronicAddress": {
            "value": "buyer@info.de",
            "scheme": "EM"
          },
          "address": {
            "line1": "[Buyer address line 1]",
            "city": "[Buyer city]",
            "postCode": "12345",
            "country": "DE"
          }
        },
        "payment": {
          "meansCode": "58",
          "creditTransfers": [
            {
              "iban": "DE79000000001234567890"
            }
          ]
        },
        "allowances": [],
        "charges": [],
        "totals": {
          "lineTotal": "314.86",
          "taxExclusive": "314.86",
          "tax": "22.04",
          "taxInclusive": "336.9",
          "payable": "336.9"
        },
        "vatBreakdown": [
          {
            "category": "S",
            "rate": "7",
            "taxableAmount": "314.86",
            "taxAmount": "22.04"
          }
        ],
        "attachments": [],
        "lines": [
          {
            "id": "Zeitschrift [...]",
            "note": "Die letzte Lieferung im Rahmen des abgerechneten Abonnements erfolgt in 12/2016 Lieferung erfolgt / erfolgte direkt vom Verlag",
            "quantity": "1",
            "unit": "XPP",
            "netAmount": "288.79",
            "orderLineReference": "6171175.1",
            "period": {
              "start": "2016-01-01",
              "end": "2016-12-31"
            },
            "allowances": [],
            "charges": [],
            "price": {
              "amount": "288.79"
            },
            "vat": {
              "category": "S",
              "rate": "7"
            },
            "item": {
              "name": "Zeitschrift [...]",
              "description": "Zeitschrift Inland",
              "sellerId": "246",
              "classifications": [
                {
                  "value": "0721-880X",
                  "scheme": "IB"
                }
              ],
              "attributes": []
            }
          },
          {
            "id": "Porto + Versandkosten",
            "quantity": "1",
            "unit": "XPP",
            "netAmount": "26.07",
            "allowances": [],
            "charges": [],
            "price": {
              "amount": "26.07"
            },
            "vat": {
              "category": "S",
              "rate": "7"
            },
            "item": {
              "name": "Porto + Versandkosten",
              "classifications": [],
              "attributes": []
            }
          }
        ],
        "nationalTerms": [],
        "source": {
          "format": "xrechnung",
          "syntax": "ubl",
          "customizationId": "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
        }
      },
      "usage": {
        "used": 11,
        "limit": 20
      }
    },
    "isError": false
  }
}

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

Which tools does the MCP server have?

list_formats, open_invoice, check_invoice, convert_invoice and convert_bank_statement. The MCP page lists their parameters.

How does the assistant authenticate?

With a Klarbon API key as a bearer token. The same key works for REST and MCP. Paid calls spend your API balance first, then plan or bought credits.

Does the assistant see or keep my invoice?

Your assistant sends the file to Klarbon and receives the result. Klarbon processes it for the request without building a document archive.

Which MCP transport is used?

Streamable HTTP with JSON-RPC 2.0 messages, at /mcp on klarbon.app.

Sources

Updated 6 November 2026