DEV Community

Cover image for Building a Multi-Platform Content Publisher: One API, Five Destinations
Aakash Gour
Aakash Gour

Posted on

Building a Multi-Platform Content Publisher: One API, Five Destinations

Every time I shipped a new article for PostAll's early beta users, I watched them paste the same content into five different dashboards. WordPress. Ghost. Webflow. Medium. Dev.to.

Copy. Paste. Adjust formatting. Fight with the rich text editor. Repeat.

I timed it once: 23 minutes per article to publish to all five platforms. If you're doing content at any kind of scale — even 10 articles a week — that's nearly 4 hours of soul-crushing manual work. Work that a computer should obviously be doing.

So I built a single publish API that handles all five simultaneously. This is the exact implementation behind PostAll's multi-platform publishing feature. I'll show you the full working code, the two approaches I tried before this one, and the specific failure modes you'll hit if you skip the parts I almost skipped.


What You'll Build

By the end of this, you'll have a Node.js service with a single POST /publish endpoint that:

  • Accepts a content payload (title, body, tags, metadata)
  • Transforms the content into each platform's required format
  • Publishes to all five destinations in parallel
  • Returns a structured result object with success/failure per platform
  • Handles partial failures without killing the whole batch

Here's what a successful response looks like:

{
  "status": "partial_success",
  "results": {
    "wordpress": { "success": true, "url": "https://yourblog.com/?p=1842" },
    "ghost": { "success": true, "url": "https://yourblog.ghost.io/p/your-post" },
    "webflow": { "success": true, "itemId": "64a3f1b2c3d4e5f6a7b8c9d0" },
    "medium": { "success": true, "url": "https://medium.com/@you/your-post-abc123" },
    "devto": { "success": false, "error": "Rate limit exceeded. Retry after 60s." }
  }
}
Enter fullscreen mode Exit fullscreen mode

That partial failure on Dev.to? I'll show you exactly how to handle it without having to re-publish everything else.


Why This Is Harder Than It Looks

Five platforms, five completely different APIs.

WordPress uses XML-RPC or a REST API (depending on version and config). Ghost has a beautiful Admin API but requires JWT authentication with a 5-minute expiry. Webflow's CMS API is REST but requires knowing your Collection ID before you can do anything. Medium requires OAuth for full access but supports integration tokens for simpler publishing. Dev.to uses API keys but has aggressive rate limiting on free accounts.

Every platform has a different concept of "content." WordPress thinks in post_content. Ghost thinks in Mobiledoc or Lexical JSON. Webflow thinks in CMS field schemas you define yourself. Medium thinks in HTML with a title field. Dev.to thinks in Markdown with frontmatter.

The naive approach — just writing five separate functions — works until you need to change something. Then you're changing it five times. The right approach is a transform layer that sits between your canonical content format and each platform's expected format.


Prerequisites

  • Node.js 18+ (uses native fetch and Promise.allSettled)
  • Active accounts on whichever platforms you're targeting
  • API credentials for each (I'll list exactly what you need per platform)
  • Basic understanding of async/await and REST APIs

You don't need to target all five platforms immediately. The architecture is additive — start with two, add the rest when you need them.


Step 1: Define Your Canonical Content Format

Before writing any platform-specific code, define the content shape you'll work with internally. This is the single source of truth that every publisher transforms from.

// types.js
/**
 * @typedef {Object} CanonicalContent
 * @property {string} title
 * @property {string} bodyMarkdown - Always store in Markdown; transform on publish
 * @property {string} bodyHtml - Pre-rendered HTML for platforms that need it
 * @property {string[]} tags - Plain strings, no hashtags
 * @property {string} [canonicalUrl] - Original URL if cross-posting
 * @property {string} [featuredImageUrl]
 * @property {Object} [meta]
 * @property {string} [meta.description]
 * @property {string} [meta.slug]
 */

/**
 * @typedef {Object} PublishTarget
 * @property {string} platform - 'wordpress' | 'ghost' | 'webflow' | 'medium' | 'devto'
 * @property {Object} credentials
 * @property {Object} [options] - Platform-specific overrides
 */
Enter fullscreen mode Exit fullscreen mode

Store content as Markdown internally. Every platform can accept some variant of HTML, and you can always convert Markdown → HTML. Going the other direction is a mess.


Step 2: The Publisher Core

This is the heart of the system. One function that fans out to all configured publishers in parallel.

// publisher.js
import { marked } from 'marked'; // npm install marked

import { publishToWordPress } from './publishers/wordpress.js';
import { publishToGhost } from './publishers/ghost.js';
import { publishToWebflow } from './publishers/webflow.js';
import { publishToMedium } from './publishers/medium.js';
import { publishToDevTo } from './publishers/devto.js';

const PUBLISHERS = {
  wordpress: publishToWordPress,
  ghost: publishToGhost,
  webflow: publishToWebflow,
  medium: publishToMedium,
  devto: publishToDevTo,
};

export async function publishToAll(content, targets) {
  // Pre-render Markdown to HTML once; all HTML-based platforms share this
  const contentWithHtml = {
    ...content,
    bodyHtml: marked.parse(content.bodyMarkdown),
  };

  // Run all publishers in parallel — don't await sequentially
  // Promise.allSettled never throws, even if individual publishers fail
  const publishJobs = targets.map(async (target) => {
    const publishFn = PUBLISHERS[target.platform];

    if (!publishFn) {
      return {
        platform: target.platform,
        success: false,
        error: `Unknown platform: ${target.platform}`,
      };
    }

    try {
      const result = await publishFn(contentWithHtml, target.credentials, target.options);
      return { platform: target.platform, success: true, ...result };
    } catch (err) {
      return {
        platform: target.platform,
        success: false,
        error: err.message,
        // Include retry hint if the publisher provides one
        retryAfter: err.retryAfter ?? null,
      };
    }
  });

  const settled = await Promise.allSettled(publishJobs);

  // allSettled always resolves; extract the values
  const results = Object.fromEntries(
    settled.map((s) => {
      const value = s.status === 'fulfilled' ? s.value : { success: false, error: s.reason?.message };
      return [value.platform, value];
    })
  );

  const successCount = Object.values(results).filter((r) => r.success).length;
  const status =
    successCount === targets.length ? 'success' :
    successCount === 0 ? 'failed' :
    'partial_success';

  return { status, results };
}
Enter fullscreen mode Exit fullscreen mode

The key decision here: Promise.allSettled instead of Promise.all. With Promise.all, one platform failure throws and you lose all your results. With allSettled, you get granular success/failure per platform and can retry only what failed.


Step 3: The Platform Publishers

WordPress

WordPress has two API options: the legacy XML-RPC endpoint and the newer REST API (available on WordPress 4.7+). Use the REST API if you can — it's significantly cleaner.

// publishers/wordpress.js

export async function publishToWordPress(content, credentials, options = {}) {
  const { siteUrl, username, appPassword } = credentials;
  // App Passwords were added in WP 5.6 — generate one in Users → Profile
  // Format: "xxxx xxxx xxxx xxxx xxxx xxxx" (spaces included)
  const authHeader = 'Basic ' + btoa(`${username}:${appPassword}`);

  const payload = {
    title: content.title,
    content: content.bodyHtml,
    status: options.status ?? 'publish', // 'draft' for review workflows
    tags: await resolveWordPressTags(content.tags, siteUrl, authHeader),
    // WordPress requires tag IDs, not strings — resolveWordPressTags handles this
    slug: content.meta?.slug,
    excerpt: content.meta?.description,
    ...(content.featuredImageUrl && {
      featured_media: await uploadWordPressMedia(content.featuredImageUrl, siteUrl, authHeader),
    }),
  };

  const response = await fetch(`${siteUrl}/wp-json/wp/v2/posts`, {
    method: 'POST',
    headers: {
      'Authorization': authHeader,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`WordPress API error: ${err.message} (${err.code})`);
  }

  const post = await response.json();
  return { url: post.link, postId: post.id };
}

async function resolveWordPressTags(tags, siteUrl, authHeader) {
  // WordPress needs tag IDs, not strings — create tags that don't exist yet
  const tagIds = await Promise.all(
    tags.map(async (tagName) => {
      // Try to find existing tag first
      const searchRes = await fetch(
        `${siteUrl}/wp-json/wp/v2/tags?search=${encodeURIComponent(tagName)}`,
        { headers: { Authorization: authHeader } }
      );
      const existing = await searchRes.json();

      if (existing.length > 0) return existing[0].id;

      // Create new tag if it doesn't exist
      const createRes = await fetch(`${siteUrl}/wp-json/wp/v2/tags`, {
        method: 'POST',
        headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: tagName }),
      });
      const newTag = await createRes.json();
      return newTag.id;
    })
  );
  return tagIds;
}
Enter fullscreen mode Exit fullscreen mode

