Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in xentral: E-Invoicing with a Node.js Service, Webhooks and the xentral API

Many German online sellers and wholesalers run xentral. Their B2B customers now expect e-invoices, and their suppliers send them. This guide builds a small Node.js service. A xentral webhook starts it when an invoice is released. It creates a validated ZUGFeRD PDF with one API call and files it on the invoice. It also covers email delivery by the API, XRechnung with the Leitweg-ID and booking supplier invoices as liabilities.

This guide adds ZUGFeRD and XRechnung to xentral. All the work happens in a single, small Node.js service. A xentral webhook tells the service when an invoice is released. The service reads the invoice through the xentral API. It sends one request to InvoiceXML. Then it files the finished ZUGFeRD PDF on the invoice. Node.js 20 is all you need. fetch, FormData, Blob and crypto come with it, so you install no npm package. The email section lets the API deliver the invoice to your customer. The import section checks supplier invoices and books them as liabilities in xentral. Every InvoiceXML call in plain JavaScript is explained in the Node.js guide.


Why xentral needs ZUGFeRD now

German B2B invoices become e-invoices. Since January 2025, every business in Germany must be able to receive e-invoices. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must also issue them. From January 2028, this applies to every business. An e-invoice carries structured data that accounting software reads without a person. ZUGFeRD puts this data as XML into a PDF/A-3 file. Your customer can still open and print it like any PDF. The Germany mandate page lists all dates and formats.

A second path next to the xentral e-invoice settings. xentral can also create XRechnung and ZUGFeRD itself, under Settings, Accounting, E-Invoice & Swiss QR Code. The service in this guide is a second path, and it has its own strengths. The API checks every file against EN 16931 and the German rules before you store it. It can send the invoice by email from your own domain. A French customer gets Factur-X from the same request body. The same service also validates the invoices your suppliers send and answers the sender with a report. xentral stays the source of invoice numbers, customers and prices.

Format updates need no work in the service. ZUGFeRD, XRechnung and the EN 16931 rules change from time to time. The API adopts each new version on the date it becomes valid. Your service sends the same JSON as before. You do not change, test or deploy anything.


What you need

  • A xentral instance with API access. Its address looks like https://yourcompany.xentral.biz, or it runs on your own domain.
  • Admin rights in xentral. You need them to create a personal access token and a webhook.
  • Released invoices. xentral gives an invoice its number when you release it. Drafts have no number, so the service ignores them.
  • An email account in xentral. The service uses it to send the validation report to a supplier.
  • Node.js 20 or later on a server with HTTPS. xentral sends webhooks to https addresses only. The server also needs access to api.invoicexml.com.
  • An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.

How the integration works

You release an invoice in xentral. xentral sends the event invoice.released to the service. The service loads the invoice with its line items and the customer of the invoice. It turns both into one JSON body and posts it to one InvoiceXML endpoint. The API calculates the totals and the VAT. It writes the XML, embeds it into a PDF/A-3 and validates the result. The service attaches the PDF to the invoice in xentral, or the API emails it to your customer.

CustomerFormatEndpointResult
German business (B2B)ZUGFeRD EN 16931/v1/create/zugferdHybrid PDF/A-3
German business, delivered by the APIZUGFeRD EN 16931/v1/create/zugferd/emailEmail with the PDF attached
German public buyer (B2G)XRechnung/v1/create/xrechnungXML with Leitweg-ID
French businessFactur-X EN 16931/v1/create/facturxHybrid PDF/A-3

The four endpoints share one JSON body. The service consists of these files. You find each one as a listing below:

  • .env and config.mjs: the secrets, the xentral address and your company data.
  • api.mjs: one helper for the xentral API and two for the InvoiceXML API.
  • mapping.mjs: loads a xentral invoice and builds the invoice JSON.
  • einvoice.mjs: creates the e-invoice, or lets the API send it.
  • service.mjs: the file upload, the webhook route, the poll loop and the supplier route.
  • inbound.mjs: validates supplier files and creates liabilities.

The service uses these xentral objects: invoices with their files and activity log, customers with their free fields, suppliers, liabilities with their documents, email accounts and webhooks.


Step 1: Store the API key and the xentral token

xentral uses personal access tokens. Create one under Administration, Account settings, Developer Settings, Personal Access Tokens. xentral shows the token only once, so copy it right away. The token has no expiry date and full access to the instance. Keep it in a .env file that only the service user can read. On a cloud host, use its secret store with the same names. Node.js 20.6 and later read the file with --env-file.

