Publicar en redes sociales desde Python sin montar diez integraciones

Publicar en diez redes sociales desde una aplicación en Python son diez integraciones OAuth, diez formatos de vídeo y diez formas de que caduque un token. O son diez líneas contra una sola API. Esta guía es la segunda opción: la librería oficial de PlanVortex para Python, de las credenciales a una publicación programada, y luego las cuatro cosas que nadie deduce solo.
pip install planvortex
Qué hace la librería de PlanVortex, y qué no
Es un cliente de servidor de la API de PlanVortex, con una sola dependencia en ejecución (httpx2) y tipos generados del mismo OpenAPI que se publica en la documentación. Necesita Python 3.10 o superior, y viene en dos sabores con la misma superficie: PlanVortex y AsyncPlanVortex.
Cubre la API entera: conectar cuentas, subir ficheros, publicar y programar, las bandejas de comentarios y de mensajes, contactos, productos, integraciones, planes de IA, el panel de métricas y los webhooks.
Lo que no hace, y conviene saber antes de escribir nada:
| No hace | Por qué |
|---|---|
| Funcionar en un navegador ni en un notebook que compartes | Autenticarse necesita el client_secret, y un secreto que cualquiera puede leer es tu cuenta entregada |
| Conectar cuentas sociales por su cuenta | Autorizar Instagram es un flujo 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 se salta. Este paquete va en un backend de Django o de FastAPI, en una función serverless, en una tarea de Celery o en un cron. No en una app de Streamlit que abren tus usuarios.
Autenticarse: dos variables de entorno
Tus credenciales son de una app de cliente, que creas en el panel. Te da un client_id y un client_secret, y la librería los lee del entorno para que no acaben escritos en un repositorio:
export PLANVORTEX_CLIENT_ID=...
export PLANVORTEX_CLIENT_SECRET=...
from planvortex import PlanVortex
pv = PlanVortex()
Ya está. El token se pide en la primera llamada, se cachea y se renueva antes de caducar — nunca tocas /oauth/token, ni escribes el bucle de refresco que todo el mundo escribe mal la primera vez.
Dos costumbres que no cuestan nada y ahorran una tarde:
# El gestor de contexto cierra el pool de conexiones al terminar.
with PlanVortex() as pv:
...
# Y guarda UNA instancia para el proceso. Un cliente nuevo por petición tira el token
# cacheado y el pool, así que cada petición paga un saludo entero.
Una app ve su cliente y las organizaciones de ese cliente, y nada más. No hay forma de llegar a los datos de otro con tus credenciales, que es también por lo que no puedes usar estas credenciales para conectar una cuenta: eso viene más abajo.
Publicar: subir y publicar
Dos llamadas, y la segunda recibe el identificador que devuelve la primera.
from datetime import datetime, timedelta, timezone
upload = pv.uploads.create(org_id, "./hogaza.jpg")
publicacion = pv.publications.create(
org_id,
account_id,
{
"social_network": "instagram",
"text": "Nuevo horno, nuevas hogazas",
"files": [upload["_id"]],
"publish_date": datetime.now(timezone.utc) + timedelta(hours=1),
},
)
Un fichero puede ser una ruta, un fichero binario abierto, bytes, o un par (nombre, bytes). La ruta es la que no pasa por memoria: la librería lo abre, lo va mandando y lo cierra, que es lo que importa el día que alguien sube un vídeo de 200 MB.
publish_date acepta un datetime además de una cadena ISO-8601, y tiene que llevar zona horaria. Uno sin ella lanza en vez de que se lo adivine nadie, y es a propósito: dar por hecho UTC publica a la hora equivocada para quien está en Madrid, y dar por hecha la zona del proceso lo hace para quien está en Docker. Las dos son erróneas para alguien, y las dos fallan en silencio — el post simplemente sale una hora antes o después.
Sin publish_date, la publicación sale en esa misma petición, y la respuesta ya dice si salió.
Lo que sorprende a todo el mundo: una publicación inválida no es una excepción
if publicacion["state"] == "withErrors":
for fallo in publicacion["publication_errors"]:
print(fallo["code"], fallo["message"])
Una publicación cuyo contenido la red no acepta —un texto pasado de largo, un vídeo de YouTube sin título, una imagen vertical donde la red quiere un cuadrado— vuelve guardada, con un 200, en estado withErrors, y con el motivo dentro. No es un fallo de tu petición: tu petición estaba bien, el contenido no.
Así que un try/except alrededor de create() no captura nada, y la publicación se queda ahí sin salir mientras tus logs siguen limpios. Mira el estado. Siempre.
publication_errors[].code es un código del catálogo de PlanVortex, nunca un status HTTP.
Síncrono o asíncrono, y por qué no es una envoltura
El mismo código en asíncrono cambia tres cosas y ninguna más — la clase, un await, y aiterate donde el síncrono pone iterate:
from planvortex import AsyncPlanVortex
async with AsyncPlanVortex() as pv:
pagina = await pv.accounts.list(org_id, limit=50)
async for publicacion in pv.publications.aiterate(org_id, state=["ready"]):
...
El cliente síncrono está generado del asíncrono, y la CI lo regenera en cada push y falla si el commiteado no coincide. Merece una frase porque el atajo obvio —escribir el asíncrono y envolverlo en asyncio.run()— está roto: lanza en cuanto se le llama desde dentro de un bucle de eventos que ya está corriendo, o sea desde dentro de FastAPI, desde dentro de un notebook, y desde dentro de la mitad de los sitios donde se usa este paquete.
Los listados devuelven una página, y hay un iterador para cuando las quieres todas:
pagina = pv.accounts.list(org_id, limit=50)
pagina.data, pagina.total
for publicacion in pv.publications.iterate(org_id, state=["ready"]):
...
El iterador tiene un tope duro de páginas, así que un servidor que ignorase el offset falla en vez de girar para siempre.
Conectar las cuentas de tus usuarios
Es el único flujo que la librería no puede terminar sola, y ninguna herramienta puede: acaba con una persona pulsando "permitir" en la página de Instagram. Intentarlo con credenciales de app contesta el error 519.
Lo que hace tu servidor es emitir un token y entregar una URL:
conexion = pv.organizations.create_connect_token(org_id)
# conexion["url"] es adonde mandas a la persona. Tu client_secret no sale de tu servidor.
persona = pv.as_temporal_token(conexion["token"])
for enlace in persona.accounts.connect_links(org_id):
...
Cuatro cosas de ese token, y cada una muerde por su cuenta: dura quince minutos, es de un solo uso, está atado a una organización, y no puede emitir otro. Guardártelo para "la próxima vez" falla de cuatro formas distintas. Emite uno por conexión — son gratis e inmediatos.
Y una que hace tropezar sin dar error: ramifica por enlace["authorization"]["type"], nunca por el link. El de WhatsApp es la cadena vacía, porque su alta es el popup Embedded Signup de Meta y no un OAuth con redirección. El código que recorre la lista redirigiendo al link manda a ese usuario de vuelta a tu propia página, y no hay error en ningún sitio.
Cuando la persona vuelve, las cuentas llegan deshabilitadas: no ocupan plaza del plan ni publican nada hasta que habilitas cada una. Y una sola autorización puede dejar varias — un Facebook con cuatro páginas son cuatro cuentas—, que es por lo que hay una pantalla de elegir en medio.
La bandeja de comentarios, y la que cuesta dinero
Aquí hay dos lecturas, y distinguirlas es la sección entera:
# LA BANDEJA. Sale de la base de datos de PlanVortex: gratis, rápida, y una foto de la última
# vez que se leyó la red.
for comentario in pv.comments.iterate(org_id, unread=True, rating=[1, 2]):
print(comentario["rating"], comentario["text"])
# EL HILO. Pregunta a la red en ese momento — y en X cuesta un crédito por comentario devuelto.
hilo = pv.comments.thread(org_id, publication_id)
hilo["credits_consumed"]
Pintar una lista con la segunda es como se hace una factura sin querer. La bandeja para la lista, el hilo para la conversación que has abierto.
Antes de pintar un botón, pregunta qué permite la red. No se deduce de "esta red tiene comentarios": Instagram, X y Bluesky no dejan borrar el de otro, LinkedIn no tiene "ocultar", y Google Business solo deja borrar tu propia respuesta.
if (pv.comments.actions_for("linkedin") or {}).get("hide"):
...
Dos formas que pillan a la gente: el texto de un comentario puede venir vacío —una reseña de Google Business de solo estrellas no lleva ninguno— y rating solo existe en las redes de reseñas, así que su ausencia significa "esta red no tiene estrellas", nunca cero.
Webhooks: el cuerpo crudo, y nada más
PlanVortex hace POST a tu aplicación cuando una cuenta cambia de estado, entra un mensaje o un comentario, o una integración deja de funcionar. Dos cosas hacen tropezar a todo el mundo, así que van primero.
El cuerpo es un array de cambios, no un objeto. Y la firma se calcula sobre los bytes crudos — si tu framework ya parseó el JSON y lo vuelves a serializar, un espacio de más o una clave reordenada cambian la firma y la verificación no cuadra nunca. request.json no vale.
La línea que te da el cuerpo crudo es lo único que cambia entre frameworks:
# Flask
@app.post("/webhooks/planvortex")
def planvortex_webhook():
cambios = handle_webhook_request(
body=request.get_data(), # ¡crudo! nunca request.json
headers=request.headers,
secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
)
for cambio in cambios:
if is_comment_change(cambio):
moderar(cambio.get("commentObj"))
return "", 200
En FastAPI es await request.body(), y en Django request.body más un @csrf_exempt, porque PlanVortex no manda el token de CSRF de nadie.
Estrecha con los predicados —is_account_state_change, is_message_change, is_comment_change, is_integration_error_change— y deja caer lo demás. La lista de eventos crece, y un field que esta versión no conoce no es un error: un receptor que se cae con un evento nuevo se cae en producción un martes.
Dos más que se aprenden por las malas. Meta repite entregas, así que deduplica por commentObj["external_id"] antes de tocar nada. Y PlanVortex no reintenta una entrega fallida: un 500 tuyo pierde ese evento, así que contesta primero y encola el trabajo lento — y luego usa pv.comments.list para recuperar lo que se perdiera.
Errores: clasifica por código, nunca por status
Todos los errores de dominio de esta API viajan con un 400. El código de verdad va en el cuerpo, y la librería convierte cada rango en su propia excepción para que captures una familia sin memorizar números:
from planvortex import PlanLimitError, PlanVortexError
try:
...
except PlanLimitError:
... # 1300-1307 y 1400-1408. Reintentar no lo arregla; cambiar de plan sí.
except PlanVortexError as error:
error.code, error.family, error.message, error.data, error.status
AuthError, AccountError, FileError, PublicationError, OrganizationError, MessagingError, ContactError, ProductError, AiPlanError, IntegrationError y PlanLimitError cubren el catálogo; lo que caiga fuera llega como la clase base con su family puesta. Dos más no son la respuesta de la API: PlanVortexConnectionError (no llegó — ya reintentado, en los métodos donde reintentar es seguro) y PlanVortexConfigError (algo está mal de tu lado, como un client_secret que falta).
La trampa aquí es escribir if error.status == 401: refrescar(). No salta nunca, porque los errores de token son el 501 y el 522 dentro de un 400.
Por dónde seguir
El paquete trae cinco ejemplos ejecutables, cada uno con su camino entero: publicar, el calendario, la bandeja de comentarios, un receptor de webhooks sin dependencias, y el flujo de conexión. El de comentarios solo lee salvo que pongas PLANVORTEX_ALLOW_REPLY=1, porque responder es público, inmediato, y le llega a una persona.
- La referencia de la librería
- La documentación de la API, y el documento OpenAPI si prefieres generarte tu propio cliente
- Crear una app
- La misma guía para Node.js
¿Qué necesito para usar la API de PlanVortex desde Python?
Una app creada en tu panel de cliente, que te da un client_id y un client_secret, y el paquete planvortex instalado con pip. Las apps están disponibles en el plan Custom. La librería necesita Python 3.10 o superior y tiene una sola dependencia en ejecución, httpx2 — un paquete distinto de httpx clásico, así que no choca con lo que ya tenga tu proyecto.
¿La librería de Python de PlanVortex funciona con asyncio o solo en síncrono?
Las dos cosas, y con la misma superficie. PlanVortex es el cliente síncrono y AsyncPlanVortex el asíncrono, y lo único que cambia en tu código es el nombre de la clase, un await delante de cada llamada, y aiterate en vez de iterate cuando encadenas páginas. El cliente síncrono se genera del asíncrono, así que no pueden separarse: no es una envoltura que arranque un bucle de eventos, que es lo que reventaría dentro de FastAPI o de un notebook.
¿Puedo usar la librería de PlanVortex en Django o en FastAPI?
Sí, y es para lo que está: es un cliente de servidor. En FastAPI usa AsyncPlanVortex y guarda UNA instancia para todo el proceso, porque una nueva por petición tira la caché del token y el pool de conexiones. En Django, el síncrono. Para recibir webhooks, handle_webhook_request recibe el cuerpo crudo y las cabeceras que tu framework ya tiene, y entre Flask, FastAPI y Django solo cambia una línea.
¿Por qué la API de PlanVortex contesta HTTP 400 en todos los errores?
Porque el código de verdad viaja en el cuerpo, en el campo code, y el status es solo el sobre. Un token caducado, una cuenta desconectada, una cuota de plan agotada y un texto demasiado largo llegan todos como un 400. La librería convierte cada rango en su propia excepción — AuthError, PublicationError, PlanLimitError — para que captures una familia sin memorizar números, y nunca por status.
¿Los tipos de la librería son reales o todo llega como un dict?
Son reales y están comprobados: las formas son TypedDict generados del mismo OpenAPI que publica la API, el paquete lleva py.typed, y mypy --strict pasa por todo, ejemplos incluidos. Son TypedDict y no modelos de pydantic por un motivo concreto: la clave primaria de todos los recursos de PlanVortex se llama _id, y pydantic no admite un campo cuyo nombre empiece por guion bajo. Así que se queda en publication["_id"], exactamente como lo dice la documentación.