Publicar en redes sociales desde Node.js sin montar diez integraciones
Publicar en diez redes sociales desde tu aplicación son diez integraciones OAuth, diez formatos de vídeo distintos y diez maneras de caducar un token. O son diez líneas de Node contra una API. Esta guía es la segunda opción: la librería oficial de PlanVortex para Node.js, de las credenciales al primer post programado, y después las cuatro cosas que nadie adivina solo.
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, y conviene saberlo antes de escribir nada:
| 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í |
Esa primera fila es la que más gente se salta. El paquete es de servidor: en un backend de Node, en una función serverless, en un cron. No 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 va en el plan Custom.
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. Es el trabajo aburrido que nadie quiere volver a escribir.
¿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 y Google Business no salen aquí, porque
// no publican.
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"),
});
Eso es todo lo que hay que escribir para programar un post. 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 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 tan tranquila.
if (publication.state === "withErrors") {
console.error(publication.publication_errors.map((error) => error.message));
}
Es la trampa número uno de integrar esta API, y es deliberada: un try/catch alrededor de create() reporta como 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 — y por eso son los buenos. Un emoji de familia son once unidades de UTF-16, un grafema y veinticinco bytes: contar con .length se equivoca en las dos direcciones, y por eso Bluesky lleva dos cuentas en unidades distintas. 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?
Esta es la pieza que nadie adivina solo, y la que decide la forma de toda la integración: una app no puede conectar una cuenta social. Ni la tuya ni la de nadie. Autorizar Instagram es un OAuth con una persona delante, así que esos endpoints rechazan credenciales de app con el error 519.
Lo que hace tu servidor es emitir un token temporal de conexión y mandar 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 una hora, va atado a una sola organización y lleva dos permisos: crear cuentas y leer la organización. Nada más. Tu client_secret no sale de tu servidor en ningún momento, y tu usuario no necesita tener cuenta en PlanVortex.
Tres cosas que sorprenden a todo el mundo aquí:
- Una red que no se puede conectar ahora mismo simplemente no aparece en la lista de enlaces. Es una respuesta, no 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 jamás puede ser una URL tuya. Dónde acaba tu usuario después es 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?
Son dos bandejas distintas, no una: 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.
Y dentro de los comentarios hay dos lecturas, que es lo que hay que tener claro antes de pintar una pantalla:
// 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, rating: [1, 2] });
// 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
Para pintar una lista, la bandeja. Para abrir una conversación, el hilo. Encadenar páginas del hilo en un bucle es como se hace una factura sin querer, 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; LinkedIn no tiene «ocultar»; Google Business solo deja borrar tu propia respuesta a una reseña. Y una reseña de Google Business puede llegar con estrellas y sin una línea de texto, lo cual no es un error de carga: es una reseña de solo estrellas.
¿Cómo se reciben los webhooks?
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);
}
},
}),
);
Hay una trampa clásica aquí, y falla en silencio: 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. Un express.json() global delante de esa ruta es exactamente lo que la rompe. 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.
La consecuencia práctica: clasifica 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. Nunca se traga ni se 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.
Y una recomendación que ahorra una tarde: 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. La app se crea en el plan Custom. 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, y no es una limitación técnica sino de 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 una hora, 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 una hora 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?
Diez: Facebook, Instagram, LinkedIn, TikTok, X (Twitter), WhatsApp, YouTube, Google Business Profile, Bluesky y Discord. No todas hacen lo mismo — Google Business no publica, WhatsApp no tiene comentarios, Discord no tiene mensajes directos —, así que la API publica su propia matriz de capacidades y la librería la cachea. Nunca hay que mantener una tabla propia de qué red hace qué.