// .env (start the service with: node --env-file=.env service.mjs)
// XENTRAL_URL=https://yourcompany.xentral.biz
// XENTRAL_TOKEN=...                 personal access token
// XENTRAL_WEBHOOK_KEY=...           signature key of the webhook (step 4)
// XENTRAL_EMAIL_ACCOUNT_ID=2        sends the reports to suppliers
// LEITWEG_FIELD_ID=20               customer free field with the Leitweg-ID
// INVOICEXML_API_KEY=...
// [email protected]
// SERVICE_TOKEN=a-long-random-string
// SELLER_NAME=Beispiel Handel GmbH  SELLER_VAT_ID=DE987654321  SELLER_IBAN=DE89370400440532013000
// SELLER_STREET=Speicherstr. 12  SELLER_CITY=Hamburg  SELLER_POSTCODE=20457
// SELLER_CONTACT=Debitorenbuchhaltung  SELLER_PHONE=+49 40 555123  [email protected]

// config.mjs
const e = process.env;
export const config = {
  xentralUrl: e.XENTRAL_URL,
  xentralToken: e.XENTRAL_TOKEN,
  webhookKey: e.XENTRAL_WEBHOOK_KEY,
  emailAccountId: e.XENTRAL_EMAIL_ACCOUNT_ID,
  leitwegFieldId: e.LEITWEG_FIELD_ID || "20",
  invoicexmlKey: e.INVOICEXML_API_KEY,
  fromEmail: e.INVOICE_FROM_EMAIL,
  serviceToken: e.SERVICE_TOKEN,
  paymentDays: Number(e.PAYMENT_DAYS || 30),
  pollMinutes: Number(e.POLL_MINUTES || 15),
  lookbackDays: Number(e.LOOKBACK_DAYS || 3),
  port: Number(e.PORT || 3000),
  iban: e.SELLER_IBAN,
  seller: {
    name: e.SELLER_NAME,
    vatIdentifier: e.SELLER_VAT_ID,
    postalAddress: { line1: e.SELLER_STREET, city: e.SELLER_CITY, postCode: e.SELLER_POSTCODE, country: "DE" },
  },
  sellerContact: { name: e.SELLER_CONTACT, phone: e.SELLER_PHONE, email: e.SELLER_EMAIL },
};

api.mjs holds every HTTP call. The xentral helper sends the token as Authorization: Bearer. When xentral answers 429, it waits for the seconds in Retry-After and tries again. It returns the raw response. Some xentral routes answer with JSON, some with a Location header and some with no body. The two InvoiceXML helpers send JSON or upload a file.

// api.mjs: xentral and InvoiceXML in one place
import { config } from "./config.mjs";

const pause = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));

export async function xentral(method, path, payload, attempt = 1) {
  const res = await fetch(config.xentralUrl + path, {
    method,
    headers: {
      Authorization: `Bearer ${config.xentralToken}`,
      Accept: "application/json",
      ...(payload ? { "Content-Type": "application/json" } : {}),
    },
    body: payload ? JSON.stringify(payload) : undefined,
  });
  if (res.status === 429 && attempt <= 3) {           // 100 requests per minute per instance and IP
    await pause(Number(res.headers.get("Retry-After") || 30));
    return xentral(method, path, payload, attempt + 1);
  }
  if (!res.ok) throw new Error(`xentral ${method} ${path}: ${res.status} ${await res.text()}`);
  return res;
}
export const xentralJson = async (...args) => (await xentral(...args)).json();

const IX_URL = "https://api.invoicexml.com";
const ixAuth = () => ({ Authorization: `Bearer ${config.invoicexmlKey}` });

