PlanVortex
  • Product
  • Pricing
  • API
  • Login
  • Get started
ProductSee the whole productUse casesComparisonResourcesMulti-network schedulingAI assistantAnalyticsAccount connectionDeveloper API
PricingAPILoginGet started
Language
Home/Resources/Publishing to social media from Python without building ten integrations
Guides

Publishing to social media from Python without building ten integrations

12 min read·Published on Aug 28, 2026
Also inEspañol
The same client on two lanes, sync and async, both reaching the same API

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
Frequently asked questions
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.

FJ
Francisco José Fernández-Medina López

Software developer and entrepreneur. He builds PlanVortex, the API other software companies use to integrate publishing to Facebook, Instagram, X, LinkedIn, TikTok, YouTube and WhatsApp inside their own product. A parent and a video game enthusiast.

LinkedIn profile
All resources
PlanVortex
Developed by TaliaSoftWorks S.A.
Phone: 640 29 96 58
About PlanVortexLegalPrivacy
FacebookTwitterInstagram