<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ViewCourier]]></title><description><![CDATA[ViewCourier]]></description><link>https://viewcourier.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 17:18:07 GMT</lastBuildDate><atom:link href="https://viewcourier.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to download all attachments from an Airtable base (3 ways)]]></title><description><![CDATA[Three ways to download all attachments from an Airtable table or base: the interface, a tested Node.js script, and a free browser tool.]]></description><link>https://viewcourier.hashnode.dev/how-to-download-all-attachments-from-an-airtable-base-3-ways</link><guid isPermaLink="true">https://viewcourier.hashnode.dev/how-to-download-all-attachments-from-an-airtable-base-3-ways</guid><category><![CDATA[airtable]]></category><category><![CDATA[nocode]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[ViewCourier]]></dc:creator><pubDate>Sat, 24 Oct 2026 00:39:16 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: drafted with AI assistance and tested by the ViewCourier team; ViewCourier makes one of the tools mentioned.</em></p>
<p>Airtable has no "download all attachments" button. If a base holds a few hundred contracts, photos or invoices and you need them on disk, you have three realistic options: click through the interface, write a script against the API, or use a tool that does the scripting for you. This article covers all three, including a complete Node.js script that I ran before publishing.</p>
<p>One scope note up front: Airtable attachments live in fields of a table, not in the base as a whole. For a whole base, run the script (or the browser tool) once per table.</p>
<h2>Way 1: the Airtable interface, and where it stops</h2>
<p>In the interface you can:</p>
<ul>
<li>Open a record (the expand icon), hover over an attachment and click the download icon. That is one file per click.</li>
<li>Export a view as CSV from the view menu (Download CSV).</li>
</ul>
<p>The CSV export is where people expect more than they get. An attachment cell in the CSV contains the file name and a URL, not the file. Those URLs are the problem. Airtable's <a href="https://support.airtable.com/articles/9671148410-airtable-attachment-url-behavior">attachment URL behavior article</a> says download URLs expire for security reasons, and that Airtable will keep them active for at least 2 hours after you receive them. The <a href="https://airtable.com/developers/web/api/field-model">Web API field model</a> says the same for the API: URLs expire 2 hours after the API returns them, and if you want to keep the files you should download them instead of storing the URL.</p>
<p>So a CSV full of attachment URLs is not a backup of your files. An hour later the links may work; a day later they will not. There is a longer write-up of this in <a href="https://viewcourier.com/guides/airtable-csv-attachment-links-expired">why Airtable attachment links in a CSV export stop working</a>.</p>
<p>Airtable's own <a href="https://support.airtable.com/articles/9139007724-attachment-fields-in-airtable">attachment fields article</a> also confirms there is no built-in bulk download, and includes a Scripting extension example for it, which it describes as simplified and likely to have limitations.</p>
<p>The manual route is fine for ten files. For anything bigger you need the API.</p>
<h2>Way 2: a Node.js script</h2>
<p>Everything below works with a personal access token. Create one at <a href="https://airtable.com/create/tokens">airtable.com/create/tokens</a> with the scope <code>data.records:read</code> and add the base you want to export. The script needs nothing else. A read-only token is all it takes; it never writes to your base.</p>
<p>You need:</p>
<ul>
<li>Node 18 or newer (for the built-in <code>fetch</code>). No npm packages.</li>
<li>Your base ID (starts with <code>app</code>, visible in the URL of the base).</li>
<li>The table name or ID.</li>
</ul>
<p>What the script does:</p>
<ul>
<li>Lists records page by page, following Airtable's <code>offset</code> until there is none. 100 records per page.</li>
<li>Optionally reads only one view, so the view's filters apply.</li>
<li>Looks at every field of every record and treats any array of objects with a <code>url</code> and a <code>filename</code> as attachments. You do not have to name the fields, though you can restrict it with <code>FIELDS</code>.</li>
<li>Saves each file into <code>attachments/&lt;record id&gt;/</code>. The file name is the attachment ID plus the original name, which keeps two files called <code>scan.pdf</code> in one record apart.</li>
<li>Cleans file names: characters that are illegal on Windows or act as path separators become <code>_</code>, leading and trailing dots and spaces are removed, Windows reserved names like <code>CON</code> get a prefix, and very long names are cut to 120 characters while keeping the extension.</li>
<li>Skips files that already exist, so you can re-run it after an interruption. Files are written to a <code>.part</code> file first and renamed when complete, so a half-downloaded file is never mistaken for a finished one.</li>
<li>Spaces API calls 220 ms apart to stay under Airtable's limit of 5 requests per second per base, and on HTTP 429 waits 30 seconds, which is what Airtable asks for. It also retries 5xx errors a few times.</li>
<li>Downloads the files of each page before fetching the next page. That matters because the URLs in a page are only good for about 2 hours. Collecting all URLs first and downloading later would break on a big table.</li>
</ul>
<p>Save this as <code>download-attachments.mjs</code>:</p>
<pre><code class="language-js">// download-attachments.mjs
// Download every attachment in an Airtable table (or one view) into one folder per record.
// Node 18+, no dependencies.
//
//   AIRTABLE_TOKEN=pat... BASE_ID=app... TABLE="My table" node download-attachments.mjs
//
// Optional: VIEW="Grid view"  OUT_DIR=./attachments  FIELDS="Photos,Contracts"

