How to Schedule Social Media Posts from Google Sheets
One row in a Google Sheet, one scheduled post across every platform. Apps Script + Postproxy.
The shape of the spreadsheet
A practical content sheet has one row per post. Columns:
| publish_at | profiles | body | media_url | status | post_id |
|---|---|---|---|---|---|
| 2026-05-12 09:00 | tiktok,instagram,youtube | New product drop … | https://cdn…/launch.mp4 | ||
| 2026-05-13 14:00 | linkedin,threads | Our Q1 retro is live … | |||
| 2026-05-14 10:00 | Hot take: … |
Six columns. Plain text. Anyone on the team can edit it. The pipeline reads rows where status is empty and writes back the resulting post ID once published.
Apps Script: ~50 lines
In Extensions → Apps Script, paste:
const POSTPROXY_API_KEY = "YOUR_KEY";
function publishPendingRows() { const sheet = SpreadsheetApp.getActiveSheet(); const rows = sheet.getDataRange().getValues(); const headers = rows[0]; const idx = (name) => headers.indexOf(name);
for (let i = 1; i < rows.length; i++) { const row = rows[i]; if (row[idx("status")]) continue; if (!row[idx("body")]) continue;
const profiles = String(row[idx("profiles")]) .split(",").map((s) => s.trim()).filter(Boolean); const media = row[idx("media_url")] ? [row[idx("media_url")]] : []; const scheduledAt = new Date(row[idx("publish_at")]).toISOString();
// Build platform-specific defaults: every platform-required field set sensibly const 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 = UrlFetchApp.fetch("https://api.postproxy.dev/api/posts", { method: "post", contentType: "application/json", headers: { Authorization: "Bearer " + POSTPROXY_API_KEY }, payload: JSON.stringify({ post: { body: row[idx("body")], scheduled_at: scheduledAt }, profiles, media, platforms, }), muteHttpExceptions: true, });
const result = JSON.parse(response.getContentText()); if (result.id) { sheet.getRange(i + 1, idx("status") + 1).setValue("scheduled"); sheet.getRange(i + 1, idx("post_id") + 1).setValue(result.id); } else { sheet.getRange(i + 1, idx("status") + 1).setValue("error: " + (result.error || JSON.stringify(result.errors || "unknown"))); } }}Then add a time-driven trigger in Apps Script (Edit → Triggers): run publishPendingRows every 15 minutes.
The script writes the Postproxy post ID into a post_id column and scheduled into status, so subsequent runs skip already-handled rows.
Reading status back into the sheet
If you want the sheet to show whether each post actually went live, add a result column:
function refreshStatuses() { const sheet = SpreadsheetApp.getActiveSheet(); const rows = sheet.getDataRange().getValues(); const headers = rows[0]; const idx = (name) => headers.indexOf(name);
for (let i = 1; i < rows.length; i++) { const postId = rows[i][idx("post_id")]; if (!postId) continue;
const response = UrlFetchApp.fetch( "https://api.postproxy.dev/api/posts/" + postId, { headers: { Authorization: "Bearer " + POSTPROXY_API_KEY } } ); const post = JSON.parse(response.getContentText()); const summary = (post.platforms || []) .map((p) => p.network + ":" + p.status) .join(", "); sheet.getRange(i + 1, idx("result") + 1).setValue(summary); }}Trigger this every hour. The result column shows tiktok:published, instagram:published, youtube:processing per row.
Note the response uses network (not platform) for the platform identifier inside each platforms[] entry.
Why use a sheet at all
Three reasons:
- Editing without engineers. Marketers, founders, and ops people already live in spreadsheets.
- Bulk operations. Copy-paste 50 rows; reorder; bulk-edit a hashtag.
- Audit trail. Every change is in version history.
For more sophisticated workflows — content review, multi-stage approval — a database-backed approach (Airtable, Notion) gives more structure. See bulk upload from Airtable.
Limits and quotas
Apps Script has a 6-minute execution limit per run and a daily UrlFetch quota. With 50 rows and a 15-minute cadence, you’ll never hit either. At 500+ rows, switch to running the script from a server (Cloud Run, an EC2 cron) using the same Postproxy API.
For the cron-driven server-side version of the same pattern, see Scheduling social media posts programmatically with cron and API.
A more realistic sheet
Teams using this in production usually have more columns:
| publish_at | profiles | body | media_url | tags | priority | reviewer | status | post_id | result |
reviewer and priority are for the team workflow — they don’t go to the API. The script ignores any column it doesn’t know.
This is what makes the spreadsheet pattern scale: the sheet is the source of truth for the team, the script is the bridge to the publishing layer.