This guide adds ZUGFeRD and XRechnung to plentymarkets. plentymarkets now carries the name PlentyONE, and everything here works for both names. You build one small Node.js service. It reads orders through the plentymarkets REST API, calls one InvoiceXML endpoint and uploads the finished PDF to the order documents. Node.js 20 is enough: fetch, FormData and Blob are built in, so there is no npm package to install. The email section lets the API send the invoice to your customer. The import section checks the invoices your suppliers send you. For the API calls in plain JavaScript, outside plentymarkets, read the Node.js guide.
Why plentymarkets needs ZUGFeRD now
Your B2B customers in Germany need e-invoices from you. The duty to accept e-invoices started in January 2025. Issuing becomes mandatory in January 2027 for businesses above EUR 800,000 turnover in the previous year. From January 2028, all other businesses must issue them too. A normal invoice PDF is made for people. The mandate asks for structured data that software can read. ZUGFeRD gives both in one file: a PDF/A-3 with the XML inside. The Germany mandate page has every date and format in one place.
A second path next to DocumentBuilder. PlentyONE also offers e-invoice output in its DocumentBuilder invoice templates. This guide shows a second path with its own strengths. Every file is validated against EN 16931 and the German rules before it leaves. The API can deliver the invoice by email from your domain. The same service checks and reads the invoices your suppliers send. Your plentymarkets invoice stays the source of the invoice number and the date.
New format versions need no change in the service. ZUGFeRD, XRechnung and the EN 16931 rules get new versions from time to time. The API moves to each version on the date it takes effect. Your service keeps sending the same JSON. There is nothing to update, test or deploy on your side.
What you need
- A plentymarkets or PlentyONE system with REST API access. REST access depends on your contract. The base address looks like
https://p12345.my.plentysystems.com. - A back end user only for the service. Give it access to orders, order documents, contacts and comments. One user for one service also keeps the number of open sessions low.
- Invoices generated in plentymarkets. The service reads the invoice number and the date from the invoice document of the order. Your number ranges stay in plentymarkets.
- Node.js 20 or later on any server. It needs HTTPS access to your plentymarkets system and to
api.invoicexml.com. For the Flow trigger, your system must reach the service over HTTPS. - An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.
How the integration works
plentymarkets generates the invoice for an order. A Flow or the poll loop gives the order ID to the service. The service reads the order, the billing address, the contact and the current invoice document. It builds one JSON body and posts it to one endpoint. The API calculates the totals and the VAT, builds the XML, embeds it in a PDF/A-3 file and validates it. The service uploads the PDF to the order, or the API emails it to the customer.
| Customer | Format | Endpoint | Result |
|---|---|---|---|
| German business (B2B) | ZUGFeRD EN 16931 | /v1/create/zugferd | Hybrid PDF/A-3 |
| German business, delivered by the API | ZUGFeRD EN 16931 | /v1/create/zugferd/email | Email with the PDF attached |
| German public buyer (B2G) | XRechnung | /v1/create/xrechnung | XML with Leitweg-ID |
| French business | Factur-X EN 16931 | /v1/create/facturx | Hybrid PDF/A-3 |
All four endpoints read one and the same JSON body. The service has these files. Each one is a listing in this guide:
.envandconfig.mjs: the API key, the plentymarkets login and your company data.invoicexml.mjs: two helpers for the InvoiceXML API.plenty.mjs: the login, the token and the handling of the API limits.mapping.mjs: reads an order and turns it into the invoice JSON.einvoice.mjs: creates the e-invoice or lets the API email it.service.mjs: the upload to the order, the poll loop and the HTTP routes.inbound.mjs: checks and stores supplier invoices.
From plentymarkets, the service uses orders, order items, order amounts, order properties, addresses with their options, contacts, order documents, comments, shipping countries and PlentyONE Flow.
Step 1: Store the API key and the plentymarkets login
Keep every secret in a .env file next to the service. Only the service user may read it. Node.js 20.6 and later load it with --env-file. On a cloud host, put the same names into its secret store instead. config.mjs reads the values once. invoicexml.mjs holds the two calls that every other file uses: one sends JSON, one uploads a file.
// .env (start the service with: node --env-file=.env service.mjs)
// PLENTY_URL=https://p12345.my.plentysystems.com
// PLENTY_USER=einvoice-service
// PLENTY_PASSWORD=...
// INVOICEXML_API_KEY=...
// [email protected]
// SERVICE_TOKEN=a-long-random-string
// WEBSTORE_ID=0
// LEITWEG_OPTION_TYPE_ID=14
// SELLER_NAME=Muster GmbH SELLER_VAT_ID=DE123456789 SELLER_IBAN=DE02120300000000202051
// SELLER_STREET=Hauptstr. 1 SELLER_CITY=Berlin SELLER_POSTCODE=10115
// SELLER_CONTACT_NAME=Buchhaltung SELLER_PHONE=+49 30 123456 [email protected]
// config.mjs
const env = process.env;
export const config = {
plentyUrl: env.PLENTY_URL,
plentyUser: env.PLENTY_USER,
plentyPassword: env.PLENTY_PASSWORD,
apiKey: env.INVOICEXML_API_KEY,
fromEmail: env.INVOICE_FROM_EMAIL,
serviceToken: env.SERVICE_TOKEN,
webstoreId: Number(env.WEBSTORE_ID || 0),
leitwegOptionTypeId: Number(env.LEITWEG_OPTION_TYPE_ID || 0),
paymentDays: Number(env.PAYMENT_DAYS || 14),
pollMinutes: Number(env.POLL_MINUTES || 10),
port: Number(env.PORT || 8080),
iban: env.SELLER_IBAN,
seller: {
name: env.SELLER_NAME,
vatIdentifier: env.SELLER_VAT_ID,
postalAddress: { line1: env.SELLER_STREET, city: env.SELLER_CITY, postCode: env.SELLER_POSTCODE, country: "DE" },
},
sellerContact: { name: env.SELLER_CONTACT_NAME, phone: env.SELLER_PHONE, email: env.SELLER_EMAIL },
};
// invoicexml.mjs
import { config } from "./config.mjs";
const BASE_URL = "https://api.invoicexml.com";
const auth = () => ({ Authorization: `Bearer ${config.apiKey}` });
// JSON in, file bytes out
export async function postJson(path, body) {
const res = await fetch(BASE_URL + path, {
method: "POST",
headers: { ...auth(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// A 400 answer is JSON with an errors array: one entry per violated rule
if (!res.ok) throw new Error(`InvoiceXML ${res.status}: ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
// The upload endpoints take a multipart form with one file field named "file"
export async function postFile(path, fileName, bytes, fields = {}) {
const form = new FormData();
form.append("file", new Blob([bytes]), fileName);
for (const [name, value] of Object.entries(fields)) form.append(name, value);
const res = await fetch(BASE_URL + path, { method: "POST", headers: auth(), body: form });
if (!res.ok) throw new Error(`InvoiceXML ${res.status}: ${await res.text()}`);
return res;
}
The plentymarkets side uses POST /rest/login with the user name and the password. The answer holds an access_token, a refresh_token and expires_in in seconds. The service sends the token as Authorization: Bearer and logs in again shortly before it expires. When plentymarkets answers 429, the service waits for the seconds in Retry-After and tries once more.
// plenty.mjs: one login, one token, polite retries
import { config } from "./config.mjs";
let session = null; // { token, expiresAt }
async function login() {
const res = await fetch(`${config.plentyUrl}/rest/login`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ username: config.plentyUser, password: config.plentyPassword }),
});
if (!res.ok) throw new Error(`plentymarkets login ${res.status}: ${await res.text()}`);
const data = await res.json();
// Renew five minutes before the token expires
session = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 300) * 1000 };
}
export async function plenty(method, path, body, retries = 2) {
if (!session || Date.now() > session.expiresAt) await login();
const res = await fetch(`${config.plentyUrl}/rest${path}`, {
method,
headers: {
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && retries > 0) {
session = null; // token no longer valid: log in again
return plenty(method, path, body, retries - 1);
}
if (res.status === 429 && retries > 0) {
const seconds = Number(res.headers.get("Retry-After") || 60);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return plenty(method, path, body, retries - 1);
}
if (!res.ok) throw new Error(`plentymarkets ${method} ${path} ${res.status}: ${await res.text()}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
Step 2: Map plentymarkets order fields to JSON
The API expects one EN 16931 invoice in JSON. Most data comes from GET /rest/orders/{orderId}?with[]=addresses. The order already contains orderItems, amounts, properties, addressRelations and relations. The invoice number and the date come from GET /rest/orders/{orderId}/documents/invoice/current. That route returns the last invoice of the order that has no reversal document. The seller is your company, from the config. This is the mapping:
| plentymarkets field | JSON path | Note |
|---|---|---|
Invoice document numberWithPrefix | invoice.invoiceNumber | BT-1, for example RE-10234 |
Invoice document displayDate | invoice.issueDate | First ten characters, yyyy-MM-dd |
Contact timeForPaymentAllowedDays | invoice.dueDate | Issue date plus the payment days, or the default from the config |
Order amounts[].currency | invoice.currency | The entry that is not the system currency, if there is one |
| Order property type 8 (customer sign) | invoice.purchaseOrderReference | The customer's own reference, BT-13 |
Billing address name1, or name2 and name3 | invoice.buyer.name | Company name first, then the person |
address1, address2, postalCode, town | invoice.buyer.postalAddress | Street and house number go into line1 |
countryId | postalAddress.country | isoCode2 from GET /rest/orders/shipping/countries |
| Address option type 1 (VAT number) | invoice.buyer.vatIdentifier | The VAT id of the business customer |
orderItems[].orderItemName, quantity | lines[].item.name, lines[].quantity | Shipping costs and payment surcharges are lines too |
orderItems[].amounts[].priceNet | lines[].priceDetails.netPrice | Net unit price after the item discount |
orderItems[].vatRate | lines[].vatInformation.rate | For example 19 or 7 |
Totals and VAT amounts are not part of the request. The API derives both from the line data. If the numbers do not add up, the API answers with an error and sends no file. A wrong price never reaches your customer that way. Orders with amounts[].isNet set to true are net orders, for example for a business customer in another EU country. Their lines then carry the VAT rate that plentymarkets stored.
// mapping.mjs: a plentymarkets order becomes the "invoice" object of the API
import { config } from "./config.mjs";
import { plenty } from "./plenty.mjs";
const BILLING_ADDRESS = 1; // addressRelations typeId 1
const OPTION_VAT_NUMBER = 1; // address option typeId 1
const OPTION_EMAIL = 5; // address option typeId 5
const PROPERTY_CUSTOMER_SIGN = 8; // order property typeId 8
const COMPONENT_TYPES = [3, 14]; // bundle and set components: the parent line has the price
export const option = (address, typeId) =>
(address.options || []).find((o) => o.typeId === typeId)?.value || null;
let countries = null;
async function isoCode(countryId) {
if (!countries) {
const list = await plenty("GET", "/orders/shipping/countries");
countries = new Map((Array.isArray(list) ? list : list.entries).map((c) => [c.id, c.isoCode2]));
}
return countries.get(countryId);
}
export async function loadOrder(orderId) {
const order = await plenty("GET", `/orders/${orderId}?with[]=addresses`);
const invoiceDoc = await plenty("GET", `/orders/${orderId}/documents/invoice/current`);
const billingId = order.addressRelations.find((r) => r.typeId === BILLING_ADDRESS)?.addressId;
const billing = order.addresses.find((a) => a.id === billingId);
const receiver = order.relations.find((r) => r.referenceType === "contact" && r.relation === "receiver");
const contact = receiver ? await plenty("GET", `/accounts/contacts/${receiver.referenceId}`) : null;
return { order, invoiceDoc, billing, contact };
}
export const customerEmail = ({ billing, contact }) => option(billing, OPTION_EMAIL) || contact?.email || null;
export async function buildInvoice({ order, invoiceDoc, billing, contact }) {
const amount = order.amounts.find((a) => !a.isSystemCurrency) || order.amounts[0];
const issueDate = invoiceDoc.displayDate.slice(0, 10);
const due = new Date(issueDate);
due.setDate(due.getDate() + (contact?.timeForPaymentAllowedDays || config.paymentDays));
const lines = order.orderItems
.filter((item) => !COMPONENT_TYPES.includes(item.typeId))
.map((item) => {
const price = (item.amounts.find((a) => a.currency === amount.currency) || item.amounts[0]).priceNet;
const sign = price < 0 ? -1 : 1; // a coupon: positive price, negative quantity
return {
quantity: item.quantity * sign,
unitCode: item.typeId === 1 ? "H87" : "C62", // variations in pieces, the rest as units
item: { name: item.orderItemName },
priceDetails: { netPrice: Math.round(price * sign * 10000) / 10000 },
vatInformation: { rate: item.vatRate },
};
});
const invoice = {
invoiceNumber: invoiceDoc.numberWithPrefix,
issueDate,
dueDate: due.toISOString().slice(0, 10),
currency: amount.currency,
purchaseOrderReference: order.properties.find((p) => p.typeId === PROPERTY_CUSTOMER_SIGN)?.value || null,
seller: config.seller,
buyer: {
name: billing.name1 || `${billing.name2 || ""} ${billing.name3 || ""}`.trim(),
vatIdentifier: option(billing, OPTION_VAT_NUMBER),
postalAddress: {
line1: `${billing.address1 || ""} ${billing.address2 || ""}`.trim(),
city: billing.town,
postCode: billing.postalCode,
country: await isoCode(billing.countryId),
},
},
paymentDetails: { paymentAccountIdentifier: config.iban },
lines,
};
// The XRechnung additions go here (see the XRechnung section)
return invoice;
}
The API ignores fields that are null, so an order without a customer sign simply has no BT-13. A promotional coupon has a negative price in plentymarkets. The mapping turns it into a positive price with a negative quantity, because EN 16931 does not accept a negative unit price. Bundle and set components are skipped, because the parent line carries the price. If you sell by weight or length, load orderItems.variation with with[] and map the unit of the variation to its UN/ECE code.
Step 3: Create the ZUGFeRD invoice from the plentymarkets order
One function loads the order, builds the body and posts it. It returns the PDF as a Buffer. A small command line file runs it for one order. Try it with a test order before you connect any trigger.
// einvoice.mjs (part 1: create)
import { postJson } from "./invoicexml.mjs";
import { loadOrder, buildInvoice } from "./mapping.mjs";
export async function createZugferd(orderId) {
const invoice = await buildInvoice(await loadOrder(orderId));
const pdf = await postJson("/v1/create/zugferd", { invoice });
return { invoice, pdf };
}
// create-one.mjs: node --env-file=.env create-one.mjs 1234
import { writeFile } from "node:fs/promises";
import { createZugferd } from "./einvoice.mjs";
const { invoice, pdf } = await createZugferd(Number(process.argv[2]));
await writeFile(`${invoice.invoiceNumber}-zugferd.pdf`, pdf);
console.log(`Created ${invoice.invoiceNumber}-zugferd.pdf (${pdf.length} bytes)`);
Open the file in any PDF viewer. It looks like a normal invoice. The XML is inside, and the API has already validated it. A French customer gets Factur-X: send the same body to /v1/create/facturx. Factur-X and ZUGFeRD share one technical standard. Choose the endpoint by the country code of the billing address.
Step 4: Upload the PDF to the order documents
The route POST /rest/orders/{orderId}/documents/{type} uploads a document to an order. The body carries the file as base64 in content. The service uses the type uploaded. The ZUGFeRD PDF then sits next to the plentymarkets invoice on the order, with the same number. Setups that create the invoice outside plentymarkets use the type invoiceExternal instead. The REST reference lists the body fields of the route under Document, Upload order documents.
// service.mjs: upload, poll and the HTTP routes
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { config } from "./config.mjs";
import { plenty } from "./plenty.mjs";
import { createZugferd } from "./einvoice.mjs";
import { handleInbound } from "./inbound.mjs";
const STATE_FILE = "./state.json"; // order IDs that are done, and the time of the last poll
const state = JSON.parse(await readFile(STATE_FILE, "utf8").catch(() => '{"done":[],"since":null}'));
const saveState = () => writeFile(STATE_FILE, JSON.stringify(state));
const w3c = (date) => date.toISOString().replace(/\.\d{3}Z$/, "+00:00");
async function processOrder(orderId) {
if (state.done.includes(orderId)) return;
const { invoice, pdf } = await createZugferd(orderId);
await plenty("POST", `/orders/${orderId}/documents/uploaded`, {
documents: [{ content: pdf.toString("base64"), numberWithPrefix: invoice.invoiceNumber, displayDate: invoice.issueDate }],
});
state.done.push(orderId);
await saveState();
}
// Trigger 1: sales orders (type 1) with an invoice, changed since the last poll
async function poll() {
const started = w3c(new Date());
const since = state.since || w3c(new Date(Date.now() - 24 * 3600 * 1000));
let failed = false;
for (let page = 1; ; page++) {
const result = await plenty("GET", `/orders?orderType=1&hasDocument=invoice&addOrderItems=false`
+ `&updatedAtFrom=${encodeURIComponent(since)}&page=${page}&itemsPerPage=50`);
for (const order of result.entries) {
try { await processOrder(order.id); }
catch (err) { failed = true; console.error(`Order ${order.id}: ${err.message}`); }
}
if (result.isLastPage) break;
}
if (!failed) { state.since = started; await saveState(); } // a failed order is tried again next time
}
// Trigger 2: a PlentyONE Flow calls POST /flow/{orderId}?token=...
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method !== "POST" || url.searchParams.get("token") !== config.serviceToken) {
return res.writeHead(404).end();
}
const flow = url.pathname.match(/^\/flow\/(\d+)$/);
if (flow) {
try { await processOrder(Number(flow[1])); res.writeHead(200).end("ok"); }
catch (err) { res.writeHead(500).end(err.message); }
return;
}
if (url.pathname === "/inbound") { // see the import section
return handleInbound(req, res).catch((err) => res.writeHead(500).end(err.message));
}
res.writeHead(404).end();
});
server.listen(config.port);
setInterval(() => poll().catch((err) => console.error(err.message)), config.pollMinutes * 60 * 1000);
poll().catch((err) => console.error(err.message));
When it runs. The Flow route reacts within seconds after plentymarkets generates the invoice. The Flow settings are in their own section. The poll loop is the safety net. It runs every ten minutes and catches every order the Flow missed, for example during a restart. state.json makes sure that each order gets exactly one e-invoice. Run the service under a process manager such as systemd, so it starts again after a reboot.
Sending from plentymarkets. If plentymarkets sends your invoice emails, build a Flow with the trigger Document uploaded and the action Send email. Check in the email template that the uploaded document is attached. The customer gets one PDF. People read it, and software reads the XML inside.
Send the invoice by email from the API
Delivery can also be the job of the API. One call creates the invoice, validates it and emails it to your customer. The PDF does not pass through plentymarkets. In Germany this counts as a complete delivery. German B2B e-invoicing has no central platform. An email with the ZUGFeRD PDF meets the mandate.
First, verify your sending domain once in the dashboard. Add the domain and publish the three CNAME records that the dashboard shows. After that, every address on the domain can send. Your customer sees your address, and replies reach your own mailbox. The request is the invoice JSON with one extra email object. The recipient is the email option of the billing address. If that is empty, the service takes email from the contact.
// einvoice.mjs (part 2: email)
import { config } from "./config.mjs";
import { plenty } from "./plenty.mjs";
import { customerEmail } from "./mapping.mjs";
export async function emailZugferd(orderId) {
const data = await loadOrder(orderId);
const invoice = await buildInvoice(data);
const to = customerEmail(data);
if (!to) throw new Error(`Order ${orderId} has no customer email address`);
await postJson("/v1/create/zugferd/email", {
invoice,
email: {
from: config.fromEmail, // an address on your verified sending domain
fromName: config.seller.name,
to,
subject: `Rechnung ${invoice.invoiceNumber}`,
message: `Guten Tag,\n\nim Anhang finden Sie unsere Rechnung ${invoice.invoiceNumber} als ZUGFeRD-PDF.\n\nFreundliche Grüße\n${config.seller.name}`,
},
});
// 200 means: created, validated and handed to delivery. The answer has no file.
await plenty("POST", "/comments", {
referenceType: "order",
referenceValue: orderId,
text: `ZUGFeRD-Rechnung ${invoice.invoiceNumber} per E-Mail an ${to} gesendet`,
isVisibleForContact: false,
});
}
The comment shows on the order, so your team sees what was sent and to whom. Use this route when the API should send. Use the plain endpoint when plentymarkets should store the file and send it itself. To switch, call emailZugferd in processOrder instead of the create and upload lines. XRechnung and Factur-X have the same route: /v1/create/xrechnung/email and /v1/create/facturx/email.
Import incoming supplier invoices
Every German business must accept e-invoices since January 2025. Your suppliers now send you ZUGFeRD PDFs or XRechnung XML. Each supplier file goes through three steps:
- Validate. Send the file to
POST /v1/validate/zugferd/reportfor a PDF or/v1/validate/xrechnung/reportfor XML. Add the form fieldvalidationLanguagewith the valuedefor a German report. It runs the EN 16931 rules and the German national rules. The body of the answer is a PDF report. TheX-Invoice-Validheader holdstrueorfalse./v1/validate/zugferdwithout/reportreturns the findings as JSON instead. - Send the report back. For an invalid invoice, the sender gets the PDF report by email, and the import stops. The report lists every broken rule with the field and a message. The supplier fixes the errors and sends a new file.
- Extract. For a valid invoice, send the same file to
POST /v1/extract/json. The answer is the invoice as JSON. It has the shape of the create request, plustotals.
The service takes supplier files on the route POST /inbound. The request is a multipart form with the fields file and sender. A mail rule, your mail server or an automation tool posts each attachment there, with the sender address. The report goes back through POST /rest/mail_templates/email_service/sendPreview, the plentymarkets route for a direct email. The REST reference lists its fields under MailTemplates. Check the attachment format there for your system.
// inbound.mjs (part 1: validate, send the report back, extract)
import { Readable } from "node:stream";
import { config } from "./config.mjs";
import { plenty } from "./plenty.mjs";
import { postFile } from "./invoicexml.mjs";
export async function handleInbound(req, res) {
const form = await new Request("http://localhost/inbound", {
method: "POST", headers: req.headers, body: Readable.toWeb(req), duplex: "half",
}).formData();
const file = form.get("file");
const result = await importSupplierInvoice(file.name, Buffer.from(await file.arrayBuffer()), form.get("sender"));
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(result));
}
async function importSupplierInvoice(fileName, bytes, sender) {
const isXml = fileName.toLowerCase().endsWith(".xml");
// 1. Validate: the verdict is in the header, the PDF report in the body
const check = await postFile(isXml ? "/v1/validate/xrechnung/report" : "/v1/validate/zugferd/report",
fileName, bytes, { validationLanguage: "de" });
// 2. Invalid: send the report to the sender and stop
if (check.headers.get("X-Invoice-Valid") !== "true") {
const report = Buffer.from(await check.arrayBuffer());
await plenty("POST", "/mail_templates/email_service/sendPreview", {
account: { type: "webstore", id: config.webstoreId, from: { name: config.seller.name, address: config.fromEmail } },
to: [{ name: sender, address: sender }],
subject: `Ihre Rechnung ${fileName}: Prüfbericht`,
body: "Die Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung.",
attachments: [{ name: "Pruefbericht.pdf", body: report.toString("base64"),
size: Math.ceil(report.length / 1024), contentType: "application/pdf" }],
});
return { valid: false };
}
// 3. Valid: read the invoice as JSON
const extracted = await (await postFile("/v1/extract/json", fileName, bytes)).json();
await storeSupplierInvoice(fileName, bytes, extracted);
return { valid: true, invoiceNumber: extracted.invoice.invoiceNumber };
}
Plain PDFs: send a PDF that has no XML inside to POST /v1/parse/json. The API reads it with AI. It handles typed, scanned and photographed invoices. The answer is the same JSON plus a confidence object with scores from 0.0 to 1.0. Store invoices with high scores directly. Send low scores to a person for review. The API rejects a file that is not an invoice, with error code 4008.
Store the data and link the reorder
The service keeps the JSON and the original file in a staging folder, one folder per supplier VAT id. Your bookkeeping export or your accounting software reads the files from there. Many suppliers print your order number on the invoice. In plentymarkets, a purchase from a supplier is a reorder, order type 12. If purchaseOrderReference is the ID of a reorder, the service also uploads the PDF to that reorder and adds a comment with the total.
// inbound.mjs (part 2: staging folder and reorder link)
import { mkdir, writeFile } from "node:fs/promises";
const safe = (text) => String(text || "unknown").replace(/[^\w.-]/g, "_");
async function storeSupplierInvoice(fileName, bytes, { invoice }) {
const totals = invoice.totals ?? {};
const folder = `./supplier-invoices/${safe(invoice.seller?.vatIdentifier)}`;
await mkdir(folder, { recursive: true });
const base = `${folder}/${safe(invoice.invoiceNumber)}`;
await writeFile(`${base}.json`, JSON.stringify({ invoice, totals }, null, 2));
await writeFile(`${base}${fileName.toLowerCase().endsWith(".xml") ? ".xml" : ".pdf"}`, bytes);
// A reorder (order type 12) with the ID from the invoice gets the PDF and a comment
const reorderId = Number(invoice.purchaseOrderReference);
if (!reorderId || fileName.toLowerCase().endsWith(".xml")) return;
const found = await plenty("GET", `/orders?orderIds=${reorderId}&orderType=12&addOrderItems=false`);
if (!found.entries.length) return;
await plenty("POST", `/orders/${reorderId}/documents/uploaded`, {
documents: [{ content: Buffer.from(bytes).toString("base64"),
numberWithPrefix: invoice.invoiceNumber, displayDate: invoice.issueDate }],
});
await plenty("POST", "/comments", {
referenceType: "order",
referenceValue: reorderId,
text: `Lieferantenrechnung ${invoice.invoiceNumber}: ${totals.grandTotalAmount} ${invoice.currency}, geprüft`,
isVisibleForContact: false,
});
}
The staging folder is the hand-over point to the bookkeeping. Each JSON file has the supplier, the lines, the VAT rates and the totals. Your team compares the total in the comment with the reorder before the payment. For XRechnung files, /v1/render/xrechnung/to/pdf creates a readable PDF. Upload that PDF to the reorder in the same way if you want it visible in plentymarkets.
XRechnung for German public buyers
German public buyers need XRechnung. This includes federal, state and municipal offices. XRechnung is pure XML, with no PDF. The upload portals ZRE and OZG-RE test every file with the KoSIT rules. The service creates it with a different endpoint and a few more fields.
Where the Leitweg-ID lives. plentymarkets lets you define your own address option types next to the standard ones such as VAT number and email. Create one with the name Leitweg-ID. Read its ID with GET /rest/accounts/addresses/option_types and put it into LEITWEG_OPTION_TYPE_ID. Then fill the option on the billing address of each public buyer. The Leitweg-ID is the routing ID that the buyer gives you when you register as a supplier. An XRechnung that lacks it breaks rule BR-DE-15.
// mapping.mjs: add at the end of buildInvoice, before the return
const leitwegId = config.leitwegOptionTypeId ? option(billing, config.leitwegOptionTypeId) : null;
if (leitwegId) {
invoice.buyerReference = leitwegId;
invoice.seller = {
...config.seller,
contact: config.sellerContact, // name, phone, email: mandatory under the German rules
electronicAddress: { identifier: config.seller.vatIdentifier, schemeId: "9930" },
};
invoice.buyer.electronicAddress = invoice.buyer.vatIdentifier
? { identifier: invoice.buyer.vatIdentifier, schemeId: "9930" } // German VAT id
: { identifier: leitwegId, schemeId: "0204" }; // Leitweg-ID
}
// einvoice.mjs (part 3): pick the format by the Leitweg-ID
export async function createEInvoice(orderId) {
const invoice = await buildInvoice(await loadOrder(orderId));
if (!invoice.buyerReference) return { invoice, pdf: await postJson("/v1/create/zugferd", { invoice }) };
return { invoice, xml: await postJson("/v1/create/xrechnung", { invoice }) };
}
In processOrder, call createEInvoice instead of createZugferd. Upload the PDF results as before. The API answers with XML that already passed the KoSIT rules. Save it in an outbox folder and deliver it the way the buyer asks: upload to ZRE or OZG-RE, send it over Peppol, or email it if the buyer accepts email. /v1/render/xrechnung/to/pdf makes a readable PDF for your own records. XRechnung is also allowed between businesses, so /v1/create/xrechnung/email works too. More on the format is in the XRechnung API toolkit.
Event procedures, polling and API limits
PlentyONE Flow. Open Automation, Flow Studio and create an event-based flow. Choose the trigger Document created and select the document type Invoice. Add the action Open URL from the Order group. Enter the full URL of the service with the template variable for the order ID, for example https://einvoice.firma.de/flow/$OrderID?token=.... Set the HTTP method to POST. The service needs no request body, because it reads everything through the REST API.
Event procedures. Older systems have event procedures under Setup, Orders, Events. The setup is the same idea: the event for the generated invoice and the procedure Open URL, with [OrderID] in the URL. The manual marks event procedures as outdated and points to PlentyONE Flow. New systems use Flow.
Polling. The poll loop from step 4 works on every system, with or without Flow. It asks for sales orders with orderType=1, hasDocument=invoice and updatedAtFrom. Dates use the W3C format, for example 2026-11-06T10:00:00+00:00. addOrderItems=false keeps the list small, because the service loads each order again in full. Pages hold up to 50 orders by default, and isLastPage ends the loop.
API limits. plentymarkets limits the number of calls. There are separate limits for reading and writing calls, per user per minute and per system per day. Some routes have their own limit. The developer portal lists the numbers for your contract. Every answer carries the current state in headers:
X-Plenty-Global-Short-Period-Limit,-Calls-Leftand-Decay: the short period, with the seconds until it resets.X-Plenty-Global-Long-Period-Limit,-Calls-Leftand-Decay: the long period.X-Plenty-Route-Limitand its partners: the limit of a single route, where one exists.Retry-After: sent with a 429 answer, in seconds. The helper in step 1 waits for it.
The service needs about five calls per invoice: the order, the invoice document, the contact, the upload and sometimes the country list. That fits easily into the limits of a normal shop. Concurrent sessions are limited too, three by default. Too many give a 429 with too_many_concurrent_sessions. This is why the service logs in once and reuses its token for the whole lifetime of expires_in, which is 24 hours. To fix the API version, send Accept: application/x.plentymarkets.v1+json. Without it, plentymarkets uses the newest version.
Endpoint reference
| Purpose | Endpoint | Input | Output |
|---|---|---|---|
| Create ZUGFeRD | POST /v1/create/zugferd | Invoice JSON | Hybrid PDF/A-3 |
| Create and email ZUGFeRD | POST /v1/create/zugferd/email | Invoice JSON + email object | Email sent from your domain |
| Create XRechnung | POST /v1/create/xrechnung | Invoice JSON with Leitweg-ID | XRechnung XML |
| Create and email XRechnung | POST /v1/create/xrechnung/email | Invoice JSON + email object | Email sent from your domain |
| Create Factur-X | POST /v1/create/facturx | Invoice JSON | Hybrid PDF/A-3 |
| Validate with PDF report | POST /v1/validate/zugferd/report, /xrechnung/report | PDF or XML file | PDF report, X-Invoice-Valid header |
| Validate as JSON | POST /v1/validate/zugferd, /xrechnung | PDF or XML file | Findings per rule |
| Extract structured data | POST /v1/extract/json | Hybrid PDF or XML file | Invoice JSON |
| Parse plain PDF | POST /v1/parse/json | Any PDF or image | Invoice JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Hybrid PDF or XML file | ZIP of embedded files |
| Render XRechnung | POST /v1/render/xrechnung/to/pdf | XRechnung XML | Readable PDF |
Get started
The whole integration is one Node.js service with seven small files. It logs in to plentymarkets, maps the order, creates the e-invoice and uploads it or lets the API email it. It also checks and stores what your suppliers send. There is no format logic in the service, and nothing changes in it when the formats change. The integrations hub lists this guide and the guides for Shopware, SAP, Odoo and more.
Related guides:
- ZUGFeRD and XRechnung in Shopware 6: the same goal as a shop plugin in PHP, with the PDF as the invoice document.
- ZUGFeRD and XRechnung in Node.js: every API call with fetch, plus serverless setups.
- ZUGFeRD API toolkit: the format, the profiles and all endpoints for German B2B.
- XRechnung API toolkit: Leitweg-ID, KoSIT validation and readable previews.