import { createWriteStream } from 'node:fs';
import { mkdir, stat, rename, rm } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';

const {
  AIRTABLE_TOKEN,
  BASE_ID,
  TABLE,
  VIEW,
  OUT_DIR = './attachments',
  FIELDS, // comma-separated field names; default: every attachment field found
  API_ROOT = 'https://api.airtable.com', // only change this for testing
  PAGE_SIZE = '100',
} = process.env;

if (!AIRTABLE_TOKEN || !BASE_ID || !TABLE) {
  console.error('Set AIRTABLE_TOKEN, BASE_ID and TABLE (and optionally VIEW, OUT_DIR, FIELDS).');
  process.exit(1);
}

const only = FIELDS ? new Set(FIELDS.split(',').map((s) =&gt; s.trim())) : null;
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));

// Airtable allows 5 requests per second per base. Space API calls 220 ms apart.
let lastCall = 0;
async function throttle() {
  const wait = lastCall + 220 - Date.now();
  if (wait &gt; 0) await sleep(wait);
  lastCall = Date.now();
}

// fetch with retry on 429 (Airtable asks you to wait 30 s) and 5xx.
async function request(url, options = {}, { api = false } = {}) {
  for (let attempt = 1; ; attempt++) {
    if (api) await throttle();
    const res = await fetch(url, options);
    if (res.ok) return res;
    const retryable = res.status === 429 || res.status &gt;= 500;
    if (!retryable || attempt &gt;= 5) {
      const body = await res.text().catch(() =&gt; '');
      throw new Error(`HTTP ${res.status} for ${url.split('?')[0]} ${body.slice(0, 200)}`);
    }
    const delay = res.status === 429 ? 30_000 : 1000 * 2 ** attempt;
    console.warn(`HTTP ${res.status}, retrying in ${delay / 1000}s`);
    await res.body?.cancel();
    await sleep(delay);
  }
}

const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;

function sanitise(name, fallback = 'file') {
  let s = String(name ?? '')
    .normalize('NFC')
    .replace(/[\u0000-\u001f\u007f&lt;&gt;:"/\\|?*]/g, '_') // illegal on Windows, or a path separator
    .replace(/^[.\s]+|[.\s]+$/g, ''); // no leading/trailing dots or spaces
  if (!s) s = fallback;
  if (RESERVED.test(s)) s = `_${s}`;
  if (s.length &gt; 120) {
    const ext = path.extname(s).slice(0, 12);
    s = s.slice(0, 120 - ext.length) + ext;
  }
  return s;
}

// An attachment is an object with id, url and filename. This also matches
// lookup fields that pull in attachments from a linked table.
const isAttachment = (x) =&gt;
  x &amp;&amp; typeof x === 'object' &amp;&amp; typeof x.url === 'string' &amp;&amp; typeof x.filename === 'string';

async function exists(file) {
  try {
    return (await stat(file)).size &gt; 0;
  } catch {
    return false;
  }
}

async function downloadOne(att, dir) {
  const file = path.join(dir, `${sanitise(att.id)}_${sanitise(att.filename)}`);
  if (await exists(file)) return 'skipped';
  await mkdir(dir, { recursive: true });
  const res = await request(att.url);
  const tmp = `${file}.part`;
  try {
    await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
    await rename(tmp, file);
  } catch (err) {
    await rm(tmp, { force: true });
    throw err;
  }
  return 'downloaded';
}

async function main() {
  const base = `${API_ROOT}/v0/${BASE_ID}/${encodeURIComponent(TABLE)}`;
  const headers = { Authorization: `Bearer ${AIRTABLE_TOKEN}` };
  const stats = { pages: 0, records: 0, withFiles: 0, downloaded: 0, skipped: 0, failed: 0 };
  let offset;

  do {
    const qs = new URLSearchParams({ pageSize: PAGE_SIZE });
    if (VIEW) qs.set('view', VIEW);
    if (offset) qs.set('offset', offset);
    const res = await request(`${base}?${qs}`, { headers }, { api: true });
    const page = await res.json();
    stats.pages++;

    // Download this page right away: the URLs in it expire about two hours after Airtable returns them.
    for (const record of page.records) {
      stats.records++;
      let hasFiles = false;
      for (const [fieldName, value] of Object.entries(record.fields)) {
        if (only &amp;&amp; !only.has(fieldName)) continue;
        if (!Array.isArray(value) || !value.length || !value.every(isAttachment)) continue;
        hasFiles = true;
        const dir = path.join(OUT_DIR, record.id);
        for (const att of value) {
          try {
            const result = await downloadOne(att, dir);
            stats[result]++;
          } catch (err) {
            stats.failed++;
            console.error(`FAILED ${record.id} / ${fieldName} / ${att.filename}: ${err.message}`);
          }
        }
      }
      if (hasFiles) stats.withFiles++;
    }

    offset = page.offset; // absent on the last page
    console.log(`page ${stats.pages}: ${stats.records} records so far, ${stats.downloaded} files downloaded`);
  } while (offset);

  console.log('done', JSON.stringify(stats));
  if (stats.failed) process.exitCode = 2;
}

main().catch((err) =&gt; {
  console.error(err.message);
  process.exit(1);
});
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">export AIRTABLE_TOKEN=patXXXXXXXXXXXXXX.xxxxxxxx   # your token
export BASE_ID=appXXXXXXXXXXXXXX
export TABLE="Clients"                              # table name or tbl... ID
# optional: export VIEW="Grid view"  OUT_DIR=./attachments  FIELDS="Photos,Contracts"
node download-attachments.mjs
</code></pre>
<p>It prints one line per page and a summary with the counts of downloaded, skipped and failed files. The exit code is 2 if any file failed, so you can use it in a cron job or CI step.</p>
<h3>What I actually ran</h3>
<p>I ran this exact file with Node on a small test base. Two things were checked, and only the first against Airtable itself:</p>
<ul>
<li><strong>Against the real Airtable API:</strong> a table of 8 records. With the default page size it read 1 page. With <code>PAGE_SIZE=2</code> it followed the offset through 4 pages and counted the same 8 records. That test table has no attachment fields, so no file was downloaded from Airtable's servers.</li>
<li><strong>Download path, against a local mock server that imitates the API:</strong> 5 records in 3 pages, one record with an empty attachment field, one linked-records field that must be ignored, file names such as <code>a/b:c?.pdf</code>, <code>CON.txt</code> and a 200-character name, one temporary HTTP 500 and one permanent HTTP 404. Result: 11 files downloaded into per-record folders, the 500 retried successfully, the 404 reported as failed with exit code 2, and a second run skipped all 11 existing files.</li>
</ul>
<p>That is the honest limit of the test. The code that talks to <code>airtableusercontent.com</code> is a plain <code>fetch</code> of a URL, so I expect it to behave the same on a real attachment, but I have not watched it do so on one.</p>
<h3>A quick curl and jq version</h3>
<p>If you only want the list of URLs, for example to feed <code>wget</code>, this does it for the first 100 records:</p>
<pre><code class="language-bash">curl -s -H "Authorization: Bearer $AIRTABLE_TOKEN" \
  "https://api.airtable.com/v0/$BASE_ID/$TABLE?pageSize=100" |
jq -r '.records[] | .id as $rec | .fields[]
  | select(type == "array") | .[]
  | select(type == "object" and has("url"))
  | [$rec, .filename, .url] | @tsv'
</code></pre>
<p>Each line is the record ID, file name and URL, separated by tabs. For more than 100 records you have to pass the <code>offset</code> from each response into the next request, which is what the script handles for you. Remember the same expiry applies: download the files soon after listing them. I ran this jq filter against the same mock data as above (12 attachment rows from 4 records) and the real table (which has no attachments, so it returns nothing there).</p>
<h2>Way 3: the free browser tool</h2>
<p>If you would rather not run code, ViewCourier has a free tool: <a href="https://viewcourier.com/tools/airtable-download-attachments">Download all Airtable attachments</a>. It does what the script does, with a form.</p>
<ol>
<li>Paste a personal access token with the scopes <code>data.records:read</code> and <code>schema.bases:read</code>, and add the base to the token.</li>
<li>Choose the base, the table and, optionally, a view.</li>
<li>Tick the attachment fields you want.</li>
<li>Pick a layout: one folder per record (named after the record's primary field), a flat list with the record name in front of each file name, or a flat list with the original file names.</li>
<li>Click Scan. The tool reads the records through the API and shows how many files there are, their total size and the largest file.</li>
<li>Click Download ZIP.</li>
</ol>
<p>Some details that matter:</p>
<ul>
<li>The page runs in your browser. The token is sent only to <code>api.airtable.com</code>, and the files go straight from Airtable's file servers to your computer. Nothing goes through ViewCourier's servers and nothing is stored there.</li>
<li>The ZIP is built in the browser's memory. Large jobs are split into parts of up to 500 MB (and 10,000 files), each of which downloads as soon as it is ready. The tool's page lists a limit of 100,000 files and 20 GB per run.</li>
<li>Each ZIP contains <code>attachments-manifest.csv</code>, which lists record ID, record name, field, path inside the ZIP, original file name, size and status for every file. That is handy for checking that nothing is missing, or for matching files to records later.</li>
<li>File names Windows or macOS cannot handle are cleaned, and duplicate names get <code>(2)</code>, <code>(3)</code> and so on.</li>
<li>It fetches fresh links when you scan, so scan and download in one sitting.</li>
</ul>
<p>For a whole base, repeat the process per table. Delete the token afterwards if you do not need it any more; you can create a new one in a minute.</p>
<h2>Which one to use</h2>
<ul>
<li>A handful of files: the interface.</li>
<li>Recurring downloads, a server, a cron job, or files going to your own storage: the script. You control naming, destination and scheduling.</li>
<li>A one-off bulk download without code, with a manifest: the browser tool.</li>
</ul>
<p>Whichever you pick, do it soon after the links are generated, and keep the files themselves. A list of URLs will not outlive the afternoon.</p>
<h2>One more thing</h2>
<p>The files are one half of a backup; the records are the other. <a href="https://viewcourier.com">ViewCourier</a> can email an Airtable view as a CSV, XLSX or PDF on a schedule, and there is a Free plan. The rest of the free tools are listed on the <a href="https://viewcourier.com/tools">tools page</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How to export an Airtable base to JSON (3 ways)]]></title><description><![CDATA[Export an Airtable base to JSON three ways: a Node.js script, curl and jq, or a no-code browser tool. Includes pagination, rate limits and gotchas.]]></description><link>https://viewcourier.hashnode.dev/how-to-export-an-airtable-base-to-json-3-ways</link><guid isPermaLink="true">https://viewcourier.hashnode.dev/how-to-export-an-airtable-base-to-json-3-ways</guid><category><![CDATA[airtable]]></category><category><![CDATA[json]]></category><category><![CDATA[api]]></category><category><![CDATA[nocode]]></category><dc:creator><![CDATA[ViewCourier]]></dc:creator><pubDate>Fri, 23 Oct 2026 08:48:43 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: this article was written with AI assistance and reviewed for accuracy. It's published by the team behind ViewCourier.</em></p>
<p>Airtable has no "Export base to JSON" button. The UI gives you CSV per view, and that's it. If you want JSON, for a backup, a migration, a static site, or just to feed another script, you have to go through the API or use a tool that does that for you.</p>
<p>Here are three ways to do it, from most flexible to least effort. I tested the script and the shell loop below against a real base before writing this.</p>
<h2>Before you start: get a token</h2>
<p>All three methods need a personal access token (PAT).</p>
<ol>
<li>Open <a href="http://airtable.com/create/tokens">airtable.com/create/tokens</a> and click <strong>Create token</strong>.</li>
<li>Add the scopes <code>data.records:read</code> and <code>schema.bases:read</code>.</li>
<li>Under <strong>Access</strong>, pick the base (or bases) you want to export.</li>
<li>Copy the token. It starts with <code>pat</code>.</li>
</ol>
<p>You also need the base ID (starts with <code>app</code>). It's in the URL when you open the base: <code>airtable.com/appXXXXXXXXXXXXXX/...</code>. Table IDs start with <code>tbl</code>, but table names work in the API too.</p>
<p>Two limits shape every script: the API returns at most <strong>100 records per page</strong>, and it allows <strong>5 requests per second per base</strong>. Over that, you get HTTP 429 and have to wait about 30 seconds.</p>
<h2>Method 1: a Node.js script against the Web API</h2>
<p>The list-records endpoint is <code>GET https://api.airtable.com/v0/{baseId}/{tableIdOrName}</code>. When a table has more than 100 records, the response includes an <code>offset</code> string. Pass it back as a query parameter to get the next page. When <code>offset</code> is missing, you're done.</p>
<p>This script (Node 18+, no dependencies) loops until <code>offset</code> disappears, backs off on 429s, and writes everything to <code>export.json</code>:</p>
<pre><code class="language-js">// export.mjs: dump every record of one Airtable table to JSON
import { writeFileSync } from "node:fs";

const { AIRTABLE_TOKEN, BASE_ID, TABLE } = process.env;
const url = `https://api.airtable.com/v0/${BASE_ID}/${encodeURIComponent(TABLE)}`;
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));

const records = [];
let offset;
do {
  const qs = new URLSearchParams({ pageSize: "100" });
  if (offset) qs.set("offset", offset);

  const res = await fetch(`${url}?${qs}`, {
    headers: { Authorization: `Bearer ${AIRTABLE_TOKEN}` },
  });
  if (res.status === 429) {          // rate limited: wait, retry the same page
    await sleep(30_000);
    continue;
  }
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);

  const page = await res.json();
  records.push(...page.records);
  offset = page.offset;              // undefined on the last page
  await sleep(250);                  // stay under 5 requests/second
} while (offset);

writeFileSync("export.json", JSON.stringify(records, null, 2));
console.log(`Saved ${records.length} records`);
</code></pre>
<p>Run it like this:</p>
<pre><code class="language-bash">AIRTABLE_TOKEN=patXXXX BASE_ID=appXXXX TABLE=Clients node export.mjs
</code></pre>
<p>Each record looks like this:</p>
<pre><code class="language-json">{
  "id": "rec2rIP4G7xcj21oe",
  "createdTime": "2026-10-21T14:51:28.000Z",
  "fields": {
    "Name": "Acme, Inc.",
    "Status": "Active",
    "Monthly fee": 1200
  }
}
</code></pre>
<p>Useful query parameters: <code>view=Grid%20view</code> limits the export to what a view shows (including its filters and sort order), <code>fields[]=Name&amp;fields[]=Email</code> returns only those columns, and <code>filterByFormula</code> filters server-side. For a whole base, call the script once per table.</p>
<h3>Get the schema too</h3>
<p>A records dump doesn't tell you field types, which matters if you ever re-import the data. The Meta API returns them:</p>
<pre><code class="language-bash">curl -s -H "Authorization: Bearer $AIRTABLE_TOKEN" \
  "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables"
</code></pre>
<p>The response lists every table with its ID, fields (name, ID, type, options) and views. That's also how you get a list of table names to loop over. If you'd rather not write code for this part, there's a browser-based <a href="https://viewcourier.com/tools/airtable-schema-export">Airtable schema export tool</a>.</p>
<h2>Method 2: curl and jq</h2>
<p>If you have a shell and <code>jq</code>, you don't need Node. The pagination loop is short:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# export.sh: needs AIRTABLE_TOKEN, BASE_ID, TABLE
set -euo pipefail
URL="https://api.airtable.com/v0/$BASE_ID/$TABLE"
OFFSET=""
while :; do
  RESP=$(curl -sS -H "Authorization: Bearer $AIRTABLE_TOKEN" \
    "$URL?pageSize=100${OFFSET:+&amp;offset=$OFFSET}")
  echo "$RESP" | jq -c '.records[]'                  # one record per line
  OFFSET=$(echo "$RESP" | jq -r '.offset // empty')   # empty on the last page
  [ -z "$OFFSET" ] &amp;&amp; break
  sleep 0.25                                          # stay under 5 req/s
done | jq -s '.' &gt; export.json
</code></pre>
<p>The trick is <code>${OFFSET:+&amp;offset=$OFFSET}</code>: the <code>&amp;offset=</code> part is only added when <code>OFFSET</code> is non-empty, so the first request has no offset. The loop prints one JSON record per line, and <code>jq -s</code> slurps them into a single array at the end.</p>
<p>For a single page (up to 100 records), you can skip the loop. This one flattens each record so the fields sit next to the ID:</p>
<pre><code class="language-bash">curl -s -H "Authorization: Bearer $AIRTABLE_TOKEN" \
  "https://api.airtable.com/v0/$BASE_ID/Clients?pageSize=100" \
  | jq '[.records[] | {id} + .fields]'
</code></pre>
<p>A flat array like that is usually easier to feed into other tools than the nested <code>{id, createdTime, fields}</code> shape.</p>
<h2>Method 3: a no-code tool in the browser</h2>
<p>If you don't want to touch a terminal, use the <a href="https://viewcourier.com/tools/airtable-to-json">Airtable to JSON tool</a>. It runs entirely in your browser: you paste a personal access token, pick a base and table, and it handles the pagination and rate limit for you. Your token is used for requests from your browser to Airtable and never goes to a server.</p>
<p>You can choose between:</p>
<ul>
<li><strong>Full JSON</strong>, the same <code>{id, createdTime, fields}</code> structure the API returns.</li>
<li><strong>Flat JSON</strong>, one plain object per record, with the field values at the top level.</li>
</ul>
<p>Then copy the result or download it as a file. It's the quickest option for a one-off export of a single table. For anything you need to repeat or automate, use Method 1 or 2.</p>
<h2>Gotchas that surprise people</h2>
<p>The JSON you get is the raw API view of your data, not a mirror of the grid. These are the usual surprises:</p>
<ul>
<li><strong>Linked records are record IDs.</strong> A "Clients" link in an "Orders" table comes back as <code>["rec123...", "rec456..."]</code>, not names. To resolve them, export the linked table too and join on <code>id</code>. Or add a lookup field in Airtable that pulls the name through.</li>
<li><strong>Lookups and rollups are arrays</strong>, even when there's only one value. A lookup of a single number is <code>[42]</code>, not <code>42</code>. Multi-select and collaborator fields are arrays too.</li>
<li><strong>Empty fields are omitted.</strong> If a cell is empty (or a checkbox is unchecked), the key isn't in <code>fields</code> at all. Don't assume every record has every key. In Node, use <code>record.fields.Email ?? ""</code>. In jq, use <code>.Email // ""</code>. Build your column list from the schema, not from the first record.</li>
<li><strong>Attachment URLs expire.</strong> Each attachment has a <code>url</code> that stops working after a few hours. If your JSON is a backup, download the files right away, or you'll end up with dead links. This is the same reason <a href="https://viewcourier.com/guides/airtable-csv-attachment-links-expired">exported CSV attachment links stop working</a>.</li>
<li><strong>Views are only honored if you ask.</strong> Without <code>view=...</code>, you get every record in the table in the table's default order, not what a filtered view shows.</li>
<li><strong>Formula and computed fields can fail per record.</strong> They may come back as an error object such as <code>{"error": "#ERROR!"}</code> instead of a value. Check for that before parsing.</li>
</ul>
<h2>Which one should you use?</h2>
<ul>
<li>A script you'll run again, or a backup in CI: <strong>Method 1</strong>.</li>
<li>Quick, scriptable, nothing to install on most Linux and macOS machines (apart from <code>jq</code>): <strong>Method 2</strong>.</li>
<li>A one-time dump and you don't want to write code: <strong>Method 3</strong>.</li>
</ul>
<h2>One more thing</h2>
<p>JSON is the right format when another program reads the data. If what you need is the opposite, a recurring CSV or Excel file of one view landing in someone's inbox without anyone running scripts, that's what ViewCourier does, and there's a walkthrough of the options in <a href="https://viewcourier.com/guides/email-airtable-view-on-a-schedule">how to email an Airtable view on a schedule</a>.</p>
<p><em>ViewCourier is not affiliated with Airtable. Airtable is a trademark of its owner.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to back up an Airtable base for free (CSV, schema and attachments)]]></title><description><![CDATA[Disclosure: this article was written with the help of AI and checked against Airtable's docs and API before publishing. Airtable keeps your data safe on…]]></description><link>https://viewcourier.hashnode.dev/how-to-back-up-an-airtable-base-for-free-csv-schema-and-attachments</link><guid isPermaLink="true">https://viewcourier.hashnode.dev/how-to-back-up-an-airtable-base-for-free-csv-schema-and-attachments</guid><category><![CDATA[airtable]]></category><category><![CDATA[nocode]]></category><category><![CDATA[Backup]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[ViewCourier]]></dc:creator><pubDate>Thu, 22 Oct 2026 13:42:31 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: this article was written with the help of AI and checked against Airtable's docs and API before publishing.</em></p>
<p>Airtable keeps your data safe on its side, but that is not the same as having your own copy. A deleted table, a bad bulk edit or a lost workspace login can still ruin your week. Snapshots and the trash help for a limited time. A copy you hold yourself does not expire.</p>
<p>A complete backup of a base has three parts, and most people only save the first:</p>
<ol>
<li><strong>Records</strong>: the data in every table, not just one view.</li>
<li><strong>Schema</strong>: the table structure, field types, select options, formulas and linked tables. Without it, a pile of CSVs is hard to rebuild.</li>
<li><strong>Attachments</strong>: the actual files. A CSV only contains links to them.</li>
</ol>
<p>Here are four ways to do this for free, from quick to automated.</p>
<h2>1. The manual way: download CSV per view</h2>
<p>In Airtable, open a view, click the view name, then <strong>Download CSV</strong>. It works, but has limits:</p>
<ul>
<li>It exports <strong>one view at a time</strong>. If the view has filters or hidden fields, the CSV has them too, so you can silently miss records or columns.</li>
<li>You have to repeat it for every table and every time you want a fresh copy.</li>
<li>Attachments come out as URLs, not files.</li>
<li>Nothing is saved about the structure: no field types, no formulas, no select options.</li>
</ul>
<p>For a one-table base you check once a month, this is fine. For anything bigger, use one of the next options.</p>
<h2>2. Free browser tools that run client-side</h2>
<p>If you don't want to write code, these tools run entirely in your browser. You paste an Airtable personal access token, and the requests go from your browser straight to <code>api.airtable.com</code>. Nothing goes through a server and nothing is stored.</p>
<p>First, create a <strong>read-only personal access token</strong> at <a href="https://airtable.com/create/tokens">airtable.com/create/tokens</a>. Give it the scopes <code>data.records:read</code> and <code>schema.bases:read</code>, and add only the base you want to back up. Delete the token when you are done.</p>
<p>Then:</p>
<ul>
<li><a href="https://viewcourier.com/tools/airtable-base-to-csv"><strong>Base to CSV</strong></a> downloads every table as its own CSV, all records and all fields, bundled in one ZIP.</li>
<li><a href="https://viewcourier.com/tools/airtable-schema-export"><strong>Schema export</strong></a> gives you a data dictionary as CSV, Markdown or raw JSON. It only needs <code>schema.bases:read</code>, so the token cannot read your records.</li>
<li><a href="https://viewcourier.com/tools/airtable-download-attachments"><strong>Attachment downloader</strong></a> downloads every file in a table or view as a ZIP, one folder per record, plus a manifest CSV.</li>
<li><a href="https://viewcourier.com/tools/airtable-record-counter"><strong>Record counter</strong></a> shows how many records each table has and how close you are to your plan's limit. It also lists which fields are never filled in. Handy before you decide what is worth backing up.</li>
</ul>
<p>Run them in that order: schema, CSVs, attachments. On a base with a few thousand records the whole thing takes a few minutes.</p>
<h2>3. A script you control</h2>
<p>If you prefer to own the whole process, the Airtable REST API is simple. Three facts matter:</p>
<ul>
<li>A list request returns at most <strong>100 records per page</strong>. If the response contains an <code>offset</code>, send it back to get the next page.</li>
<li>Airtable allows about <strong>5 requests per second per base</strong>, so pause between pages.</li>
<li><strong>Attachment URLs expire after about two hours.</strong> Download the files in the same run that fetched the records. A URL saved in last month's CSV will not work anymore (more on that in <a href="https://viewcourier.com/guides/airtable-csv-attachment-links-expired">this guide on expired attachment links</a>).</li>
</ul>
<p>This Node.js script (version 18 or newer, no dependencies) saves the schema, every table as JSON, and every attachment:</p>
<pre><code class="language-js">// backup.mjs: AIRTABLE_TOKEN=pat... BASE_ID=app... node backup.mjs
import { mkdir, writeFile } from "node:fs/promises";

const { AIRTABLE_TOKEN: token, BASE_ID: base } = process.env;
const api = "https://api.airtable.com/v0";
const headers = { Authorization: `Bearer ${token}` };
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));

async function get(url) {
  const res = await fetch(url, { headers });
  if (res.status === 429) { await sleep(30000); return get(url); }
  if (!res.ok) throw new Error(`${res.status} ${url}`);
  return res.json();
}

const stamp = new Date().toISOString().slice(0, 10);
const dir = `backup-${stamp}`;
await mkdir(`${dir}/files`, { recursive: true });

// 1. Schema
const schema = await get(`${api}/meta/bases/${base}/tables`);
await writeFile(`${dir}/schema.json`, JSON.stringify(schema, null, 2));

// 2. Records, page by page
for (const table of schema.tables) {
  const records = [];
  let offset;
  do {
    const qs = new URLSearchParams({ pageSize: "100" });
    if (offset) qs.set("offset", offset);
    const page = await get(`${api}/${base}/${table.id}?${qs}`);
    records.push(...page.records);
    offset = page.offset;
    await sleep(250);
  } while (offset);
  await writeFile(`${dir}/${table.name}.json`, JSON.stringify(records, null, 2));

  // 3. Attachments, right away while the URLs are valid
  for (const rec of records) {
    for (const value of Object.values(rec.fields)) {
      if (!Array.isArray(value)) continue;
      for (const f of value) {
        if (!f?.url || !f?.filename) continue;
        const file = await fetch(f.url);
        if (!file.ok) { console.warn("skipped", f.filename); continue; }
        await writeFile(`${dir}/files/${rec.id}-${f.id}-${f.filename}`,
          Buffer.from(await file.arrayBuffer()));
      }
    }
  }
  console.log(`${table.name}: ${records.length} records`);
}
</code></pre>
<p>Two notes. The JSON keeps linked records, lookups and multi-selects exactly as Airtable returns them, which is more faithful than CSV. If you want CSV for spreadsheets, convert the JSON afterwards or use the browser tool from section 2. And the script lives on your machine, so the token never leaves it. Keep the token in an environment variable, not in the file.</p>
<h2>4. Make it happen on a schedule</h2>
<p>A backup you have to remember is a backup that stops. Some options for automating it:</p>
<ul>
<li><strong>Cron or a scheduled GitHub Action</strong> running the script above, committing or uploading the output to storage you control. The most flexible option, and you maintain it.</li>
<li><strong>An Airtable automation</strong> with a scheduled trigger that runs a script or sends a webhook. It works inside Airtable's own limits, and the free plan has few automation runs.</li>
<li><strong>Make or Zapier</strong> with a schedule trigger that lists records and writes them to Drive or Dropbox. Fine for small bases, but watch the operation counts.</li>
<li><strong>Emailing a view as CSV</strong>: if what you need is a regular CSV of one view in your inbox, <a href="https://viewcourier.com">ViewCourier</a> does this on a daily, weekly or monthly schedule, with a free plan. It exports views, so it is a good fit for key tables rather than a full base dump with attachments.</li>
</ul>
<p>There is a step-by-step version of the CSV route in <a href="https://viewcourier.com/guides/airtable-automatic-backup-csv">this guide to automatic Airtable CSV backups</a>. I also compared three ways of scheduling CSV emails <a href="https://telegra.ph/Scheduled-CSV-emails-from-an-Airtable-view-3-ways-compared-10-21">in this write-up</a>.</p>
<h2>A sensible routine</h2>
<ul>
<li><strong>Once now</strong>: run the schema export and the full-base CSV export, store the ZIPs somewhere that is not Airtable.</li>
<li><strong>Weekly or monthly</strong>: a scheduled script or emailed view CSVs for the tables that change.</li>
<li><strong>Before big changes</strong>: run the tools again. A fresh copy takes minutes.</li>
<li><strong>Test a restore</strong> once. Open a CSV, check that linked records and dates look right, and check that the schema file tells you what each field was.</li>
</ul>
<p>The one habit that matters is keeping the copy outside the system it is backing up.</p>
<p><em>Disclosure: written by the team behind ViewCourier.</em></p>
]]></content:encoded></item></channel></rss>