Skip to Content
GuidesScheduled campaign — end-to-end

Scheduled campaign — end-to-end

Schedule a week of posts across multiple accounts in one pass. Uses batch.createPosts() with deterministic idempotency keys so the whole script is safe to re-run if it fails partway through.

Define your campaign posts

import { VoxBurstClient } from '@voxburst/sdk' import crypto from 'crypto' const client = new VoxBurstClient({ apiKey: process.env.VOXBURST_API_KEY! }) // Discover active accounts once const { data: accounts } = await client.accounts.list({ status: 'ACTIVE' }) const twitter = accounts.find(a => a.platform === 'twitter')! const linkedin = accounts.find(a => a.platform === 'linkedin')! const bluesky = accounts.find(a => a.platform === 'bluesky')! // One post per day for 5 days, sent to all three accounts const campaignId = 'launch-week-2026-07' const posts = [ { content: 'Day 1: We shipped it. 🚀', slot: '2026-07-01T14:00:00Z' }, { content: 'Day 2: Here\'s what we built.', slot: '2026-07-02T14:00:00Z' }, { content: 'Day 3: The numbers so far 📊', slot: '2026-07-03T14:00:00Z' }, { content: 'Day 4: What\'s next on the roadmap.', slot: '2026-07-04T14:00:00Z' }, { content: 'Day 5: Thank you for the support 🙏', slot: '2026-07-05T14:00:00Z' }, ]

Build idempotency-keyed batch input

A deterministic key means you can safely re-run this script after a network failure — duplicate keys return the cached response instead of creating duplicate posts.

function idempotencyKey(campaignId: string, slot: string, accountIds: string[]) { return crypto .createHash('sha256') .update(`${campaignId}:${slot}:${accountIds.sort().join(',')}`) .digest('hex') .slice(0, 64) } // batch.createPosts() accepts up to 50 items per call const batchInput = posts.map(p => ({ content: p.content, accountIds: [twitter.id, linkedin.id, bluesky.id], scheduledFor: p.slot, // Platform-specific overrides — LinkedIn gets a longer version platformOverrides: { LINKEDIN: { content: `${p.content}\n\nFollow along this week as we share the story behind the build.`, }, }, // Idempotency key per post (passed as metadata so the batch result is traceable) metadata: { idempotencyKey: idempotencyKey(campaignId, p.slot, [twitter.id, linkedin.id, bluesky.id]), campaignId, slot: p.slot, }, }))

batch.createPosts() itself is idempotent per item when you pass an Idempotency-Key header on the HTTP request. Storing the key in metadata also lets you look up which posts belong to a campaign later.

Submit the batch

const { results } = await client.batch.createPosts(batchInput) const created = results.filter(r => r.status === 'created') const failed = results.filter(r => r.status === 'failed') console.log(`Created: ${created.length}/${posts.length}`) if (failed.length > 0) { console.error('Some posts failed to create:') for (const f of failed) { console.error(` index ${f.index}: ${JSON.stringify(f.errors)}`) } } const postIds = created.map(r => r.postId!)

Set up a webhook to receive results

Register once (not per campaign) to receive publish events for all posts.

// One-time setup — store endpoint.id and endpoint.secret const endpoint = await client.webhooks.create({ url: 'https://yourapp.com/webhooks/voxburst', events: ['post.published', 'post.failed', 'post.partial'], }) console.log('Webhook secret (save this):', endpoint.secret)
// Express webhook handler import { createHmac, timingSafeEqual } from 'crypto' import express from 'express' const app = express() app.use(express.raw({ type: 'application/json' })) app.post('/webhooks/voxburst', (req, res) => { // Verify signature const sig = req.headers['x-voxburst-signature'] as string const body = req.body as Buffer const expected = createHmac('sha256', process.env.WEBHOOK_SECRET!) .update(body).digest('hex') if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.sendStatus(401) } res.sendStatus(200) // ack before any async work const event = JSON.parse(body.toString()) if (event.type === 'post.published') { const post = event.data console.log(`✅ Published: ${post.id} (${post.platforms[0]?.platformPostUrl})`) } if (event.type === 'post.failed' || event.type === 'post.partial') { const post = event.data const failed = post.platforms.filter((p: any) => p.status === 'FAILED') for (const p of failed) { console.error(`❌ ${post.id} / ${p.platform}: ${p.error?.message}`) // Queue for remediation remediationQueue.push({ postId: post.id, postPlatformId: p.postPlatformId }) } } })

Recover partial failures

When a post publishes to 2 of 3 platforms, status is partial. Fix only the failed destinations — the successful ones are not re-sent.

async function fixFailedPlatforms(postId: string) { const post = await client.posts.get(postId) if (post.status !== 'partial' && post.status !== 'failed') { console.log(`Post ${postId} is ${post.status} — nothing to fix`) return } const toFix = post.platforms.filter(p => p.status === 'failed') console.log(`Fixing ${toFix.length} failed platform(s) on ${postId}`) for (const platform of toFix) { console.log(` ${platform.platform}: ${platform.error?.message}`) // fixPlatform is REST-only — use raw fetch await fetch(`https://api.voxburst.io/v1/posts/${postId}/platforms/${platform.postPlatformId}/fix`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.VOXBURST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ /* content: 'Adjusted version for this platform' */ }), }) } // Each fixed platform re-enters the publish queue independently }

Verify the full campaign after the week

// Pull all posts in this campaign by metadata tag const allPosts = [] for await (const post of client.posts.listAll({ status: 'published' })) { if (post.metadata?.campaignId === campaignId) { allPosts.push(post) } } const summary = { total: allPosts.length, fullyPublished: allPosts.filter(p => p.status === 'published').length, partial: allPosts.filter(p => p.status === 'partial').length, failed: allPosts.filter(p => p.status === 'failed').length, } console.table(summary)

Last updated on