This guide connects weclapp to ZUGFeRD and XRechnung. A small Node.js service listens to weclapp webhooks and reads each finished sales invoice through the weclapp REST API. It sends the invoice data as JSON to one REST API. The answer is a validated ZUGFeRD PDF. The service uploads it to the sales invoice in weclapp, where your team finds it under the documents. The email section lets the API deliver the invoice to the customer instead. The import section shows how supplier invoices are checked and created as purchase invoices. All code uses Node.js 20 with built-in fetch, FormData and Blob, without npm packages. The Node.js guide covers the plain API calls outside weclapp.
Why weclapp needs ZUGFeRD now
The German e-invoice mandate applies to every weclapp customer in Germany. Receiving structured e-invoices has been mandatory for all German businesses since January 2025. Issuing them is mandatory from January 2027 if the turnover of the previous year was above EUR 800,000. From January 2028, it is mandatory for all businesses. A plain PDF invoice does not meet these rules. The invoice must carry data that software can read. In ZUGFeRD, this data is an XML file inside a PDF/A-3. Your customer opens the PDF, and their software reads the XML. See the Germany mandate page for every date and format.
A second path next to the weclapp functions. weclapp also has its own e-invoice functions in the invoice workflow. This guide adds a path through the API, with its own strengths. One service can serve many weclapp tenants, which helps partners. The API can send the invoice from your own domain. A supplier with a broken invoice gets a PDF report that names each error. The same JSON also creates Factur-X for French customers.
Format updates need no change to the service. New versions of ZUGFeRD, XRechnung and the EN 16931 rules appear regularly. Each new version goes live in the API on its official start date. The service keeps sending the same JSON. You deploy nothing new.
What you need
- A weclapp tenant, for example
meinefirma.weclapp.com, and a weclapp user for the service. The user needs read rights on sales invoices and customers, and write rights on documents. For the import, it also needs rights to create purchase invoices. - The API token of that user. Each user finds it in weclapp under My settings, API. A new token makes the old one invalid.
- Node.js 20 or later on a server with a public HTTPS address, for example
https://einvoice.meinefirma.de. weclapp runs in the cloud, so the webhook must reach the server from the internet. - Your company data: name, VAT id, address, IBAN and an invoice contact. The service reads them from environment variables.
- An InvoiceXML account. Sign up to get your API key and 100 free credits. You do not need a credit card.
How the integration works
A user finishes a sales invoice in weclapp. weclapp calls the webhook of the service. The service loads the salesInvoice and the customer, builds one JSON body and posts it. The API calculates the totals and the VAT, builds the XML, embeds it in a PDF/A-3 file, validates the result and returns the PDF. The service uploads the PDF to the invoice with POST /document/upload. As an option, the API sends the invoice to the customer by email.
| 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 |
The JSON body is the same for every endpoint. The service has five small files:
weclapp.js: calls to the weclapp REST API, retries, cached lists and date conversion.invoicexml.js: one function for JSON requests and one for file uploads.mapping.js: turns a weclapp sales invoice into the invoice JSON.einvoice.js: creates the file, attaches it or sends it by email, and remembers what is done.server.jsandinbound.js: receive the webhook, accept supplier files and import them.
In weclapp, the service uses the resources salesInvoice, party, tax, unit, currency, document, webhook and purchaseInvoice.
Step 1: Store the weclapp token and the API key
The weclapp REST API has one base address per tenant: https://<tenant>.weclapp.com/webapp/api/v2/. Every request sends the token in the header AuthenticationToken. The InvoiceXML API uses a bearer token. Both keys belong in environment variables, never in the code. Node.js 20 loads a .env file with the --env-file flag:
# .env (keep this file out of version control)
WECLAPP_TENANT=meinefirma # https://meinefirma.weclapp.com
WECLAPP_TOKEN=your-weclapp-api-token
INVOICEXML_API_KEY=your-invoicexml-key
PUBLIC_URL=https://einvoice.meinefirma.de
WEBHOOK_SECRET=a-long-random-string
INBOUND_SECRET=another-long-random-string
POLL_MINUTES=15
[email protected] # an address on your verified sending domain
MAIL_API_URL=https://api.your-mail-provider.example/send
MAIL_API_KEY=your-mail-key
SELLER_NAME=Meine Firma GmbH
SELLER_VAT_ID=DE123456789
SELLER_STREET=Lindenallee 5
SELLER_CITY=Köln
SELLER_ZIP=50668
SELLER_IBAN=DE89370400440532013000
SELLER_CONTACT_NAME=Buchhaltung
SELLER_CONTACT_PHONE=+49 221 1234567
[email protected]
# Test the token: the number of sales invoices in the tenant
curl --compressed -H "AuthenticationToken: $WECLAPP_TOKEN" -H "Accept: application/json" \
"https://meinefirma.weclapp.com/webapp/api/v2/salesInvoice/count"
# Start the service
node --env-file=.env server.js
Two helper files do all HTTP work. weclapp sends decimal numbers as strings and dates as milliseconds since 1970. The helper converts dates in the German time zone, so an invoice date stays on the right day.
// weclapp.js: REST API v2 with the AuthenticationToken header
const BASE = `https://${process.env.WECLAPP_TENANT}.weclapp.com/webapp/api/v2/`;
async function call(method, path, { json, bytes, contentType } = {}) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(BASE + path, {
method,
headers: {
AuthenticationToken: process.env.WECLAPP_TOKEN,
Accept: "application/json",
"User-Agent": "einvoice-service/1.0",
...(json ? { "Content-Type": "application/json" } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
},
body: json ? JSON.stringify(json) : bytes,
signal: AbortSignal.timeout(90_000), // weclapp may queue a request, so wait more than a minute
});
if (response.status === 429 && attempt < 5) { // too many requests: wait 2, 4, 8, 16 seconds
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
continue;
}
const text = await response.text();
if (!response.ok) throw new Error(`weclapp ${method} ${path} ${response.status}: ${text}`);
return text ? JSON.parse(text) : null;
}
}
export const weclappGet = (path) => call("GET", path);
export const weclappPost = (path, json) => call("POST", path, { json });
export const weclappUpload = (path, bytes, contentType) => call("POST", path, { bytes, contentType });
// tax, unit and currency change rarely: load each list once and keep it in memory
const lists = new Map();
export async function lookup(resource, id) {
if (!lists.has(resource)) {
const { result } = await weclappGet(`${resource}?pageSize=1000`);
lists.set(resource, new Map(result.map((row) => [row.id, row])));
}
return lists.get(resource).get(id);
}
// Milliseconds -> "2026-11-06", read in the German time zone
export const isoDate = (ms) =>
ms == null ? undefined : new Date(ms).toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });
// "2026-11-06" -> milliseconds. Noon UTC is the same calendar day in Germany.
export const weclappDate = (iso) => Date.parse(`${iso}T12:00:00Z`);
// invoicexml.js: JSON in and file out, or file in and result out
const API = "https://api.invoicexml.com";
const AUTH = { Authorization: `Bearer ${process.env.INVOICEXML_API_KEY}` };
export async function createFile(path, body) {
const response = await fetch(API + path, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// 400: an errors array with the rule id and the JSON path of each problem
if (!response.ok) throw new Error(`${path} ${response.status}: ${await response.text()}`);
return Buffer.from(await response.arrayBuffer());
}
// Upload endpoints: one form field named "file", plus optional fields
export async function uploadFile(path, bytes, fileName, 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 response = await fetch(API + path, { method: "POST", headers: AUTH, body: form });
if (!response.ok) throw new Error(`${path} ${response.status}: ${await response.text()}`);
return response;
}
weclapp has no fixed rate limit. Too many parallel requests wait in a queue, and after 30 seconds the API answers with 429. The helper retries those answers with a growing delay. The platform section has the details.
Step 2: Map weclapp sales invoice fields to JSON
Two calls deliver the data: GET /salesInvoice/id/{id} and GET /party/id/{customerId}. The sales invoice already holds the invoice address, the VAT id of the buyer and the email recipients. Units, taxes and the currency are ids. The helper lookup resolves them from the cached lists. The seller is your company, read from the environment variables.
| weclapp field | JSON path | Note |
|---|---|---|
salesInvoice.invoiceNumber | invoice.invoiceNumber | BT-1 |
invoiceDate, dueDate | invoice.issueDate, invoice.dueDate | Milliseconds, converted to yyyy-MM-dd |
recordCurrencyId, resolved to currency.name | invoice.currency | For example EUR |
orderNumberAtCustomer | invoice.purchaseOrderReference | The order number of the customer, BT-13 |
recordAddress (company, street1, zipcode, city, countryCode) | invoice.buyer.name, buyer.postalAddress | The country is already an ISO code |
vatRegistrationNumber | invoice.buyer.vatIdentifier | Else party.vatIdentificationNumber |
salesInvoiceItems[].title | lines[].item.name | Items of type FREE_TEXT are skipped |
quantity, unitId resolved to unit.name | lines[].quantity, lines[].unitCode | UN/ECE code: H87 pieces, HUR hours |
netAmount divided by quantity | lines[].priceDetails.netPrice | Net, after the line discount |
taxId resolved to tax.taxValue | lines[].vatInformation.rate | For example 19 or 7 |
shippingCostItems[] | One more line each | Shipping costs with their own tax |
party.xRechnungLeitwegId | invoice.buyerReference | XRechnung only |
| Environment variables | invoice.seller, paymentDetails | Name, VAT id, address, IBAN |
The service never sends totals or VAT amounts. The API works both out from the lines. If the numbers do not add up, the API answers with an error and creates no file. You see rounding or discount errors before your customer does.
// mapping.js: weclapp salesInvoice + party -> InvoiceXML JSON
import { weclappGet, lookup, isoDate } from "./weclapp.js";
const env = process.env;
// UN/ECE Recommendation 20 codes for the unit names in your tenant
const UNIT_CODES = { "Stk.": "H87", "Std.": "HUR", "h": "HUR", "kg": "KGM", "m": "MTR", "pausch.": "LS" };
export async function buildInvoice(salesInvoiceId) {
const inv = await weclappGet(`salesInvoice/id/${salesInvoiceId}`);
const party = await weclappGet(`party/id/${inv.customerId}`);
const address = inv.recordAddress ?? {};
const lines = [];
for (const item of inv.salesInvoiceItems ?? []) {
const quantity = Number(item.quantity); // decimals arrive as strings
if (item.itemType === "FREE_TEXT" || !quantity) continue;
const unit = await lookup("unit", item.unitId);
const tax = await lookup("tax", item.taxId);
lines.push({
quantity,
unitCode: UNIT_CODES[unit?.name] ?? "C62",
item: { name: item.title },
// netAmount: net line total after the line discount, in the invoice currency
priceDetails: { netPrice: Math.round((Number(item.netAmount) / quantity) * 10000) / 10000 },
vatInformation: { rate: Number(tax.taxValue) },
});
}
for (const cost of inv.shippingCostItems ?? []) {
const tax = await lookup("tax", cost.taxId);
lines.push({
quantity: 1,
unitCode: "C62",
item: { name: "Versandkosten" },
priceDetails: { netPrice: Number(cost.netAmount) },
vatInformation: { rate: Number(tax.taxValue) },
});
}
const currency = await lookup("currency", inv.recordCurrencyId);
const invoice = {
invoiceNumber: inv.invoiceNumber,
issueDate: isoDate(inv.invoiceDate),
dueDate: isoDate(inv.dueDate),
currency: currency?.name,
purchaseOrderReference: inv.orderNumberAtCustomer,
buyerReference: party.xRechnungLeitwegId, // Leitweg-ID, XRechnung only
seller: {
name: env.SELLER_NAME,
vatIdentifier: env.SELLER_VAT_ID,
postalAddress: { line1: env.SELLER_STREET, city: env.SELLER_CITY, postCode: env.SELLER_ZIP, country: "DE" },
},
buyer: {
name: address.company ?? party.company,
vatIdentifier: inv.vatRegistrationNumber ?? party.vatIdentificationNumber,
postalAddress: {
line1: address.street1,
city: address.city,
postCode: address.zipcode,
country: address.countryCode,
},
},
paymentDetails: { paymentAccountIdentifier: env.SELLER_IBAN },
lines,
};
// First address from the email field of the invoice, else the email of the customer
const recipient = (inv.recordEmailAddresses?.toAddresses ?? party.email ?? "").split(/[;,]/)[0].trim();
return { invoice, party, recipient, status: inv.status };
}
Compare the unit names with the list from GET /unit once and extend UNIT_CODES. The cached tax list gives the rate for each taxId. weclapp also has a discount on the whole invoice (headerDiscount). Create one test invoice with such a discount and compare the totals before you go live.
Step 3: Create the ZUGFeRD invoice
With the mapping in place, the ZUGFeRD file is one request. The function returns the PDF as a Buffer.
// einvoice.js: create, attach or email, remember what is done
import { appendFile, readFile } from "node:fs/promises";
import { createFile } from "./invoicexml.js";
import { buildInvoice } from "./mapping.js";
import { weclappUpload } from "./weclapp.js";
export async function createZugferd(salesInvoiceId) {
const { invoice } = await buildInvoice(salesInvoiceId);
return createFile("/v1/create/zugferd", { invoice }); // the hybrid PDF/A-3
}
A customer in France needs Factur-X. Send the same body to /v1/create/facturx and choose the endpoint by recordAddress.countryCode. Both formats follow the same European standard. The French rules are in the Factur-X API toolkit.
Step 4: Attach the PDF to the weclapp sales invoice
weclapp keeps files as documents of a record. POST /document/upload takes the record in the query (entityName, entityId and the file name). The file itself is the raw request body. Your team then sees the ZUGFeRD PDF in the documents of the sales invoice.
The service only handles invoices in the status DOCUMENT_CREATED or OPEN_ITEM_CREATED. Invoices in NEW or ENTRY_COMPLETED can still change. A small log file holds the ids of the processed invoices, so no invoice is handled twice.
// einvoice.js (continued)
const FINAL = ["DOCUMENT_CREATED", "OPEN_ITEM_CREATED"];
const LOG = "processed.log";
const done = new Set((await readFile(LOG, "utf8").catch(() => "")).split("\n").filter(Boolean));
const running = new Set();
export async function attach(entityName, entityId, bytes, fileName, contentType) {
const query = new URLSearchParams({ entityName, entityId, name: fileName, description: "E-Rechnung, geprüft" });
await weclappUpload(`document/upload?${query}`, bytes, contentType);
}
export async function markDone(id) {
await appendFile(LOG, `${id}\n`);
done.add(id);
}
// Called by the webhook and by the polling job
export async function processInvoice(id) {
if (done.has(id) || running.has(id)) return;
running.add(id);
try {
const { invoice, status } = await buildInvoice(id);
if (!FINAL.includes(status)) return; // not final yet, a later event will come
const pdf = await createFile("/v1/create/zugferd", { invoice });
await attach("salesInvoice", id, pdf, `${invoice.invoiceNumber}-zugferd.pdf`, "application/pdf");
await markDone(id);
} finally {
running.delete(id);
}
}
// server.js: weclapp webhook, upload route for supplier invoices, polling
import { createServer } from "node:http";
import { processInvoice } from "./einvoice.js";
import { processInbound } from "./inbound.js";
import { weclappGet, weclappPost } from "./weclapp.js";
// Once: node --env-file=.env server.js --register
if (process.argv.includes("--register")) {
await weclappPost("webhook", {
entityName: "salesInvoice",
atCreate: false, atUpdate: true, atDelete: false,
requestMethod: "POST",
url: `${process.env.PUBLIC_URL}/weclapp?token=${process.env.WEBHOOK_SECRET}`,
});
process.exit(0);
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
try {
if (req.method === "POST" && url.pathname === "/weclapp"
&& url.searchParams.get("token") === process.env.WEBHOOK_SECRET) {
const event = JSON.parse(await readBody(req)); // { entityId, entityName, type }
res.writeHead(200).end(); // answer first, then work
if (event.entityName === "salesInvoice" && event.type !== "DELETE") await processInvoice(event.entityId);
return;
}
if (req.method === "POST" && url.pathname === "/inbound"
&& req.headers["x-inbound-secret"] === process.env.INBOUND_SECRET) {
const file = await readBody(req);
res.writeHead(202).end();
await processInbound(file, req.headers["x-file-name"], req.headers["x-sender"]);
return;
}
res.writeHead(404).end();
} catch (error) {
console.error(error);
if (!res.headersSent) res.writeHead(500).end();
}
}).listen(8080);
// Safety net: invoices that changed in the last 24 hours
setInterval(async () => {
const query = new URLSearchParams({
"status-in": JSON.stringify(["DOCUMENT_CREATED", "OPEN_ITEM_CREATED"]),
"lastModifiedDate-gt": String(Date.now() - 24 * 60 * 60 * 1000),
properties: "id",
pageSize: "1000",
});
const { result } = await weclappGet(`salesInvoice?${query}`);
for (const { id } of result) await processInvoice(id).catch(console.error);
}, Number(process.env.POLL_MINUTES) * 60 * 1000);
Run it on a weclapp webhook
The webhook. Start the service once with --register. This creates a webhook for the entity salesInvoice that fires on every update. You can also create it by hand in weclapp under Globale Einstellungen, Integration, Webhooks. weclapp then posts a small JSON body with entityId, entityName and type. The service loads the full invoice itself. Put a long random token into the webhook URL, as in the code. The server ignores every call without it.
The polling job. If your server is down, weclapp repeats a webhook a few times and then switches it off. So the service also asks for changed invoices every POLL_MINUTES. The filters status-in and lastModifiedDate-gt keep the answer small. The log skips invoices that are already done.
The log. processed.log is enough for one server. With several instances, keep the ids in a shared database instead.
Send the invoice by email from the API
You can also leave the sending to the API. One request creates the ZUGFeRD PDF, validates it and emails it to the customer. The PDF does not pass through your service. For B2B e-invoices, Germany uses no central platform. So one email with the ZUGFeRD PDF fully delivers the invoice.
Before the first email, verify your sending domain in the dashboard. Enter the domain, then publish the three CNAME records shown there. After that, every address on the domain may send. The customer sees your address and replies to your mailbox. The body holds the invoice JSON and one more object, email. The recipient comes from the email field of the sales invoice (recordEmailAddresses.toAddresses) or of the customer:
// einvoice.js (continued)
export async function sendByEmail(id) {
const { invoice, recipient } = await buildInvoice(id);
await createFile("/v1/create/zugferd/email", {
invoice,
email: {
from: process.env.INVOICE_FROM, // an address on your verified domain
to: recipient,
subject: `Rechnung ${invoice.invoiceNumber}`,
message: `Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie unsere Rechnung ${invoice.invoiceNumber} als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\n${invoice.seller.name}`,
},
});
// 200: created, validated and handed to delivery. The answer holds no file.
await markDone(id);
}
Use this route when the API should send. Use the plain endpoint when weclapp should store the file and send it itself. For XRechnung, the route is /v1/create/xrechnung/email, and Factur-X has one too. To choose the route per customer, use a custom attribute, as the platform section shows.
Import incoming supplier invoices
German businesses must accept e-invoices since January 2025. Suppliers deliver ZUGFeRD as PDF and XRechnung as XML. A mail rule or a small script posts each file to the /inbound route of the service. The import then runs in three steps:
- Validate. Send a PDF to
POST /v1/validate/zugferd/reportand an XML file to/v1/validate/xrechnung/report. Add the form fieldvalidationLanguage=de. It tests the file against EN 16931 and the German national rules. TheX-Invoice-Validheader holds the result. The response body is the report as a PDF, written in German. If you need the findings as JSON, call/v1/validate/zugferdwithout/report. - Return invalid invoices. An invalid invoice stops here. Email the PDF report back to the sender. The report lists each failed rule and the affected field. The supplier sees what to correct and needs no call with your team.
- Extract. A valid invoice goes on to
POST /v1/extract/json. You get the invoice as JSON in the shape of the create body, with the totals ininvoice.totals.
// inbound.js: validate, return the report or extract, create the purchaseInvoice
import { uploadFile } from "./invoicexml.js";
import { attach } from "./einvoice.js";
import { weclappGet, weclappPost, weclappDate } from "./weclapp.js";
export async function processInbound(bytes, fileName, sender) {
const isXml = fileName.toLowerCase().endsWith(".xml");
// 1. Validate, with the report in German
const check = await uploadFile(isXml ? "/v1/validate/xrechnung/report" : "/v1/validate/zugferd/report",
bytes, fileName, { validationLanguage: "de" });
const report = Buffer.from(await check.arrayBuffer());
// 2. Invalid: the report goes back to the supplier
if (check.headers.get("X-Invoice-Valid") !== "true") {
await sendMail(sender, `Ihre Rechnung ${fileName}: Prüfung nicht bestanden`,
"Ihre E-Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang nennt jeden Fehler. Bitte senden Sie uns eine korrigierte Rechnung.",
{ filename: `Pruefbericht-${fileName}.pdf`, content: report.toString("base64") });
return;
}
// 3. Valid: read the data, create the purchase invoice, attach the original file
const extracted = await (await uploadFile("/v1/extract/json", bytes, fileName)).json();
const purchaseInvoiceId = await createPurchaseInvoice(extracted.invoice);
await attach("purchaseInvoice", purchaseInvoiceId, bytes, fileName, isXml ? "application/xml" : "application/pdf");
}
// The HTTP API of your mail provider; the field names differ per provider
async function sendMail(to, subject, text, attachment) {
const response = await fetch(process.env.MAIL_API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MAIL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: process.env.INVOICE_FROM, to, subject, text, attachments: [attachment] }),
});
if (!response.ok) throw new Error(`Mail ${response.status}: ${await response.text()}`);
}
Supplier PDFs without XML. Some suppliers still send plain PDF invoices. The endpoint POST /v1/parse/json handles them. The API reads typed, scanned and photographed invoices with AI and returns the same JSON. A confidence object adds scores from 0.0 to 1.0. Create the purchase invoice automatically for high scores, and let a person check the rest. The API refuses files that are not invoices and returns error code 4008.
Create the weclapp purchase invoice from the JSON
Both endpoints return the same JSON, so one function creates the record in weclapp. It looks for a party with the supplier role and the VAT id of the seller. Then it creates a purchaseInvoice with one item per invoice line. For each line, it asks weclapp for the right input tax with GET /tax/findPurchaseTax. The create call answers with the new purchase invoice and its id.
// inbound.js (continued)
const RATE_TYPES = { 19: "STANDARD", 7: "REDUCED", 0: "ZERO" }; // VAT rate -> weclapp taxRateType
async function createPurchaseInvoice(invoice) {
// The supplier: a party with the supplier role and the same VAT id
const vatId = invoice.seller.vatIdentifier;
const { result: suppliers } = await weclappGet(`party?${new URLSearchParams({
"supplier-eq": "true", "vatIdentificationNumber-eq": vatId, properties: "id" })}`);
if (!suppliers.length) throw new Error(`Kein Lieferant mit USt-IdNr. ${vatId}`);
const { result: [currency] } = await weclappGet(`currency?name-eq=${invoice.currency}`);
const items = [];
for (const line of invoice.lines) {
const tax = await weclappGet(`tax/findPurchaseTax?${new URLSearchParams({
recipientCountryCode: "DE",
dispatchCountryCode: invoice.seller.postalAddress.country,
taxRateType: RATE_TYPES[line.vatInformation.rate],
date: String(weclappDate(invoice.issueDate)),
})}`);
items.push({
title: line.item.name,
quantity: String(line.quantity), // weclapp decimals are strings
unitPrice: String(line.priceDetails.netPrice),
taxId: tax.result.id,
accountId: process.env.PURCHASE_ACCOUNT_ID, // optional: your default expense account
});
}
const created = await weclappPost("purchaseInvoice", {
supplierId: suppliers[0].id,
purchaseInvoiceType: "STANDARD_INVOICE",
invoiceNumber: invoice.invoiceNumber, // the number of the supplier
invoiceDate: weclappDate(invoice.issueDate),
dueDate: invoice.dueDate ? weclappDate(invoice.dueDate) : undefined,
recordCurrencyId: currency.id,
grossPrices: false,
description: `E-Rechnung ${invoice.invoiceNumber}, Gesamtbetrag ${invoice.totals.grandTotalAmount} ${invoice.currency}`,
purchaseInvoiceItems: items,
});
return created.id;
}
Test the payload first with POST /purchaseInvoice?dryRun=true. weclapp then runs its checks and returns any errors, but saves nothing. Some setups need an expense account on items without an article. Set PURCHASE_ACCOUNT_ID to the id of that account, or add an articleId for a cost article. The description holds the total from the e-invoice. The clerk compares it with the weclapp total before the invoice is booked. The original file sits in the documents of the new purchase invoice.
XRechnung for German public buyers
Public buyers in Germany at federal, state and city level ask for XRechnung. An XRechnung has no visible PDF. It is the XML alone. The portals ZRE and OZG-RE test it with the KoSIT rules. The same service creates it. Compared with ZUGFeRD, it needs three more things: the Leitweg-ID, a seller contact and electronic addresses for both parties:
// mapping.js, at the end of buildInvoice: extra fields when the customer has a Leitweg-ID
if (party.xRechnungLeitwegId) {
invoice.seller.contact = { name: env.SELLER_CONTACT_NAME, phone: env.SELLER_CONTACT_PHONE, email: env.SELLER_CONTACT_EMAIL };
invoice.seller.electronicAddress = { identifier: env.SELLER_VAT_ID, schemeId: "9930" }; // 9930 = German VAT id
invoice.buyer.electronicAddress = { identifier: invoice.buyer.vatIdentifier, schemeId: "9930" };
}
// einvoice.js, in processInvoice: the Leitweg-ID decides the format
const xrechnung = Boolean(invoice.buyerReference);
const file = await createFile(xrechnung ? "/v1/create/xrechnung" : "/v1/create/zugferd", { invoice });
await attach("salesInvoice", id, file,
`${invoice.invoiceNumber}-${xrechnung ? "xrechnung.xml" : "zugferd.pdf"}`,
xrechnung ? "application/xml" : "application/pdf");
The Leitweg-ID tells the portal of the public buyer where the invoice goes. The buyer gives it to you. Without it, the check fails on rule BR-DE-15. In weclapp, you enter it in the financial data (Finanzdaten) of the customer. The REST API returns it as xRechnungLeitwegId on the party. The mapping puts it into buyerReference. Start with the customers whose customerBusinessType is B2G. The German rules require the seller contact and the electronic addresses. With scheme 9930, the identifier is a German VAT id. If the authority has no VAT id, use the address it names, for example its Peppol participant id with that scheme.
The XML you get back has passed the KoSIT check. Deliver it the way the buyer wants: upload to ZRE or OZG-RE, send it over Peppol, or email it where the buyer accepts email. For your own files, /v1/render/xrechnung/to/pdf turns the XML into a readable PDF. Read the XRechnung API toolkit for the format details.
weclapp API versions, rate limits and custom attributes
API versions. weclapp offers its REST API in the versions v1 and v2. This guide uses v2 at /webapp/api/v2/. In v2, customers and suppliers both live in the party resource, with the flags customer and supplier. The endpoints /customer and /supplier exist only in v1. v2 also removed name fields such as recordCurrencyName, unitName and taxName. You read the ids and resolve them, as lookup does. According to weclapp, a version stays available for at least one year after the next one is released. The API reference, the OpenAPI file and the changelog between v1 and v2 are at www.weclapp.com/api.
Data types. Decimal numbers such as quantities and prices are JSON strings. Dates are milliseconds since 1970. An empty string counts as null. Lists return 100 records by default and at most 1000 per page (pageSize, page). POST, PUT and DELETE accept dryRun=true for a check without saving.
Load and rate limits. weclapp sets no fixed rate limit. It limits the parallel requests per tenant instead. Requests above that limit wait in a queue for up to 30 seconds. After that, the API answers with HTTP 429. Use timeouts of at least one minute and retry with a growing delay. When a request had to wait, the headers X-Weclapp-Wait-Ms and X-Weclapp-Wait-Reason say how long and why. weclapp recommends webhooks over unfiltered polling.
Webhook retries. When your server does not answer, weclapp repeats the call after 1, 5 and 30 minutes, after 5 hours and after 1 day. Then it deactivates the webhook and shows an error in the webhook list. The polling job from step 4 covers that gap. Check the webhook list after a longer outage.
Custom attributes. weclapp lets you add your own fields to customers, invoices and other records. In the API they appear in the customAttributes array of the record. Each entry has an attributeDefinitionId and a value field such as booleanValue or stringValue. A checkbox on the customer, for example "E-Rechnung per API senden", can choose the delivery route:
// Once: the id of the definition, found by its key
const { result: [definition] } = await weclappGet(`customAttributeDefinition?${new URLSearchParams({
"attributeKey-eq": "einvoiceByEmail", properties: "id" })}`);
// Per invoice: read the checkbox on the customer
const { party } = await buildInvoice(id);
const byEmail = party.customAttributes?.find((a) => a.attributeDefinitionId === definition.id)?.booleanValue === true;
if (byEmail) await sendByEmail(id); else await processInvoice(id);
Filters work on custom attributes too, with the pattern customAttribute<definitionId>-eq. weclapp recommends loading the definitions once and keeping them in memory. Use the attribute key from your own tenant. You find it in the definition of the field.
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 full integration is five small Node.js files and a log file. The service reacts to the weclapp webhook, maps the sales invoice and creates the e-invoice. It stores the file in the documents of the invoice, or the API emails it. Supplier invoices are checked first and then become purchase invoices in weclapp. The service contains no format rules, so format updates need no new release. More platforms, such as Shopify, SelectLine and Odoo, are in the integrations hub.
Related guides:
- SelectLine ZUGFeRD: the same pattern for another German ERP with its own API.
- ZUGFeRD and XRechnung in Node.js: the API calls with fetch, outside any ERP.
- ZUGFeRD API toolkit: every ZUGFeRD endpoint and the profiles for German B2B.
- XRechnung API toolkit: the format for public buyers, its routing id and the preview.