Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in plentymarkets (PlentyONE): E-Invoicing with a Small Node.js Service and the REST API

Many German online sellers with B2B customers run plentymarkets, now called PlentyONE. They now must handle e-invoices in both directions. This guide shows a small Node.js service that reads the order through the plentymarkets REST API, creates a ZUGFeRD invoice with one API call and uploads the PDF to the order documents. It also covers email delivery by the API, XRechnung for public buyers, the Flow trigger and checking supplier invoices.

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.

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

All four endpoints read one and the same JSON body. The service has these files. Each one is a listing in this guide:

  • .env and config.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 fieldJSON pathNote
Invoice document numberWithPrefixinvoice.invoiceNumberBT-1, for example RE-10234
Invoice document displayDateinvoice.issueDateFirst ten characters, yyyy-MM-dd
Contact timeForPaymentAllowedDaysinvoice.dueDateIssue date plus the payment days, or the default from the config
Order amounts[].currencyinvoice.currencyThe entry that is not the system currency, if there is one
Order property type 8 (customer sign)invoice.purchaseOrderReferenceThe customer's own reference, BT-13
Billing address name1, or name2 and name3invoice.buyer.nameCompany name first, then the person
address1, address2, postalCode, towninvoice.buyer.postalAddressStreet and house number go into line1
countryIdpostalAddress.countryisoCode2 from GET /rest/orders/shipping/countries
Address option type 1 (VAT number)invoice.buyer.vatIdentifierThe VAT id of the business customer
orderItems[].orderItemName, quantitylines[].item.name, lines[].quantityShipping costs and payment surcharges are lines too
orderItems[].amounts[].priceNetlines[].priceDetails.netPriceNet unit price after the item discount
orderItems[].vatRatelines[].vatInformation.rateFor 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:

  1. Validate. Send the file to POST /v1/validate/zugferd/report for a PDF or /v1/validate/xrechnung/report for XML. Add the form field validationLanguage with the value de for a German report. It runs the EN 16931 rules and the German national rules. The body of the answer is a PDF report. The X-Invoice-Valid header holds true or false. /v1/validate/zugferd without /report returns the findings as JSON instead.
  2. 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.
  3. 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, plus totals.

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.


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-Left and -Decay: the short period, with the seconds until it resets.
  • X-Plenty-Global-Long-Period-Limit, -Calls-Left and -Decay: the long period.
  • X-Plenty-Route-Limit and 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

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

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