// Invoice JSON in, finished file out (PDF or XML) as a Buffer
export async function createFile(path, payload) {
  const res = await fetch(IX_URL + path, {
    method: "POST",
    headers: { ...ixAuth(), "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  // 400: JSON with an errors array, one entry per broken rule, with rule id and JSON path
  if (!res.ok) throw new Error(`InvoiceXML ${path}: ${res.status} ${await res.text()}`);
  return Buffer.from(await res.arrayBuffer());
}

// Supplier file in: multipart/form-data with exactly one file field named "file"
export async function upload(path, fileName, bytes, fields = {}) {
  const form = new FormData();
  form.append("file", new Blob([bytes]), fileName);
  Object.entries(fields).forEach(([key, value]) => form.append(key, value));
  const res = await fetch(IX_URL + path, { method: "POST", headers: ixAuth(), body: form });
  if (!res.ok) throw new Error(`InvoiceXML ${path}: ${res.status} ${await res.text()}`);
  return res;
}

Step 2: Map xentral invoice fields to JSON

The API expects one EN 16931 invoice as JSON. The service reads it from GET /api/v3/invoices/{id}?include=lineItems,files,activity. Without include, the line items are not part of the answer. The invoice holds the effective billing address in effectiveAddresses.billTo. The customer comes from GET /api/v2/customers/{id}, with the ID from address.id of the invoice. It adds the invoice email address, the VAT id and the free fields. Your own company is the seller and comes from the config.

xentral fieldJSON pathNote
documentNumberinvoice.invoiceNumberBT-1, set when the invoice is released
documentDateinvoice.issueDateAlready in yyyy-MM-dd
financials.paymentTerms.paymentTargetDaysinvoice.dueDateInvoice date plus these days
financials.currencyinvoice.currencyISO 4217, for example EUR
customerOrderNumberinvoice.purchaseOrderReferenceThe order number of your customer, BT-13
effectiveAddresses.billTo.nameinvoice.buyer.nameThe company name on the invoice
billTo.street, zipCode, city, countryinvoice.buyer.postalAddressThe country is a two-letter code such as DE
vatId, or customer financials.tax.vatIdinvoice.buyer.vatIdentifierThe VAT id of the business customer
lineItems[].name, quantitylines[].item.name, lines[].quantityOnly line items of type product
lineItems[].unitlines[].unitCodeMapped to a UN/ECE code
lineItems[].itemRevenue.net.amountlines[].priceDetails.netPriceNet price of one unit after the line discount
lineItems[].effectiveTaxRatelines[].vatInformation.rateFor example 19 or 7

Totals and VAT amounts stay out of the request. The API adds up the lines and works out the VAT itself. If the figures do not fit together, it answers with an error and creates no file. So a broken invoice cannot reach your customer. xentral keeps its own totals in totals on the invoice. The service does not need them.

// mapping.mjs: a released xentral invoice becomes the "invoice" object of the API
import { config } from "./config.mjs";
import { xentralJson } from "./api.mjs";

// xentral unit (lower case) to UN/ECE code. Add the units you use.
const UNIT_CODES = { "stk": "H87", "stück": "H87", "h": "HUR", "std": "HUR", "kg": "KGM", "m": "MTR", "l": "LTR" };
const unitCode = (unit) => UNIT_CODES[String(unit || "").trim().toLowerCase()] || "C62";

export async function loadInvoice(invoiceId) {
  const { data: invoice } = await xentralJson("GET",
    `/api/v3/invoices/${invoiceId}?include=lineItems,files,activity`);
  const { data: customer } = await xentralJson("GET", `/api/v2/customers/${invoice.address.id}`);
  return { invoice, customer };
}

export const freeField = (customer, id) =>
  (customer.customFields || []).find((field) => String(field.id) === String(id))?.value || null;

export const customerEmail = ({ invoice, customer }) =>
  customer.documentDelivery?.invoice?.email || invoice.effectiveAddresses.billTo.email
  || customer.contactDetails?.email || null;

export function buildInvoice({ invoice: inv, customer }) {
  const billTo = inv.effectiveAddresses.billTo;
  const due = new Date(inv.documentDate);
  due.setDate(due.getDate() + (inv.financials.paymentTerms?.paymentTargetDays ?? config.paymentDays));

  const lines = inv.lineItems
    .filter((item) => item.type === "product") // headings, subtotals, images and page breaks carry no price
    .map((item) => {
      const net = Number(item.itemRevenue.net.amount);
      const sign = net < 0 ? -1 : 1; // a discount line: the minus moves from the price to the quantity
      return {
        quantity: item.quantity * sign,
        unitCode: unitCode(item.unit),
        item: { name: item.name },
        priceDetails: { netPrice: net * sign },
        vatInformation: { rate: item.effectiveTaxRate },
      };
    });

  return {
    invoiceNumber: inv.documentNumber,
    issueDate: inv.documentDate.slice(0, 10),
    dueDate: due.toISOString().slice(0, 10),
    currency: inv.financials.currency || "EUR",
    purchaseOrderReference: inv.customerOrderNumber,
    seller: config.seller,
    buyer: {
      name: billTo.name,
      vatIdentifier: inv.vatId || customer.financials?.tax?.vatId || null,
      postalAddress: { line1: billTo.street, city: billTo.city, postCode: billTo.zipCode, country: billTo.country },
    },
    paymentDetails: { paymentAccountIdentifier: config.iban },
    lines,
  };
}

Fields with the value null are left out by the API. An invoice without a customer order number therefore has no BT-13. In xentral, shipping costs are product lines, so they appear as normal lines. A discount product has a negative price. EN 16931 allows no negative unit price, so the mapping uses a positive price with a negative quantity. The unit table covers common German units. The help center explains the e-invoice unit codes of xentral under Settings, Accounting, E-Invoice & Swiss QR Code. Use the same codes here.


Step 3: Create the ZUGFeRD invoice from the xentral invoice

Creating the PDF is one function with one call. A short script runs it for one invoice ID and saves the file. Release a test invoice in xentral first. GET /api/v3/invoices?search= plus the invoice number gives you its ID. Run the script before you connect the webhook.

// einvoice.mjs (part 1: create)
import { createFile } from "./api.mjs";

export const createZugferd = (invoice) => createFile("/v1/create/zugferd", { invoice });

// try-invoice.mjs: node --env-file=.env try-invoice.mjs 4711
import { writeFile } from "node:fs/promises";
import { loadInvoice, buildInvoice } from "./mapping.mjs";
import { createZugferd } from "./einvoice.mjs";

const invoice = buildInvoice(await loadInvoice(process.argv[2]));
const pdf = await createZugferd(invoice);
await writeFile(`${invoice.invoiceNumber}-zugferd.pdf`, pdf);
console.log(`Saved ${invoice.invoiceNumber}-zugferd.pdf, ${pdf.length} bytes, validated`);

Any PDF viewer opens the file. You see a normal invoice, and the XML sits inside. The API validated it before it sent the file back. For a customer in France, post the same body to /v1/create/facturx. Factur-X and ZUGFeRD follow one technical standard. The country code in billTo.country tells you which endpoint to use.


Step 4: Attach the PDF to the xentral invoice

The route POST /api/v3/invoices/{id}/files attaches a file to an invoice. The body is JSON with keyword, fileName and fileContent as base64. These three fields are required. title and description are optional. The service uses the keyword eInvoice, one of the values the reference lists. The PDF then appears on the Files tab of the invoice.

The service needs no database. xentral itself remembers what is done. The service skips an invoice that already has its file. It also skips an invoice with an activity entry from the email route. The webhook route checks the signature of each request first.

// service.mjs: attach, webhook, poll loop and supplier route
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import { config } from "./config.mjs";
import { xentral, xentralJson } from "./api.mjs";
import { loadInvoice, buildInvoice } from "./mapping.mjs";
import { createZugferd } from "./einvoice.mjs";
import { handleSupplierFile } from "./inbound.mjs";

const FILE_TITLE = "E-Rechnung (API)";
const LOG_PREFIX = "E-Rechnung erstellt";
const isDone = (inv) =>
  (inv.files || []).some((file) => file.title === FILE_TITLE)
  || (inv.activity || []).some((entry) => entry.message.startsWith(LOG_PREFIX));

async function attachFile(invoiceId, fileName, bytes) {
  await xentral("POST", `/api/v3/invoices/${invoiceId}/files`, {
    title: FILE_TITLE,
    description: "EN 16931, validated before upload",
    keyword: "eInvoice",
    fileName,
    fileContent: bytes.toString("base64"),
  });
}

export async function processInvoice(invoiceId) {
  const data = await loadInvoice(invoiceId);
  const inv = data.invoice;
  if (!["released", "sent"].includes(inv.status) || isDone(inv)) return;
  const pdf = await createZugferd(buildInvoice(data));
  await attachFile(invoiceId, `${inv.documentNumber}-zugferd.pdf`, pdf);
}

// Webhook check: HMAC SHA-256 over the raw body plus the timestamp header, as hex
function signatureOk(headers, raw) {
  const expected = createHmac("sha256", config.webhookKey)
    .update(raw.toString("utf8") + (headers["xentral-request-timestamp"] || "")).digest("hex");
  const received = String(headers["xentral-signature"] || "");
  return received.length === expected.length
    && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

// Safety net: released or sent invoices of the last days that have no e-invoice yet
async function poll() {
  const since = new Date(Date.now() - config.lookbackDays * 86400000).toISOString().slice(0, 10);
  const query = "/api/v3/invoices?include=files,activity&perPage=200"
    + "&filter[0][key]=status&filter[0][op]=in&filter[0][value][0]=released&filter[0][value][1]=sent"
    + `&filter[1][key]=documentDate&filter[1][op]=greaterThanOrEquals&filter[1][value]=${since}`;
  for (let page = 1; ; page++) {
    const result = await xentralJson("GET", `${query}&page=${page}`);
    for (const inv of result.data.filter((i) => !isDone(i))) {
      await processInvoice(inv.id).catch((err) => console.error(`Invoice ${inv.id}: ${err.message}`));
    }
    if (!result.links?.next) break;
  }
}

const readBody = async (req) => { const parts = []; for await (const part of req) parts.push(part); return Buffer.concat(parts); };

createServer(async (req, res) => {
  const url = new URL(req.url, "http://service");
  const raw = await readBody(req);
  try {
    if (req.method === "POST" && url.pathname === "/xentral/webhook") {
      if (!signatureOk(req.headers, raw)) return res.writeHead(401).end();
      res.writeHead(202).end(); // answer xentral at once, then do the work
      const event = JSON.parse(raw.toString("utf8")); // { type, body: { invoiceId, updatedAt } }
      if (event.type === "com.xentral.invoice.released.v1") await processInvoice(String(event.body.invoiceId));
      return;
    }
    if (req.method === "POST" && url.pathname === "/inbound" && url.searchParams.get("token") === config.serviceToken) {
      const result = await handleSupplierFile(req.headers["content-type"], raw); // see the import section
      return res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(result));
    }
    res.writeHead(404).end();
  } catch (err) {
    console.error(err.message);
    if (!res.headersSent) res.writeHead(500).end();
  }
}).listen(config.port);

setInterval(() => poll().catch((err) => console.error(err.message)), config.pollMinutes * 60000);
poll().catch((err) => console.error(err.message));

When it runs. The webhook reaches the service a moment after you release an invoice. The service answers with 202 at once and creates the e-invoice afterwards. The poll loop runs every 15 minutes. It checks released and sent invoices of the last three days. So it catches every invoice the webhook missed, for example during a restart. The webhook setup has its own section. Keep the service running with a process manager such as systemd or a container restart policy.

Sending from xentral. If xentral sends your invoice emails, the ZUGFeRD PDF is already on the invoice. Choose Send invoice, Send by email and select the file in the send dialog. Test it once with a test customer. The customer gets one PDF: people read the page, and software reads the XML inside.


Send the invoice by email from the API

The API can also do the delivery. One call creates the invoice, validates it and emails it to the customer. The file does not go through xentral. In Germany, this is a complete delivery. There is no central platform for B2B e-invoices. An email with the ZUGFeRD PDF fulfils the mandate.

First, verify your sending domain in the dashboard. You do this only once. Enter the domain, then publish the three CNAME records shown there in your DNS. Every address on that domain can then send. The customer sees your address, and replies go to your own mailbox. The request is the invoice JSON plus an email object. The recipient is the invoice email address that xentral stores on the customer for document delivery. If it is empty, the service tries the billing address email, then the customer email.

// einvoice.mjs (part 2: email)
import { config } from "./config.mjs";
import { xentral, createFile } from "./api.mjs";
import { loadInvoice, buildInvoice, customerEmail } from "./mapping.mjs";

export async function emailZugferd(invoiceId) {
  const data = await loadInvoice(invoiceId);
  const invoice = buildInvoice(data);
  const to = customerEmail(data);
  if (!to) throw new Error(`Invoice ${invoice.invoiceNumber}: the customer has no email address`);

  await createFile("/v1/create/zugferd/email", {
    invoice,
    email: {
      from: config.fromEmail, // an address on your verified sending domain
      fromName: config.seller.name,
      replyTo: config.sellerContact.email,
      to,
      subject: `Ihre Rechnung ${invoice.invoiceNumber} von ${config.seller.name}`,
      message: `Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie unsere Rechnung ${invoice.invoiceNumber} als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\n${config.seller.name}`,
    },
  });
  // 200: created, validated and handed over for delivery. The answer contains no file.
  await xentral("PATCH", `/api/v3/invoices/${invoiceId}/actions/logActivity`, {
    message: `E-Rechnung erstellt und an ${to} gesendet`,
  });
}

The entry appears in the activity log of the invoice. Your team sees what went out and to whom. It also tells the poll loop that this invoice is done. Use this route when the API should send. Use the plain endpoint when xentral should store the file and send it itself. To switch, call emailZugferd(invoiceId) in processInvoice instead of the create and attach lines. The same route exists for XRechnung and Factur-X: /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

Since January 2025, every German business must accept e-invoices. Your suppliers send ZUGFeRD PDFs or XRechnung XML files. The service handles each file in three steps:

  1. Validate. Post a PDF to POST /v1/validate/zugferd/report and an XML file to /v1/validate/xrechnung/report. Send the form field validationLanguage as de to get the report in German. The check covers EN 16931 and the German national rules. The answer body is a PDF report. The verdict is in the header X-Invoice-Valid: true or false. If you prefer the findings as JSON, call /v1/validate/zugferd without /report.
  2. Return the report. If the file is invalid, the sender gets the PDF report by email, and the import ends here. The report names each broken rule, the field and a message. The supplier fixes the invoice and sends a new file.
  3. Extract. If the file is valid, post it to POST /v1/extract/json. You get the invoice as JSON, in the same shape as the create request, plus totals inside invoice.

Supplier files arrive on POST /inbound?token=... as a multipart form with the fields file and sender. A rule on your mail server or an automation tool posts each attachment there with the sender address. The report goes out through PATCH /api/v3/emailAccounts/{id}/actions/sendEmail. That route sends from an email account you already set up in xentral. It takes base64 attachments of up to 10 MB each. GET /api/v3/emailAccounts lists the accounts with their IDs. The reference marks that list as Beta. So call it once by hand and put the ID into the config.

// inbound.mjs (part 1: validate, report back, extract)
import { mkdir, writeFile } from "node:fs/promises";
import { config } from "./config.mjs";
import { xentral, xentralJson, upload } from "./api.mjs";

export async function handleSupplierFile(contentType, raw) {
  const form = await new Response(raw, { headers: { "Content-Type": contentType } }).formData();
  const file = form.get("file");
  const sender = form.get("sender");
  const bytes = Buffer.from(await file.arrayBuffer());
  const isXml = file.name.toLowerCase().endsWith(".xml");

  // 1. Validate: verdict in the header, PDF report in the body
  const check = await upload(isXml ? "/v1/validate/xrechnung/report" : "/v1/validate/zugferd/report",
    file.name, bytes, { validationLanguage: "de" });

  // 2. Invalid: the sender gets the report from your xentral email account
  if (check.headers.get("X-Invoice-Valid") !== "true") {
    const report = Buffer.from(await check.arrayBuffer());
    await xentral("PATCH", `/api/v3/emailAccounts/${config.emailAccountId}/actions/sendEmail`, {
      to: sender,
      subject: `Prüfbericht zu Ihrer Rechnung ${file.name}`,
      body: "<p>Ihre E-Rechnung hat die Prüfung nach EN 16931 nicht bestanden.</p>"
        + "<p>Der Bericht im Anhang nennt jeden Fehler. Bitte senden Sie uns eine korrigierte Rechnung.</p>",
      addSignature: true,
      attachments: [{ fileName: "Pruefbericht.pdf", fileContent: report.toString("base64") }],
    });
    return { valid: false, reportSentTo: sender };
  }

  // 3. Valid: read the invoice as JSON and book it (part 2)
  const { invoice } = await (await upload("/v1/extract/json", file.name, bytes)).json();
  return { valid: true, ...(await bookLiability(invoice, file.name, bytes)) };
}

Plain PDFs: some suppliers still send a PDF with no XML inside. Post it to POST /v1/parse/json. The API reads it with AI, also when it is scanned or photographed. You get the same JSON and a confidence object. Its scores run from 0.0 to 1.0. Book high scores directly and give low scores to a person. A file that is not an invoice is rejected with error code 4008.

Create the xentral liability from the JSON

In xentral, a supplier invoice is a liability (Verbindlichkeit). The service finds the supplier by VAT id with GET /api/v1/suppliers and the filter key vatNumber. Then it creates the liability with POST /api/v1/liabilities. Only supplier is required. xentral answers with 201 and the address of the new liability in the Location header. The last part of that address is the ID. POST /api/v1/liabilities/{id}/documents then adds the original file as base64.

// inbound.mjs (part 2: supplier, liability, document)
const safeName = (text) => String(text || "unknown").replace(/[^\w.-]/g, "_");

async function bookLiability(invoice, fileName, bytes) {
  const totals = invoice.totals ?? {};
  const vatId = invoice.seller?.vatIdentifier;
  const found = vatId
    ? await xentralJson("GET", "/api/v1/suppliers?filter[0][key]=vatNumber&filter[0][op]=equals"
        + `&filter[0][value]=${encodeURIComponent(vatId)}`)
    : { data: [] };

  if (!found.data.length) { // unknown supplier: park the data for a person
    const folder = `./unmatched/${safeName(vatId)}`;
    await mkdir(folder, { recursive: true });
    await writeFile(`${folder}/${safeName(invoice.invoiceNumber)}.json`, JSON.stringify({ invoice }, null, 2));
    await writeFile(`${folder}/${safeName(fileName)}`, bytes);
    return { liabilityId: null, parkedIn: folder };
  }

  const created = await xentral("POST", "/api/v1/liabilities", {
    supplier: { id: found.data[0].id },
    supplierInvoiceNumber: invoice.invoiceNumber,
    date: invoice.issueDate,
    receiptDate: new Date().toISOString(),
    status: "open",
    financials: {
      amountGross: { amount: String(totals.grandTotalAmount), currency: invoice.currency },
      ...(invoice.dueDate ? { paymentTerms: { dueDate: invoice.dueDate } } : {}),
    },
    internalComment: `E-Rechnung geprüft (EN 16931). Zahlbetrag ${totals.duePayableAmount} ${invoice.currency}`,
  });
  const liabilityId = created.headers.get("Location").split("/").pop();

  await xentral("POST", `/api/v1/liabilities/${liabilityId}/documents`, {
    title: `Lieferantenrechnung ${invoice.invoiceNumber}`,
    keyword: "receipt",
    fileName,
    fileContent: bytes.toString("base64"),
  });
  return { liabilityId };
}

The liability now shows the supplier, the invoice number, the gross amount and the due date. The original file is attached. Your accounts team checks it and releases it as usual. The extracted JSON also holds every line with its VAT rate. Use it if you want to add positions or account assignments. For XRechnung XML, /v1/render/xrechnung/to/pdf makes a readable PDF that you can add as a second document. The reference also lists V3 routes for supplier invoices. They are marked Beta, so this guide uses the liability routes.


XRechnung for German public buyers

Public buyers in Germany need XRechnung. This covers federal, state and municipal offices. XRechnung is XML only, with no PDF. ZRE and OZG-RE, the federal upload portals, test each file with the KoSIT rules. The service creates it with a different endpoint and a few extra fields.

Where the Leitweg-ID lives. The xentral help center keeps the Leitweg-ID in free field 20 of the customer address. Open Basic settings, Master data, Custom fields and label address free field 20 as Leitweg ID. The field then appears on the customer under Details, Address data, Additional fields. GET /api/v2/customers/{id} returns it in customFields with the ID 20. The V3 customer route also has a field routingId for the Leitweg-ID, but the reference marks it as Beta. The public buyer gives you the Leitweg-ID when you register as a supplier. An XRechnung without it breaks rule BR-DE-15.

// mapping.mjs: XRechnung fields, only when the customer has a Leitweg-ID
export function addXrechnungFields(json, customer) {
  const leitwegId = freeField(customer, config.leitwegFieldId);
  if (!leitwegId) return json; // a normal business customer: ZUGFeRD as before
  const buyerVat = json.buyer.vatIdentifier;
  return {
    ...json,
    buyerReference: leitwegId,
    seller: {
      ...json.seller,
      contact: config.sellerContact, // name, phone and email are required by the German rules
      electronicAddress: { identifier: json.seller.vatIdentifier, schemeId: "9930" },
    },
    buyer: {
      ...json.buyer,
      electronicAddress: buyerVat
        ? { identifier: buyerVat, schemeId: "9930" }  // German VAT id
        : { identifier: leitwegId, schemeId: "0204" }, // Leitweg-ID
    },
  };
}

// service.mjs: import addXrechnungFields and createFile, then replace the last two lines of processInvoice
const json = addXrechnungFields(buildInvoice(data), data.customer);
if (json.buyerReference) {
  const xml = await createFile("/v1/create/xrechnung", { invoice: json });
  await attachFile(invoiceId, `${inv.documentNumber}-xrechnung.xml`, xml);
} else {
  await attachFile(invoiceId, `${inv.documentNumber}-zugferd.pdf`, await createZugferd(json));
}

The API returns XML that already passed the KoSIT rules. The service attaches it to the invoice like the PDF. Deliver it the way the buyer asks: upload it to ZRE or OZG-RE, send it over Peppol, or email it if the buyer accepts email. /v1/render/xrechnung/to/pdf creates a readable PDF for your records. Businesses may also exchange XRechnung, and /v1/create/xrechnung/email delivers it by email. The XRechnung API toolkit explains the format in more detail.


xentral API versions, limits and triggers

Versions. xentral puts the version into the path: /api/v1, /api/v2 and /api/v3. Each endpoint has its own version. There is no version for the API as a whole. A breaking change comes as a new version of the endpoint, and the older version becomes deprecated. Paths without a version use media type headers instead, and xentral marks that style as deprecated. This guide uses V3 for invoices, invoice files, the activity log and email. It uses V2 for customers. Suppliers, liabilities and webhooks use V1. The token from step 1 also works for the older REST API and the Standard API.

Rate limit. xentral allows 100 requests per minute per instance and client IP address. Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining. A 429 answer adds Retry-After in seconds and X-RateLimit-Reset as a Unix timestamp. The service needs three calls per invoice: the invoice, the customer and the upload. xentral can raise the limit on request, for example for a large migration.

Pagination. V3 lists use page and perPage. The header X-Pagination selects simple (the default), table with a total count, or cursor. The invoice list takes up to 1000 entries per page. V1 and V2 lists use page[number] and page[size], with at most 50 entries per page. Filters look the same in all versions: filter[0][key], filter[0][op] and filter[0][value].

Webhook in the user interface. Open Settings, Integrations, Webhooks and click Create new Webhook. Enter a name and the HTTPS address of the service, for example https://einvoice.beispiel-handel.de/xentral/webhook. Copy the signature key into XENTRAL_WEBHOOK_KEY. It has 20 to 255 characters. Select the event for released invoices and save. Webhooks exist since xentral version 23.2.3. The help center still labels them as beta, so keep the poll loop running.

Webhook through the API. The same webhook can be created with POST /api/v1/webhooks. GET /api/v1/webhookEventTypes lists the exact event IDs of your instance. Each request from xentral has the body { "type": ..., "body": ... }. The headers xentral-signature and xentral-request-timestamp let the service check that the request comes from xentral.

# List the event types, then register the webhook for released invoices
curl -s "https://yourcompany.xentral.biz/api/v1/webhookEventTypes" \
  -H "Authorization: Bearer $XENTRAL_TOKEN" -H "Accept: application/json"

curl -X POST "https://yourcompany.xentral.biz/api/v1/webhooks" \
  -H "Authorization: Bearer $XENTRAL_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "E-invoice service",
    "url": "https://einvoice.beispiel-handel.de/xentral/webhook",
    "signatureKey": "use-the-value-from-XENTRAL_WEBHOOK_KEY",
    "events": [{ "id": "com.xentral.invoice.released.v1" }]
  }'

Polling. The poll loop from step 4 works with or without webhooks. It filters the V3 invoice list by status and documentDate. Dates use the format yyyy-MM-dd. include=files,activity lets the loop skip finished invoices without another call. The links.next field of the answer is empty on the last page.


Endpoint reference

PurposeEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdInvoice JSONHybrid PDF/A-3
Create and email ZUGFeRDPOST /v1/create/zugferd/emailInvoice JSON + email objectEmail sent from your domain
Create XRechnungPOST /v1/create/xrechnungInvoice JSON with Leitweg-IDXRechnung XML
Create and email XRechnungPOST /v1/create/xrechnung/emailInvoice JSON + email objectEmail sent from your domain
Create Factur-XPOST /v1/create/facturxInvoice JSONHybrid PDF/A-3
Validate with PDF reportPOST /v1/validate/zugferd/report, /xrechnung/reportPDF or XML filePDF report, X-Invoice-Valid header
Validate as JSONPOST /v1/validate/zugferd, /xrechnungPDF or XML fileFindings per rule
Extract structured dataPOST /v1/extract/jsonHybrid PDF or XML fileInvoice JSON
Parse plain PDFPOST /v1/parse/jsonAny PDF or imageInvoice JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or XML fileZIP of embedded files
Render XRechnungPOST /v1/render/xrechnung/to/pdfXRechnung XMLReadable PDF

Get started

The whole integration is one Node.js service in six files. A signed webhook starts it, and a poll loop backs it up. It maps the released xentral invoice and creates the e-invoice. It files the result on the invoice, or the API emails it. It also checks supplier invoices and books the valid ones as liabilities. The service contains no format rules, so a new format version changes nothing in it. The integrations hub lists this guide next to the guides for plentymarkets, Shopify, Shopware and more.

Related guides:

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded