← All posts
Tutorials

How to Build a Social Media Scheduler with a REST API

A minimal working social media scheduler in Node.js — a queue table, a cron job, and calls to a REST API to publish posts on schedule.

Zakir Hossen profile imageZakir Hossen··6 min read

How to Build a Social Media Scheduler with a REST API

A social media scheduler is three parts: a table that holds posts and their scheduled time, a cron job that checks the table on an interval, and an API call that actually publishes when a post's time arrives. This tutorial builds all three in Node.js, calling Schedule & Chill's REST API for the publish step.

This is worth building yourself if you want scheduling logic embedded in your own product rather than a separate tool your users have to open. For the underlying API concepts, see What Is a Unified Social Media API?, and if you'd rather have an AI agent decide what to post instead of a human queuing it, see Building an AI Social Media Agent with LangGraph.

#The Queue Table

Use whatever database you already have. Here's the schema in SQL, deliberately minimal:

 1CREATE TABLE scheduled_posts (
 2  id INTEGER PRIMARY KEY AUTOINCREMENT,
 3  profile_id TEXT NOT NULL,
 4  text TEXT NOT NULL,
 5  platforms TEXT NOT NULL,       -- JSON array, e.g. ["x", "linkedin"]
 6  media_ids TEXT,                -- JSON array, nullable
 7  scheduled_for TEXT NOT NULL,   -- ISO-8601 timestamp
 8  status TEXT NOT NULL DEFAULT 'pending',  -- pending | published | failed
 9  published_post_id TEXT,
10  error_message TEXT,
11  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
12);

Two columns carry all the state a scheduler needs: scheduled_for (when it should go out) and status (what happened when it tried). Everything else is metadata for the API call itself.

#Install Dependencies

 1npm install node-cron axios

#The Publish Function

 1// publish.js
 2const axios = require('axios');
 3
 4const API_KEY = process.env.SCHEDULENCHILL_API_KEY;
 5const BASE_URL = 'https://api.schedulenchill.com/v1';
 6
 7async function publishPost(post) {
 8  const response = await axios.post(
 9    `${BASE_URL}/posts`,
10    {
11      profile_ids: [post.profile_id],
12      text: post.text,
13      platforms: JSON.parse(post.platforms),
14      media_ids: post.media_ids ? JSON.parse(post.media_ids) : undefined,
15      when: 'now',
16    },
17    {
18      headers: { Authorization: `Bearer ${API_KEY}` },
19      timeout: 30000,
20    }
21  );
22  return response.data;
23}
24
25module.exports = { publishPost };

Note when: 'now' here — the timing decision already happened in your own queue table. By the time this function runs, you want the post to go out immediately, not scheduled a second time inside the API itself.

#The Cron Job

 1// scheduler.js
 2const cron = require('node-cron');
 3const db = require('./db'); // your database client
 4const { publishPost } = require('./publish');
 5
 6async function processQueue() {
 7  const now = new Date().toISOString();
 8
 9  const duePosts = await db.all(
10    `SELECT * FROM scheduled_posts WHERE status = 'pending' AND scheduled_for <= ?`,
11    [now]
12  );
13
14  for (const post of duePosts) {
15    try {
16      const result = await publishPost(post);
17      await db.run(
18        `UPDATE scheduled_posts SET status = 'published', published_post_id = ? WHERE id = ?`,
19        [result.id, post.id]
20      );
21      console.log(`Published post ${post.id} -> ${result.id}`);
22    } catch (err) {
23      const message = err.response?.data?.message || err.message;
24      await db.run(
25        `UPDATE scheduled_posts SET status = 'failed', error_message = ? WHERE id = ?`,
26        [message, post.id]
27      );
28      console.error(`Failed to publish post ${post.id}: ${message}`);
29    }
30  }
31}
32
33// Run every minute
34cron.schedule('* * * * *', processQueue);
35
36console.log('Scheduler running, checking queue every minute.');

Run it with:

 1node scheduler.js

#Why the Queue Table Matters More Than the Cron Expression

A common mistake is scheduling the API call directly with a setTimeout or a per-post cron entry. That breaks the moment your process restarts — a deploy, a crash, a server reboot wipes every pending timer with it. A database table survives restarts by design: the cron job just re-reads what's due every minute, regardless of whether the process has been running for five seconds or five days.

The status column also gives you a natural retry and audit trail. A failed row isn't lost — it's sitting in the table with an error_message you can inspect, and you can add a second, less-frequent cron job that retries failed rows a limited number of times before giving up.

#Adding an Insert Endpoint

To actually queue posts, expose a simple endpoint in your own app:

 1// api/schedule.js (Express example)
 2app.post('/api/schedule', async (req, res) => {
 3  const { profile_id, text, platforms, media_ids, scheduled_for } = req.body;
 4
 5  await db.run(
 6    `INSERT INTO scheduled_posts (profile_id, text, platforms, media_ids, scheduled_for)
 7     VALUES (?, ?, ?, ?, ?)`,
 8    [profile_id, text, JSON.stringify(platforms), JSON.stringify(media_ids || []), scheduled_for]
 9  );
10
11  res.status(201).json({ status: 'queued' });
12});

This is the piece your own frontend or another automation calls to add work to the queue — everything downstream is the cron job and the publish function already built above.

#Handling Rate Limits

If you're queuing at volume, don't assume every due post can publish in the same minute-long cron tick without hitting a platform's rate limit. Check Social Media API Rate Limits: A Platform-by-Platform Reference before scaling this past a handful of posts per run, and consider spacing out calls within processQueue with a small delay between each publishPost call rather than firing them all concurrently.

#Media Uploads Ahead of Time

If a queued post needs an image or video, upload it when the post is created, not when it's about to publish — store the returned media ID in the media_ids column instead of re-uploading at publish time. This keeps the publish step fast and avoids a slow upload blocking the cron tick for every other due post behind it.

#Frequently Asked Questions

Why not just use the API's own when parameter with a future timestamp instead of building a queue table? You can, and for simple cases it works — Schedule & Chill's API accepts an ISO-8601 timestamp directly, which is closer to what the MCP server's schedule_post tool does. Build your own queue table when you need visibility into pending posts inside your own product, custom retry logic, or the ability to edit a scheduled post before it fires.

What if two cron ticks try to publish the same post? Add a guard: update the row's status to processing in the same query that selects it (UPDATE ... WHERE status = 'pending' ... RETURNING *, or a two-step select-then-conditional-update), so a slow-running previous tick can't double-publish if a run overlaps the next one.

Does this work with a hosted cron service instead of a long-running Node process? Yes — replace node-cron with a hosted scheduler (a serverless cron trigger, a platform's built-in scheduled job) that calls processQueue() once per invocation. The queue table and publish logic don't change.

How does this compare to using n8n or Make.com instead of writing this myself? Same underlying API call, different execution environment. n8n and Make.com give you the queue and trigger logic through a visual builder instead of code — better if you don't want to maintain a running process yourself.

Try it in a minute.Free, no card. One URL into your AI tool.
Start free