planvortex
The official Python client for the PlanVortex API — connect social accounts, schedule and publish posts, read comments and messages, and pull stats, from Python.
pip install planvortex
- Synchronous and asynchronous, same surface:
PlanVortexandAsyncPlanVortex. - Typed, from the same OpenAPI specification the API publishes at
https://planvortex.com/openapi.json. Returned shapes are
TypedDict, so_idstays_id. - One runtime dependency,
httpx2— a different package fromhttpxclassic, so it will not collide with whatever your project already uses. - Server-side. The
client_credentialsflow needs yourclient_secret, which must never reach a browser. Connecting an account from one is what the temporal connect token is for. - Python 3.10 and newer.
Reference: https://taliasoftworks.github.io/PlanVortexPython/ · Guides: planvortex.com/developers
Authentication
Your credentials are a client app's: you create one in the PlanVortex panel and it gives you a
client_id and a client_secret. They are read from the environment, so nothing is hardcoded:
export PLANVORTEX_CLIENT_ID=...
export PLANVORTEX_CLIENT_SECRET=...
# Only if you are not talking to production:
export PLANVORTEX_BASE_URL=http://localhost:3000/v1.0.0
from planvortex import PlanVortex
pv = PlanVortex() # from the environment
pv = PlanVortex(client_id="...", client_secret="...") # or explicitly
The token is fetched on the first call, cached, and renewed before it expires — you never touch
/oauth/token. Use the client as a context manager (with PlanVortex() as pv:) so the connection
pool closes, and keep one instance: a new one per request throws away the cache and the pool.
An app sees its own client and that client's organizations, and nothing else.
Publishing
from datetime import datetime, timedelta, timezone
from planvortex import PlanVortex
pv = PlanVortex()
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 publication that could not be built is NOT an exception: it comes back saved, in `withErrors`,
# with the reason inside. The content is validated against the network, and that is not a failure
# of your request.
if publication["state"] == "withErrors":
for failure in publication["publication_errors"]:
print(failure["code"], failure["message"])
publish_date takes a datetime as well as an ISO-8601 string, and it has to carry a timezone:
a naive one raises rather than being guessed at, because assuming UTC publishes at the wrong time
for whoever is in Madrid and assuming the process's zone does it for whoever is in Docker. With no
publish_date at all it goes out in that same request, and the answer already says whether it did.
A file can be a path, an open file, bytes, or a (name, bytes) pair. Per-network limits —
characters, images, video length, file size — come from pv.catalog.social_limits(), which is the
server's own copy: whoever enforces a limit is who gets to announce it.
Listing gives you a page, and there is a chaining 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 same code in async changes three things and no more — the class, an await, and aiterate:
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"]):
...
Connecting an account
Connecting is the one flow the library cannot finish on its own: it ends with a person pressing "allow" on Instagram's page. What the library does is hand you a URL to send them to.
connection = pv.organizations.create_connect_token(org_id)
# connection["url"] is where the person goes. Never send them your client_secret.
person = pv.as_temporal_token(connection["token"]) # a client that can only do this
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 one that trips people without giving an error: branch on link["authorization"]["type"], never
on the link. WhatsApp's is the empty string, because its sign-up is Meta's Embedded Signup popup
and not an OAuth redirect; walking the list redirecting to link sends your user to your own page.
Accounts come back disabled and take no plan slot until pv.accounts.enable(...), and one
authorization can leave several — a Facebook user with four pages is four of them.
Comments and messages
# The comments inbox comes out of PlanVortex's database: free, fast, and a photograph of the last
# time the network was read. The thread asks the network right then, and on X that costs credits.
for comment in pv.comments.iterate(org_id, unread=True, rating=[1, 2]):
print(comment["rating"], comment["text"])
thread = pv.comments.thread(org_id, publication_id)
thread["credits_consumed"] # real money on X, 0 everywhere else
# Before painting a button, ask what the network allows: they are not all the same.
if (pv.comments.actions_for("linkedin") or {}).get("hide"):
...
Errors
Errors are classified by code, never by the HTTP status — every domain error in this API
travels with a 400. Each range has its own exception class, so you can catch a family without
memorising numbers:
| Codes | Family | Exception |
|---|---|---|
| 500-544 | auth |
AuthError |
| 601-612 | user |
UserError |
| 700-715 | account |
AccountError |
| 800-810 | file |
FileError |
| 900-960 | publication |
PublicationError |
| 1000-1003 | general |
PlanVortexError |
| 1100-1111 | organization |
OrganizationError |
| 1200-1207 | role |
PlanVortexError |
| 1300-1307, 1400-1408 | plan_limit |
PlanLimitError |
| 1500-1512 | messaging |
MessagingError |
| 1600-1601 | contact |
ContactError |
| 1900-1906 | payment |
PlanVortexError |
| 2000-2099 | product |
ProductError |
| 2100-2199 | ai_plan |
AiPlanError |
| 2200-2299 | integration |
IntegrationError |
from planvortex import PlanLimitError, PlanVortexError
try:
...
except PlanLimitError as error:
... # not fixed by retrying: fixed by changing plan
except PlanVortexError as error:
error.code, error.family, error.message, error.data, error.status
The runtime list is PLANVORTEX_ERROR_RANGES. Two more that are not the API's answer:
PlanVortexConnectionError (it never got there — retried already, on the methods where retrying is
safe) and PlanVortexConfigError (something is wrong on this side, like a missing client_secret).
Webhooks
PlanVortex POSTs to your app when something happens: an account changed state, a message or a
comment came in, an integration stopped 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 body — if your framework already parsed the JSON and you serialise it again, the bytes are not the same ones and the signature never matches. The line that gives you the raw body is the only line of the recipe that changes:
# Flask
import os
from flask import request
from planvortex.webhooks import handle_webhook_request, is_comment_change
@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
# FastAPI
@app.post("/webhooks/planvortex")
async def planvortex_webhook(request: Request):
changes = handle_webhook_request(
body=await request.body(), # raw! never the parsed model
headers=request.headers,
secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
)
...
# Django
@csrf_exempt
def planvortex_webhook(request):
changes = handle_webhook_request(
body=request.body, # raw! never request.POST
headers=request.headers,
secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
)
...
handle_webhook_request raises WebhookSignatureError if the signature is missing or does not
match (answer 401) and WebhookBodyError if the body is not what it has to be (answer 400). If you
would rather do it in two steps, verify_webhook_signature(payload, signature, secret) returns a
plain True/False and parse_webhook_body(payload) gives you the changes.
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.
PlanVortex does not retry a failed delivery. A 500 of yours loses the event, so if your work is
slow, queue it and answer — and use pv.comments.list / pv.messages.list to catch up on anything
you missed.
The whole API, in fourteen resources
pv.catalog · pv.clients · pv.organizations · pv.accounts · pv.uploads · pv.publications
· pv.comments · pv.messages · pv.contacts · pv.products · pv.integrations · pv.ai_plans
· pv.dashboard · pv.apps
That is 112 of the 112 operations the specification documents — everything except the 19 routes of roles and invitations, which are out of scope. A script walks the OpenAPI bundle on every test run and fails if a route is left without a method, so the sentence above stays true.
Examples
Five runnable scripts, each one the whole of its path and with a test of its own:
examples/publish.py |
Credentials, quota, account, network limits, upload, scheduled publication. |
examples/schedule.py |
The calendar: what is queued, moving it, and rescuing what failed. |
examples/comments.py |
The inbox, the actions matrix, the live thread, and replying. |
examples/webhooks.py |
A receiver with no dependencies. --self-test signs a delivery to itself. |
examples/connect.py |
The connection flow, and what the browser has to do. |
comments.py only reads unless you set PLANVORTEX_ALLOW_REPLY=1: replying is public, immediate,
and reaches a person.
Links
MIT © Talia Softworks
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file planvortex-0.1.0.tar.gz.
File metadata
- Download URL: planvortex-0.1.0.tar.gz
- Upload date:
- Size: 339.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7a0f865d1d779daa0a95961f348bf1dd7cddf780204b793e229555bbf5638259
|
|
| MD5 |
f2a0e645edd8f0334dd91323dacde06c
|
|
| BLAKE2b-256 |
efbd399b7d7bddb14319bbac013530882d1b5a5cd692a6b246a5c6f7fbd49579
|
Provenance
The following attestation bundles were made for planvortex-0.1.0.tar.gz:
Publisher:
release.yml on taliasoftworks/PlanVortexPython
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
planvortex-0.1.0.tar.gz -
Subject digest:
7a0f865d1d779daa0a95961f348bf1dd7cddf780204b793e229555bbf5638259 - Sigstore transparency entry: 2623858750
- Sigstore integration time:
-
Permalink:
taliasoftworks/PlanVortexPython@85753b59389bb7732af372138b22142b96addf04 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/taliasoftworks
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@85753b59389bb7732af372138b22142b96addf04 -
Trigger Event:
push
-
Statement type:
File details
Details for the file planvortex-0.1.0-py3-none-any.whl.
File metadata
- Download URL: planvortex-0.1.0-py3-none-any.whl
- Upload date:
- Size: 196.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dfd8da6bb222fddc81a63214910d89962674ab176bcc9c8515d011aef6862d45
|
|
| MD5 |
802c4d90c3944f4a86c5018a739041b7
|
|
| BLAKE2b-256 |
12d9ccd44da26d7b4ff89a37afab617d8963c46f58adde885e757bda2f39b016
|
Provenance
The following attestation bundles were made for planvortex-0.1.0-py3-none-any.whl:
Publisher:
release.yml on taliasoftworks/PlanVortexPython
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
planvortex-0.1.0-py3-none-any.whl -
Subject digest:
dfd8da6bb222fddc81a63214910d89962674ab176bcc9c8515d011aef6862d45 - Sigstore transparency entry: 2623858785
- Sigstore integration time:
-
Permalink:
taliasoftworks/PlanVortexPython@85753b59389bb7732af372138b22142b96addf04 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/taliasoftworks
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@85753b59389bb7732af372138b22142b96addf04 -
Trigger Event:
push
-
Statement type: