PostifyPostify Docs
Public API

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:

ConcernYour own Python scripts, one per networkCalling the Postify API
Developer apps and app reviewYou 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 refreshYou 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 rulesEach 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 idempotencyYou 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 infrastructureSomething 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 happenedYou 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

  1. Install requests: pip install requests.
  2. 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 the channels:read and posts:write scopes.
  3. 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.

postify.py
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.

postify.py (continued)
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):

200 OK · application/json
{
  "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.

postify.py (continued)
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.

CodeWhat the code above does
rate_limitedSleeps for Retry-After seconds, then retries. Gives up if the wait is over a minute.
idempotency_in_progressWaits, then retries with the same key.
internal_errorRetries with backoff and the same key, so a request that did land is not duplicated.
quota_exhaustedStops. The monthly allowance does not come back until the period resets.
validation_failedStops and prints each entry of errors (its pointer and message).
idempotency_key_reusedStops. The key was used with a different body, which is always a client bug.
authentication_required, invalid_api_key, insufficient_scopeStops 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.published or post.failed event instead of polling.
  • For AI agents: the same API through the MCP server and Markdown twins.

On this page