PlanVortex
  • Product
  • Pricing
  • API
  • Login
  • Get started
ProductSee the whole productUse casesComparisonResourcesMulti-network schedulingAI assistantAnalyticsAccount connectionDeveloper API
PricingAPILoginGet started
Language
Home/Resources/Publishing to social media from Node.js without building ten integrations
Guides

Publishing to social media from Node.js without building ten integrations

11 min read·Published on Aug 24, 2026
Also inEspañol

Publishing to ten social networks from your application is ten OAuth integrations, ten video formats and ten ways for a token to expire. Or it is ten lines of Node against one API. This guide is the second option: the official PlanVortex library for Node.js, from credentials to a scheduled post, and then the four things nobody works out on their own.

npm install planvortex

What does the PlanVortex library do, and what does it not?

It is a server-side client for the PlanVortex API, written in TypeScript, with no runtime dependencies and types generated from the same OpenAPI specification published in the documentation. It needs Node 20 or newer, where fetch, FormData, Blob and fs.openAsBlob are already globals.

It covers the whole API: connecting accounts, uploading files, publishing and scheduling, the comment and message inboxes, contacts, products, integrations, AI plans, the metrics dashboard and webhooks.

What it does not do, and is worth knowing before you write anything:

It does not Why
Work in a browser Authenticating needs the client_secret, and a secret in a bundle is your account handed over
Connect social accounts by itself Authorizing Instagram is an OAuth flow with a person in front of it
Retry a POST that reached the server With no idempotency key, retrying means duplicating the publication
Invent each network's limits The server publishes them and the library reads them from there

That first row is the one most people skip. This package is server-side: in a Node backend, in a serverless function, in a cron job. Not in a React component.

How do you get the credentials?

In your client panel, by creating an app. An app is API access — not to be confused with integrations, which are connections to third-party tools you pull material from — and it is available on the Custom plan.

Creating it gives you a client_id and a client_secret. With those:

import { PlanVortex } from "planvortex";

const pv = new PlanVortex({
    clientId: process.env.PLANVORTEX_CLIENT_ID,
    clientSecret: process.env.PLANVORTEX_CLIENT_SECRET,
});

There is no token to request, cache or watch for expiry. The library exchanges it at POST /oauth/token the first time it is needed, stores it, refreshes it a minute before it expires and, if twenty calls arrive while there is no token, makes a single token request and lets them all wait for it. It is the boring work nobody wants to write again.

How do you publish a post from Node.js?

Three steps: pick the account, upload the file, create the publication.

// 1. The accounts you can actually publish with. The server resolves the filter with its own
//    capability matrix: WhatsApp and Google Business do not appear here, because they do
//    not publish.
const { data: accounts } = await pv.accounts.list(orgId, { capability: "publications" });
const account = accounts[0];

// 2. The file. A path on disk is the form that does NOT read it into memory; a Buffer or a
//    Blob work too.
const upload = await pv.uploads.create(orgId, { file: "./sourdough.jpg" });

// 3. The publication. Without `publish_date` it goes out now; with it, it is scheduled.
const publication = await pv.publications.create(orgId, account._id, {
    social_network: account.social_network,
    text: "New oven, new loaves",
    files: [upload._id],
    publish_date: new Date("2026-09-01T10:00:00Z"),
});

That is everything you write to schedule a post. What happens underneath — refreshing the network's token, chunking the video where that network demands it, retrying when the network is down, flagging the account when its token dies — runs on PlanVortex's servers.

Every listing pages the same way, {data, total}, and every one has an iterator that chains the pages:

for await (const publication of pv.publications.iterate(orgId, { state: ["ready"] })) {
    // ...
}

Why can a publication fail to go out without throwing an error?

Because it does not fit the network, and that is not a failure of the request: the publication is stored in state withErrors with the reason inside, and the call happily returns a 200.

if (publication.state === "withErrors") {
    console.error(publication.publication_errors.map((error) => error.message));
}

This is trap number one when integrating this API, and it is deliberate: a try/catch around create() reports as published something that will never go out. A 400-character text on Bluesky, a two-minute video in a reel, an image with an aspect ratio Instagram will not take. Check the state.

To avoid getting there, each network's limits are asked for and validated up front:

const limits = await pv.catalog.socialLimits();
limits.characters.bluesky;       // 300 graphemes
limits.max_post_bytes.bluesky;   // and 3,000 bytes as well

The server publishes them, and the server is what validates them — which is what makes them the right ones. A family emoji is eleven UTF-16 units, one grapheme and twenty-five bytes: counting with .length is wrong in both directions, which is why Bluesky has two limits in different units. The library caches the catalogue per instance, so consulting it does not cost a request per publication.

How does one of your users connect their Instagram account?

This is the piece nobody works out on their own, and the one that decides the shape of the whole integration: an app cannot connect a social account. Not yours, not anybody's. Authorizing Instagram is an OAuth flow with a person in front of it, so those endpoints refuse app credentials with error 519.

What your server does is mint a temporal connect token and send that person to the URL it returns:

// On your server, when your user clicks "connect Instagram":
const connect = await pv.organizations.createConnectToken(orgId, {
    // Must be one of your app's registered `redirect_urls`, or you get error 532.
    redirect_uri: "https://your-app.example/done",
});

response.redirect(connect.url); // PlanVortex takes over and returns your user to your URL

The token lasts an hour, is tied to a single organization and carries two permissions: create accounts and read the organization. Nothing else. Your client_secret never leaves your server, and your user does not need a PlanVortex account.

Three things that surprise everyone here:

  • A network that cannot be connected right now simply does not appear in the list of links. That is an answer, not a failure: it happens to Discord in an organization that has not saved its own bot credentials yet.
  • The network sends the user back to PlanVortex, not to you. Its redirect_uri has to be registered in that network's own app settings, so it can never be a URL of yours. Where your user ends up afterwards is the redirect_uri you passed when minting the token.
  • A connected account is not an enabled account. One authorization can produce several — a Facebook user with four pages — and none of them takes a plan slot or publishes until it is enabled.

How do you read comments and messages?

They are two different inboxes, not one: a comment hangs off a publication and its author may be somebody you can never write to; a message hangs off a contact.

And within comments there are two reads, which is what to be clear about before painting a screen:

// THE INBOX: served from PlanVortex's database. Free, fast, and a photograph.
const { data: comments } = await pv.comments.list(orgId, { unread: true, rating: [1, 2] });

// THE THREAD: asked of the network right now and reconciled with what was stored.
const thread = await pv.comments.thread(orgId, publicationId);
thread.credits_consumed; // on X, one credit per comment returned; zero everywhere else

To paint a list, the inbox. To open a conversation, the thread. Chaining thread pages in a loop is how you run up a bill by accident, which is why the thread is the one read in the library that has no iterator.

Before painting a button it is worth asking what that network allows, because they do not all allow the same things:

const actions = await pv.comments.actions("instagram");
actions.reply;          // true
actions.delete_others;  // false: on Instagram you cannot delete somebody else's comment

Instagram, X and Bluesky do not let you delete somebody else's comment; LinkedIn has no "hide"; Google Business only lets you delete your own reply to a review. And a Google Business review can arrive with stars and not one line of text, which is not a loading failure: it is a stars-only review.

How do you receive webhooks?

PlanVortex POSTs to your app's URL when something happens. The body is an array of changes, and each one carries a field saying what it is.

import express from "express";
import { planvortexWebhooks, isCommentChange } from "planvortex/webhooks";

const app = express();

app.post(
    "/webhooks/planvortex",
    planvortexWebhooks({
        secret: process.env.PLANVORTEX_CLIENT_SECRET,
        onChanges: async (changes) => {
            for (const change of changes) {
                if (isCommentChange(change)) await moderate(change.commentObj);
            }
        },
    }),
);

There is a classic trap here, and it fails silently: the signature is computed over the raw body, not over a re-serialized copy of the parsed JSON. The bytes differ and the signature never matches. A global express.json() in front of that route is exactly what breaks it. The library's middleware reads the stream itself if nothing has touched it first; outside Express there is a framework-agnostic function you hand the raw body and the headers.

Why does every error arrive as HTTP 400?

Because the real code travels in the body, in code, and the status is only the envelope. An expired token, a disconnected account, an exhausted plan quota and a text that is too long all arrive as a 400. Only 520, the permissions one, answers 401.

The practical consequence: classify by code, never by status. An if (res.status === 401) refresh() never fires when the token dies, because the token errors are 501 and 522 inside a 400.

The library turns every error body into a class according to the range the code falls in:

Codes What went wrong Class
500-542 Authentication, tokens, permissions, apps AuthError
700-715 Social accounts: disconnected, revoked, no slot left AccountError
800-810 Files FileError
900-960 Publications, including every per-network limit PublicationError
1100-1111 Organizations OrganizationError
1300-1408 Plan quota exhausted PlanLimitError
1500-1512 Messaging MessagingError
2000-2299 Products, AI plans, integrations ProductError, AiPlanError, IntegrationError
try {
    await pv.publications.create(orgId, accountId, { social_network: "instagram", text: "..." });
} catch (error) {
    if (error instanceof PlanLimitError) {
        // Quota exhausted. Retrying fixes nothing: the plan has to grow.
    }
}

A code outside every range — the catalogue grows — arrives as the base class, with its code and message untouched. It is never swallowed and never renamed.

Where to go next

  • The full reference: every method, option and type, generated from the source.
  • The API documentation: the endpoints, their parameters and their responses, for integrating without the library or from another language.
  • The repository: the runnable examples — publishing, the comment inbox, webhooks and the connection flow — are in examples/, and each one starts with npx tsx.
  • What people build on top: what the API is being used for.

And one recommendation that saves an afternoon: build the account connection flow on day one, even if you publish by hand at first. It is the one piece of this API whose shape cannot be changed later without touching your whole product, because it decides where the person who authorizes lives.

Frequently asked questions
What do I need to use the PlanVortex API from Node.js?

An app created in your client panel, which gives you a client_id and a client_secret, and the planvortex package installed with npm. Apps are available on the Custom plan. The library needs Node 20 or newer, because it uses fetch, FormData, Blob and fs.openAsBlob as globals, and it has no runtime dependencies at all.

Can the PlanVortex library be used in a browser or in React?

No, and the reason is security rather than technology. Authenticating uses the client_credentials flow, which requires the client_secret, and a secret inside a front-end bundle is your whole account handed over: anyone who opens the developer tools takes it. The package is server-side. To let a person connect their own social account from a browser there is the temporal connect token, which lasts an hour, is tied to a single organization and can only create accounts.

Can my application connect a client's Instagram account through the API?

Not directly, and no tool can: authorizing Instagram is an OAuth flow with a person in front of it, so the connection endpoints refuse app credentials with error 519. What your server can do is mint a one-hour temporal connect token and send that person to the URL it returns. They authorize, and the account appears in your organization without your client_secret ever leaving your server.

Why does the PlanVortex API answer HTTP 400 on every error?

Because the real code travels in the body, in the code field, and the status is only the envelope. An expired token, a disconnected account, an exhausted plan quota and a text that is too long all arrive as a 400. Only error 520, the permissions one, answers 401. That is why you classify by code and never by status: an if (res.status === 401) refresh() never fires when the token dies, because the token errors are 501 and 522 inside a 400.

Which social networks does the API cover?

Ten: Facebook, Instagram, LinkedIn, TikTok, X (Twitter), WhatsApp, YouTube, Google Business Profile, Bluesky and Discord. They do not all do the same things — Google Business does not publish, WhatsApp has no comments, Discord has no direct messages — so the API publishes its own capability matrix and the library caches it. You never keep your own table of what each network does.

FJ
Francisco José Fernández-Medina López

Software developer and entrepreneur. He builds PlanVortex, the API other software companies use to integrate publishing to Facebook, Instagram, X, LinkedIn, TikTok, YouTube and WhatsApp inside their own product. A parent and a video game enthusiast.

LinkedIn profile
All resources
PlanVortex
Developed by TaliaSoftWorks S.A.
Phone: 640 29 96 58
About PlanVortexLegalPrivacy
FacebookTwitterInstagram