Zum Hauptinhalt springen

Airtable

Eine Formfeed-Erweiterung im Marketplace von Airtable gibt es nicht, und Sie brauchen sie nicht: Die Automatisierungsaktion Run a script kann die API direkt aufrufen, und damit rendert eine Base ein Dokument pro Datensatz und schreibt die Datei in etwa zwanzig Zeilen zurück. Anders als in der Scripting-Erweiterung läuft diese Aktion auf den Servern von Airtable, fetch funktioniert dort also ohne CORS-Einschränkung.

Airtable ist englischsprachig; Aktionen und Felder heißen dort so, wie sie hier stehen.

Vorbereitung​

  • Eine veröffentlichte Vorlage (Schnellstart) und ein API-Schlüssel aus der App unter API-Schlüssel. Die Standard-Scopes genügen; die Skripte hier brauchen render:create und render:read.
  • Ein ff_test_-Schlüssel, solange Sie die Automatisierung bauen: Seine Renderings werden nicht berechnet und seine Dateien nach spätestens 24 Stunden gelöscht (im Free-Plan tragen sie eine kleine Markierung). Tauschen Sie ihn gegen einen Live-Schlüssel, wenn die Automatisierung läuft.
  • Zwei Felder in der Tabelle: ein Attachment-Feld für das Dokument (unten Document) und ein einzeiliges Textfeld für die Render-ID (Render ID). Behalten Sie die ID, nicht die URL — ein signierter Link läuft nach spätestens sieben Tagen ab, und GET /renders/{id} stellt jederzeit einen neuen aus, solange die Datei aufbewahrt wird.
Den Schlüssel liest jeder, der die Base bearbeiten darf

Airtable-Automatisierungen haben keinen Schlüsselspeicher. Der Schlüssel steht im Skript, und jede Person, die die Automatisierung öffnen kann, liest ihn mit. Geben Sie jeder Base einen eigenen Schlüssel mit den kleinstmöglichen Scopes und widerrufen Sie ihn in der App unter API-Schlüssel, wenn die Base den Besitzer wechselt.

Ein Dokument pro Datensatz​

Als Auslöser eignet sich, was Ihnen passt — When record matches conditions, When record enters view oder ein Button-Feld mit Run automation. Fügen Sie die Aktion Run a script hinzu und unter Input variables die Variable recordId mit der Datensatz-ID aus dem Auslöser.

const { recordId } = input.config();
const API_KEY = 'ff_test_…';

const table = base.getTable('Invoices');
const record = await table.selectRecordAsync(recordId);
const number = record.getCellValueAsString('Number');
const filename = `rechnung-${number}.pdf`;

const response = await fetch('https://api-eu.formfeed.dev/v1/renders', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
// Ein zweiter Lauf desselben Datensatzes liefert binnen 24 Stunden das erste Dokument zurück.
'Idempotency-Key': `airtable-${recordId}-${number}`,
},
body: JSON.stringify({
template: 'invoice-de',
filename,
data: {
invoice: { number, date: record.getCellValueAsString('Date') },
customer: { name: record.getCellValueAsString('Customer') },
},
meta: { record_id: recordId },
}),
});

const render = await response.json();
if (!response.ok)
throw new Error(`${render.title}: ${render.detail} (${render.code})`);
// 202: Das Rendering musste warten und läuft im Hintergrund weiter (siehe „Lange Dokumente“ unten)
if (render.status !== 'succeeded')
throw new Error(`Rendering ${render.id} ist ${render.status}`);

await table.updateRecordAsync(recordId, {
Document: [{ url: render.download_url, filename }],
'Render ID': render.id,
});

Mehr ist die Integration nicht. Ersetzen Sie die Namen von Tabelle, Feldern und Vorlage durch Ihre: template ist der Slug Ihrer Vorlage, und data enthält die Felder, die sie druckt. meta reist mit dem Rendering mit und kommt in GET /renders/{id} und in jedem Webhook dazu zurück — so finden die asynchronen Varianten weiter unten ihren Datensatz wieder.

Die Anfrage ist synchron: Sie antwortet mit dem fertigen Rendering. Ein Rendering, das auf einen freien Render-Slot warten muss, antwortet stattdessen mit 202 und wird im Hintergrund fertig (der automatische Fallback); das Skript bricht dann mit dem Status des Renderings ab, statt ein leeres Attachment zu schreiben. Dokumente, die so lange dauern können, gehören in ein asynchrones Rendering.

Der Idempotency-Key macht einen wiederholten Lauf harmlos: Airtable führt eine Automatisierung öfter erneut aus, als man denkt, und derselbe Schlüssel liefert 24 Stunden lang die erste Antwort, statt ein zweites Mal zu rendern und zu berechnen. Das gilt auch für eine Ablehnung: Eine Fehlerantwort kommt für denselben Schlüssel erneut zurück. Nehmen Sie einen Wert mit in den Schlüssel, der sich mit den Daten ändert — etwa ein „Last modified“-Feld —, wenn ein korrigierter Datensatz innerhalb von 24 Stunden ein neues Dokument ergeben soll.

Positionen aus einer verknüpften Tabelle​

Rechnungszeilen, Auftragspositionen und Ähnliches stehen meist in einer verknüpften Tabelle. Lesen Sie sie einmal und bilden Sie daraus die Liste, über die Ihre Vorlage läuft:

const lines = await base.getTable('Line items').selectRecordsAsync({
fields: ['Description', 'Quantity', 'Unit price'],
});
const items = (record.getCellValue('Line items') ?? []).map((link) => {
const row = lines.getRecord(link.id);
return {
description: row.getCellValueAsString('Description'),
quantity: row.getCellValue('Quantity'),
unit_price: row.getCellValue('Unit price'),
};
});

Geben Sie die Liste in den Daten der Anfrage mit, etwa data: { invoice: …, customer: …, items }. Jedes selectRecordsAsync zählt gegen die 30 Abfragen, die ein Skript machen darf: Fragen Sie die Tabelle einmal ab und greifen Sie in das Ergebnis, statt pro Zeile abzufragen.

Das Attachment-Feld​

Airtable nimmt keine Bytes entgegen. Sie geben eine URL an, Airtable lädt die Datei selbst herunter und liefert danach die eigene Kopie aus. Daraus folgt dreierlei:

  • Der Link muss in dem Moment funktionieren, in dem die Automatisierung läuft. Eine signierte download_url tut das, Sie können sie also direkt weitergeben. Sobald Airtable die Datei kopiert hat, hängt das Attachment nicht mehr an Formfeed.
  • Schicken Sie filename in der Render-Anfrage mit und wiederholen Sie ihn in der Attachment-Zelle, sonst zeigt der Datensatz den Speichernamen.
  • Ein geschriebenes Attachment startet einen Upload, der einen Moment später fertig ist. Eine Automatisierung, die auf dieses Feld hört, startet mit dem Beginn des Uploads, nicht mit seinem Ende: Hängen Sie deshalb keine zweite Automatisierung an „Attachment ist nicht leer“ und erwarten Sie dort keine fertige Datei — erledigen Sie den nächsten Schritt im selben Skript.

Wenn Ihnen ein Link lieber ist als eine Kopie, schreiben Sie render.download_url in ein URL-Feld und render.id in ein Textfeld. Eine zweite Automatisierung erneuert den Link jederzeit aus der ID.

Was ein Skriptschritt kann und was nicht​

Grenze in AirtableWas das für Renderings bedeutet
30 Sekunden pro fetchEin synchrones Rendering eines langen Dokuments kann länger dauern. Rendern Sie es asynchron.
50 fetch-Aufrufe pro LaufEin Rendering pro Lauf ist bequem, eine Schleife über eine View nicht: Dafür gibt es den Batch.
30 Tabellenabfragen pro LaufFragen Sie eine Tabelle einmal ab und suchen Sie Datensätze im Ergebnis.
50 Datensätze pro Mutation, 15 Mutationen pro SekundeSchreiben Sie viele Datensätze mit updateRecordsAsync in Blöcken zu 50 zurück.
6 MB Skript-AusgabeGeben Sie IDs und URLs zwischen Schritten weiter, niemals das Dokument. Kein Skript hier lädt die Datei herunter.

Ein Skript, das eine Exception wirft, markiert den Lauf als fehlgeschlagen und zeigt die Meldung im Verlauf der Automatisierung — genau dort wollen Sie einen Vorlagen- oder Kontingentfehler sehen. Fehler sind RFC-9457-Probleme: Verzweigen Sie über code, nie über den Wortlaut von detail.

Lange Dokumente und ganze Views​

Alles, was länger dauern kann als die 30 Sekunden von Airtable, gehört in ein asynchrones Rendering, dessen Ergebnis per Webhook zurückkommt. Der Auslöser When webhook received liefert Ihnen eine URL, und ein Rendering kann seine eigene webhook_url mitbringen — in Formfeed muss dafür nichts registriert werden.

  1. Legen Sie eine zweite Automatisierung mit dem Auslöser When webhook received an und kopieren Sie die URL, die Airtable anzeigt.

  2. Schicken Sie das Rendering mit "mode": "async" und dieser URL. Die Antwort ist 202 mit dem Rendering im Status queued; zurückgeschrieben wird noch nichts:

    body: JSON.stringify({
    template: 'report',
    filename,
    mode: 'async',
    webhook_url: 'https://hooks.airtable.com/workflows/v1/genericWebhook/…',
    data: { … },
    meta: { record_id: recordId, filename },
    }),
  3. Lassen Sie es einmal laufen, damit Airtable eine Zustellung erhält und die Form der Daten lernt; ohne eine erste Zustellung bietet der Auslöser den folgenden Schritten keine Felder an. Die Zustellung ist ein Ereignis render.completed oder render.failed, dessen data das Rendering ist.

  4. Fügen Sie in der zweiten Automatisierung Run a script mit den Input-Variablen renderId (data.id aus dem Auslöser) und recordId (data.meta.record_id) hinzu und schreiben Sie die Datei zurück:

    const { renderId, recordId } = input.config();
    const API_KEY = 'ff_test_…';

    const response = await fetch(
    `https://api-eu.formfeed.dev/v1/renders/${renderId}`,
    {
    headers: { Authorization: `Bearer ${API_KEY}` },
    },
    );
    const render = await response.json();
    if (render.status !== 'succeeded')
    throw new Error(render.error?.detail ?? render.status);

    await base.getTable('Invoices').updateRecordAsync(recordId, {
    Document: [{ url: render.download_url, filename: render.meta.filename }],
    'Render ID': render.id,
    });

Dass das Skript das Rendering abruft, statt dem Inhalt der Zustellung zu glauben, hat einen Grund: Der Auslöser von Airtable kann den Header Webhook-Signature nicht prüfen, und wer die Webhook-URL kennt, kann dorthin schreiben. Nehmen Sie die ID aus der Zustellung, lesen Sie das Rendering mit Ihrem Schlüssel, und eine gefälschte Zustellung bringt nichts ein. Siehe Webhooks.

Eine ganze View als ein Batch​

POST /v1/renders/batch macht aus bis zu 1.000 Datensätzen mit einem Aufruf einen Job — weit innerhalb jeder Grenze eines Skriptschritts. Jedes Element trägt die ID und den Dateinamen seines Datensatzes in meta, und die webhook_url des Batch zeigt auf eine empfangende Automatisierung wie die oben:

const API_KEY = 'ff_test_…';
const WEBHOOK_URL = 'https://hooks.airtable.com/workflows/v1/genericWebhook/…';

const table = base.getTable('Certificates');
const view = table.getView('To render');
const query = await view.selectRecordsAsync({ fields: ['Name', 'Course'] });

const items = query.records.slice(0, 1000).map((record) => {
const filename = `${record.getCellValueAsString('Name')}.pdf`;
return {
data: {
name: record.getCellValueAsString('Name'),
course: record.getCellValueAsString('Course'),
},
filename,
meta: { record_id: record.id, filename },
};
});

const response = await fetch('https://api-eu.formfeed.dev/v1/renders/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `certificates-${view.id}-${new Date().toISOString().slice(0, 10)}`,
},
body: JSON.stringify({
template: 'certificate',
zip: true,
items,
webhook_url: WEBHOOK_URL,
}),
});

