Scheduling Posts at Scale
Use the Nodukan Social API to batch-schedule social media posts across multiple platforms programmatically from a spreadsheet or CMS.
Scheduling Posts at Scale
This guide shows you how to schedule dozens (or hundreds) of social media posts programmatically from a CSV, a CMS, or a content calendar tool. By the end, you'll have a script that reads a content calendar and queues it all up via the Nodukan API.
Use cases#
- Content calendar tools โ build a Notion or Airtable integration that syncs directly to Nodukan's scheduler
- Agency dashboards โ manage multiple creator accounts from a single internal tool
- Programmatic content โ generate and schedule posts based on trends, events, or new product launches
Prerequisites#
- API key with
social:readandsocial:writescopes - At least one connected social account (LinkedIn, Instagram, or Facebook)
Step 1 โ Discover connected accounts#
Before scheduling, fetch the list of connected social accounts to get connectionId values:
// get-connections.js
const res = await fetch('https://api.nodukan.com/v1/social/connections', {
headers: { Authorization: `Bearer ${process.env.NODUKAN_API_KEY}` },
});
const { data: connections } = await res.json();
// Map platform โ connectionId for easy lookup
const byPlatform = Object.fromEntries(
connections.map((c) => [c.platform, c]),
);
console.log('LinkedIn:', byPlatform.linkedin?.id);
console.log('Instagram:', byPlatform.instagram?.id);
Step 2 โ Prepare your content calendar#
Here's a sample CSV format (content-calendar.csv):
platform,scheduledAt,content,mediaUrl,firstComment
linkedin,2026-05-07T06:30:00+05:30,"3 lessons from 1000 days as a creator ๐งต",,https://ndk.in/blog
linkedin,2026-05-09T07:00:00+05:30,"The Indian creator economy is hitting โน3000 Cr. Here's what that means for you...",,
instagram,2026-05-08T09:00:00+05:30,"Morning routine of a full-time creator ๐
",https://cdn.example.com/reel-thumb.jpg,
instagram,2026-05-10T18:30:00+05:30,"Behind the scenes: how I made โน1L in a single day ๐",https://cdn.example.com/behind-scenes.jpg,
Step 3 โ Batch schedule the posts#
// schedule-batch.js
import fs from 'fs';
import { parse } from 'csv-parse/sync';
const API_KEY = process.env.NODUKAN_API_KEY;
const BASE = 'https://api.nodukan.com/v1';
// Load connections
async function getConnections() {
const res = await fetch(`${BASE}/social/connections`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { data } = await res.json();
return Object.fromEntries(data.map((c) => [c.platform, c]));
}
// Schedule a single post with retry on rate limit
async function schedulePost(payload, retries = 3) {
for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(`${BASE}/social/posts`, {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') || 5);
console.warn(` โณ Rate limited โ waiting ${retryAfter}s`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`API error: ${body.error?.message}`);
return body.data;
}
throw new Error('Max retries exceeded');
}
async function main() {
const connections = await getConnections();
const csvRaw = fs.readFileSync('./content-calendar.csv', 'utf-8');
const rows = parse(csvRaw, { columns: true, skip_empty_lines: true });
const results = { scheduled: 0, failed: 0 };
for (const row of rows) {
const conn = connections[row.platform];
if (!conn) {
console.warn(` โ ๏ธ No connection for platform: ${row.platform} โ skipping`);
results.failed++;
continue;
}
if (!conn.isActive) {
console.warn(` โ ๏ธ Connection ${conn.id} is inactive โ skipping`);
results.failed++;
continue;
}
const payload = {
platform: row.platform,
connectionId: conn.id,
content: row.content,
scheduledAt: row.scheduledAt,
...(row.mediaUrl ? { mediaUrls: [row.mediaUrl] } : {}),
...(row.firstComment ? { firstComment: row.firstComment } : {}),
};
try {
const post = await schedulePost(payload);
console.log(` โ
Scheduled [${row.platform}] for ${row.scheduledAt}: ${post.id}`);
results.scheduled++;
} catch (err) {
console.error(` โ Failed [${row.platform}]: ${err.message}`);
results.failed++;
}
// Polite delay between requests to stay well within rate limits
await new Promise((r) => setTimeout(r, 200));
}
console.log(`\n๐ Done โ ${results.scheduled} scheduled, ${results.failed} failed`);
}
main().catch(console.error);
Run it:
node schedule-batch.js
# โ
Scheduled [linkedin] for 2026-05-07T06:30:00+05:30: post_01j9x...
# โ
Scheduled [instagram] for 2026-05-08T09:00:00+05:30: post_01j9y...
# ...
# ๐ Done โ 4 scheduled, 0 failed
Step 4 โ Monitor scheduled posts#
After scheduling, check your queue to confirm everything looks right:
// check-queue.js
const res = await fetch(
'https://api.nodukan.com/v1/social/posts?status=scheduled&limit=100',
{ headers: { Authorization: `Bearer ${process.env.NODUKAN_API_KEY}` } },
);
const { data: posts, pagination } = await res.json();
console.log(`${pagination.total} posts scheduled:`);
for (const post of posts) {
const local = new Date(post.scheduledAt).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' });
console.log(` [${post.platform.toUpperCase()}] ${local} โ ${post.content.substring(0, 50)}...`);
}
Step 5 โ Subscribe to publish events#
Register a webhook for post.published and post.failed to know when posts go live or encounter errors:
curl -X POST https://api.nodukan.com/v1/webhooks \
-H "Authorization: Bearer NDK_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/webhooks/nodukan",
"events": ["post.published", "post.failed"],
"description": "Social scheduler notifications"
}'
When a post fails (e.g., expired LinkedIn token), you'll receive:
{
"id": "evt_01j9x...",
"event": "post.failed",
"data": {
"postId": "post_01j9x...",
"platform": "linkedin",
"failureReason": "access_token_expired",
"scheduledAt": "2026-05-07T01:00:00Z"
}
}
Airtable integration pattern#
If your content calendar lives in Airtable, you can sync it with a simple script:
// airtable-sync.js โ runs nightly via cron
import Airtable from 'airtable';
const base = new Airtable({ apiKey: process.env.AIRTABLE_KEY }).base('YOUR_BASE_ID');
const records = await base('Content Calendar')
.select({
filterByFormula: "AND({Status} = 'Ready to Schedule', {Scheduled} = FALSE())",
maxRecords: 50,
})
.all();
for (const record of records) {
const post = await schedulePost({
platform: record.get('Platform').toLowerCase(),
connectionId: connections[record.get('Platform').toLowerCase()].id,
content: record.get('Copy'),
scheduledAt: record.get('Publish Date'),
mediaUrls: record.get('Media') ? [record.get('Media')[0].url] : [],
});
// Mark as scheduled in Airtable
await base('Content Calendar').update(record.id, {
Scheduled: true,
'Nodukan Post ID': post.id,
});
}
LinkedIn limits post frequency to avoid spam signals. Aim for no more than 1โ3 posts per day per connected account for best engagement performance.
What's next?#
- Social API reference โ all endpoints, schemas, and filters
- Webhook Verification โ verify post.published/failed event signatures
Last updated