Skip to main content

postify-sdk

Official Python SDK for the Postify API — schedule and publish social posts, manage media, read analytics, and consume webhooks from Python.

  • Sync and async clients, both context managers
  • Fully typed, ships py.typed
  • RFC 9457 problems mapped to a typed exception hierarchy you can branch on
  • Auto-paginating keyset cursors that carry your filters forward
  • Idempotency keys generated for you, and mutations that are never silently retried
  • Rate-limit headers parsed — and never invented
  • Standard Webhooks signature verification
  • Runtime dependencies: httpx and pydantic. That's it.
pip install postify-sdk

Requires Python 3.9+.

The distribution is published as postify-sdk, but the import package is postifypip install postify-sdk, then from postify import Postify.

60-second quickstart

import os

from postify import Postify

with Postify(api_key=os.environ["POSTIFY_API_KEY"]) as client:
    # Which accounts can we publish to?
    channels = client.channels.list()
    live = [c for c in channels if c.status == "live"]
    print(f"{len(live)} live channels")

    # Schedule a post to the first live channel.
    post = client.posts.create(
        variants=[{"channel_id": live[0].id, "body": "Shipping the new API today."}],
        scheduled_at="2026-09-01T15:00:00Z",
    )
    print(post.id, post.status)

    # Walk everything that's queued up.
    for scheduled in client.posts.list(status="scheduled"):
        print(scheduled.id, scheduled.scheduled_at)

Authentication

Mint an organization API key in Postify → Settings → API keys. Keys look like postify_live_…. The client reads $POSTIFY_API_KEY when you don't pass one.

from postify import Postify

# Default: Authorization: Bearer postify_live_…
client = Postify(api_key="postify_live_examplekey1234")

# Alternate scheme the API also accepts: x-api-key: postify_live_…
header_client = Postify(api_key="postify_live_examplekey1234", auth_style="header")

# Keys are never printed in full — repr and logs are redacted.
print(repr(client))
client.close()
header_client.close()

Full constructor:

import httpx

from postify import Postify

client = Postify(
    api_key="postify_live_examplekey1234",
    base_url="https://app.usepostify.com",  # DEFAULT_BASE_URL
    timeout=60.0,  # DEFAULT_TIMEOUT, per attempt
    max_retries=2,  # DEFAULT_MAX_RETRIES
    auth_style="bearer",  # "bearer" | "header"
    default_headers={"X-App": "my-service"},
    http_client=httpx.Client(),  # bring your own pool/proxies
)
client.close()

Scopes map to resources: channels:read, posts:read, posts:write, analytics:read, webhooks:read, webhooks:write. Two things that surprise people: media rides the posts:* scopes (there is no media:* scope), and usage rides analytics:read (there is no usage:read).

Errors

Every non-2xx response is an RFC 9457 problem document. Branch on err.code — never on title or detail, and always keep a fallback branch, because codes are append-only and a future one must not crash your integration.

Status Exception Codes
400 BadRequestError invalid_request, validation_failed
401 AuthenticationError authentication_required, invalid_api_key
403 PermissionDeniedError insufficient_scope, feature_not_enabled, dangerous_ops_disabled
404 NotFoundError resource_not_found
409 ConflictError resource_conflict, idempotency_in_progress
422 UnprocessableEntityError idempotency_key_reused
429 RateLimitError rate_limited, quota_exhausted
≥500 InternalServerError internal_error
other APIError

Transport failures raise APIConnectionError (or its subclass APITimeoutError), and a cancelled call raises APIUserAbortError. All of them derive from PostifyError.

The 429 pair is the case that matters most: rate_limited is a burst window you should wait out, quota_exhausted is your plan's period allowance and will not clear until the billing period resets.

from postify import Postify, RateLimitError, APIError

client = Postify(api_key="postify_live_examplekey1234")
try:
    client.posts.get("post_missing")
except RateLimitError as err:
    if err.code == "quota_exhausted":
        print("Plan quota is gone until the period resets — do not retry.")
    else:
        print("Burst limit; retry after", err.retry_after_seconds)
except APIError as err:
    print(err.status, err.code, err.request_id)
    for field in err.field_errors:  # populated on validation_failed
        print(field.pointer, field.message)
client.close()

Pagination

posts.list() and media.list() are keyset-paginated; channels.list() and webhook_endpoints.list() are plan-bounded and return plain lists.

from postify import Postify

client = Postify(api_key="postify_live_examplekey1234")

# Auto-paginate items — filters are carried forward on every page.
for post in client.posts.list(status="scheduled", limit=100):
    print(post.id)

# Or walk page by page.
page = client.posts.list(limit=100).first_page()
while page is not None:
    print(len(page.data), page.has_more, page.next_cursor)
    page = page.get_next_page()

# Or just collect everything.
all_posts = list(client.posts.list())
print(len(all_posts))
client.close()

Cursors embed a fingerprint of the query they were minted with, so replaying one against different filters is rejected server-side. The pager only ever swaps after — it never lets a cursor and a changed filter set mix.

Idempotency

posts.create is the one idempotent operation. The SDK generates an Idempotency-Key (a UUID4) for every create, which is what makes retrying one safe. Same key + same body within 24 h replays the original response; same key + a different body is a 422 idempotency_key_reused; a still-in-flight original is a 409 idempotency_in_progress.

from postify import Postify

client = Postify(api_key="postify_live_examplekey1234")

# Auto-generated key (default).
post = client.posts.create(variants=[{"channel_id": "chn_1", "body": "hi"}], draft=True)

# Your own key — safe to re-issue after a crash.
again = client.posts.create(
    variants=[{"channel_id": "chn_1", "body": "hi"}],
    draft=True,
    idempotency_key="order-42-post",
)
print(again.id, again.idempotency_replayed)

# Opt out entirely (this also makes the request non-retryable).
unkeyed = client.posts.create(
    variants=[{"channel_id": "chn_1", "body": "hi"}],
    draft=True,
    idempotency_key=False,
)
print(post.id, unkeyed.id)
client.close()

Retries and rate limits

Default max_retries=2 (three attempts). The policy:

Condition Retry? Delay
Connection/timeout error on a GET or a keyed mutation yes full-jitter backoff, base 0.5 s, cap 8 s
Anything at all on an unkeyed mutation no
429 rate_limited yes Retry-After, else draft-11 t, else backoff
429 quota_exhausted no
408, and 409 idempotency_in_progress yes Retry-After
500 / 502 / 503 / 504 yes backoff
every other 4xx no

The unkeyed-mutation rule is deliberate: posts.delete, posts.reschedule and posts.publish are not idempotent server-side, so a connection failure surfaces to you rather than risking a double publish. posts.create is always keyed, so it is always safe to retry. A Retry-After longer than 60 s is not slept through — the error surfaces so you can schedule the work properly.

Rate-limit headers (both the IETF draft-11 structured fields and the legacy X- trio) are parsed onto the client and onto errors. Absent values stay None — the SDK never guesses that remaining == limit.

from postify import Postify

client = Postify(api_key="postify_live_examplekey1234")
client.usage.get()

state = client.last_rate_limit
if state is not None:
    print(state.policy, state.limit, state.remaining, state.reset_at)
print("request id:", client.last_request_id)
client.close()

Media uploads

Uploading is create-ticket → presigned PUT → complete. media.upload() does all three, and issues the PUT with no Postify credential (signing a presigned S3 URL with an API key breaks the signature).

from postify import Postify

client = Postify(api_key="postify_live_examplekey1234")

asset = client.media.upload(b"\x89PNG\r\n\x1a\n", filename="teaser.png")
print(asset.id, asset.status, asset.url)

client.posts.create(
    variants=[
        {
            "channel_id": "chn_1",
            "body": "Sneak peek",
            "media": [{"url": asset.url, "type": "image"}],
        }
    ],
    draft=True,
)
client.close()

Webhooks

Postify signs deliveries Standard-Webhooks style. Verify over the raw request bytes, before parsing JSON.

from postify import verify_webhook, WebhookVerificationError

WEBHOOK_SECRET = "whsec_examplesecretvaluegoeshere=="
seen_event_ids = set()


def handle_delivery(raw_body: bytes, headers: dict) -> int:
    try:
        event = verify_webhook(payload=raw_body, headers=headers, secret=WEBHOOK_SECRET)
    except WebhookVerificationError as err:
        print("rejected:", err.reason)
        return 400

    # Delivery is at-least-once: webhook-id == event.id and is stable across
    # every retry and replay, so it is your dedup key.
    if event.id in seen_event_ids:
        return 200
    seen_event_ids.add(event.id)

    if event.type == "post.published":
        print("published", event.data.get("postId"))
    return 200


print(handle_delivery.__name__)