const job = await response.json();
if (!response.ok) throw new Error(`${job.title}: ${job.detail} (${job.code})`);
output.set('jobId', job.id);

Die webhook_url des Batch erhält ein Ereignis je Element, render.completed oder render.failed mit dem Rendering dieses Elements, und, sobald alle Elemente fertig sind, ein job.completed oder job.failed mit dem ganzen Job. Die empfangende Automatisierung schreibt also je Ereignis einen Datensatz zurück, wie bei einem einzelnen asynchronen Rendering. Geben Sie ihr eine dritte Input-Variable, type (type aus dem Auslöser), und lassen Sie das Skript nur bei den Ereignissen der Elemente handeln:

const { type, renderId, recordId } = input.config();
const API_KEY = 'ff_test_…';

// der Batch meldet jedes Element und dann sich selbst: nur ein Element nennt einen Datensatz
if (type.startsWith('render.')) {
const response = await fetch(
`https://api-eu.formfeed.dev/v1/renders/${renderId}`,
{
headers: { Authorization: `Bearer ${API_KEY}` },
},
);
const render = await response.json();
if (render.status !== 'succeeded')
throw new Error(render.error?.detail ?? render.status);

await base.getTable('Certificates').updateRecordAsync(recordId, {
Document: [{ url: render.download_url, filename: render.meta.filename }],
'Render ID': render.id,
});
}

Was das auf der Seite von Airtable bedeutet:

  • Jede Zustellung ist ein Lauf der empfangenden Automatisierung, und Läufe zählen gegen die monatlichen Automatisierungsläufe Ihres Airtable-Plans: Ein Batch aus 1.000 Datensätzen ergibt 1.001 davon.
  • Fünf Anfragen pro Sekunde nimmt der Webhook-Auslöser von Airtable an. Den Rest lehnt er ab, und Formfeed stellt diese Ereignisse später erneut zu, nach dem Wiederholungsplan unter Webhooks; ein großer Batch füllt seine Datensätze also nach und nach.
  • 100 KB pro Anfrage nimmt der Auslöser höchstens an. Das Ereignis des Jobs selbst trägt jedes Element als vollständiges Rendering und ist deshalb bei allen außer kleinen Batches größer; Airtable lehnt es dann ab. Das Skript oben braucht es nicht.

Ein fehlgeschlagenes Element hält die anderen nie auf: Sein Ereignis render.failed lässt diesen Lauf scheitern, mit dem Grund im Verlauf der Automatisierung, und der Job endet als partially_failed. Schicken Sie diese Datensätze in einem neuen Batch erneut. Mit "zip": true hat der Job zusätzlich eine zip_url mit allen erfolgreichen Dokumenten in einem Archiv — praktisch, wenn ein Mensch den Stapel herunterlädt, statt dass die Base Kopien behält. Lesen Sie sie mit GET /v1/jobs/{id}, mit der ID, die das erste Skript als jobId weitergibt.

Aus der Scripting-Erweiterung​

Die Erweiterung Scripting läuft in Ihrem Browser, und dort weist die API fetch ab: Einen API-Schlüssel, der mit einem Browser-Origin kommt, beantwortet sie mit 403 (siehe Aus dem Browser), und ein Browser-Tab ist ohnehin kein guter Aufbewahrungsort für einen Schlüssel. Nehmen Sie stattdessen remoteFetchAsync, das die Anfrage von den Servern von Airtable aus schickt und dieselben Argumente nimmt wie fetch. Zwei weitere Dinge sind dort anders: input.config() und output.set() gehören zu Automatisierungen, ein Skript in der Erweiterung fragt deshalb mit input.recordAsync('Record', table) nach seinem Datensatz und meldet mit output.text(). Die Anfragen und alles andere auf dieser Seite bleiben, wie sie sind.

Ganz ohne Skript​

Braucht die Base nur ab und zu Dokumente, nimmt der Dialog Renderings → Batch aus Tabelle in der App den CSV-Export einer View, ordnet seine Spalten einmal den Feldern der Vorlage zu und gibt Ihnen ein Zip: siehe Batch aus einer Tabelle. In Airtable wird dabei nichts gespeichert, was für einen einmaligen Versand oft genau richtig ist.