Publishing to social media from Python without building ten integrations

Publishing to ten social networks from a Python application is ten OAuth integrations, ten video formats and ten ways for a token to expire. Or it is ten lines against one API. This guide is the second option: the official PlanVortex library for Python, from credentials to a scheduled post, and then the four things nobody works out on their own.
pip install planvortex
What does the PlanVortex library do, and what does it not?
It is a server-side client for the PlanVortex API, with one runtime dependency (httpx2) and types generated from the same OpenAPI specification published in the documentation. It needs Python 3.10 or newer, and it comes in two flavours with the same surface: PlanVortex and AsyncPlanVortex.
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 |
|---|---|
| Run in a browser or a notebook you share | Authenticating needs the client_secret, and a secret anyone can read 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 belongs in a Django or FastAPI backend, a serverless function, a Celery task or a cron job. Not in a Streamlit app your users open.
Authenticating: two environment variables
Your credentials belong to a client app, which you create in the panel. It gives you a client_id and a client_secret, and the library reads them from the environment so nothing ends up hardcoded in a repository:
export PLANVORTEX_CLIENT_ID=...
export PLANVORTEX_CLIENT_SECRET=...
from planvortex import PlanVortex
pv = PlanVortex()
That is all. The token is fetched on the first call, cached, and renewed before it expires — you never touch /oauth/token yourself, and you never write the refresh loop that everybody writes wrong the first time.
Two habits that cost nothing and save an afternoon:
# The context manager closes the connection pool when you are done.
with PlanVortex() as pv:
...
# And keep ONE instance for the process. A new client per request throws away the cached
# token and the pool, so every request pays for a fresh handshake.
An app sees its own client and that client's organizations, and nothing else. There is no way to reach somebody else's data with your credentials, which is also why you cannot use these credentials to connect an account: more on that below.
Publishing: upload, then publish
Two calls, and the second one takes the identifier the first returns.
from datetime import datetime, timedelta, timezone
upload = pv.uploads.create(org_id, "./sourdough.jpg")
publication = pv.publications.create(
org_id,
account_id,
{
"social_network": "instagram",
"text": "New oven, new loaves",
"files": [upload["_id"]],
"publish_date": datetime.now(timezone.utc) + timedelta(hours=1),
},
)
A file can be a path, an open binary file, bytes, or a (name, bytes) pair. A path is the one that does not go through memory: the library opens it, streams it and closes it, which matters the day somebody uploads a 200 MB video.
publish_date takes a datetime as well as an ISO-8601 string, and it has to carry a timezone. A naive one raises instead of being guessed at, and that is deliberate: assuming UTC publishes at the wrong time for whoever is in Madrid, and assuming the process's local zone does it for whoever is in Docker. Both guesses are wrong for somebody, and both fail silently — the post simply goes out an hour off.
With no publish_date at all, the publication goes out in that same request, and the answer already says whether it did.
The thing that surprises everybody: an invalid post is not an exception
if publication["state"] == "withErrors":
for failure in publication["publication_errors"]:
print(failure["code"], failure["message"])
A publication whose content the network will not accept — a text over the limit, a YouTube video with no title, a vertical image where the network wants a square — comes back saved, with a 200, in state withErrors, and the reason inside. It is not a failure of your request: your request was fine, the content is not.
So a try/except around create() catches nothing, and the publication sits there never going out while your logs stay clean. Check the state. Always.
publication_errors[].code is a PlanVortex catalogue code, never an HTTP status.
Sync or async, and why it is not a wrapper
The same code in async changes three things and no more — the class, an await, and aiterate where the sync one says iterate:
from planvortex import AsyncPlanVortex
async with AsyncPlanVortex() as pv:
page = await pv.accounts.list(org_id, limit=50)
async for publication in pv.publications.aiterate(org_id, state=["ready"]):
...
The synchronous client is generated from the asynchronous one, and CI regenerates it on every push and fails if the committed one differs. That is worth a sentence because the obvious shortcut — writing the async one and wrapping it in asyncio.run() — is broken: it raises the moment it is called from inside a running event loop, which is to say from inside FastAPI, from inside a notebook, and from inside half the places this package gets used.
Listings give you a page, and there is an iterator for when you want them all:
page = pv.accounts.list(org_id, limit=50)
page.data, page.total
for publication in pv.publications.iterate(org_id, state=["ready"]):
...
The iterator has a hard page cap, so a server that ignored offset fails instead of spinning forever.
Connecting your users' accounts
This is the one flow the library cannot finish on its own, and no tool can: it ends with a person pressing "allow" on Instagram's page. Trying it with app credentials answers error 519.
What your server does is mint a token and hand over a URL:
connection = pv.organizations.create_connect_token(org_id)
# connection["url"] is where you send the person. Your client_secret never leaves your server.
person = pv.as_temporal_token(connection["token"])
for link in person.accounts.connect_links(org_id):
...
Four things about that token, and each one bites separately: it lasts fifteen minutes, it is single-use, it is tied to one organization, and it cannot issue another one. Saving it for "next time" fails four different ways. Issue a fresh one per connection — they are free and instant.
And one that trips people without giving an error: branch on link["authorization"]["type"], never on the link itself. WhatsApp's link is the empty string, because its sign-up is Meta's Embedded Signup popup rather than an OAuth redirect. Code that walks the list redirecting to link sends that user straight back to your own page, with no error anywhere.
When the person comes back, accounts arrive disabled: they take no plan slot and publish nothing until you enable each one. And a single authorization can leave several — a Facebook user with four pages is four accounts — which is why there is a choosing step in the middle.
The comment inbox, and the one that costs money
There are two reads here, and telling them apart is the whole section:
# The INBOX. Comes out of PlanVortex's database: free, fast, and a photograph of the last time
# the network was read.
for comment in pv.comments.iterate(org_id, unread=True, rating=[1, 2]):
print(comment["rating"], comment["text"])
# The THREAD. Asks the network right now — and on X that costs one credit per comment returned.
thread = pv.comments.thread(org_id, publication_id)
thread["credits_consumed"]
Painting a list with the second one is how you run up a bill without noticing. Use the inbox for the list and the thread for the conversation you have opened.
Before painting a button, ask what the network allows. It does not follow from "this network has comments": Instagram, X and Bluesky do not let you delete somebody else's, LinkedIn has no "hide", and Google Business only lets you delete your own reply.
if (pv.comments.actions_for("linkedin") or {}).get("hide"):
...
Two shapes that catch people out: a comment's text can be empty — a Google Business review with only stars carries none — and rating only exists on review networks, so its absence means "this network has no stars", never zero.
Webhooks: the raw body, and nothing else
PlanVortex POSTs to your app when an account changes state, a message or a comment comes in, or an integration stops working. Two things trip up everybody, so they go first.
The body is an array of changes, not an object. And the signature is computed over the raw bytes — if your framework already parsed the JSON and you serialise it again, one extra space or one reordered key changes the signature and verification fails forever. request.json will not do.
The line that gives you the raw body is the only line that differs between frameworks:
# Flask
@app.post("/webhooks/planvortex")
def planvortex_webhook():
changes = handle_webhook_request(
body=request.get_data(), # raw! never request.json
headers=request.headers,
secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
)
for change in changes:
if is_comment_change(change):
moderate(change.get("commentObj"))
return "", 200
In FastAPI it is await request.body(), and in Django request.body plus a @csrf_exempt, because PlanVortex does not carry anybody's CSRF token.
Narrow with the predicates — is_account_state_change, is_message_change, is_comment_change, is_integration_error_change — and let anything else fall through. The event list grows, and a field this release has never heard of is not an error: a receiver that crashes on an unknown event crashes in production on a Tuesday.
Two more that are learned the hard way. Meta repeats deliveries, so deduplicate on commentObj["external_id"] before you act. And PlanVortex does not retry a failed delivery: a 500 of yours loses that event, so answer first and queue the slow work — then use pv.comments.list to catch up on whatever you missed.
Errors: classify by code, never by status
Every domain error in this API travels with a 400. The real code is in the body, and the library turns each range into its own exception so you can catch a family without memorising numbers:
from planvortex import PlanLimitError, PlanVortexError
try:
...
except PlanLimitError:
... # 1300-1307 and 1400-1408. Retrying does not fix this; changing plan does.
except PlanVortexError as error:
error.code, error.family, error.message, error.data, error.status
AuthError, AccountError, FileError, PublicationError, OrganizationError, MessagingError, ContactError, ProductError, AiPlanError, IntegrationError and PlanLimitError cover the catalogue; anything outside it arrives as the base class with its family filled in. Two more are not the API's answer at all: PlanVortexConnectionError (it never got there — already retried, on the methods where retrying is safe) and PlanVortexConfigError (something is wrong on your side, like a missing client_secret).
The trap here is writing if error.status == 401: refresh(). It never fires, because the token errors are 501 and 522 inside a 400.
Where to go next
The package ships five runnable examples, each one the whole of its path: publishing, the calendar, the comment inbox, a webhook receiver with no dependencies, and the connection flow. The comment one only reads unless you set PLANVORTEX_ALLOW_REPLY=1, because replying is public, immediate, and reaches a person.
- The library's reference
- The API documentation, and the OpenAPI document if you would rather generate your own client
- Creating an app
- The same guide for Node.js
What do I need to use the PlanVortex API from Python?
An app created in your client panel, which gives you a client_id and a client_secret, and the planvortex package installed with pip. Apps are available on the Custom plan. The library needs Python 3.10 or newer and has a single runtime dependency, httpx2 — a different package from httpx classic, so it will not collide with whatever your project already has.
Does the PlanVortex Python library work with asyncio, or only synchronously?
Both, with the same surface. PlanVortex is the synchronous client and AsyncPlanVortex the asynchronous one, and the only differences in your code are the class name, an await in front of each call, and aiterate instead of iterate when you chain pages. The synchronous client is generated from the asynchronous one, so they cannot drift apart: it is not a wrapper that runs an event loop, which would break inside FastAPI or a notebook.
Can I use the PlanVortex library in Django or FastAPI?
Yes, and that is what it is for — it is a server-side client. In FastAPI use AsyncPlanVortex and keep one instance for the whole process, because a new one per request throws away the token cache and the connection pool. In Django, the synchronous one. For receiving webhooks, handle_webhook_request takes the raw body and the headers your framework already has, and there is one line of difference between Flask, FastAPI and Django.
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. The library turns each range into its own exception — AuthError, PublicationError, PlanLimitError — so you catch a family without memorising numbers, and never by status.
Are the library's types real, or does everything come back as a dict?
They are real and they are checked: the shapes are TypedDict generated from the same OpenAPI specification the API publishes, the package ships py.typed, and mypy --strict runs over the whole thing including the examples. They are TypedDict and not pydantic models for one concrete reason: the primary key of every PlanVortex resource is called _id, and pydantic cannot have a field whose name starts with an underscore. So it stays publication["_id"], exactly as the documentation says.