Publicar en redes sociales desde Node.js sin montar una integración por red

Publicar en todas las redes sociales desde tu aplicación pide, por cada red, una integración OAuth, un formato de vídeo y una manera distinta de caducar el token. Contra una sola API son unas líneas de Node. Esta guía lleva la librería oficial de PlanVortex para Node.js de las credenciales al primer post programado, y después cubre los errores silenciosos, la conexión de cuentas, los comentarios, los webhooks y los códigos de error.
npm install planvortex
¿Qué hace la librería de PlanVortex y qué no?
Es un cliente de servidor para la API de PlanVortex, escrito en TypeScript, sin ninguna dependencia en tiempo de ejecución y con los tipos generados de la misma especificación OpenAPI que se publica en la documentación. Necesita Node 20 o superior, donde fetch, FormData, Blob y fs.openAsBlob ya son globales.
Cubre toda la API: conectar cuentas, subir ficheros, publicar y programar, la bandeja de comentarios y la de mensajes, contactos, productos, integraciones, planes de IA, el panel de métricas y los webhooks.
Lo que no hace:
| No hace | Por qué |
|---|---|
| Funcionar en el navegador | Autenticarse exige el client_secret, y un secreto en un bundle es la cuenta regalada |
| Conectar cuentas sociales por su cuenta | Autorizar Instagram es un OAuth con una persona delante |
Reintentar un POST que llegó al servidor |
Sin clave de idempotencia, reintentar es duplicar la publicación |
| Inventarse los límites de cada red | Los publica el servidor y la librería los lee de ahí |
Por la primera fila, el paquete va en un backend de Node, en una función serverless o en un cron, y nunca en un componente de React.
¿Cómo se consiguen las credenciales?
En tu panel de cliente, creando una app. Una app es acceso a la API (no confundir con las integraciones, que son conexiones a herramientas de terceros de las que sacas material), y está en los cuatro planes, incluido el gratuito: lo que cambia es cuántas caben (1 en free, 2 en basic, 5 en pro y hasta 10 en custom) y a qué ritmo puedes llamar.
Al crearla obtienes un client_id y un client_secret. Con eso:
import { PlanVortex } from "planvortex";
const pv = new PlanVortex({
clientId: process.env.PLANVORTEX_CLIENT_ID,
clientSecret: process.env.PLANVORTEX_CLIENT_SECRET,
});
No hay que pedir el token, ni cachearlo, ni vigilar cuándo caduca. La librería lo canjea en POST /oauth/token la primera vez que hace falta, lo guarda, lo renueva un minuto antes de que expire y, si veinte llamadas coinciden mientras no hay token, hace una sola petición de token y las deja esperando a esa.
¿Cómo se publica un post desde Node.js?
Tres pasos: elegir la cuenta, subir el fichero y crear la publicación.
// 1. Las cuentas con las que se puede publicar de verdad. El filtro lo resuelve el servidor
// con su propia matriz de capacidades: WhatsApp no sale aquí, porque no publica.
const { data: accounts } = await pv.accounts.list(orgId, { capability: "publications" });
const account = accounts[0];
// 2. El fichero. Una ruta en disco es la forma que NO lo pasa por memoria; un Buffer o un
// Blob también valen.
const upload = await pv.uploads.create(orgId, { file: "./hogaza.jpg" });
// 3. La publicación. Sin `publish_date` sale ahora; con ella, queda programada.
const publication = await pv.publications.create(orgId, account._id, {
social_network: account.social_network,
text: "Nuevo horno, nuevas hogazas",
files: [upload._id],
publish_date: new Date("2026-09-01T10:00:00Z"),
});
Con eso el post queda programado. Lo que pasa por debajo (renovar el token de la red, trocear el vídeo si esa red lo exige, reintentar cuando la red se cae, marcar la cuenta si el token murió) corre en el servidor de PlanVortex.
Todos los listados paginan igual, {data, total}, y todos tienen un iterador que encadena las páginas:
for await (const publication of pv.publications.iterate(orgId, { state: ["ready"] })) {
// ...
}
¿Por qué una publicación puede quedarse sin salir sin lanzar un error?
Porque el contenido no cabe en la red, y eso no es un fallo de la petición: la publicación se guarda en estado withErrors con el motivo dentro, y la llamada devuelve un 200.
if (publication.state === "withErrors") {
console.error(publication.publication_errors.map((error) => error.message));
}
Es así a propósito, y por eso un try/catch alrededor de create() da por publicado algo que nunca va a salir: un texto de 400 caracteres en Bluesky, un vídeo de dos minutos en un reel, una imagen con una proporción que Instagram no acepta. Hay que mirar el state.
Para no llegar ahí, los límites de cada red se piden y se validan antes:
const limits = await pv.catalog.socialLimits();
limits.characters.bluesky; // 300 grafemas
limits.max_post_bytes.bluesky; // y además 3.000 bytes
Los publica el servidor, que es quien los valida, así que coinciden con lo que se va a rechazar. Bluesky lleva dos cuentas en unidades distintas, y .length se equivoca con las dos, en direcciones opuestas: un emoji de familia son once unidades de UTF-16, un grafema y veinticinco bytes. La librería cachea el catálogo por instancia, así que consultarlo no cuesta una petición por publicación.
¿Cómo conecta un usuario tuyo su cuenta de Instagram?
Con un token temporal de conexión que emite tu servidor, porque una app no puede conectar una cuenta social: autorizar Instagram es un OAuth con una persona delante, así que esos endpoints rechazan credenciales de app con el error 519. Es la pieza que decide la forma de toda la integración.
Tu servidor emite el token y manda a esa persona a la URL que devuelve:
// En tu servidor, cuando tu usuario pulsa "conectar Instagram":
const connect = await pv.organizations.createConnectToken(orgId, {
// Tiene que ser uno de los `redirect_urls` registrados en tu app, o sale el error 532.
redirect_uri: "https://tu-app.example/listo",
});
response.redirect(connect.url); // PlanVortex se encarga y devuelve a tu usuario a tu URL
El token dura quince minutos, va atado a una sola organización y lleva solo dos permisos: crear cuentas y leer la organización. Tu client_secret no sale de tu servidor en ningún momento, y tu usuario no necesita tener cuenta en PlanVortex.
Tres detalles del flujo:
- Una red que no se puede conectar ahora mismo no aparece en la lista de enlaces, y no es un fallo: le pasa a Discord en una organización que todavía no ha guardado sus credenciales de bot.
- La red devuelve al usuario a PlanVortex, no a ti. El
redirect_uride la red tiene que estar registrado en la configuración de esa red, así que nunca puede ser una URL tuya. Dónde acaba tu usuario después lo decide elredirect_urique pasaste al crear el token. - Una cuenta conectada no es una cuenta activa. Una sola autorización puede producir varias (un usuario de Facebook con cuatro páginas), y ninguna ocupa plaza del plan ni publica hasta que se la habilita.
¿Cómo se leen los comentarios y los mensajes?
En dos bandejas distintas: un comentario cuelga de una publicación y su autor puede ser alguien a quien nunca podrás escribir; un mensaje cuelga de un contacto.
Dentro de los comentarios hay dos lecturas:
// LA BANDEJA: sale de la base de datos de PlanVortex. Gratis, rápida, y es una foto.
const { data: comments } = await pv.comments.list(orgId, { unread: true });
// EL HILO: se pregunta a la red en ese momento y se reconcilia con lo guardado.
const thread = await pv.comments.thread(orgId, publicationId);
thread.credits_consumed; // en X, un crédito por comentario devuelto; en el resto, 0
Usa la bandeja para pintar una lista y el hilo para abrir una conversación. Encadenar páginas del hilo en un bucle gasta créditos de X sin que te des cuenta, y por eso el hilo es la única lectura de la librería que no tiene iterador.
Antes de pintar un botón conviene preguntar qué deja hacer esa red, porque no todas dejan lo mismo:
const actions = await pv.comments.actions("instagram");
actions.reply; // true
actions.delete_others; // false: en Instagram no se borra el comentario de otro
Instagram, X y Bluesky no dejan borrar el comentario ajeno, y LinkedIn no tiene «ocultar».
¿Cómo se reciben los webhooks?
Con el middleware de la librería, que verifica la firma. PlanVortex hace un POST a la URL de tu app cuando pasa algo; el cuerpo es un array de cambios, y cada uno lleva un field que dice qué es.
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 moderar(change.commentObj);
}
},
}),
);
La firma se calcula sobre el cuerpo crudo, no sobre una copia re-serializada del JSON ya parseado: los bytes no son los mismos y la firma no cuadra nunca, sin más aviso. Basta un express.json() global delante de esa ruta para romperla. El middleware de la librería lee el flujo él mismo si nadie lo ha tocado antes; fuera de Express hay una función agnóstica a la que le pasas el cuerpo crudo y las cabeceras.
¿Por qué todos los errores llegan con un HTTP 400?
Porque el código de verdad viaja en el cuerpo, en code, y el status es solo el envoltorio. Un token caducado, una cuenta desconectada, un plan agotado y un texto demasiado largo llegan los cuatro con un 400. Solo el 520, el de permisos, contesta 401.
Por eso hay que clasificar por code, nunca por el status. Un if (res.status === 401) refresh() no se dispara jamás cuando el token muere, porque el error de token es el 501 o el 522 dentro de un 400.
La librería convierte cada cuerpo de error en una clase según el rango en el que cae el código:
| Códigos | Qué ha fallado | Clase |
|---|---|---|
| 500-542 | Autenticación, tokens, permisos, apps | AuthError |
| 700-715 | Cuentas sociales: desconectada, revocada, sin plaza | AccountError |
| 800-810 | Ficheros | FileError |
| 900-960 | Publicaciones, incluidos los límites de cada red | PublicationError |
| 1100-1111 | Organizaciones | OrganizationError |
| 1300-1408 | Cupo del plan agotado | PlanLimitError |
| 1500-1512 | Mensajería | MessagingError |
| 2000-2299 | Productos, planes de IA, integraciones | ProductError, AiPlanError, IntegrationError |
try {
await pv.publications.create(orgId, accountId, { social_network: "instagram", text: "..." });
} catch (error) {
if (error instanceof PlanLimitError) {
// Cupo agotado. Reintentar no arregla nada: hay que ampliar el plan.
}
}
Un código fuera de todos los rangos (el catálogo crece) llega como la clase base, con su code y su message intactos: la librería no se lo traga ni lo renombra.
¿Por dónde seguir?
- La referencia completa: cada método, cada opción y cada tipo, generados del código.
- La documentación de la API: los endpoints, sus parámetros y sus respuestas, por si integras sin la librería o desde otro lenguaje.
- El repositorio: los ejemplos ejecutables (publicar, bandeja de comentarios, webhooks y flujo de conexión) están en
examples/, y cada uno arranca connpx tsx. - Qué se puede construir encima: para qué usa la API la gente que ya la usa.
- La misma guía desde Python: el cliente síncrono y el asíncrono, si tu servidor no es Node.
- Comparativa con Ayrshare: si todavía estás eligiendo entre las dos APIs.
Una recomendación: monta el flujo de conexión de cuentas el primer día, aunque publiques a mano al principio. Es la única pieza de esta API cuya forma no se puede cambiar después sin tocar el producto entero, porque decide dónde vive la persona que autoriza.
¿Qué se necesita para usar la API de PlanVortex desde Node.js?
Una app creada en tu panel de cliente, que te da un client_id y un client_secret, y el paquete planvortex instalado con npm. Las apps están en los cuatro planes, incluido el gratuito. La librería necesita Node 20 o superior, porque usa fetch, FormData, Blob y fs.openAsBlob como globales, y no tiene ninguna dependencia en tiempo de ejecución.
¿Se puede usar la librería de PlanVortex en el navegador o en React?
No, por seguridad. Autenticarse usa el flujo client_credentials, que exige el client_secret, y un secreto dentro de un bundle de navegador es la cuenta entera regalada: cualquiera que abra las herramientas de desarrollo se lo lleva. La librería es de servidor. Para que una persona conecte su cuenta social desde el navegador existe el token temporal de conexión, que dura quince minutos, va atado a una sola organización y solo puede crear cuentas.
¿Puede mi aplicación conectar la cuenta de Instagram de un cliente por API?
No directamente, y ninguna herramienta puede: autorizar Instagram es un OAuth con una persona delante, así que los endpoints de conexión rechazan las credenciales de app con el error 519. Lo que sí puede hacer tu servidor es emitir un token temporal de conexión de quince minutos y mandar a esa persona a la URL que devuelve. Ella autoriza, y la cuenta aparece en tu organización sin que tu client_secret salga nunca de tu servidor.
¿Por qué la API de PlanVortex devuelve HTTP 400 en todos los errores?
Porque el código real viaja en el cuerpo, en el campo code, y el status es solo el envoltorio. Un token caducado, una cuenta desconectada, un plan agotado y un texto demasiado largo llegan los cuatro con un 400. Solo el error 520, el de permisos, contesta 401. Por eso hay que clasificar por code y nunca por el status: un if (res.status === 401) refresh() no se dispara jamás cuando el token muere, porque el error de token es el 501 o el 522 dentro de un 400.
¿Qué redes sociales cubre la API?
Facebook, Instagram, Threads, LinkedIn, TikTok, X (Twitter), WhatsApp, YouTube, Bluesky, Discord, Telegram, Slack y Pinterest. No todas hacen lo mismo (WhatsApp y Pinterest no tienen comentarios, Discord no tiene mensajes directos, un pin de Pinterest necesita un tablero), así que la API publica su propia matriz de capacidades y la librería la cachea. No hace falta mantener una tabla propia de qué red hace qué.