Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Airtable: E-Invoicing with One Automation Script and One API Call

Many small businesses and agencies write their invoices in an Airtable base. E-invoices are now mandatory for them in Germany. This guide shows how to create ZUGFeRD invoices with the automation action Run a script and one REST API. No extension, no server and no third-party automation tool. It also covers email delivery by the API, XRechnung for public buyers, and checking and importing supplier invoices into a table.

This guide adds ZUGFeRD and XRechnung to an Airtable base. Your customers, invoices and line items stay in Airtable tables. An Airtable automation runs one short script with the action "Run a script". The script reads the invoice record, sends it as JSON to one REST API and gets back a finished ZUGFeRD PDF. You need no extension, no server and no third-party automation tool. In the email section, the API sends the invoice to your customer. In the import section, supplier invoices are checked and written into a table. If you prefer a no-code scenario, read the Make guide. It reads Airtable records with Make modules. This guide does everything inside Airtable.


Why Airtable needs ZUGFeRD now

German businesses that invoice from Airtable must switch to e-invoices. Receiving is already required. Since 1 January 2025, German businesses must be able to accept e-invoices. Issuing becomes mandatory in January 2027 for businesses above EUR 800,000 turnover in the previous year. Everyone else follows in January 2028. Airtable can turn a record into a PDF, for example with the Page Designer extension or the Document automator action. That PDF has no XML inside. The law asks for a structured invoice. For German B2B, this is ZUGFeRD: a PDF/A-3 file for people, with CII XML inside for software. The Germany mandate page shows every date and format in one place.

Your base stays the place where invoices are made. Customers, invoices and lines stay in your tables and views. The script only reads them and writes the result back. Your team keeps working in the same base as before.

New format versions need no script change. ZUGFeRD, XRechnung and the EN 16931 rules get new versions from time to time. The API uses each new version from its effective date. The JSON your script sends stays the same. You change nothing in the automation.


What you need

  • An Airtable base and permission to create automations. The base needs the tables from the next section. An existing invoicing base usually needs only a few new fields.
  • A paid Airtable plan. The action "Run a script" is not part of the Free plan. Secrets for API keys work wherever scripting works.
  • A personal access token with the scope data.records:write and access to this base. Create it at airtable.com/create/tokens. The script needs it to put files into attachment fields. With the email route, outgoing invoices do not need it.
  • An InvoiceXML account. Sign up here. You get an API key and 100 free credits. No credit card is needed.

The script runs on Airtable's servers. Nothing runs on your computer, and you host nothing yourself.


How the integration works

A user sets the status of an invoice record to Freigegeben. An automation starts and runs a script. The script reads the invoice, the linked customer and the linked line items. It builds a JSON body and posts it to one endpoint. The API calculates the totals and the VAT. It builds the XML, embeds it in a PDF/A-3 file and checks it. Then it returns the PDF. The script stores the PDF in an attachment field. Or the API emails it to the customer directly.

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

Every endpoint takes the same JSON body. The guide uses this base design. The field names are German, as in most German bases. If yours differ, rename them in the code.

TableFieldsPurpose
KundenName, USt-IdNr, Straße, PLZ, Ort, Land, E-Mail, Leitweg-IDOne record per customer
RechnungenRechnungsnummer, Kunde (link), Rechnungsdatum, Fällig am, Bestellnummer, Positionen (link), Status, E-Rechnung (attachment), Fehler (long text)One record per invoice
PositionenBeschreibung, Rechnung (link), Menge, Einheit, Einzelpreis, USt-SatzOne record per line item
Eingangsrechnungen, Eingangspositionen, LieferantenSee the import sectionSupplier invoices

Status is a single select with the options Entwurf, Freigegeben, Erstellt, Versendet and Fehler. Rollup fields such as a net total on Rechnungen are fine for people. The script does not send them, because the API calculates every total itself. You create these parts from the listings in this guide:

  • Two secrets: invoicexml-api-key and airtable-token.
  • The automation "E-Rechnung erstellen": creates the ZUGFeRD or XRechnung file, or lets the API email it.
  • The automation "Rechnungseingang": validates supplier invoices and writes the data into the base.
  • The automation "Prüfbericht senden": emails the validation report to the supplier when an invoice fails.

