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_atprofilesbodymedia_urlstatuspost_id
2026-05-12 09:00tiktok,instagram,youtubeNew product drop …https://cdn…/launch.mp4
2026-05-13 14:00linkedin,threadsOur Q1 retro is live …
2026-05-14 10:00twitterHot 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:

  1. Editing without engineers. Marketers, founders, and ops people already live in spreadsheets.
  2. Bulk operations. Copy-paste 50 rows; reorder; bulk-edit a hashtag.
  3. 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.

Ready to get started?

Start with our free plan and scale as your needs grow. No credit card required.