WordPress gotcha: App Passwords are different from your login password. Go to Users → Your Profile → Application Passwords and generate one specifically for your publishing integration. Your regular password won't work with the REST API in most configurations.


Ghost

Ghost's Admin API uses JWT authentication with a short expiry. You can't use a static bearer token — you have to generate a new JWT per request.

// publishers/ghost.js
import crypto from 'crypto';

export async function publishToGhost(content, credentials, options = {}) {
  const { adminUrl, adminApiKey } = credentials;
  // Admin API Key format from Ghost Admin: "key_id:secret" — split on the colon
  const [keyId, secret] = adminApiKey.split(':');

  const jwt = generateGhostJWT(keyId, secret);

  // Ghost uses Mobiledoc OR Lexical depending on version
  // Sending HTML with `html` field works for Ghost 5.x with the html conversion flag
  const payload = {
    posts: [{
      title: content.title,
      html: content.bodyHtml,
      status: options.status ?? 'published',
      tags: content.tags.map((name) => ({ name })), // Ghost accepts tag names directly
      custom_excerpt: content.meta?.description,
      canonical_url: content.canonicalUrl,
      ...(content.featuredImageUrl && { feature_image: content.featuredImageUrl }),
    }],
  };

  const response = await fetch(`${adminUrl}/ghost/api/admin/posts/?source=html`, {
    method: 'POST',
    headers: {
      'Authorization': `Ghost ${jwt}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`Ghost API error: ${err.errors?.[0]?.message}`);
  }

  const result = await response.json();
  const post = result.posts[0];
  return { url: post.url, postId: post.id };
}

function generateGhostJWT(keyId, secret) {
  // Ghost uses a specific JWT structure — don't try to use a generic JWT library
  const now = Math.floor(Date.now() / 1000);
  const header = { alg: 'HS256', typ: 'JWT', kid: keyId };
  const payload = { iat: now, exp: now + 300, aud: '/admin/' }; // 5-minute expiry

  const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
  const headerB64 = encode(header);
  const payloadB64 = encode(payload);
  const signingInput = `${headerB64}.${payloadB64}`;

  const signature = crypto
    .createHmac('sha256', Buffer.from(secret, 'hex'))
    .update(signingInput)
    .digest('base64url');

  return `${signingInput}.${signature}`;
}
Enter fullscreen mode Exit fullscreen mode

Ghost gotcha: The ?source=html query param is critical. Without it, Ghost tries to parse your HTML as Mobiledoc and the content will be empty. This burned me for two days.


Webflow

Webflow is the most setup-intensive because it requires knowing your Collection ID and the field schema before publishing anything.

// publishers/webflow.js

export async function publishToWebflow(content, credentials, options = {}) {
  const { apiToken, collectionId } = credentials;
  // collectionId: get from Webflow Dashboard → CMS → your blog collection → Settings
  // The ID is in the URL: webflow.com/design/.../cms/[collectionId]

  // Webflow field names depend on YOUR collection schema
  // These are the defaults — override via options.fieldMap if yours are different
  const fieldMap = {
    title: 'name',         // Webflow always uses 'name' as the required display field
    body: 'post-body',     // Whatever you named your rich text field
    slug: 'slug',
    description: 'post-summary',
    tags: 'tags',
    featuredImage: 'main-image',
    ...options.fieldMap,
  };

  const fields = {
    [fieldMap.title]: content.title,
    [fieldMap.body]: content.bodyHtml,
    [fieldMap.slug]: content.meta?.slug ?? slugify(content.title),
    [fieldMap.description]: content.meta?.description ?? '',
  };

  const response = await fetch(
    `https://api.webflow.com/v2/collections/${collectionId}/items/live`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ fieldData: fields }),
    }
  );

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`Webflow API error: ${err.message} — field: ${err.code}`);
  }

  const item = await response.json();
  // /live endpoint publishes immediately; use /items (without /live) for drafts
  return { itemId: item.id };
}

function slugify(title) {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}
Enter fullscreen mode Exit fullscreen mode

Webflow gotcha: The /live suffix on the endpoint publishes immediately. Without it, you create a draft that only exists in staging. I spent an embarrassing amount of time wondering why my published items weren't showing up on the live site.


Medium

Medium's API is officially deprecated-ish — they stopped accepting new OAuth app registrations but still support Integration Tokens for personal publishing.

// publishers/medium.js

export async function publishToMedium(content, credentials, options = {}) {
  const { integrationToken } = credentials;
  // Get from medium.com/me/settings → Integration Tokens

  // First, get your user ID — required for the posts endpoint
  const userRes = await fetch('https://api.medium.com/v1/me', {
    headers: { Authorization: `Bearer ${integrationToken}` },
  });

  if (!userRes.ok) throw new Error('Medium auth failed — check your integration token');

  const { data: user } = await userRes.json();

  const payload = {
    title: content.title,
    contentFormat: 'html', // 'markdown' also works but HTML renders more reliably
    content: content.bodyHtml,
    tags: content.tags.slice(0, 5), // Medium enforces a 5-tag limit
    canonicalUrl: content.canonicalUrl,
    publishStatus: options.status ?? 'public', // 'draft' | 'public' | 'unlisted'
  };

  const response = await fetch(`https://api.medium.com/v1/users/${user.id}/posts`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${integrationToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`Medium API error: ${err.errors?.[0]?.message}`);
  }

  const { data: post } = await response.json();
  return { url: post.url, postId: post.id };
}
Enter fullscreen mode Exit fullscreen mode

Medium gotcha: They silently truncate tags beyond 5. If you pass 8 tags, the API succeeds but only the first 5 appear. The .slice(0, 5) is defensive, not cosmetic.


Dev.to

The cleanest API of the five. API key auth, straightforward payload, good documentation.

// publishers/devto.js

