appscript.dev
Automation Intermediate Sheets

Schedule posts to social media APIs

Publish Northwind content from a Sheet automatically — drafts queued, posted on date.

Published Sep 17, 2025

Northwind’s marketing lead drafts a week of social posts in one sitting, then wants them to go out on the right days without anyone logging into a dashboard. Scheduling tools exist, but a spreadsheet is where the copy already lives — alongside the campaign, the channel, and the sign-off column.

This script turns that Sheet into a publishing queue. Each row is one post with a platform, the text, and a scheduled time. On every run it finds the rows that are due, posts them through the platform’s API, and stamps the row as posted so it never goes out twice. Add a time-driven trigger and the queue empties itself.

What you’ll need

  • A Google Sheet with one row per post and a header in row 1, with these columns: platform, text, scheduledAt (a real date-time, not text), and posted (left blank — the script fills it).
  • The spreadsheet’s ID for the POSTS_SHEET_ID config value.
  • An access token for each platform you post to, saved in Script Properties — this script uses MASTO_TOKEN for Mastodon. See Store API keys and secrets securely.
  • The base URL of your Mastodon instance for the MASTODON_INSTANCE config.

The script

// The spreadsheet that holds your post queue.
const POSTS_SHEET_ID = '1abcPostsId';

// Base URL of your Mastodon instance — no trailing slash.
const MASTODON_INSTANCE = 'https://mastodon.example';

/**
 * Finds every post that is due, publishes it, and marks the row as
 * posted so it is never sent twice. Designed to run on a timer.
 */
function publishDuePosts() {
  const sheet = SpreadsheetApp.openById(POSTS_SHEET_ID).getSheets()[0];

  // 1. Read the whole sheet, splitting the header off the data rows.
  const values = sheet.getDataRange().getValues();
  const [header, ...rows] = values;

  if (!rows.length) {
    Logger.log('No posts in the queue — nothing to do.');
    return;
  }

  // 2. Map column names to indexes so the code does not depend on
  //    column order — only on the header text.
  const col = Object.fromEntries(header.map((name, i) => [name, i]));
  const now = new Date();
  let published = 0;

  // 3. Walk every row. Skip anything already posted, anything without a
  //    real date in scheduledAt, and anything still in the future.
  rows.forEach((row, i) => {
    const alreadyPosted = row[col.posted];
    const when = row[col.scheduledAt];
    if (alreadyPosted || !(when instanceof Date) || when > now) return;

    // 4. Publish the post, then stamp the posted column on the matching
    //    row in `values` (i + 1 accounts for the header row).
    postToPlatform(row[col.platform], row[col.text]);
    values[i + 1][col.posted] = new Date();
    published++;
  });

  // 5. Write the whole grid back in one call — cheaper than cell-by-cell.
  sheet.getDataRange().setValues(values);
  Logger.log('Published ' + published + ' post(s).');
}

/**
 * Sends one post to the named platform. Add a branch here for each
 * extra network you support — the queue logic above stays the same.
 */
function postToPlatform(platform, text) {
  if (platform === 'mastodon') {
    const token = PropertiesService.getScriptProperties()
      .getProperty('MASTO_TOKEN');
    UrlFetchApp.fetch(MASTODON_INSTANCE + '/api/v1/statuses', {
      method: 'post',
      headers: { Authorization: 'Bearer ' + token },
      payload: { status: text },
    });
    return;
  }

  // An unknown platform is a data error — surface it rather than
  // silently dropping the post.
  throw new Error('Unsupported platform: ' + platform);
}

How it works

  1. publishDuePosts opens the queue spreadsheet and reads the entire grid, splitting row 1 off as the header.
  2. If there are no data rows, it logs a message and stops.
  3. It builds a col lookup from header text to column index, so the script keeps working even if someone reorders the columns.
  4. It walks every row and skips three cases: posts already stamped in posted, rows where scheduledAt is not a real date, and posts whose time is still in the future.
  5. For each due post it calls postToPlatform, then writes the current time into that row’s posted cell — the guard in step 4 means the next run will skip it.
  6. After the loop it writes the whole grid back in a single setValues call.
  7. postToPlatform holds one branch per network. The Mastodon branch reads the token from Script Properties and POSTs the status; an unknown platform throws so a typo never goes unnoticed.

Example run

The queue sheet before a run, with the clock at 09:05 on 17 Sept:

platformtextscheduledAtposted
mastodonNew case study: how Northwind cut onboarding time2025-09-17 09:00
mastodonWe’re hiring a junior designer — link in bio2025-09-17 14:00
mastodonFriday tip: name your layers2025-09-18 09:00

After publishDuePosts runs, only the first row is due — it is published and stamped. The other two are left untouched until their time comes:

platformtextscheduledAtposted
mastodonNew case study: how Northwind cut onboarding time2025-09-17 09:002025-09-17 09:05:12
mastodonWe’re hiring a junior designer — link in bio2025-09-17 14:00
mastodonFriday tip: name your layers2025-09-18 09:00

The log reads Published 1 post(s).

Trigger it

This is a scheduled job — it does nothing useful unless it runs on a timer:

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Click Add Trigger.
  3. Choose publishDuePosts, event source Time-driven, and a Minutes timer of every 15 or 30 minutes.

The interval is also your scheduling precision: a post set for 09:00 goes out at the first run after 09:00, so a 15-minute timer means posts land within a quarter-hour of their slot.

Watch out for

  • Make scheduledAt a real date-time, not text. Format the column as a date in the Sheet — the instanceof Date guard skips any cell that is plain text, so a mistyped date silently never posts.
  • Tokens expire. Mastodon access tokens are long-lived, but most other networks (X, LinkedIn, Facebook) use short-lived OAuth tokens that need refreshing. Plan for a refresh step before adding those branches.
  • Watch the platform rate limits. Mastodon allows generous posting, but a long backlog firing at once can still trip a limit — keep the timer frequent so the queue drains a few posts at a time rather than dozens.
  • A failed API call throws and stops the run, leaving later rows unposted. They are picked up on the next run because their posted cell is still blank, but check the execution log if a post seems stuck.
  • The script does not validate post length. Mastodon rejects statuses over its character limit, so keep the text column within 500 characters.

Related