Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Retool: An E-Invoicing App and a Workflow on Your Own Database

Many teams run billing on their own PostgreSQL or MySQL tables and build the tools around them in Retool. E-invoices are now mandatory in Germany. This guide builds a Retool app that creates ZUGFeRD invoices with one REST API call, a Retool Workflow that sends them on a schedule, XRechnung for public buyers, and an import screen that checks supplier invoices and saves them to your database.

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) and customers (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:

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

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 queries getInvoices, getInvoice and savePdf, the transformer invoiceBody, the REST queries createZugferd and emailZugferd, and the JavaScript query createEInvoice.
  • App "Supplier invoices": the File Dropzone invoiceDropzone, the Text Input senderEmail, the REST queries validateReport and extractJson, the SMTP query sendReport, the SQL query insertSupplierInvoice and the JavaScript query routeInvoice.
  • 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:

FieldValue
NameInvoiceXML
Base URLhttps://api.invoicexml.com
AuthenticationBearer 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:

ColumnJSON pathNote
invoices.invoice_numberinvoice.invoiceNumberBT-1, unique per invoice
invoices.issue_date, due_dateinvoice.issueDate, dueDateFormatted as yyyy-MM-dd in SQL
invoices.currencyinvoice.currencyISO code, for example EUR
invoices.po_numberinvoice.purchaseOrderReferenceThe customer's order number, BT-13
Company constants in the transformerinvoice.sellerName, VAT id, address
customers.name, vat_id, street, city, zip, countryinvoice.buyerCountry as a 2-letter ISO code
Company IBANinvoice.paymentDetails.paymentAccountIdentifierWhere the customer pays
invoice_lines.descriptionlines[].item.nameOne entry per line
quantity, unit_pricelines[].quantity, lines[].priceDetails.netPriceNet price per unit
invoice_lines.unitlines[].unitCodeUN/ECE code: H87 pieces, HUR hours
invoice_lines.vat_ratelines[].vatInformation.ratePercent, for example 19 or 7
customers.leitweg_idinvoice.buyerReferenceXRechnung 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) or LONGBLOB (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:

  1. Validate. Upload the file to POST /v1/validate/zugferd/report for a PDF, or to /v1/validate/xrechnung/report for an XML file. Add the form field validationLanguage with the value de for a German report. The API checks the EN 16931 rules and the German rules. The response body is a PDF report. The header X-Invoice-Valid is true or false. If you want the findings as JSON instead, call /v1/validate/zugferd without /report.
  2. 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.
  3. 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

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

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