Post to social media from Python
Automate social media posting from Python with one REST call instead of a script per network. Working requests code with Idempotency-Key, Retry-After and RFC 9457 errors.
To automate posting to social media from Python, call the Postify API once
instead of writing a script per network. One authenticated POST /v1/posts carries a
variants array with one entry per connected channel, and Postify holds the
network connections, refreshes tokens, publishes at the scheduled time and
reports each channel's outcome. Your Python stays a few requests calls against
https://app.usepostify.com/v1. There is no Python SDK to install; the API is plain REST, and the
official @postify/sdk package is for JavaScript and TypeScript.
Python scripts or the Postify API?
A script against one network's own API is a fair choice when you post to a single network, rarely, with credentials you already hold. The cost shows up as you add networks or cadence. Here is where that cost sits, and what changes when you call Postify instead:
| Concern | Your own Python scripts, one per network | Calling the Postify API |
|---|---|---|
| Developer apps and app review | You register an app with each network, and some networks require an app review or audit before an app can publish publicly. | You connect each account once in the Postify app. Your script holds one Postify API key. Connecting a channel happens in the app, not through the API. |
| Token refresh | You store and refresh each network's tokens. Lifetimes differ by network, and some credentials never expire at all. | Postify runs a background token-refresh job where the platform supports refresh. A channel whose status is reauth_required needs a person to reconnect it in the app. |
| Per-network text and media rules | Each network has its own text, media and format limits, and you write and maintain the checks. | One request carries one variant per channel (variants[].body, variants[].media), so you write what each channel says. The request schema caps body at 63206 characters. |
| Retries and idempotency | You decide which failures to retry, and you guard against a retry publishing the same post twice. | POST /v1/posts accepts an Idempotency-Key. A 429 rate_limited answer carries Retry-After. Postify retries a rate-limited network call and lets its scheduler pick a post up on a later pass. |
| Scheduling infrastructure | Something has to run your script at the right time: a cron host, a container or a hosted scheduler, plus monitoring for a missed run. | Send scheduled_at and exit. Postify's scheduler publishes it, so the call can come from a laptop, a CI job or a serverless function. |
| Knowing what happened | You collect each network's response and reconcile it yourself. | The post's deliveries array reports a stage, an external_id and an error per channel, and webhooks push post.published and post.failed events. |
The API has limits of its own. Posting immediately (publish_now) and deleting
posts stay off for API callers until a workspace owner opts in (see
dangerous operations), and plan quotas apply (see
rate limits and plan quotas). Postify supports
10 networks, and the roster is in
Channels and platforms.
Comparing Postify with another scheduler rather than with your own scripts? The Postify vs Publer page compares the two.
Set up
- Install
requests:pip install requests. - Mint an organization API key at Settings → API keys (org owners and admins only). Keys
start with
postify_live_and are shown once. This guide's key needs thechannels:readandposts:writescopes. - Export it, so the code never holds the secret:
export POSTIFY_API_KEY="postify_live_XXXXXXXXXXXXXXXX"The code below is one file, shown in three parts. It keeps the API origin in
BASE_URL, spells each /v1/... path out in full, and sends the key as
Authorization: Bearer <key>.
A small client that retries what is safe
This part turns every non-2xx answer into an exception built from the RFC 9457
application/problem+json body. It branches on the stable code, never on the
detail text, and retries only the three cases the API documents as retryable.
import os
import time
import requests
BASE_URL = "https://app.usepostify.com"
MAX_ATTEMPTS = 4
MAX_WAIT_SECONDS = 60
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['POSTIFY_API_KEY']}"
class PostifyError(Exception):
"""A non-2xx response, read from its problem+json body."""
def __init__(self, status, problem, request_id=None):
self.status = status
self.code = problem.get("code") # stable and machine-readable
self.detail = problem.get("detail") or problem.get("title")
self.errors = problem.get("errors", []) # validation_failed only
self.request_id = problem.get("request_id") or request_id
super().__init__(
f"{status} {self.code}: {self.detail} (request_id={self.request_id})"
)
def read_problem(resp):
if "problem+json" in resp.headers.get("Content-Type", ""):
try:
return resp.json()
except ValueError:
pass
# Something in front of the API answered: there is no `code` to branch on.
return {"title": f"HTTP {resp.status_code}", "detail": resp.text[:200]}
def seconds_to_wait(resp, error, default):
"""Honour Retry-After (delay-seconds form); give up rather than wait minutes."""
raw = resp.headers.get("Retry-After", "").strip()
seconds = int(raw) if raw.isdigit() else default
if seconds > MAX_WAIT_SECONDS:
raise error
return seconds
def request(method, path, *, json=None, idempotency_key=None):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
# A POST is only safe to repeat when it carries an Idempotency-Key.
can_repeat = method == "GET" or idempotency_key is not None
for attempt in range(MAX_ATTEMPTS):
final = attempt == MAX_ATTEMPTS - 1
try:
resp = session.request(
method, f"{BASE_URL}{path}", json=json, headers=headers, timeout=30
)
except (requests.ConnectionError, requests.Timeout):
if final or not can_repeat:
raise
time.sleep(2**attempt) # the same key goes out again
continue
if resp.ok:
return resp.json()
error = PostifyError(
resp.status_code, read_problem(resp), resp.headers.get("Request-Id")
)
if final:
raise error
if error.code == "rate_limited":
# Per-key burst limit: clears within seconds, Retry-After says when.
wait = seconds_to_wait(resp, error, default=2**attempt)
elif error.code == "idempotency_in_progress":
# The first attempt with this key is still running: wait, same key.
wait = seconds_to_wait(resp, error, default=2)
elif error.code == "internal_error" and can_repeat:
wait = 2**attempt # it may or may not have landed: the key decides
else:
# quota_exhausted, validation_failed, auth and scope errors:
# retrying cannot change the answer.
raise error
time.sleep(wait)List your channels
listChannels returns every connected account with its health.
Use a channel's id in a post variant. Only live channels can publish.
def list_channels():
return request("GET", "/v1/channels")["data"]
def live_channels():
return [c for c in list_channels() if c["status"] == "live"]Each channel in the data array looks like this (trimmed to the fields used here):
{
"id": "cmc1x2y3z0001abcd",
"platform": "linkedin",
"handle": "@acme-co",
"status": "live"
}Schedule a post with an Idempotency-Key
createPost takes variants (one per target channel) and exactly
one mode: draft, scheduled_at or publish_now. This sends a scheduled post
with an Idempotency-Key, so a retry after a timeout cannot publish it twice.
import uuid
from datetime import datetime, timedelta, timezone
def schedule_post(channel_id, body, when, idempotency_key=None):
payload = {
"variants": [{"channel_id": channel_id, "body": body}],
# ISO-8601 in UTC. Mutually exclusive with `draft` and `publish_now`.
"scheduled_at": when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
# One key per logical post: request() re-sends it on every retry. If your
# own job queue can retry this call, create the key with the job and store
# it, so a rerun reuses it instead of minting a new one.
key = idempotency_key or str(uuid.uuid4())
return request("POST", "/v1/posts", json=payload, idempotency_key=key)
if __name__ == "__main__":
try:
channels = live_channels()
if not channels:
raise SystemExit("No live channel. Connect one in the Postify app.")
post = schedule_post(
channels[0]["id"],
"Hello from Python.",
datetime.now(timezone.utc) + timedelta(hours=1),
)
print(post["id"], post["status"], post["scheduled_at"])
except PostifyError as err:
if err.code == "validation_failed":
for field in err.errors:
print(f"{field['pointer']}: {field['message']}")
elif err.code == "quota_exhausted":
print("The monthly API allowance is used up; wait for the reset.")
elif err.code in ("authentication_required", "invalid_api_key"):
print("Check POSTIFY_API_KEY.")
elif err.code == "insufficient_scope":
print("Mint a key with the channels:read and posts:write scopes.")
else:
print(err)
raise SystemExit(1)Running it schedules a real post for the first live channel, one hour from now.
To try the call without scheduling anything, replace scheduled_at with
"draft": True in the payload: it saves a draft and publishes nothing.
A successful call answers 201 with the post. Its status is scheduled, and
deliveries stays empty until a publish is attempted. If you send the same key
and the same body again within 24 hours, you get the original response back with
an Idempotency-Replayed: true header instead of a second post.
How each error code is handled
Every error is application/problem+json with a stable code; the type URI
links to a page for that exact code (see errors). New codes may
appear over time, so the client treats an unknown code as a generic failure.
| Code | What the code above does |
|---|---|
rate_limited | Sleeps for Retry-After seconds, then retries. Gives up if the wait is over a minute. |
idempotency_in_progress | Waits, then retries with the same key. |
internal_error | Retries with backoff and the same key, so a request that did land is not duplicated. |
quota_exhausted | Stops. The monthly allowance does not come back until the period resets. |
validation_failed | Stops and prints each entry of errors (its pointer and message). |
idempotency_key_reused | Stops. The key was used with a different body, which is always a client bug. |
authentication_required, invalid_api_key, insufficient_scope | Stops and tells you to fix the key or its scopes. |
Quote request_id from the error when you contact support: it is the same value
as the Request-Id response header.
Where to go next
- API documentation: authentication, scopes, pagination and every operation.
- API reference: every schema, with a try-it-out console.
- Webhooks: get a
post.publishedorpost.failedevent instead of polling. - For AI agents: the same API through the MCP server and Markdown twins.
API documentation
Everything to build on the Postify REST API — 5-minute quickstart, API-key auth, RFC 9457 errors, cursor pagination, idempotency keys, rate limits, and all 17 operations with examples.
Error codes
Every Postify API error is RFC 9457 problem+json with a stable code. The full registry — what each of the 14 codes means, its HTTP status, and how to fix it.