export async function publishToDevTo(content, credentials, options = {}) {
  const { apiKey } = credentials;
  // Get from dev.to/settings/extensions → DEV Community API Keys

  const payload = {
    article: {
      title: content.title,
      body_markdown: content.bodyMarkdown, // Dev.to wants Markdown, not HTML
      published: options.status !== 'draft',
      tags: content.tags
        .slice(0, 4) // Dev.to enforces a 4-tag limit
        .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, '')), // tags: lowercase, alphanumeric only
      canonical_url: content.canonicalUrl,
      description: content.meta?.description,
      ...(content.featuredImageUrl && { main_image: content.featuredImageUrl }),
    },
  };

  const response = await fetch('https://dev.to/api/articles', {
    method: 'POST',
    headers: {
      'api-key': apiKey,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (response.status === 429) {
    // Rate limit: 10 articles per 30 seconds on free accounts
    const retryAfter = parseInt(response.headers.get('retry-after') ?? '60', 10);
    const err = new Error('Dev.to rate limit exceeded');
    err.retryAfter = retryAfter; // Bubble up to the retry system
    throw err;
  }

  if (!response.ok) {
    const err = await response.json();
    throw new Error(`Dev.to API error: ${JSON.stringify(err)}`);
  }

  const article = await response.json();
  return { url: article.url, articleId: article.id };
}
Enter fullscreen mode Exit fullscreen mode

Dev.to gotcha: Tags must be lowercase and contain only letters and numbers — no hyphens, no spaces. "web-dev" becomes "webdev". Pass them through the sanitizer above or they'll silently fail validation.


Step 4: The HTTP Endpoint

Wire everything together with a simple Express endpoint.

// server.js
import express from 'express';
import { publishToAll } from './publisher.js';

const app = express();
app.use(express.json({ limit: '2mb' })); // Articles can get big with embedded code

app.post('/publish', async (req, res) => {
  const { content, targets } = req.body;

  // Basic validation — add more for production
  if (!content?.title || !content?.bodyMarkdown) {
    return res.status(400).json({ error: 'content.title and content.bodyMarkdown are required' });
  }

  if (!Array.isArray(targets) || targets.length === 0) {
    return res.status(400).json({ error: 'targets must be a non-empty array' });
  }

  try {
    const result = await publishToAll(content, targets);
    // Return 207 Multi-Status for partial success — it's the correct HTTP status here
    const statusCode = result.status === 'failed' ? 500 : result.status === 'partial_success' ? 207 : 200;
    return res.status(statusCode).json(result);
  } catch (err) {
    // This only fires if publishToAll itself throws — individual platform errors are caught inside
    return res.status(500).json({ error: err.message });
  }
});

app.listen(3000, () => console.log('Publisher running on :3000'));
Enter fullscreen mode Exit fullscreen mode

A sample request to test all five platforms:

curl -X POST http://localhost:3000/publish \
  -H "Content-Type: application/json" \
  -d '{
    "content": {
      "title": "How I Optimized Our Node.js Queue",
      "bodyMarkdown": "## The Problem\n\nAt 500 concurrent jobs...",
      "tags": ["nodejs", "javascript", "performance", "webdev"],
      "meta": {
        "description": "A practical guide to Node.js queue optimization",
        "slug": "nodejs-queue-optimization"
      }
    },
    "targets": [
      {
        "platform": "wordpress",
        "credentials": { "siteUrl": "https://yourblog.com", "username": "admin", "appPassword": "xxxx xxxx xxxx xxxx xxxx xxxx" }
      },
      {
        "platform": "devto",
        "credentials": { "apiKey": "your_devto_api_key" }
      }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

What Can Go Wrong

Rate limits cascade. If you're publishing high volume, you'll hit rate limits on Dev.to (10/30s) and Medium first. The retryAfter field on the error lets you implement a per-platform retry queue. Don't retry all platforms when only one rate-limited — that's how you get banned.

Ghost JWT clock drift. Ghost's JWT validation is strict about the iat and exp fields. If your server clock is off by more than a few seconds from Ghost's server, you'll get mysterious 401s. Add NTP sync to your server checklist if you're running this in production.

Webflow field mismatches. If your Webflow collection has required fields you're not supplying, the API returns a 400 with a cryptic field name error. Before implementing, dump your collection schema: GET https://api.webflow.com/v2/collections/{collectionId} and map every required field.

WordPress multisite installs. If your WordPress is a multisite network, the API path changes. Instead of /wp-json/wp/v2/posts, it becomes /site-path/wp-json/wp/v2/posts. The base URL you pass in credentials needs to include the subsite path.

Medium's canonicalUrl field. Medium strips HTTP/HTTPS from canonical URLs if they point to Medium itself. If you're cross-posting from another platform, always set canonicalUrl to the original source URL to avoid duplicate content penalties. The API accepts it but doesn't validate it — a wrong canonical URL won't cause an error, it'll just quietly hurt your SEO.


Where to Take This

The implementation above covers the publish path. In PostAll, this publisher sits behind two more layers I haven't covered here:

A retry queue. When platforms return rate-limit errors or 5xx responses, failed publish jobs go into a Redis-backed retry queue with exponential backoff. Platform-specific retryAfter hints from the error object feed directly into the delay calculation.

A format validator. Before hitting any platform API, content runs through a validator that checks Markdown for broken image links, missing alt text, and code blocks without language identifiers. Catching these before publish is faster than debugging broken posts after the fact.

Both of those are topics for separate posts. If you want to see either covered next, drop it in the comments.

The complete source for this publisher — with a few more error-handling details I cut for brevity — is at: github.com/your-handle/multi-platform-publisher. If it saves you the 3 days it took me to figure out Ghost's JWT implementation, a star would be appreciated.


What platforms are you publishing to that I missed? LinkedIn articles and Hashnode are both on my list — curious if anyone's built a reliable LinkedIn publisher, since their API for articles is historically finicky.


PostAll is a content automation platform I'm building for teams that publish at scale. If the multi-platform publishing problem is one you're actively dealing with, I write about the architecture behind it here regularly.

Top comments (0)