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:writeand 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.
| 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 |
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.
| Table | Fields | Purpose |
|---|---|---|
Kunden | Name, USt-IdNr, Straße, PLZ, Ort, Land, E-Mail, Leitweg-ID | One record per customer |
Rechnungen | Rechnungsnummer, Kunde (link), Rechnungsdatum, Fällig am, Bestellnummer, Positionen (link), Status, E-Rechnung (attachment), Fehler (long text) | One record per invoice |
Positionen | Beschreibung, Rechnung (link), Menge, Einheit, Einzelpreis, USt-Satz | One record per line item |
Eingangsrechnungen, Eingangspositionen, Lieferanten | See the import section | Supplier 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-keyandairtable-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 field | JSON path | Note |
|---|---|---|
Rechnungen.Rechnungsnummer | invoice.invoiceNumber | BT-1. Text or a formula field |
Rechnungsdatum, Fällig am | invoice.issueDate, dueDate | Date fields without time. Read as yyyy-MM-dd |
Bestellnummer | invoice.purchaseOrderReference | The customer's order number, BT-13 |
Kunden.Name, USt-IdNr, Straße, PLZ, Ort, Land | invoice.buyer | Through the link field Kunde. Land as ISO code (DE, AT, FR) |
Kunden.Leitweg-ID | invoice.buyerReference | XRechnung only |
Positionen.Beschreibung | lines[].item.name | Through the link field Positionen |
Menge, Einzelpreis | lines[].quantity, lines[].priceDetails.netPrice | Number and currency fields. Net price per unit |
Einheit | lines[].unitCode | Single select. UN/ECE code: H87 for pieces, HUR for hours |
USt-Satz | lines[].vatInformation.rate | Percent field. Airtable stores 19 % as 0.19 |
Script constants SELLER, IBAN | invoice.seller, paymentDetails | Your 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:
- Validate. Send the file to
POST /v1/validate/zugferd/reportfor a PDF, or to/v1/validate/xrechnung/reportfor XML. Add the form fieldvalidationLanguagewith the valuedefor a German report. The response body is a PDF validation report. The headerX-Invoice-Validholds the result:trueorfalse. To get the findings as JSON, use/v1/validate/zugferd(no/reportat the end). - 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.
- 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
FormDataand 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
selectRecordsqueries 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
uploadAttachmentendpoint 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,
fetchruns in the browser.remoteFetchAsyncsends the request from Airtable's servers instead.input.config()and secrets work only in automations, so this guide uses automations.
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 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:
- E-invoicing from Google Sheets: the same idea with Apps Script, for invoices kept in a spreadsheet.
- ZUGFeRD and XRechnung in Make: a no-code scenario that can read Airtable records with Make modules.
- E-invoicing in Zapier: the InvoiceXML apps for Zapier. A Zap can start from a new Airtable record.
- ZUGFeRD API toolkit: how the format works and which endpoints exist for German B2B.