Everything else is standard Airtable: linked record fields, attachment fields, single selects, forms, the trigger "When a record matches conditions", the actions "Run a script" and "Send email", the scripting objects base, table and record, and the Web API endpoint for attachment uploads.


Step 1: Store the API key as a secret

Airtable automations have secrets for API keys. Create the automation "E-Rechnung erstellen" and add the action "Run a script". In the script editor, open the Secrets tab. Create a secret named invoicexml-api-key with your InvoiceXML key. Create a second secret named airtable-token with the personal access token. You can also manage secrets in the Builder Hub at airtable.com/create/secrets.

The script reads a secret with input.secret('name'). The value never appears in the code or in a cell. Console output hides it. Only users with access to the secret can edit the script. Other editors can still run the automation.

Next, add one input variable in the script input settings. Name it recordId and pick the value "Airtable record ID" from the trigger step. input.config() returns it. This is the first part of the script:

// Automation "E-Rechnung erstellen", action "Run a script" (part 1: setup and API helper)
const API = 'https://api.invoicexml.com';
const API_KEY = input.secret('invoicexml-api-key');
const PAT = input.secret('airtable-token');   // personal access token, scope data.records:write
const { recordId } = input.config();          // input variable: Airtable record ID of the trigger

const rechnungen = base.getTable('Rechnungen');
const kunden = base.getTable('Kunden');
const positionen = base.getTable('Positionen');

async function postJson(path, body) {
    const response = await fetch(API + path, {
        method: 'POST',
        headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
    });
    if (!response.ok) {
        // 400 returns JSON with an errors array: one entry per violated rule
        throw new Error(path + ' ' + response.status + ': ' + await response.text());
    }
    return response;
}

Each "Run a script" action is its own script. Scripts in different actions do not share code. So each automation in this guide starts with its own short setup block.


Step 2: Map Airtable fields to JSON

The request body is an EN 16931 invoice in JSON. The invoice data comes from the Rechnungen record. The buyer comes from the linked Kunden record. The lines come from the linked Positionen records. Your own company is the seller. Its data sits in a constant at the top of the script. This is the mapping:

Airtable fieldJSON pathNote
Rechnungen.Rechnungsnummerinvoice.invoiceNumberBT-1. Text or a formula field
Rechnungsdatum, Fällig aminvoice.issueDate, dueDateDate fields without time. Read as yyyy-MM-dd
Bestellnummerinvoice.purchaseOrderReferenceThe customer's order number, BT-13
Kunden.Name, USt-IdNr, Straße, PLZ, Ort, Landinvoice.buyerThrough the link field Kunde. Land as ISO code (DE, AT, FR)
Kunden.Leitweg-IDinvoice.buyerReferenceXRechnung only
Positionen.Beschreibunglines[].item.nameThrough the link field Positionen
Menge, Einzelpreislines[].quantity, lines[].priceDetails.netPriceNumber and currency fields. Net price per unit
Einheitlines[].unitCodeSingle select. UN/ECE code: H87 for pieces, HUR for hours
USt-Satzlines[].vatInformation.ratePercent field. Airtable stores 19 % as 0.19
Script constants SELLER, IBANinvoice.seller, paymentDetailsYour company data and bank account

The body has no totals and no VAT amounts. The API works them out from the line items. When the numbers do not add up, the API returns an error and no file. A wrong price or a rounding problem in the base never reaches the customer.

// part 2: the mapping
const SELLER = {
    name: 'Muster Agentur GmbH',
    vatIdentifier: 'DE123456789',
    postalAddress: { line1: 'Hansaring 12', city: 'Köln', postCode: '50670', country: 'DE' },
};
const IBAN = 'DE02120300000000202051';
// Options of the single select "Einheit" and their UN/ECE codes
const UNIT_CODES = { 'Stück': 'H87', 'Stunde': 'HUR', 'Tag': 'DAY', 'Pauschale': 'C62' };

function day(value) {
    return value ? String(value).slice(0, 10) : null;   // date fields arrive as ISO strings
}

async function buildInvoice(id) {
    const rechnung = await rechnungen.selectRecordAsync(id, {
        fields: ['Rechnungsnummer', 'Rechnungsdatum', 'Fällig am', 'Bestellnummer', 'Kunde', 'Positionen'],
    });
    const kundeLink = (rechnung.getCellValue('Kunde') || [])[0];
    if (!kundeLink) throw new Error('The invoice has no customer.');
    const kunde = await kunden.selectRecordAsync(kundeLink.id, {
        fields: ['Name', 'USt-IdNr', 'Straße', 'PLZ', 'Ort', 'Land', 'E-Mail', 'Leitweg-ID'],
    });
    const text = name => kunde.getCellValueAsString(name) || null;

    // Linked records arrive as [{id, name}]. Load the lines in the order of the link field.
    const lineIds = (rechnung.getCellValue('Positionen') || []).map(link => link.id);
    const query = await positionen.selectRecordsAsync({
        fields: ['Beschreibung', 'Menge', 'Einheit', 'Einzelpreis', 'USt-Satz'],
        recordIds: lineIds,
    });
    const lines = lineIds.map(lineId => query.getRecord(lineId)).map(line => ({
        quantity: line.getCellValue('Menge'),
        unitCode: UNIT_CODES[line.getCellValueAsString('Einheit')] || 'C62',
        item: { name: line.getCellValueAsString('Beschreibung') },
        priceDetails: { netPrice: line.getCellValue('Einzelpreis') },
        // A percent field stores 19 % as 0.19. The API expects 19.
        vatInformation: { rate: Math.round((line.getCellValue('USt-Satz') || 0) * 10000) / 100 },
    }));

    const invoice = {
        invoiceNumber: rechnung.getCellValueAsString('Rechnungsnummer'),
        issueDate: day(rechnung.getCellValue('Rechnungsdatum')),
        dueDate: day(rechnung.getCellValue('Fällig am')),
        currency: 'EUR',
        purchaseOrderReference: rechnung.getCellValueAsString('Bestellnummer') || null,
        buyerReference: text('Leitweg-ID'),   // XRechnung only, null for B2B
        seller: SELLER,
        buyer: {
            name: text('Name'),
            vatIdentifier: text('USt-IdNr'),
            postalAddress: { line1: text('Straße'), city: text('Ort'), postCode: text('PLZ'), country: text('Land') },
        },
        paymentDetails: { paymentAccountIdentifier: IBAN },
        lines,
    };
    return { invoice, customerEmail: text('E-Mail') };
}

The API ignores fields with null, so buyerReference stays out of B2B invoices. Make PLZ a text field, not a number field. A number field turns 01067 into 1067. A line with an unknown unit gets C62, the generic code. selectRecordsAsync takes up to 100 record ids per call. Invoices with more lines need two calls.


Step 3: Create the ZUGFeRD invoice from Airtable

The main block builds the body, posts it and reads the PDF as bytes. It hands the bytes to the upload function from step 4. Then it sets the status to Erstellt. If anything fails, it writes the message into the Fehler field and sets the status to Fehler. Put this block at the end of the script, after parts 1, 2 and 4.

// part 3: create the ZUGFeRD invoice (main block, at the end of the script)
try {
    const { invoice } = await buildInvoice(recordId);
    const response = await postJson('/v1/create/zugferd', { invoice });
    const pdf = new Uint8Array(await response.arrayBuffer());
    await uploadAttachment(recordId, 'E-Rechnung', invoice.invoiceNumber + '-zugferd.pdf', 'application/pdf', pdf);
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Erstellt' }, 'Fehler': '' });
} catch (error) {
    // The rule findings of a 400 land in the Fehler field, for the person who fixes the data
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Fehler' }, 'Fehler': String(error.message) });
}

A single select is written as { name: 'Erstellt' }. The option must exist in the field. For French customers, post the same body to /v1/create/facturx. Factur-X and ZUGFeRD share one technical standard. Pick the endpoint by the Land field of the customer.


Step 4: Store the PDF in the attachment field

In a script, an attachment field takes files by URL: updateRecordAsync accepts [{ url, filename }]. The API returns the PDF as bytes, not as a URL. So the script uses the Airtable Web API endpoint uploadAttachment on content.airtable.com. It takes the file as base64 in a JSON body, up to 5 MB per file. It needs the personal access token from step 1. The script brings its own small base64 function, so it does not depend on browser helpers:

// part 4: upload bytes into an attachment field (Airtable Web API)
function toBase64(bytes) {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    let out = '';
    for (let i = 0; i < bytes.length; i += 3) {
        const n = (bytes[i] << 16) | ((bytes[i + 1] || 0) << 8) | (bytes[i + 2] || 0);
        out += chars[(n >> 18) & 63] + chars[(n >> 12) & 63]
            + (i + 1 < bytes.length ? chars[(n >> 6) & 63] : '=')
            + (i + 2 < bytes.length ? chars[n & 63] : '=');
    }
    return out;
}

async function uploadAttachment(id, field, filename, contentType, bytes) {
    const url = 'https://content.airtable.com/v0/' + base.id + '/' + id + '/'
        + encodeURIComponent(field) + '/uploadAttachment';
    const response = await fetch(url, {
        method: 'POST',
        headers: { 'Authorization': 'Bearer ' + PAT, 'Content-Type': 'application/json' },
        body: JSON.stringify({ contentType, filename, file: toBase64(bytes) }),
    });
    if (!response.ok) throw new Error('uploadAttachment: ' + await response.text());
}

The trigger. Set the trigger of the automation to "When a record matches conditions". Pick the table Rechnungen and the condition "Status is Freigegeben". The automation runs once when a record starts to match. A user releases an invoice by setting the status. A few seconds later the PDF is in the E-Rechnung field and the status is Erstellt. To run an invoice again after a fix, set the status back to Freigegeben. The trigger does not run for records that matched before you turned the automation on.

Sending from Airtable. The PDF in the attachment field is the readable invoice and the e-invoice in one file. A "Send email" action can attach it with the token of the E-Rechnung field. These emails come from an Airtable sender address. For delivery from your own domain, use the API route in the next section.


Send the invoice by email from the API

The sending step can also move to the API. With one call, the API creates the invoice, checks it and emails it to your customer. The PDF never passes through the base. In Germany this is a complete delivery. There is no central platform for B2B e-invoices. An email with a ZUGFeRD PDF meets the mandate.

This is the best route for Airtable. The script makes one fetch call. It needs no base64, no attachment upload and no personal access token. Verify your sending domain once in the dashboard. There you add the domain and publish the three CNAME records shown for it. Then every address on that domain can send invoices. The customer sees your address, and replies reach your mailbox. The request has two parts: the invoice JSON and an email object. The recipient is the E-Mail field of the linked customer. Replace the main block from step 3 with this one:

// part 3, email variant: the API creates, validates and sends the invoice
try {
    const { invoice, customerEmail } = await buildInvoice(recordId);
    if (!customerEmail) throw new Error('The customer has no email address.');
    await postJson('/v1/create/zugferd/email', {
        invoice,
        email: {
            from: '[email protected]',      // an address on your verified sending domain
            fromName: 'Muster Agentur',
            to: customerEmail,
            bcc: '[email protected]',    // optional: a copy for your own records
            subject: 'Rechnung ' + invoice.invoiceNumber,
            message: 'Guten Tag,\n\nanbei erhalten Sie unsere Rechnung ' + invoice.invoiceNumber
                + ' als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\nMuster Agentur',
        },
    });
    // 200 means: created, validated and handed to delivery
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Versendet' }, 'Fehler': '' });
} catch (error) {
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Fehler' }, 'Fehler': String(error.message) });
}

Use this route when the API should send. Use the plain endpoint when Airtable should store the file and send it itself. The response does not contain the file. The bcc copy puts the PDF into your own mailbox. cc and bcc take at most two addresses together. XRechnung and Factur-X have the same route: /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

German businesses have had to accept e-invoices from their suppliers since January 2025. They come as ZUGFeRD PDFs or as XRechnung XML files. The import has three steps:

  1. Validate. Send the file to POST /v1/validate/zugferd/report for a PDF, or to /v1/validate/xrechnung/report for XML. Add the form field validationLanguage with the value de for a German report. The response body is a PDF validation report. The header X-Invoice-Valid holds the result: true or false. To get the findings as JSON, use /v1/validate/zugferd (no /report at the end).
  2. Send the report back. An invalid invoice stops here. The PDF report goes back to the sender by email. The report lists every violated rule with the field and a message. The supplier fixes the invoice and sends it again.
  3. Extract. If the invoice is valid, upload the file to POST /v1/extract/json. The answer is the invoice as JSON. It has the same structure as the body you send to create an invoice.

