# 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? [#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](https://usepostify.com/docs/api/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](https://usepostify.com/docs/api#dangerous-operations)), and plan quotas apply (see
[rate limits and plan quotas](https://usepostify.com/docs/api#rate-limits)). Postify supports
10 networks, and the roster is in
[Channels and platforms](https://usepostify.com/docs/channels-and-platforms).

Comparing Postify with another scheduler rather than with your own scripts? The
[Postify vs Publer page](https://usepostify.com/compare/publer) compares the two.

## Set up [#set-up]

1. Install `requests`: `pip install requests`.
2. Mint an organization API key at [Settings → API keys](https://app.usepostify.com/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:

```bash
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 [#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.

```python title="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 [#list-your-channels]

[`listChannels`](https://usepostify.com/docs/api-reference/channels/listChannels) returns every connected account with its health.
Use a channel's `id` in a post variant. Only `live` channels can publish.

```python title="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):

```json title="200 OK · application/json"
{
  "id": "cmc1x2y3z0001abcd",
  "platform": "linkedin",
  "handle": "@acme-co",
  "status": "live"
}
```

## Schedule a post with an Idempotency-Key [#schedule-a-post-with-an-idempotency-key]

[`createPost`](https://usepostify.com/docs/api-reference/posts/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.

```python title="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 [#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](https://usepostify.com/docs/api#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`](https://usepostify.com/docs/api/problems/rate-limited)                                                                                                                                                                                       | Sleeps for `Retry-After` seconds, then retries. Gives up if the wait is over a minute. |
| [`idempotency_in_progress`](https://usepostify.com/docs/api/problems/idempotency-in-progress)                                                                                                                                                                            | Waits, then retries with the same key.                                                 |
| [`internal_error`](https://usepostify.com/docs/api/problems/internal-error)                                                                                                                                                                                     | Retries with backoff and the same key, so a request that did land is not duplicated.   |
| [`quota_exhausted`](https://usepostify.com/docs/api/problems/quota-exhausted)                                                                                                                                                                                    | Stops. The monthly allowance does not come back until the period resets.               |
| [`validation_failed`](https://usepostify.com/docs/api/problems/validation-failed)                                                                                                                                                                                  | Stops and prints each entry of `errors` (its `pointer` and `message`).                 |
| [`idempotency_key_reused`](https://usepostify.com/docs/api/problems/idempotency-key-reused)                                                                                                                                                                             | Stops. The key was used with a different body, which is always a client bug.           |
| [`authentication_required`](https://usepostify.com/docs/api/problems/authentication-required), [`invalid_api_key`](https://usepostify.com/docs/api/problems/invalid-api-key), [`insufficient_scope`](https://usepostify.com/docs/api/problems/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 [#where-to-go-next]

* [API documentation](https://usepostify.com/docs/api): authentication, scopes, pagination and every
  operation.
* [API reference](https://usepostify.com/docs/api-reference): every schema, with a try-it-out console.
* [Webhooks](https://usepostify.com/docs/api/webhooks): get a `post.published` or `post.failed` event
  instead of polling.
* [For AI agents](https://usepostify.com/docs/ai): the same API through the MCP server and Markdown twins.

