Publicar en redes sociales desde Python sin montar una integración por red

Publicar en todas las redes sociales desde una aplicación en Python pide, por cada red, una integración OAuth, un formato de vídeo y una forma distinta de caducar el token. Contra una sola API son unas líneas. Esta guía lleva la librería oficial de PlanVortex para Python de las credenciales a una publicación programada, y después cubre la conexión de cuentas, los comentarios, los webhooks y los errores.
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:
| 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í |
Por la primera fila, 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, y nunca en una app de Streamlit que abren tus usuarios.
¿Cómo se autentica?
Con dos variables de entorno. Tus credenciales son de una app de cliente, que creas en el panel y que te da un client_id y un client_secret. 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()
Con eso basta. El token se pide en la primera llamada, se cachea y se renueva antes de caducar, así que no tocas /oauth/token ni escribes el bucle de refresco.
Conviene usar el gestor de contexto y guardar una sola instancia:
# 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: con tus credenciales no se llega a los datos de otro. Por eso mismo tampoco sirven para conectar una cuenta.
¿Cómo se publica?
Con dos llamadas: la primera sube el fichero y la segunda crea la publicación con 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 única que no pasa por memoria: la librería abre el fichero, 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 una excepción, a propósito: suponer UTC publica a la hora equivocada para quien está en Madrid, y suponer la zona del proceso hace lo mismo para quien está en Docker. En ninguno de los dos casos salta un error: el post 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ó.
¿Qué pasa si la red no acepta la publicación?
Que no salta ninguna excepción. La publicación vuelve guardada, con un 200, en estado withErrors y con el motivo dentro:
if publicacion["state"] == "withErrors":
for fallo in publicacion["publication_errors"]:
print(fallo["code"], fallo["message"])
Pasa cuando la red no acepta el contenido (un texto pasado de largo, un vídeo de YouTube sin título, una imagen vertical donde la red quiere un cuadrado). Tu petición estaba bien; lo que falla es el contenido.
Así que un try/except alrededor de create() no captura nada, y la publicación se queda sin salir mientras tus logs siguen limpios. Hay que mirar el estado después de cada create().
publication_errors[].code es un código del catálogo de PlanVortex, nunca un status HTTP.
¿Qué cambia entre el cliente síncrono y el asíncrono?
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; la CI lo regenera en cada push y falla si el commiteado no coincide. No es una envoltura con asyncio.run(), que es el atajo obvio y está roto: lanza en cuanto se le llama desde un bucle de eventos que ya está corriendo, como el de FastAPI o el de un notebook.
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.
¿Cómo se conectan las cuentas de tus usuarios?
Tu servidor emite un token y le entrega una URL a la persona. 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.
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):
...
El token dura quince minutos, es de un solo uso, está atado a una organización y no puede emitir otro, así que guardarlo para "la próxima vez" no sirve. Emite uno por conexión: son gratis e inmediatos.
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, y el código que recorre la lista redirigiendo al link manda a ese usuario de vuelta a tu propia página sin dar ningún error.
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.
¿Cómo se leen los comentarios sin gastar créditos?
Con la bandeja, que sale de la base de datos de PlanVortex. El hilo, en cambio, pregunta a la red en ese momento:
# 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):
print(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"]
Si pintas una lista con el hilo, cada comentario de X que devuelve te cuesta un crédito. Usa la bandeja para la lista y el hilo para la conversación que has abierto.
En X se nota porque su API cobra por llamada, no con una cuota fija por cuenta conectada.
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, y LinkedIn no tiene "ocultar".
if (pv.comments.actions_for("linkedin") or {}).get("hide"):
...
¿Cómo se reciben los webhooks?
Leyendo el cuerpo crudo de la petición, porque la firma se calcula sobre esos bytes. 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.
El cuerpo es un array de cambios, no un objeto. Y 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 cosas más. 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, encola el trabajo lento y usa pv.comments.list para recuperar lo que se perdiera.
¿Cómo se manejan los errores?
Por código, nunca por status. Todos los errores de dominio de esta API viajan con un 400 y el código de verdad va en el cuerpo. 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).
Por eso if error.status == 401: refrescar() no salta nunca: 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 en los cuatro planes, incluido el gratuito. 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?
Con las dos, 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í, porque es un cliente de servidor. En FastAPI usa AsyncPlanVortex y guarda una sola 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. Lo que no hay que hacer es clasificar 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.