How files get into the base. Create the table Eingangsrechnungen with these fields: Datei (attachment), Absender (email), Status (single select: Abgelehnt, Geprüft), Prüfbericht (attachment), Rechnungsnummer, Rechnungsdatum, Fällig am, Lieferant (link to Lieferanten), Bruttobetrag and Zahlbetrag (currency). Build an Airtable form on this table with the fields Datei and Absender. Share the form link with your suppliers. Or use it yourself for invoices that arrive by email. Records from other tools or the Web API start the same automation.

The automation "Rechnungseingang" uses the trigger "When a record matches conditions" with two conditions: Datei is not empty, and Status is empty. Its action "Run a script" gets the input variable recordId and both secrets. The upload endpoints take a multipart form with one file field named file. Automation scripts have no FormData, so the script builds the multipart body as bytes:

// Automation "Rechnungseingang", action "Run a script"
const API = 'https://api.invoicexml.com';
const API_KEY = input.secret('invoicexml-api-key');
const PAT = input.secret('airtable-token');
const { recordId } = input.config();
const eingang = base.getTable('Eingangsrechnungen');
// Copy toBase64 and uploadAttachment from step 4 here.

function ascii(text) {
    return Uint8Array.from(text, c => c.charCodeAt(0) & 255);
}

// multipart/form-data by hand: text fields first, then the file field "file"
async function postFile(path, filename, mime, bytes, fields = {}) {
    const boundary = 'invoicexml' + Date.now();
    let head = '';
    for (const [name, value] of Object.entries(fields)) {
        head += '--' + boundary + '\r\nContent-Disposition: form-data; name="' + name + '"\r\n\r\n' + value + '\r\n';
    }
    head += '--' + boundary + '\r\nContent-Disposition: form-data; name="file"; filename="' + filename
        + '"\r\nContent-Type: ' + mime + '\r\n\r\n';
    const parts = [ascii(head), bytes, ascii('\r\n--' + boundary + '--\r\n')];
    const body = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
    let offset = 0;
    for (const part of parts) { body.set(part, offset); offset += part.length; }

    const response = await fetch(API + path, {
        method: 'POST',
        headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'multipart/form-data; boundary=' + boundary },
        body,
    });
    if (!response.ok) throw new Error(path + ' ' + response.status + ': ' + await response.text());
    return response;
}

const record = await eingang.selectRecordAsync(recordId, { fields: ['Datei', 'Absender'] });
const file = record.getCellValue('Datei')[0];     // {id, url, filename, size, type}
const isXml = file.filename.toLowerCase().endsWith('.xml');
const filename = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
const mime = isXml ? 'application/xml' : 'application/pdf';
const bytes = new Uint8Array(await (await fetch(file.url)).arrayBuffer());

// 1. Validate. PDF files are ZUGFeRD, XML files are XRechnung.
const check = await postFile(isXml ? '/v1/validate/xrechnung/report' : '/v1/validate/zugferd/report',
    filename, mime, bytes, { validationLanguage: 'de' });

if (check.headers.get('X-Invoice-Valid') !== 'true') {
    // 2. Invalid: store the PDF report. The automation "Prüfbericht senden" emails it.
    const report = new Uint8Array(await check.arrayBuffer());
    await uploadAttachment(recordId, 'Prüfbericht', 'Pruefbericht-' + filename + '.pdf', 'application/pdf', report);
    await eingang.updateRecordAsync(recordId, { 'Status': { name: 'Abgelehnt' } });
} else {
    // 3. Valid: read the data and write it into the base
    const data = await (await postFile('/v1/extract/json', filename, mime, bytes)).json();
    await saveIncoming(recordId, data.invoice);
}

The url of an attachment is a download link. It expires two hours after the script reads it. The script downloads the file at once, so the expiry does not matter here. Before you turn the automation on, test it once with a small file in the test step.

Send the report back. Create a second automation "Prüfbericht senden". Its trigger is "When a record matches conditions" on Eingangsrechnungen with "Status is Abgelehnt". Its action is "Send email". Set To to the Absender field. Put the Prüfbericht field token into the Attachments section. Write a short German text, for example: "Die Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang zeigt die Fehler. Bitte senden Sie eine korrigierte Rechnung." Set "Reply To" to your accounts payable address. Airtable also has Gmail and Outlook actions that send from your own mailbox.

Plain PDFs: send a PDF without embedded XML to POST /v1/parse/json instead. AI reads the content. This works for typed PDFs, scans and photos. The response is the same JSON plus a confidence object with scores from 0.0 to 1.0. Write the score into a number field and filter a view on low values for manual review. If the file is not an invoice, the API rejects it with error code 4008.


Write the incoming invoice into the base

The JSON from both endpoints has the same shape, so one function writes it into the base. It finds the supplier in Lieferanten by the VAT id from the invoice. An unknown supplier gets a new record. Then the function fills the fields of the Eingangsrechnungen record. It also creates one Eingangspositionen record per line. Add this function to the import script:

// Import script, continued: write the extracted invoice into the base
async function saveIncoming(id, inv) {
    const num = value => (value === undefined || value === null) ? null : Number(value);

    // The supplier comes from the VAT id the API read from the invoice
    const lieferanten = base.getTable('Lieferanten');
    const vat = (inv.seller && inv.seller.vatIdentifier) || '';
    const known = await lieferanten.selectRecordsAsync({ fields: ['Name', 'USt-IdNr'] });
    const match = known.records.find(r => vat && r.getCellValueAsString('USt-IdNr') === vat);
    const supplierId = match ? match.id
        : await lieferanten.createRecordAsync({ 'Name': inv.seller.name, 'USt-IdNr': vat });

    // totals sits inside invoice: grandTotalAmount and duePayableAmount
    const totals = inv.totals || {};
    await eingang.updateRecordAsync(id, {
        'Rechnungsnummer': inv.invoiceNumber,
        'Rechnungsdatum': inv.issueDate,
        'Fällig am': inv.dueDate || null,
        'Lieferant': [{ id: supplierId }],
        'Bruttobetrag': num(totals.grandTotalAmount),
        'Zahlbetrag': num(totals.duePayableAmount),
        'Status': { name: 'Geprüft' },
    });

    // One record per line. createRecordsAsync takes up to 50 records per call.
    const lines = (inv.lines || []).map(line => ({ fields: {
        'Rechnung': [{ id }],
        'Beschreibung': line.item.name,
        'Menge': num(line.quantity),
        'Einzelpreis': num(line.priceDetails.netPrice),
        'USt-Satz': num((line.vatInformation && line.vatInformation.rate) || 0) / 100,   // percent field: 19 becomes 0.19
    }}));
    const table = base.getTable('Eingangspositionen');
    for (let i = 0; i < lines.length; i += 50) {
        await table.createRecordsAsync(lines.slice(i, i + 50));
    }
}

Accounts payable now sees the checked invoice in the base. The supplier is linked, and the lines are listed below it. A view "Geprüft, nicht bezahlt" shows the open bills. For a second check, compare Bruttobetrag with a rollup over Eingangspositionen. The original file stays in the Datei field.


XRechnung for German public buyers

Federal, state and municipal buyers in Germany expect XRechnung instead of ZUGFeRD. An XRechnung is a pure XML file. The upload portals ZRE and OZG-RE test each file against the KoSIT rules. The script creates it with one more endpoint and a few more fields. Replace the main block of step 3 with this version. It picks the format by the Leitweg-ID of the customer:

// part 3, with XRechnung: customers with a Leitweg-ID get XRechnung, all others ZUGFeRD
try {
    const { invoice } = await buildInvoice(recordId);
    let file;
    if (invoice.buyerReference) {
        // The German rules need the seller contact and electronic addresses on both sides
        invoice.seller = {
            ...SELLER,
            contact: { name: 'Anna Beispiel', phone: '+49 221 1234560', email: '[email protected]' },
            electronicAddress: { identifier: SELLER.vatIdentifier, schemeId: '9930' },
        };
        invoice.buyer.electronicAddress = invoice.buyer.vatIdentifier
            ? { identifier: invoice.buyer.vatIdentifier, schemeId: '9930' }
            : { identifier: invoice.buyerReference, schemeId: '0204' };
        const response = await postJson('/v1/create/xrechnung', { invoice });
        file = { name: invoice.invoiceNumber + '-xrechnung.xml', type: 'application/xml', response };
    } else {
        const response = await postJson('/v1/create/zugferd', { invoice });
        file = { name: invoice.invoiceNumber + '-zugferd.pdf', type: 'application/pdf', response };
    }
    const bytes = new Uint8Array(await file.response.arrayBuffer());
    await uploadAttachment(recordId, 'E-Rechnung', file.name, file.type, bytes);
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Erstellt' }, 'Fehler': '' });
} catch (error) {
    await rechnungen.updateRecordAsync(recordId, { 'Status': { name: 'Fehler' }, 'Fehler': String(error.message) });
}

