This guide adds ZUGFeRD and XRechnung to the internal tools you build in Retool. Your invoices live in your own PostgreSQL or MySQL database, in the tables invoices, invoice_lines and customers. Retool reads them with SQL, a JavaScript transformer builds the JSON, and one REST query returns the finished e-invoice. A Retool Workflow does the same work on a schedule. In the email section, the API delivers the invoice to the customer itself. In the import section, supplier invoices are checked and stored in your database. If you want to see the API calls in plain JavaScript first, read the Node.js guide.
Why Retool needs ZUGFeRD now
German businesses must exchange structured e-invoices. Since January 2025, every business in Germany must be able to receive them. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must also issue them. From January 2028, this applies to all businesses. The Germany mandate page lists the dates and the formats.
A PDF from your app is not an e-invoice. Retool can export a page as a PDF, and many teams render invoices with a template service. That file is for people only. The mandate asks for data that software can read. ZUGFeRD solves both needs in one file: a PDF/A-3 that people read, with CII XML inside that accounting software reads.
The app stays the same when the formats change. ZUGFeRD, XRechnung and the EN 16931 rules get new versions over time. The API moves to each new version on its effective date. Your Retool queries keep sending the same JSON. You do not edit the app or the workflow for a format update.
What you need
- Retool, on Retool Cloud or self-hosted. The app uses standard components: Table, Button, File Dropzone and Text Input. The batch part uses Retool Workflows.
- Your database as a Retool resource. The examples use PostgreSQL. MySQL works the same way; the guide names the few functions that differ.
- The three billing tables:
invoices(number, dates, currency, customer),invoice_lines(text, quantity, unit, net price, VAT rate) andcustomers(name, VAT id, address, email). Step 1 adds a few columns. - Your company data: legal name, VAT id, address and IBAN. They are the seller on every invoice.
- Admin rights in Retool to create the resource and the configuration variable.
- An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.
How the integration works
A user selects an invoice in a Retool table and clicks a button. A SQL query reads the invoice with its customer and lines. A transformer turns the result into the invoice JSON. A REST query sends the JSON to the API. The API calculates the totals and the VAT, builds the XML, embeds it in a PDF/A-3 file, validates it and sends the PDF back. A second SQL query saves the file and sets the status. All four endpoints take the same JSON body:
| 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 |
You create these parts in Retool. The names are the ones the code in this guide uses:
- Resource
InvoiceXML: a REST API resource with the base URL and the API key. - App "E-invoicing": the table
invoicesTable, the SQL queriesgetInvoices,getInvoiceandsavePdf, the transformerinvoiceBody, the REST queriescreateZugferdandemailZugferd, and the JavaScript querycreateEInvoice. - App "Supplier invoices": the File Dropzone
invoiceDropzone, the Text InputsenderEmail, the REST queriesvalidateReportandextractJson, the SMTP querysendReport, the SQL queryinsertSupplierInvoiceand the JavaScript queryrouteInvoice. - Workflow "Send e-invoices": a schedule trigger, SQL blocks, a code block, a branch block and a loop block.
The rest is standard Retool: the database resource, an SMTP resource, configuration variables, event handlers, formatDataAsArray, utils.downloadFile and utils.showNotification.
Step 1: Add the API as a Retool resource
Go to Resources, click Create new, then Resource, and pick REST API. Fill in these fields:
| Field | Value |
|---|---|
| Name | InvoiceXML |
| Base URL | https://api.invoicexml.com |
| Authentication | Bearer token |
| Token | {{ environment.variables.INVOICEXML_API_KEY }}, or the key itself |
With Bearer token, Retool sends Authorization: Bearer <key> on every query of this resource. The query runs on the Retool server, so the browser never sees the key. To keep the key out of the resource form, create a configuration variable under Settings, Configuration variables. Name it INVOICEXML_API_KEY and turn on "Mark variable as secret". Retool allows secret variables only in resource settings and in workflows, not in app queries. That is why the key belongs on the resource and not in a query header. Click Test connection to check the resource.
The database needs a few new columns: a status and the file on invoices, the Leitweg-ID on customers, and two tables for supplier invoices. Run this once:
-- PostgreSQL. For MySQL use LONGBLOB instead of bytea and DATETIME instead of timestamptz.
ALTER TABLE invoices
ADD COLUMN einvoice_status text, -- null, 'sending', 'created', 'emailed'
ADD COLUMN einvoice_pdf bytea,
ADD COLUMN einvoice_created_at timestamptz;
ALTER TABLE customers ADD COLUMN leitweg_id text; -- only for German public buyers
CREATE TABLE supplier_invoices (
id serial PRIMARY KEY,
supplier_id integer REFERENCES suppliers(id), -- null when the VAT id is unknown
supplier_vat_id text,
supplier_name text,
invoice_number text,
issue_date date,
due_date date,
currency text,
grand_total numeric(14,2),
amount_due numeric(14,2),
status text DEFAULT 'to_review',
imported_at timestamptz DEFAULT now()
);
CREATE TABLE supplier_invoice_lines (
id serial PRIMARY KEY,
supplier_invoice_id integer REFERENCES supplier_invoices(id),
description text,
quantity numeric(14,4),
unit_code text,
net_price numeric(14,4),
vat_rate numeric(5,2)
);
Change the table and column names to the ones in your schema. Every later query only depends on the column aliases it selects.
Step 2: Map the Retool query results to JSON
The API expects an EN 16931 invoice as JSON. Your tables hold the same data under other names. This table shows the mapping:
| Column | JSON path | Note |
|---|---|---|
invoices.invoice_number | invoice.invoiceNumber | BT-1, unique per invoice |
invoices.issue_date, due_date | invoice.issueDate, dueDate | Formatted as yyyy-MM-dd in SQL |
invoices.currency | invoice.currency | ISO code, for example EUR |
invoices.po_number | invoice.purchaseOrderReference | The customer's order number, BT-13 |
| Company constants in the transformer | invoice.seller | Name, VAT id, address |
customers.name, vat_id, street, city, zip, country | invoice.buyer | Country as a 2-letter ISO code |
| Company IBAN | invoice.paymentDetails.paymentAccountIdentifier | Where the customer pays |
invoice_lines.description | lines[].item.name | One entry per line |
quantity, unit_price | lines[].quantity, lines[].priceDetails.netPrice | Net price per unit |
invoice_lines.unit | lines[].unitCode | UN/ECE code: H87 pieces, HUR hours |
invoice_lines.vat_rate | lines[].vatInformation.rate | Percent, for example 19 or 7 |
customers.leitweg_id | invoice.buyerReference | XRechnung only |
You do not send totals or VAT amounts. The API calculates them from the lines. If the numbers do not add up, the API returns an error and no file. A rounding mistake in your data stops at the API and never reaches a customer.
One SQL query reads everything for the selected row. The lines come back as one JSON array, so the invoice is one row. The query runs again each time the user selects another row in invoicesTable. Retool sends the {{ }} value as a parameter of a prepared statement, so it needs no quotes.
-- Query getInvoice (resource: your PostgreSQL database)
SELECT i.id,
i.invoice_number,
to_char(i.issue_date, 'YYYY-MM-DD') AS issue_date,
to_char(i.due_date, 'YYYY-MM-DD') AS due_date,
i.currency,
i.po_number,
c.name AS customer_name, c.vat_id, c.street, c.city, c.zip, c.country,
c.email, c.leitweg_id,
(SELECT json_agg(json_build_object(
'description', l.description,
'quantity', l.quantity,
'unit', l.unit,
'unit_price', l.unit_price,
'vat_rate', l.vat_rate) ORDER BY l.position)
FROM invoice_lines l
WHERE l.invoice_id = i.id) AS lines
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.id = {{ invoicesTable.selectedRow.id }};
The column position keeps the line order; use your own column name. On MySQL, use DATE_FORMAT(i.issue_date, '%Y-%m-%d') and JSON_ARRAYAGG(JSON_OBJECT(...)). The transformer invoiceBody builds the invoice object from this row. Create it in the Code panel as a new transformer. It runs again whenever getInvoice returns new data.
// Transformer: invoiceBody. Returns the "invoice" object, or null if no row is selected.
const SELLER = {
name: "Muster GmbH",
vatIdentifier: "DE123456789",
postalAddress: { line1: "Hauptstraße 1", city: "Berlin", postCode: "10115", country: "DE" },
};
const SELLER_IBAN = "DE02120300000000202051";
// UN/ECE Recommendation 20 codes for the values in invoice_lines.unit
const UNIT_CODES = { pcs: "H87", h: "HUR", kg: "KGM", m: "MTR" };
function toInvoice(row) {
// Some drivers return a JSON column as text, others as an array
const lines = typeof row.lines === "string" ? JSON.parse(row.lines) : row.lines || [];
const invoice = {
invoiceNumber: row.invoice_number,
issueDate: row.issue_date,
dueDate: row.due_date || null,
currency: row.currency,
purchaseOrderReference: row.po_number || null,
buyerReference: row.leitweg_id || null, // XRechnung only, null for B2B
seller: SELLER,
buyer: {
name: row.customer_name,
vatIdentifier: row.vat_id || null,
postalAddress: { line1: row.street, city: row.city, postCode: row.zip, country: row.country },
},
paymentDetails: { paymentAccountIdentifier: SELLER_IBAN },
lines: lines.map((l) => ({
quantity: Number(l.quantity),
unitCode: UNIT_CODES[l.unit] || "C62",
item: { name: l.description },
priceDetails: { netPrice: Number(l.unit_price) },
vatInformation: { rate: Number(l.vat_rate) },
})),
};
// The XRechnung fields are added here (see the XRechnung section)
return invoice;
}
// A SQL query in an app returns columns; formatDataAsArray turns them into rows
const row = formatDataAsArray({{ getInvoice.data }})[0];
return row ? toInvoice(row) : null;
The API ignores fields that are null, so buyerReference is left out of B2B invoices. Number() matters because PostgreSQL returns numeric columns as strings. A unit that is not in UNIT_CODES gets C62, the generic "one" unit. If your company data lives in a settings table, read it with a second query instead of the constants.
Step 3: Create the ZUGFeRD invoice
Create a REST query createZugferd on the InvoiceXML resource. A JavaScript query createEInvoice runs it, saves the result and gives the user the file. The "Create e-invoice" button runs createEInvoice with a Click event handler of the type Control query.
// REST query: createZugferd (resource InvoiceXML)
// Action type: POST URL path: /v1/create/zugferd
// Headers: Content-Type = application/json
// Body: Raw
// {{ JSON.stringify({ invoice: invoiceBody.value }) }}
// Run behavior: manual (it runs only from createEInvoice)
// JavaScript query: createEInvoice
const row = invoicesTable.selectedRow;
if (!row || !invoiceBody.value) {
utils.showNotification({ title: "Select an invoice first", notificationType: "warning" });
return;
}
// 1. The API calculates, builds and validates the invoice and returns the PDF.
// Retool returns a binary response as a file object: { name, type, sizeBytes, base64Data }.
// A 400 answer makes the query fail, and Retool shows the "errors" array of the API.
const pdf = await createZugferd.trigger();
if (!pdf || !pdf.base64Data) return;
// 2. Save the PDF and the status in the database (query savePdf, step 4)
await savePdf.trigger({ additionalScope: { invoiceId: row.id, pdfBase64: pdf.base64Data } });
// 3. Give the user the file and reload the table
utils.downloadFile({ base64Binary: pdf.base64Data }, row.invoice_number + "-zugferd", "pdf");
await getInvoices.trigger();
utils.showNotification({ title: "E-invoice created", description: row.invoice_number, notificationType: "success" });
Open the state of createZugferd in the left panel after the first run. Check that data.base64Data holds the PDF in your Retool version. For a French customer, post the same body to /v1/create/facturx. Factur-X is the French name of the same standard. To pick the format by customer, put an expression in the URL path, for example on the country column.
Step 4: Store the PDF and run a Retool Workflow
The PDF must be kept for the legal retention period. Retool gives you three places for it. Each one is a valid choice:
- In the database, as
bytea(PostgreSQL) orLONGBLOB(MySQL). The invoice and its file are in one row and in one backup. The table grows by the size of each PDF. - In Retool Storage, with a Retool Storage query and the Upload file action. The table keeps the file id. The PDF component shows Retool Storage files directly.
- In your own S3 bucket, through an Amazon S3 resource. The table keeps the object key. Bucket policies and lifecycle rules then apply to the files.
This guide uses the database. savePdf decodes the base64 string into bytes in SQL. The JavaScript query passes invoiceId and pdfBase64 as additional scope.
-- Query savePdf (app, resource: your database). Run behavior: manual.
UPDATE invoices
SET einvoice_pdf = decode({{ pdfBase64 }}, 'base64'), -- MySQL: FROM_BASE64({{ pdfBase64 }})
einvoice_status = 'created',
einvoice_created_at = now()
WHERE id = {{ invoiceId }};
-- Workflow block getDueInvoices: claims up to 50 approved invoices without an e-invoice.
-- The status 'sending' stops the next run from taking the same invoices again.
WITH claimed AS (
UPDATE invoices
SET einvoice_status = 'sending'
WHERE id IN (SELECT id FROM invoices
WHERE status = 'approved' AND einvoice_status IS NULL
ORDER BY id LIMIT 50)
RETURNING id
)
SELECT i.id, i.invoice_number,
to_char(i.issue_date, 'YYYY-MM-DD') AS issue_date,
to_char(i.due_date, 'YYYY-MM-DD') AS due_date,
i.currency, i.po_number,
c.name AS customer_name, c.vat_id, c.street, c.city, c.zip, c.country,
c.email, c.leitweg_id,
(SELECT json_agg(json_build_object(
'description', l.description, 'quantity', l.quantity, 'unit', l.unit,
'unit_price', l.unit_price, 'vat_rate', l.vat_rate) ORDER BY l.position)
FROM invoice_lines l WHERE l.invoice_id = i.id) AS lines
FROM claimed
JOIN invoices i ON i.id = claimed.id
JOIN customers c ON c.id = i.customer_id;
-- Workflow block markSent: runs after the loop block
UPDATE invoices
SET einvoice_status = 'emailed', einvoice_created_at = now()
WHERE id = ANY({{ buildBodies.data.map((x) => x.id) }});
When to run it. The button is right for single invoices that a person checks first. For a steady flow, create a Retool Workflow with a schedule trigger, for example every 15 minutes. The workflow has these blocks: the SQL block getDueInvoices, a branch block with the condition getDueInvoices.data.length > 0, the code block buildBodies, a loop block sendEach and the SQL block markSent. The workflow cannot read the app transformer, so paste the toInvoice function from step 2 into the code block.
// Workflow code block: buildBodies (on the "if" path of the branch block)
// Paste SELLER, SELLER_IBAN, UNIT_CODES and toInvoice() from the transformer in step 2 here.
return getDueInvoices.data.map((row) => ({
id: row.id,
body: {
invoice: toInvoice(row),
email: {
from: "[email protected]", // an address on your verified sending domain
to: row.email,
subject: "Rechnung " + row.invoice_number,
message: "Guten Tag,\n\nanbei erhalten Sie unsere Rechnung " + row.invoice_number +
" als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\nMuster GmbH",
},
},
}));
// Loop block: sendEach
// Loop input: {{ buildBodies.data }}
// Mode: Sequential
// Resource: InvoiceXML, Action type POST, URL path /v1/create/zugferd/email
// Headers: Content-Type = application/json
// Body (Raw): {{ JSON.stringify(value.body) }}
The workflow uses the email route, so it never handles a file. If one call fails, the loop block fails and markSent does not run. Those invoices keep the status sending. Add a filter for that status to the app table, so a person sees them and fixes the data. To start the workflow from another system as well, add a webhook trigger. The caller sends the workflow API key in the X-Workflow-Api-Key header.
Send the invoice by email from the API
The API can deliver the invoice for you. One call creates the ZUGFeRD PDF, validates it and emails it to the customer. Germany has no central platform for B2B e-invoices. An email with a valid ZUGFeRD PDF attached is a complete delivery under the German rules.
First, verify your sending domain in the InvoiceXML dashboard. Add the domain and publish the three CNAME records that the dashboard shows. Then any address on that domain can send. Customers see your address and reply to your mailbox. The body is the invoice JSON plus an email object with from, to, subject and message. Optional fields are fromName, replyTo, cc and bcc. In the app, a second button "Email e-invoice" runs this REST query:
// REST query: emailZugferd (resource InvoiceXML)
// Action type: POST URL path: /v1/create/zugferd/email
// Headers: Content-Type = application/json
// Body: Raw
{{ JSON.stringify({
invoice: invoiceBody.value,
email: {
from: "[email protected]", // an address on your verified sending domain
fromName: "Muster GmbH Buchhaltung",
to: formatDataAsArray(getInvoice.data)[0].email,
replyTo: "[email protected]",
subject: "Rechnung " + invoiceBody.value.invoiceNumber,
message: "Guten Tag,\n\nanbei erhalten Sie unsere Rechnung " + invoiceBody.value.invoiceNumber +
" als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\nMuster GmbH",
},
}) }}
// Success event handler of emailZugferd: run the SQL query markEmailed
// UPDATE invoices SET einvoice_status = 'emailed', einvoice_created_at = now()
// WHERE id = {{ invoicesTable.selectedRow.id }};
A 200 answer means the invoice was created, validated and handed to delivery. The response does not contain the file. Use this route when the API should send. Use the plain endpoint when Retool should store the file and send it itself. XRechnung and Factur-X have the same route: /v1/create/xrechnung/email and /v1/create/facturx/email.
Import incoming supplier invoices
Since January 2025, German businesses must accept e-invoices. Your suppliers send ZUGFeRD PDFs and XRechnung XML files. A second Retool app, "Supplier invoices", checks each file and stores the data. The flow has three steps:
- Validate. Upload the file to
POST /v1/validate/zugferd/reportfor a PDF, or to/v1/validate/xrechnung/reportfor an XML file. Add the form fieldvalidationLanguagewith the valuedefor a German report. The API checks the EN 16931 rules and the German rules. The response body is a PDF report. The headerX-Invoice-Validistrueorfalse. If you want the findings as JSON instead, call/v1/validate/zugferdwithout/report. - Return the report. If the file is invalid, email the PDF report to the sender and stop. The report lists each broken rule with the field and a message, so the supplier knows what to fix.
- Extract. If the file is valid, upload it to
POST /v1/extract/json. The API returns the invoice as JSON, in the same shape as the create request.
All upload endpoints take multipart/form-data with one file field named file. In a Retool REST query, set the body to Form data. Add the key file, pick the type File and set the value to the first file of the dropzone. Retool then sends the upload as multipart. Add an SMTP resource for the reply to the supplier. Its Attachment field takes file objects, and a binary query result is a file object.
// REST query: validateReport (resource InvoiceXML), Action type POST
// URL path: {{ invoiceDropzone.value[0]?.name.toLowerCase().endsWith(".xml")
// ? "/v1/validate/xrechnung/report" : "/v1/validate/zugferd/report" }}
// Body: Form data
// file type File {{ invoiceDropzone.value[0] }}
// validationLanguage type Text de
// Success event handler: run the JavaScript query routeInvoice
//
// REST query: extractJson (resource InvoiceXML), Action type POST, URL path /v1/extract/json
// Body: Form data, file type File {{ invoiceDropzone.value[0] }}
//
// SMTP query: sendReport (your SMTP resource)
// From: [email protected]
// To: {{ senderEmail.value }}
// Subject: Ihre Rechnung {{ invoiceDropzone.value[0].name }}: Prüfbericht
// Body: Die Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang nennt die Fehler.
// Attachment: {{ [{ ...validateReport.data, name: "Pruefbericht.pdf" }] }}
// JavaScript query: routeInvoice
// Read X-Invoice-Valid without depending on the case of the header name
const headers = validateReport.metadata?.headers || {};
const key = Object.keys(headers).find((k) => k.toLowerCase() === "x-invoice-valid");
const raw = key ? headers[key] : null;
const valid = String(Array.isArray(raw) ? raw[0] : raw).toLowerCase() === "true";
if (!valid) {
// Invalid: send the PDF report to the supplier and stop
await sendReport.trigger();
utils.showNotification({ title: "Invoice rejected",
description: "The report was sent to " + senderEmail.value, notificationType: "warning" });
return;
}
// Valid: read the invoice as JSON and save it (next section)
const extracted = await extractJson.trigger();
await insertSupplierInvoice.trigger({ additionalScope: { inv: extracted.invoice } });
await getSupplierInvoices.trigger();
utils.showNotification({ title: "Invoice imported",
description: extracted.invoice.invoiceNumber, notificationType: "success" });
The import button runs validateReport. Its success handler starts routeInvoice, which then reads the fresh response headers of validateReport. Open the state of validateReport in the left panel once. Check how your Retool version lists the headers under metadata. The code accepts the header name in any case, and the value as text or as a list.
Plain PDFs: a PDF without XML inside goes to POST /v1/parse/json. The API reads it with AI. Typed, scanned and photographed invoices all work. The response is the same JSON plus a confidence object with scores from 0.0 to 1.0. Save the invoice with the status to_review when a score is low. A file that is not an invoice returns error code 4008.
Save the supplier invoice from the JSON
Both extraction endpoints return the same JSON shape, so one SQL query stores the result. It finds the supplier by the VAT id from the invoice, writes the header to supplier_invoices and the lines to supplier_invoice_lines. The totals are inside the invoice object, at invoice.totals. Every new row gets the status to_review. Accounts payable checks it in a table in the same app before payment.
-- Query insertSupplierInvoice (resource: your PostgreSQL database). inv comes from additionalScope.
WITH new_invoice AS (
INSERT INTO supplier_invoices
(supplier_id, supplier_vat_id, supplier_name, invoice_number,
issue_date, due_date, currency, grand_total, amount_due, status)
VALUES (
(SELECT id FROM suppliers WHERE vat_id = {{ inv.seller.vatIdentifier }} LIMIT 1),
{{ inv.seller.vatIdentifier }},
{{ inv.seller.name }},
{{ inv.invoiceNumber }},
{{ inv.issueDate }}::date,
{{ inv.dueDate }}::date,
{{ inv.currency }},
{{ inv.totals?.grandTotalAmount }},
{{ inv.totals?.duePayableAmount }},
'to_review')
RETURNING id
)
INSERT INTO supplier_invoice_lines
(supplier_invoice_id, description, quantity, unit_code, net_price, vat_rate)
SELECT new_invoice.id,
l -> 'item' ->> 'name',
(l ->> 'quantity')::numeric,
l ->> 'unitCode',
(l -> 'priceDetails' ->> 'netPrice')::numeric,
(l -> 'vatInformation' ->> 'rate')::numeric
FROM new_invoice,
jsonb_array_elements({{ JSON.stringify(inv.lines || []) }}::jsonb) AS l;
If no supplier has the VAT id, supplier_id stays empty and the clerk assigns it in the review table. To match a purchase order, look up inv.purchaseOrderReference in your orders table in the same query. On MySQL, run two queries instead: the INSERT for the header, then one for the lines with JSON_TABLE and LAST_INSERT_ID().
XRechnung for German public buyers
German public buyers (federal, state and local) require XRechnung, not ZUGFeRD. XRechnung is pure XML, with no PDF. The portals ZRE and OZG-RE check every file against the KoSIT rules. In Retool, the change is a few fields in the transformer and a second URL path:
// In toInvoice() from step 2, before "return invoice":
if (row.leitweg_id) {
invoice.seller = {
...SELLER,
contact: { name: "Erika Mustermann", phone: "+49 30 1234567", email: "[email protected]" },
electronicAddress: { identifier: SELLER.vatIdentifier, schemeId: "9930" },
};
invoice.buyer.electronicAddress = row.vat_id
? { identifier: row.vat_id, schemeId: "9930" } // German VAT id
: { identifier: row.leitweg_id, schemeId: "0204" }; // Leitweg-ID
}
// REST query createEInvoiceFile: one query for both formats
// URL path: {{ invoiceBody.value?.buyerReference ? "/v1/create/xrechnung" : "/v1/create/zugferd" }}
// Body (Raw): {{ JSON.stringify({ invoice: invoiceBody.value }) }}
//
// In the workflow, the email route works the same way for XRechnung:
// URL path: {{ value.body.invoice.buyerReference ? "/v1/create/xrechnung/email" : "/v1/create/zugferd/email" }}
The Leitweg-ID is the routing id of the public buyer. The buyer gives it to you when you register as a supplier. Rule BR-DE-15 rejects an XRechnung without it. Store it in the leitweg_id column of customers from step 1. Make it an editable column in a customer table in Retool, so the billing team can maintain it. The seller contact (name, phone, email) and the electronic addresses of seller and buyer are mandatory under the German rules. Scheme 9930 is a German VAT id. Scheme 0204 is the Leitweg-ID. For Peppol, use the Peppol participant id with its scheme.
The API returns the XML, already checked against the KoSIT rules. Check in the query state how your Retool version returns the XML, and save the XML text in a text column. Deliver the file as the buyer requires: upload to ZRE or OZG-RE, send it over Peppol, or email it if the buyer accepts email. The email route /v1/create/xrechnung/email does this without any file in Retool. For a readable copy, post the XML to /v1/render/xrechnung/to/pdf. The XRechnung API toolkit explains the format in more detail.
Self-hosted Retool, environments and permissions
Where the calls run. Resource queries run on the Retool backend, not in the browser. On Retool Cloud, the calls to api.invoicexml.com leave from Retool's servers. On self-hosted Retool, they leave from your instance. The instance needs outbound HTTPS to api.invoicexml.com. If your network uses an allowlist for outbound traffic, add that host.
Environments. Retool has the resource environments production and staging. Each environment can have its own resource settings. Configuration variables can also have a value per environment, with a default for the rest. Use a separate API key for staging, so test invoices are easy to tell apart from real ones. Point the staging database resource at a copy of your tables. Configuration variables are available on Retool Cloud and on self-hosted Retool from version 3.4.0.
Secrets in workflows. Workflow code blocks can read secret configuration variables through retoolContext.configVars. They are passed between blocks only when the whole workflow runs, not when you run one block by itself for a test. On self-hosted Retool from version 3.379.0, code blocks can also read secrets from your secrets manager through retoolContext.secrets.
Permissions. Only admins create and edit configuration variables. Users need the Use permission on a resource to run queries against it. Give the billing team access to the "E-invoicing" app and the InvoiceXML resource. Give accounts payable access to the "Supplier invoices" app. Retool admins can also limit access per environment, for example staging only for developers. Set this under Settings, Permissions.
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 setup in Retool is one REST resource, two apps and one workflow. SQL reads your tables, one transformer maps them, and REST queries do the rest: create, store or email, then check and import what suppliers send. Your apps contain no format logic, so a format update needs no change in Retool. The integrations hub has guides for more platforms, from ERP systems to no-code tools.
Related guides:
- ZUGFeRD and XRechnung in Node.js: the same API calls in plain JavaScript, outside Retool.
- Power Automate custom connector: the same flows in the Microsoft low-code platform.
- ZUGFeRD API toolkit: the format, the profiles and all endpoints for German B2B.
- XRechnung API toolkit: the Leitweg-ID, the KoSIT rules and the PDF preview.