WebhookVerificationError.reason is one of missing_headers, malformed_header, timestamp_out_of_toleranceWEBHOOK_TOLERANCE_SEC, 300 s) or no_matching_signature. Space-separated signatures during a secret rotation are handled — any one that verifies passes.

Managing endpoints:

from postify import Postify

client = Postify(api_key="postify_live_examplekey1234")

endpoint = client.webhook_endpoints.create(
    url="https://api.example.com/postify/webhooks",
    event_types=["post.published", "post.failed"],
)
print(endpoint.signing_secret)  # shown ONCE — store it now

client.webhook_endpoints.test(endpoint.id)
client.webhook_endpoints.update(endpoint.id, enabled=False)
client.webhook_endpoints.delete(endpoint.id)
client.close()

An endpoint auto-disables after 20 consecutive failed deliveries; re-enable it with update(id, enabled=True), which also clears the failure counter.

Async

Every method has an async twin with identical arguments and return types.

import asyncio

from postify import AsyncPostify


async def main():
    async with AsyncPostify(api_key="postify_live_examplekey1234") as client:
        usage = await client.usage.get()
        print(usage.plan)

        async for post in client.posts.list(status="scheduled"):
            print(post.id)

        page = await client.media.list(limit=50).first_page()
        print(len(page.data))


asyncio.run(main())

API reference

All 17 /v1 operations, and the SDK method for each:

Method Path SDK
GET /v1/channels client.channels.list()
GET /v1/posts client.posts.list(...)
POST /v1/posts client.posts.create(...)
GET /v1/posts/{id} client.posts.get(id)
PATCH /v1/posts/{id} client.posts.reschedule(id, scheduled_at=...)
DELETE /v1/posts/{id} client.posts.delete(id)
POST /v1/posts/{id}/publish client.posts.publish(id)
GET /v1/media client.media.list(...)
POST /v1/media/uploads client.media.create_upload(...)
POST /v1/media/uploads/{id}/complete client.media.complete_upload(id)
GET /v1/analytics client.analytics.get()
GET /v1/usage client.usage.get()
GET /v1/webhook-endpoints client.webhook_endpoints.list()
POST /v1/webhook-endpoints client.webhook_endpoints.create(...)
PATCH /v1/webhook-endpoints/{id} client.webhook_endpoints.update(id, ...)
DELETE /v1/webhook-endpoints/{id} client.webhook_endpoints.delete(id)
POST /v1/webhook-endpoints/{id}/test client.webhook_endpoints.test(id)

Plus client.media.upload(file), a Python-only convenience that chains the three upload steps.

Full HTTP reference: https://app.usepostify.com/docs/api-reference.

Development

pip install -e ".[dev]"
ruff check . && ruff format --check .
ty check
pytest tests/unit --cov=postify --cov-fail-under=85
./scripts/sync-spec.sh          # refresh the vendored OpenAPI parity fixture
pytest tests/smoke              # hits production; skips loudly without POSTIFY_API_KEY

tests/unit/test_openapi_parity.py diffs src/postify/_registry.py against spec/v1.json in both directions — a new server operation fails CI here until the SDK implements it.

License

MIT © Devino Solutions Inc.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

postify_sdk-0.1.0.tar.gz (69.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

postify_sdk-0.1.0-py3-none-any.whl (44.3 kB view details)

Uploaded Python 3

File details

Details for the file postify_sdk-0.1.0.tar.gz.

File metadata

  • Download URL: postify_sdk-0.1.0.tar.gz
  • Upload date:
  • Size: 69.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for postify_sdk-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f88e60140b08fb90c779a075216498db15dd710d18b6d96571ad54e5fe133393
MD5 32d7a5d2dee40a07223a612cf2373e37
BLAKE2b-256 3ce742336c7fb87efe92ccf81e1ed8495da319db4488fc097b067856029f8f83

See more details on using hashes here.

Provenance

The following attestation bundles were made for postify_sdk-0.1.0.tar.gz:

Publisher: publish.yml on DevinoSolutions/postify-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file postify_sdk-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: postify_sdk-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 44.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for postify_sdk-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 721af6e710e5597d08b1454752a487988b1aa5fdf843d85a138edcb56488e1e3
MD5 e81bc8d8f02a090ce639798c3e3a70da
BLAKE2b-256 5965523db6e51fdb39e52a651b91ceefcef1819ee619ddcca14c9ea1163febb5

See more details on using hashes here.

Provenance

The following attestation bundles were made for postify_sdk-0.1.0-py3-none-any.whl:

Publisher: publish.yml on DevinoSolutions/postify-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page