The Leitweg-ID tells the public buyer's systems where the invoice belongs. You receive it from the public buyer when you register as its supplier. Without it, the invoice fails rule BR-DE-15. Keep it in the text field Leitweg-ID on Kunden. A text field keeps the dashes and leading zeros. The mapping from step 2 copies it into buyerReference. The German rules also require a seller contact with name, phone and email, and an electronic address for seller and buyer. The scheme 9930 marks a German VAT id. If the buyer has no VAT id, its Leitweg-ID becomes the electronic address, with scheme 0204. If you deliver over Peppol, the electronic address is the Peppol participant id with its scheme.

The response is the XML. It has already passed the KoSIT rules. The script stores it in the E-Rechnung field. The buyer decides the channel: the ZRE or OZG-RE portal, Peppol, or email where the buyer allows it. /v1/render/xrechnung/to/pdf turns the XML into a readable PDF. Use it for your records or for a colleague who approves the invoice. XRechnung is also allowed between businesses, so the /email route works for it too. More about the format is in the XRechnung API toolkit.


Limits of Airtable scripts

These limits come from the Airtable documentation and matter for this integration. One outgoing invoice uses one or two fetch calls. One incoming invoice uses up to four.

  • Run time: a script in an automation may run for 120 seconds. Airtable describes this as a temporary increase from 30 seconds. One invoice takes a few seconds.
  • Fetch: 50 fetch requests per run. Each request times out after 30 seconds.
  • Response size: a fetch response in an automation may be up to 4.5 MB. This covers the downloaded supplier file, the PDF from the API and the validation report.
  • Fetch features: automation scripts have no FormData and do not follow redirects. This is why the import script builds the multipart body by hand.
  • Memory and queries: 512 MB of memory and 30 selectRecords queries per run. A scripting API call times out after 12 seconds.
  • Writes: up to 15 write calls per second, with up to 50 records per call.
  • Attachments: the uploadAttachment endpoint takes files up to 5 MB. Attachment URLs expire two hours after a script reads them.
  • Send email action: up to 100 attachments and 25 MB per email. The sender is an Airtable address. Your Airtable plan sets a daily cap for recipients who are not collaborators of the base.
  • Automation runs: each Airtable plan has a monthly number of automation runs per workspace. Failed runs count too.
  • Scripting extension: the Scripting extension runs scripts from the base, for example from a button field. There, fetch runs in the browser. remoteFetchAsync sends the request from Airtable's servers instead. input.config() and secrets work only in automations, so this guide uses automations.

Endpoint reference

PurposeEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdInvoice JSONHybrid PDF/A-3
Create and email ZUGFeRDPOST /v1/create/zugferd/emailInvoice JSON + email objectEmail sent from your domain
Create XRechnungPOST /v1/create/xrechnungInvoice JSON with Leitweg-IDXRechnung XML
Create and email XRechnungPOST /v1/create/xrechnung/emailInvoice JSON + email objectEmail sent from your domain
Create Factur-XPOST /v1/create/facturxInvoice JSONHybrid PDF/A-3
Validate with PDF reportPOST /v1/validate/zugferd/report, /xrechnung/reportPDF or XML filePDF report, X-Invoice-Valid header
Validate as JSONPOST /v1/validate/zugferd, /xrechnungPDF or XML fileFindings per rule
Extract structured dataPOST /v1/extract/jsonHybrid PDF or XML fileInvoice JSON
Parse plain PDFPOST /v1/parse/jsonAny PDF or imageInvoice JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or XML fileZIP of embedded files
Render XRechnungPOST /v1/render/xrechnung/to/pdfXRechnung XMLReadable PDF

Get started

The whole integration is two scripts and three automations in the base you already use. Store the keys as secrets and map the fields. Create the file, or let the API send it. Check and import what comes in. Airtable holds no format logic. When the formats change, the script stays the same. This guide is part of the integrations hub, with guides for other no-code tools, ERPs and shop systems.

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