How to Schedule Social Media Posts from Airtable
Drive a multi-platform publishing pipeline from an Airtable base — automation script, status writeback, and review workflow.
The base
A practical Airtable content base has these fields:
- Body — long text
- Profiles — multi-select (
tiktok,instagram,youtube,twitter,linkedin,threads,pinterest,facebook,bluesky) - Media — attachment (single)
- Publish at — datetime
- Status — single select (Draft, Approved, Scheduled, Published, Failed)
- Postproxy ID — single line text (filled by automation)
- Result — long text (filled by automation)
The team works with views: “Drafts,” “Pending review,” “Approved,” “Live this week.”
Airtable automation: when status flips to Approved → schedule
In the base, Automations → Create automation:
- Trigger: When record matches conditions —
StatusisApprovedandPostproxy IDis empty. - Action: Run script. In the input config, pass
recordIdfrom the trigger.
const POSTPROXY_API_KEY = "YOUR_KEY";
const inputConfig = input.config();const recordId = inputConfig.recordId;
const table = base.getTable("Posts");const record = await table.selectRecordAsync(recordId);
const body = record.getCellValueAsString("Body");const profiles = record.getCellValue("Profiles").map((p) => p.name);const publishAt = new Date(record.getCellValue("Publish at")).toISOString();const attachments = record.getCellValue("Media") || [];const media = attachments.map((a) => a.url);
// Sensible per-platform defaults so platform-required params don't fail validationconst platforms = {};if (profiles.includes("tiktok")) { platforms.tiktok = { privacy_status: "PUBLIC_TO_EVERYONE" };}if (profiles.includes("youtube")) { platforms.youtube = { privacy_status: "public" };}if (profiles.includes("instagram") && media[0] && /\.(mp4|mov)$/i.test(media[0])) { platforms.instagram = { format: "reel" };}
const response = await fetch("https://api.postproxy.dev/api/posts", { method: "POST", headers: { Authorization: `Bearer ${POSTPROXY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ post: { body, scheduled_at: publishAt }, profiles, media, platforms, }),});
const result = await response.json();
if (result.id) { await table.updateRecordAsync(recordId, { "Postproxy ID": result.id, Status: { name: "Scheduled" }, });} else { await table.updateRecordAsync(recordId, { Status: { name: "Failed" }, Result: result.error || JSON.stringify(result.errors || result), });}The trigger condition (Postproxy ID is empty) is what stops a row from being re-published. Don’t clear the Postproxy ID by hand unless you actually want to republish.
A second automation: status writeback
Run on a schedule (every hour) — find records with Status = Scheduled and refresh their state from Postproxy:
const POSTPROXY_API_KEY = "YOUR_KEY";
const table = base.getTable("Posts");const query = await table.selectRecordsAsync({ fields: ["Postproxy ID", "Status"],});
for (const record of query.records) { if (record.getCellValueAsString("Status") !== "Scheduled") continue; const postId = record.getCellValueAsString("Postproxy ID"); if (!postId) continue;
const response = await fetch( `https://api.postproxy.dev/api/posts/${postId}`, { headers: { Authorization: `Bearer ${POSTPROXY_API_KEY}` } } ); const post = await response.json();
const summary = (post.platforms || []) .map((p) => `${p.network}: ${p.status}`) .join("\n");
const allPublished = (post.platforms || []).every( (p) => p.status === "published" ); const anyFailed = (post.platforms || []).some( (p) => p.status === "failed" );
await table.updateRecordAsync(record.id, { Result: summary, Status: { name: allPublished ? "Published" : anyFailed ? "Failed" : "Scheduled", }, });}Now the base shows live status next to each row. The response uses network (e.g. instagram) inside each platforms[] entry.
Webhooks instead of polling
Polling every hour is fine for ~100 posts a week. Past that, switch to Postproxy webhooks pointing at an Airtable webhook URL (or a small lambda that writes back to Airtable). See webhooks vs polling for the tradeoffs.
Review workflow
Because the trigger fires on Status = Approved, a reviewer flips a single field to schedule. Common workflow:
- Writer drafts in
Draft - Editor reviews, sets
Status→Approved - Automation fires, posts to Postproxy
- Status flips to
Scheduledwith the Postproxy ID - Hourly job updates per-platform results
If you want a hard “no surprises” gate — every approval needs a second pair of eyes — add a Reviewer linked field and condition the automation on Reviewer is not empty. See content approval workflows for variations.
Multi-brand bases
Agencies often have one base per client or one shared base with a Brand field. Pass profile_group_id in the request body to target a specific Postproxy profile group:
body: JSON.stringify({ post: { body, scheduled_at: publishAt }, profile_group_id: brandToProfileGroup[record.getCellValueAsString("Brand")], profiles, media, platforms,}),Limits
Airtable Automation scripts can run for up to 30 seconds per execution. For a single-base content calendar with ~50 posts/week, that’s enough. Past that, run the same script logic on a server using the Airtable API + Postproxy API directly — only thing that changes is where the script runs.
For the underlying Postproxy scheduling primitives, see Scheduling social media posts programmatically with cron and API.
If your team works in Google Sheets rather than Airtable, the same script pattern with a different source spreadsheet is covered in How to schedule social media posts from Google Sheets.