Skip to main content

Airtable

There is no Formfeed extension in Airtable's marketplace, and you do not need one: Airtable's Run a script automation action can call the API directly, so a base renders a document per record and writes the file back in about twenty lines. Unlike the Scripting extension, that action runs on Airtable's servers, so fetch works without any CORS limitation.

Before you start​

  • A published template (quickstart) and an API key from the app under API keys. The default scopes are enough; the scripts here need render:create and render:read.
  • A ff_test_ key while you build the automation: its renders are not metered and its files are deleted after 24 hours (on the Free plan they carry a small mark). Swap it for a live key when the automation goes on.
  • Two fields in the table: an attachment field for the document (called Document below) and a single-line text field for the render id (Render ID). Keep the id, not the URL — a signed link expires after seven days at the latest, and GET /renders/{id} issues a fresh one whenever you need it, for as long as the file is kept.
The key is readable by everyone who can edit the base

Airtable automations have no secret store. The key sits in the script, and every collaborator who can open the automation can read it. Give each base its own key with the smallest scopes it needs, and revoke it in the app under API keys when the base changes hands.

One document per record​

Trigger the automation however you like — When record matches conditions, When record enters view, or a button field with Run automation. Add the action Run a script and, under Input variables, add recordId with the record id from the trigger.

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 = `invoice-${number}.pdf`;

const response = await fetch('https://api-eu.formfeed.dev/v1/renders', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
// A rerun of the same record within 24 hours returns the first document instead of a second one.
'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: the render had to wait and goes on in the background (see "Long documents" below)
if (render.status !== 'succeeded')
throw new Error(`Render ${render.id} is ${render.status}`);

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

That is the whole integration. Replace the table, field and template names with yours: template is the slug of your template, and data has the fields it prints. meta travels with the render and comes back in GET /renders/{id} and in every webhook for it, which is how the asynchronous variants below find their record again.

The request is synchronous: it answers with the finished render. A render that has to wait for a free render slot answers 202 instead and finishes in the background (the automatic fallback); the script then stops with the render's status rather than writing an empty attachment. Documents that may take that long belong in an asynchronous render.

The idempotency key makes a rerun harmless: Airtable runs an automation again more often than you think, and a repeated key returns the first response for 24 hours instead of rendering and charging twice. That includes a refusal: an error answer comes back again for the same key. Put a value that changes with the data into the key — a "Last modified" field, say — if correcting a record must produce a new document within 24 hours.

Line items from a linked table​

Invoice lines, order positions and the like usually live in a linked table. Read them once and map them onto the list your template loops over:

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'),
};
});

Put the list into the request's data, for example data: { invoice: …, customer: …, items }. Each selectRecordsAsync counts against the 30 queries a script may make, so query the table once and index into the result rather than querying per line.

The attachment field​

Airtable does not take bytes. You give it a URL, Airtable downloads the file itself and then serves its own copy, which has three consequences:

  • The link must work at the moment the automation runs. A signed download_url does, so passing it straight on is fine. Once Airtable has copied the file, the attachment no longer depends on Formfeed at all.
  • Send filename in the render request and repeat it in the attachment cell, otherwise the record shows the storage name.
  • Writing an attachment starts an upload that finishes a moment later. An automation that triggers on that field fires when the upload begins, not when it ends, so do not chain a second automation on "attachment is not empty" and expect a finished file — put the next step in the same script.

If you would rather keep a link than a copy, write render.download_url into a URL field and render.id into a text field. A second automation can refresh the link from the id at any time.

What a script step can and cannot do​

Airtable limitWhat it means for renders
30 seconds per fetchA synchronous render of a long document can outlive it. Render asynchronously instead.
50 fetch calls per runOne render per run is comfortable. A loop over a view is not: use a batch.
30 table queries per runQuery a table once and look records up in the result.
50 records per mutation, 15 mutations a secondWrite many records back with updateRecordsAsync in chunks of 50.
6 MB of script outputPass ids and URLs between steps, never the document. Nothing here downloads the file into the script.

A script that throws marks the automation run as failed and shows the message in the run history, which is where you want a template or quota error to appear. Errors are RFC 9457 problems: branch on code, never on the wording of detail.

Long documents and whole views​

Anything that may take longer than Airtable's 30-second fetch belongs in an asynchronous render whose result comes back through a webhook. Airtable's When webhook received trigger gives you a URL, and a render can carry its own webhook_url, so nothing has to be registered in Formfeed.

  1. Create a second automation with the trigger When webhook received and copy the URL it shows.

  2. Send the render with "mode": "async" and that URL. The answer is 202 with the render in status queued; nothing is written back yet:

    body: JSON.stringify({
    template: 'report',
    filename,
    mode: 'async',
    webhook_url: 'https://hooks.airtable.com/workflows/v1/genericWebhook/…',
    data: { … },
    meta: { record_id: recordId, filename },
    }),
  3. Run it once so Airtable receives a delivery and learns the payload's shape; the trigger needs one before it can offer fields to later steps. The delivery is a render.completed or render.failed event whose data is the render.

  4. In the second automation, add Run a script with the input variables renderId (data.id from the trigger) and recordId (data.meta.record_id), and write the file back:

    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,
    });

The script fetches the render rather than trusting the body for a reason: Airtable's trigger cannot check the Webhook-Signature header, so anyone who learns the webhook URL can post to it. Take the id from the delivery, read the render with your key, and a forged delivery gets you nothing. See Webhooks.

A whole view in one batch​

POST /v1/renders/batch turns up to 1,000 records into one job with a single call — well inside every limit a script step has. Each item carries its record's id and file name in meta, and the batch's webhook_url points at a receiving automation like the one above:

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);

The batch's webhook_url receives one event per item, render.completed or render.failed with that item's render, and, once every item is done, one job.completed or job.failed with the whole job. So the receiving automation writes back one record per event, as it does for a single asynchronous render. Give it a third input variable, type (type from the trigger), and let the script act on the items' events only:

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

// the batch reports every item, then itself: only an item's event names a record
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,
});
}

What that means on Airtable's side:

  • Every delivery is a run of the receiving automation, and runs count against your Airtable plan's monthly automation runs: a batch of 1,000 records makes 1,001 of them.
  • Five requests a second is what Airtable's webhook trigger accepts. It refuses the rest, and Formfeed delivers those events again later, on the retry schedule in Webhooks, so a large batch fills its records over a while.
  • 100 KB per request is the most the trigger accepts. The job's own event carries every item as a full render, so for all but small batches it is larger than that and Airtable refuses it. The script above does not need it.

A failed item never stops the others: its render.failed event fails that run, with the reason in Airtable's run history, and the job ends as partially_failed. Send those records again in a new batch. With "zip": true the job also has a zip_url with every successful document in one archive — useful when a human downloads the lot instead of the base keeping copies. Read it with GET /v1/jobs/{id}, using the id the first script hands on as jobId.

From the Scripting extension​

The Scripting extension runs in your browser, where fetch to the API is refused: the API answers 403 to an API key sent with a browser Origin (see From a browser), and a browser tab is not a good place for a key anyway. Use remoteFetchAsync instead, which sends the request from Airtable's servers and takes the same arguments as fetch. Two more things differ there: input.config() and output.set() belong to automations, so a script in the extension asks for its record with input.recordAsync('Record', table) and reports with output.text(). The requests and the rest of this page stay as they are.

Without any script​

If the base only needs documents now and then, the app's Renders → Batch from spreadsheet dialog takes the CSV export of a view, maps its columns onto the template's fields once and gives you a zip: see Batch from a spreadsheet. Nothing is stored in Airtable that way, which is often exactly right for a one-off mailing.