NodukanNodukan/ Developers
Guides

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:read and social:write scopes
  • 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,
  });
}
Note:

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?#

Was this helpful?
Edit on GitHub

Last updated