unipost
Official UniPost API client for Python. Post to 7 social platforms with one API call.
Latest release: v0.6.0
Scoped Inbox support is now available for server-side applications.
- Bind every Inbox operation to either
client.inbox.managed_user(id)orclient.inbox.workspace(). - List, read, reply, thread state, media context, sync, X backfill, X reply reconciliation, and WebSocket connection details are typed for sync and async clients.
- X replies distinguish completed delivery from accepted-but-reconciling delivery.
- WebSocket helpers return connection details without opening a connection or adding a production dependency.
See the changelog for the complete release history.
Installation
pip install unipost
For async support:
pip install unipost[async]
Quick Start
from unipost import UniPost
# Reads UNIPOST_API_KEY from environment automatically
client = UniPost()
post = client.posts.create(
caption="Hello from UniPost! 🚀",
account_ids=["sa_twitter_xxx", "sa_linkedin_xxx"],
)
Usage
List Accounts
result = client.accounts.list()
accounts = result["data"]
# Filter by platform
twitter = client.accounts.list(platform="twitter")
Create Posts
# Immediate publish
post = client.posts.create(
caption="Hello world!",
account_ids=["sa_twitter_xxx"],
)
# Scheduled
post = client.posts.create(
caption="Scheduled post",
account_ids=["sa_twitter_xxx"],
scheduled_at="2026-04-28T09:00:00Z",
)
# Per-platform captions
post = client.posts.create(
platform_posts=[
{"account_id": "sa_twitter_xxx", "caption": "Short tweet 🐦"},
{"account_id": "sa_linkedin_xxx", "caption": "Longer LinkedIn version..."},
]
)
# Save as draft
draft = client.posts.create(
caption="Work in progress",
account_ids=["sa_twitter_xxx"],
status="draft",
)
Analytics Explorer
posts = client.analytics.posts(
platform="tiktok",
limit=25,
sort="engagement_rate",
)
platforms = client.analytics.platforms()
tiktok = client.analytics.platform("tiktok")
csv = client.analytics.export_posts_csv(platform="pinterest")
client.analytics.refresh(
platform="threads",
limit=100,
)
Developer Logs
page = client.logs.list(status="error", limit=50)
if page["data"]:
log = client.logs.get(page["data"][0]["id"])
print(log["action"], log.get("request_payload"))
for log in client.logs.stream(status="error", after_id=page["data"][0]["id"] if page["data"] else 0):
print(log["id"], log["action"])
break
Media Upload
reserved = client.media.upload(
filename="voiceover.mp3",
content_type="audio/mpeg",
# size_bytes is optional; upload_file calculates it automatically
)
Custom Audio Overlay
from time import sleep
job = client.media.audio_overlays.create(
video_media_id="media_video_123",
audio_media_id="media_audio_456",
mode="mix",
video_volume=70,
audio_volume=100,
fit="trim_to_video",
idempotency_key="overlay-demo-001",
)
while job.status in ("queued", "processing"):
sleep(1.5)
job = client.media.audio_overlays.get(job.id)
if job.status != "succeeded":
raise RuntimeError(job.error.message if job.error else "audio overlay failed")
client.posts.create(
caption="Video with custom audio",
account_ids=["sa_tiktok_xxx"],
media_ids=[job.output_media_id],
)
Async
from unipost import AsyncUniPost
async def main():
client = AsyncUniPost()
post = await client.posts.create(
caption="Async post!",
account_ids=["sa_twitter_xxx"],
)
Get Connect URL (Your Own Accounts)
connect = client.connect.get_connect_url(
profile_id="pr_brand_us",
platform="linkedin",
redirect_url="https://app.acme.com/integrations/done", # optional
)
print(connect.auth_url)
Connect (Managed Users)
session = client.connect.create_session(
platform="twitter",
external_user_id="your_user_123",
return_url="https://yourapp.com/callback",
allow_quickstart_creds=True, # optional
)
print(session.url)
Inbox (server-side apps)
Keep the workspace API key on your application backend. Never expose it to managed users, browser code, or a mobile app. Derive the external user ID from your authenticated application session—not an arbitrary scope value supplied by the caller—and bind every managed-user operation with client.inbox.managed_user(id). Managed-user scope never falls back to workspace scope. client.inbox.workspace() is allowed only while the creator of that workspace API key remains a UniPost workspace owner or admin. This UniPost role check is separate from your end application's authenticated user: an authenticated managed user in your app must never receive workspace-wide access.
Create the scoped resources on your backend:
from unipost import UniPost
def inbox_scopes(workspace_api_key: str, authenticated_external_user_id: str):
client = UniPost(api_key=workspace_api_key)
return {
"managed": client.inbox.managed_user(authenticated_external_user_id),
"owner_admin": client.inbox.workspace(),
}
The selected scope is carried by every Inbox request. Listing accepts source, is_read, is_own, and limit; explicit False values are preserved. It is limit-only and returns one non-paginated page. An omitted, invalid, zero, or negative limit falls back to 50 items; a limit above 500 is clamped to 500.
inbox = inbox_scopes(workspace_api_key, authenticated_external_user_id)["managed"]
page = inbox.list(source="x_dm", is_read=False, is_own=False, limit=25)
unread = inbox.unread_count()
if page.data:
item = inbox.get(page.data[0].id)
inbox.mark_read(item.id)
item = inbox.update_thread_state(
item.id,
thread_status="assigned",
assigned_to="owner_123",
)
media = inbox.media_context(item.id)
marked = inbox.mark_all_read()
Replies are response-aware: HTTP 200 maps to a completed result containing the reply item, while a valid HTTP 202 maps to reconciling, meaning X accepted the reply while UniPost is still reconciling it. Generate one stable idempotency key per logical X reply, reuse that same key for transport retries, and poll x_outbound_status(...) when reconciliation is required. Never resend a reconciling reply under a new key.
item_id = "inbox_item_from_scoped_list"
result = inbox.reply(
item_id,
text="Thanks—we are looking into this.",
idempotency_key="reply_01JSTABLEKEY",
)
if result.state == "completed":
print(result.item.id)
else:
status = inbox.x_outbound_status(result.operation_id)
print(status.status)
websocket_connection_details() is backend-only and does not open a connection. It returns a URL plus the API key only in the Authorization header. Pass those details to a server-side WebSocket client that supports custom headers; never log the header or put the key in the URL. Native browser WebSocket clients cannot set the required authorization header.
details = inbox.websocket_connection_details()
# Connect from your backend with details.url and details.headers.
Calling sync() without arguments performs ordinary polling for the selected scope. Passing x_backfill requests metered X history. Managed-user scope narrows eligible accounts, while workspace scope can span every eligible managed user and account in the workspace. Inspect the estimate and confirmation response, review its scope and X credit cost, then repeat the exact request with the returned confirmation token. Treat the token as a secret: do not log it, send it to a browser, or store it in client-visible state. Never schedule an unreviewed workspace-wide X backfill.
from unipost import XInboxBackfillRequest
ordinary = inbox.sync()
request = XInboxBackfillRequest(
account_id="sa_x_123",
lookback_days=7,
max_items=100,
include_replies=True,
include_dms=False,
)
estimate = inbox.sync(x_backfill=request)
if estimate.confirmation_required:
confirmed = inbox.sync(
x_backfill=XInboxBackfillRequest(
account_id=request.account_id,
lookback_days=request.lookback_days,
max_items=request.max_items,
include_replies=request.include_replies,
include_dms=request.include_dms,
confirmation_token=estimate.confirmation_token,
)
)
print(
{
"accepted": confirmed.accepted,
"suppressed": confirmed.suppressed,
"duplicates": confirmed.duplicates,
"read": confirmed.read,
}
)
The async client exposes the same scopes and contract. Await network operations; websocket_connection_details() remains synchronous because it only prepares immutable connection details.
from unipost import AsyncUniPost, InboxSyncResult
async def handle_inbox(workspace_api_key: str, external_user_id: str) -> None:
client = AsyncUniPost(api_key=workspace_api_key)
inbox = client.inbox.managed_user(external_user_id)
page = await inbox.list(is_read=False, limit=25)
unread = await inbox.unread_count()
if page.data:
await inbox.mark_read(page.data[0].id)
ordinary = await inbox.sync()
assert isinstance(ordinary, InboxSyncResult)
details = inbox.websocket_connection_details()
print(unread.count, ordinary.new_items, details.url)
Webhook Verification
from unipost import verify_webhook_signature
is_valid = verify_webhook_signature(
payload=request.body,
signature=request.headers["X-UniPost-Signature"],
secret=os.environ["UNIPOST_WEBHOOK_SECRET"],
)
Error Handling
from unipost import UniPost, AuthError, RateLimitError, UniPostError
try:
post = client.posts.create(...)
except AuthError:
print("API key invalid")
except RateLimitError as e:
print(f"Rate limited, retry after {e.retry_after}s")
except UniPostError as e:
print(f"API error: {e.status} {e.code} {e}")
Type Hints
Full type annotations included. Works with mypy.
from unipost import Post, SocialAccount
License
MIT
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 unipost-0.6.0.tar.gz.
File metadata
- Download URL: unipost-0.6.0.tar.gz
- Upload date:
- Size: 28.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f22f3047fe8abd357a3b40109dc22a26d8d56adcb917bc39d1ea1439836b858e
|
|
| MD5 |
f738dea40496e56c2498bf31fd18f0cb
|
|
| BLAKE2b-256 |
1cf5e5818c9205f70c6e878d454ec50943fa6fa3149f16d25bb6bffc89665664
|
Provenance
The following attestation bundles were made for unipost-0.6.0.tar.gz:
Publisher:
publish.yml on unipost-dev/sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unipost-0.6.0.tar.gz -
Subject digest:
f22f3047fe8abd357a3b40109dc22a26d8d56adcb917bc39d1ea1439836b858e - Sigstore transparency entry: 2228717222
- Sigstore integration time:
-
Permalink:
unipost-dev/sdk-python@05ccc9b9c9c46e31a7a54e333540cf8e59164823 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/unipost-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@05ccc9b9c9c46e31a7a54e333540cf8e59164823 -
Trigger Event:
push
-
Statement type:
File details
Details for the file unipost-0.6.0-py3-none-any.whl.
File metadata
- Download URL: unipost-0.6.0-py3-none-any.whl
- Upload date:
- Size: 37.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
088d5781d554db09d17fb61e25a6acda85d1fcd223169b7ecb4b83b668e7263d
|
|
| MD5 |
de4d51b6668c88ee77ed2fce84d4658c
|
|
| BLAKE2b-256 |
c1de6f2e116ebe3e5215f1073faf0b2cc39d7a8eb28e503b912c42e705b097f7
|
Provenance
The following attestation bundles were made for unipost-0.6.0-py3-none-any.whl:
Publisher:
publish.yml on unipost-dev/sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unipost-0.6.0-py3-none-any.whl -
Subject digest:
088d5781d554db09d17fb61e25a6acda85d1fcd223169b7ecb4b83b668e7263d - Sigstore transparency entry: 2228717838
- Sigstore integration time:
-
Permalink:
unipost-dev/sdk-python@05ccc9b9c9c46e31a7a54e333540cf8e59164823 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/unipost-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@05ccc9b9c9c46e31a7a54e333540cf8e59164823 -
Trigger Event:
push
-
Statement type: