PostEverywhere Agents Guide
How to build AI agents that schedule and publish social media posts via the PostEverywhere API. Works with OpenClaw, LangChain, CrewAI, AutoGen, and any agent framework.
Use the PostEverywhere API to give your AI agent the ability to schedule and publish social media posts across all top platforms.
Quick reference for AI agents
Your agent needs these endpoints:
| Action | Method | Endpoint | Description |
|---|---|---|---|
| List accounts | GET |
/api/v1/accounts |
Get connected social accounts and their IDs |
| Create a post | POST |
/api/v1/posts |
Create, schedule, immediately publish, or save as a draft (draft: true) |
| List / get posts | GET |
/api/v1/posts · /api/v1/posts/{id} |
List or fetch posts; drafts include their account_ids and platform_content |
| Schedule a draft | POST |
/api/v1/posts/{id}/schedule |
Turn a draft into a scheduled or immediately-publishing post (drafts only) |
| Check results | GET |
/api/v1/posts/{id}/results |
See per-platform publish status |
| Retry failures | POST |
/api/v1/posts/{id}/retry |
Retry failed platform destinations |
| Upload media from URL | POST |
/api/v1/media/upload-from-url |
One-call image import (preferred when source is a public URL: server fetches it) |
| Upload media (3-step) | POST |
/api/v1/media/upload |
Get presigned URL for image/video upload (use for local files) |
| Complete upload | POST |
/api/v1/media/{id}/complete |
Finalize upload (verifies file exists) |
| Get media | GET |
/api/v1/media/{id} |
Check media status and get permanent URL |
| Best times | GET |
/api/v1/best-times |
Best times to post per account or platform, with the basis of each (guide) |
| Posting queue | GET |
/api/v1/queue |
The workspace's weekly slots and next openings |
| Brand Kit | GET |
/api/v1/brand |
Voice, language, rules, words to avoid, hashtags, colours and fonts (guide) |
Base URL: https://app.posteverywhere.ai/api/v1
Authentication: Authorization: Bearer pe_live_<your-key>
The round-trip principle
Every top-level field name in a PostEverywhere request body also appears in the response with the same name and the same meaning. Take a post you got back from
GET /v1/posts/{id}, grab its top-level fields, POST them straight back: you get a clone. No renaming. Teach your agent this and it will stop inventing field names.The canonical request fields are:
content,account_ids,scheduled_for,timezone,media_ids,platform_content.Optional fields for specific jobs:
draft,thread_posts,cover_photoanduse_queue(see threads, video covers and the queue below).
Common Mistakes (the ones LLMs keep making)
- Endpoint: Create a post via
POST /api/v1/posts, NOT/api/v1/publish. There is no top-level/api/v1/scheduleendpoint for creating a post: to schedule, sendscheduled_foronPOST /api/v1/posts. (There IS aPOST /api/v1/posts/{id}/scheduleendpoint, but it only schedules an existing draft, see below.)scheduled_for, notscheduled_at. The old namescheduled_atis a deprecated alias and still accepted, but every new integration must usescheduled_for. Responses always return the value underscheduled_for.media_ids, nevermedia/media_id/mediaId/attachments. The API returns400 invalid_field_namewith adid you mean media_ids?hint if you send any of those aliases. The canonical field is an array of UUID strings:"media_ids": ["a1b2-..."].- Account IDs are integers, e.g.
2280, not platform usernames, not strings, not slugs. Use the integeridfrom theGET /accountsresponse.- All timestamps are UTC. Always send ISO 8601 strings ending in
Z, e.g."2026-04-15T14:30:00Z". Convert from the user's local time in your code. Thetimezonefield is display metadata for rendering in the dashboard: it does not affect when the post actually fires.- The error envelope is
{data, error: {message, code, details?}, meta: {request_id, timestamp}}. Parseerror.code(machine-readable) anderror.message(human-readable) and echo themeta.request_idback to the user if something fails, since support will ask for it.
How the next_step hint guides an agent
Where it makes sense, endpoint responses include a next_step string that tells your agent the recommended next action in plain English (drafts return a next_steps object with review and schedule hints instead). For example, after creating a scheduled post the response might include:
{
"data": {
"post_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "scheduled",
"scheduled_for": "2026-04-15T14:30:00Z",
"next_step": "Post is scheduled. Poll GET /v1/posts/f47ac10b-58cc-4372-a567-0e02b2c3d479/results after 2026-04-15T14:30:00Z to check which platforms published."
}
}
Your agent should treat next_step as the default next tool call when present. It is intended to reduce the amount of ambient domain knowledge the agent needs to carry in its system prompt.
Drafts: keeping a human in the loop
For agents, the safest publishing pattern is draft first, schedule after approval. Instead of immediately scheduling everything it generates, an agent can save posts as drafts, let a human (or a reviewing agent) check them, and only then schedule or publish. The full loop is:
create_post(draft: true) → review with list_posts(status: "draft") / get_post → schedule_post
1. Create a draft
Send draft: true. The post is saved but neither published nor scheduled. account_ids is optional for a draft: you can choose targets now or at schedule time.
draft = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Proposed launch post — needs review.",
"draft": True,
# account_ids optional for drafts
}).json()["data"]
post_id = draft["post_id"]
assert draft["status"] == "draft"
# draft["next_steps"] points at POST /v1/posts/{id}/schedule
2. Review drafts
GET /v1/posts?status=draft (and GET /v1/posts/{id}) return each draft's target account_ids and platform_content, so the reviewer can see exactly what every draft will publish before anything goes out.
drafts = requests.get(f"{BASE}/posts", headers=headers,
params={"status": "draft"}).json()["data"]
for d in drafts["posts"]:
print(d["post_id"], d["content"], d.get("account_ids"), d.get("platform_content"))
3. Schedule (or publish) the approved draft
POST /v1/posts/{id}/schedule turns a draft into a scheduled or immediately-publishing post. Send scheduled_for (ISO 8601 UTC) or publish_now: true. Optionally set/override targets with account_ids and pass a display timezone. This endpoint only works on drafts: calling it on a post that is already scheduled or published returns 409 (use PATCH /v1/posts/{id} to reschedule those).
# Schedule it…
requests.post(f"{BASE}/posts/{post_id}/schedule", headers=headers, json={
"scheduled_for": "2026-04-15T14:30:00Z",
"account_ids": [2280, 2281], # optional if the draft already had targets
"timezone": "America/New_York" # optional display metadata
})
# …or publish it right now
requests.post(f"{BASE}/posts/{post_id}/schedule", headers=headers, json={
"publish_now": True,
"account_ids": [2280],
})
An agent posting flow, step by step
1. Get account IDs
import requests
API_KEY = "pe_live_..."
BASE = "https://app.posteverywhere.ai/api/v1"
headers = {"Authorization": f"Bearer {API_KEY}"}
# List connected accounts
accounts = requests.get(f"{BASE}/accounts", headers=headers).json()
for acc in accounts["data"]["accounts"]:
print(f"{acc['platform']}: {acc['account_name']} (ID: {acc['id']})")
2. Create and publish a post
# Post immediately to specific accounts
post = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Posted by my AI agent!",
"account_ids": [2280, 2281], # Instagram + X account IDs
}).json()
print(f"Post ID: {post['data']['post_id']}")
print(f"Status: {post['data']['status']}") # 'publishing' for immediate
3. Schedule a post for later
# Schedule for tomorrow at 10am UTC
post = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Scheduled by my AI agent!",
"account_ids": [2280],
"scheduled_for": "2026-04-03T10:00:00Z", # canonical — NOT scheduled_at
"timezone": "America/New_York" # display metadata only, defaults to UTC
}).json()
print(f"Status: {post['data']['status']}") # 'scheduled'
print(f"Scheduled for: {post['data']['scheduled_for']}") # same field, round-trip
4. Check publish results
# Check per-platform results. The response uses the `results` array
# with per-platform context: platform, account_id, account_name, status,
# platform_post_url, error (if failed), published_at.
post_id = post["data"]["post_id"]
results = requests.get(f"{BASE}/posts/{post_id}/results", headers=headers).json()
for dest in results["data"]["results"]:
status = dest["status"]
label = f"{dest['platform']} (@{dest.get('account_name', dest['account_id'])})"
if status == "done":
print(f"{label}: published — {dest.get('platform_post_url')}")
elif status == "failed":
err = dest.get("error") or {}
print(f"{label}: FAILED — {err.get('code', 'unknown')}: {err.get('message', '')}")
else:
print(f"{label}: {status}")
5. Retry failed platforms
# If any platform failed, retry it
retry = requests.post(f"{BASE}/posts/{post_id}/retry", headers=headers).json()
print(f"Retried: {retry['data']['retried_count']} destinations")
Setting platform-specific content from an agent
Customize content per platform using platform_content:
post = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Default content for all platforms",
"account_ids": [2280, 2281, 2282], # Instagram, X, LinkedIn
"platform_content": {
"instagram": {
"content": "Instagram version with #hashtags",
"contentType": "Reels"
},
"x": {
"content": "Short version for X (280 chars)"
},
"linkedin": {
"content": "Professional version for LinkedIn"
}
}
}).json()
Publishing an X Article from an agent
X Articles are X's long-form format: a title, a formatted body and a cover image. An agent sets post_type to "article" in the X settings, adds a title, and attaches the cover as the first media_ids entry. Articles are X only: include X accounts and nothing else in account_ids.
article = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Article body",
"account_ids": [2281], # X accounts only
"media_ids": [cover_media_id], # first image becomes the cover
"scheduled_for": "2026-10-01T09:00:00Z",
"platform_content": {
"x": {
"content": "## Why we rebuilt onboarding\n\nThree things changed...",
"settings": {"post_type": "article", "title": "What 38 seconds taught us about onboarding"}
}
}
}).json()
The connected account needs X Premium, and each X account can publish 2 articles in any rolling 24 hours. See Publishing an X Article for formatting and limits.
Threads, video covers and the queue
Three more POST /v1/posts fields cover common agent jobs:
thread_posts: the parts aftercontent, in order (up to 25). The post goes out as a linked thread on X and Bluesky, and as follow-up messages on Telegram and Discord; other accounts getcontentonly.cover_photo:{"media_id": "..."}or{"url": "https://..."}for a video post. It becomes the custom thumbnail on YouTube (verified channels only; 1280x720 JPG or PNG is best), the Reel cover on Instagram and the video thumbnail on Facebook and LinkedIn. TikTok takes a frame instead:platform_content.tiktok.settings.videoCoverTimestampin milliseconds.use_queue:truelets the workspace posting queue pick the next free slot. Do not send it withscheduled_for.
video = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "How we roast our beans",
"account_ids": [2290], # YouTube
"media_ids": [video_media_id],
"cover_photo": {"media_id": thumbnail_media_id},
"platform_content": {
"youtube": {"settings": {"title": "How we roast our beans", "privacyStatus": "public"}}
},
"use_queue": True,
}).json()
To post at the best time over REST, read GET /v1/best-times and send next_best.next.iso as scheduled_for (sending schedule_at over REST returns 400 unsupported_schedule_at). MCP clients can pass schedule_at: "best_time" instead, also per item in bulk_create_posts. Agents that write captions themselves should read the Brand Kit first.
Uploading media from an agent in three steps
If your image is already at a public URL, use the one-call import:
import requests
upload = requests.post(f"{BASE}/media/upload-from-url", headers=headers, json={
"url": "https://example.com/hero.webp",
}).json()["data"]
media_id = upload["media_id"] # already 'ready' — attach immediately
Images (JPEG/PNG/GIF/WebP/HEIC/HEIF, 25 MB) return ready immediately. MP4 videos (up to 4 GB) import asynchronously: the call returns media_status: "uploading" instantly, then poll GET /media/{id} until ready, then attach. On failed, the poll's error_message says exactly why. For local files, use the 3-step flow below.
3-step flow (local files):
Attaching media to a post requires three steps: request a presigned URL, upload the file bytes, then confirm completion. The /complete endpoint verifies that the file actually exists before marking it ready: if you skip the upload step, /complete returns 400 file_not_uploaded.
import requests
# Step 1: Request a presigned upload URL
upload = requests.post(f"{BASE}/media/upload", headers=headers, json={
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 2048576,
}).json()["data"]
media_id = upload["media_id"]
# Step 2: Upload the file bytes to the presigned URL
with open("photo.jpg", "rb") as f:
if upload["upload_method"]["method"] == "POST":
# Images — multipart POST, field name "file"
requests.post(upload["upload_url"], files={"file": f})
else:
# Videos and documents — PUT with Content-Type header
requests.put(
upload["upload_url"],
data=f,
headers={"Content-Type": "image/jpeg"},
)
# Step 3: Finalize — confirms file was received and marks media ready
complete = requests.post(
f"{BASE}/media/{media_id}/complete", headers=headers
).json()
# Returns {"data": {"media_id": "...", "media_status": "ready", ...}} on success
# Optional: verify media status and get permanent URL
media = requests.get(
f"{BASE}/media/{media_id}", headers=headers
).json()["data"]
assert media["media_status"] == "ready"
print(f"Permanent URL: {media['url']}") # never expires
# Step 4: Attach to a post
post = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "Check this out!",
"account_ids": [2280],
"media_ids": [media_id],
}).json()
Verify before attaching
Use
GET /v1/media/{id}to confirmmedia_statusis"ready"(orreadyistrue) before passing the media ID to a post. The response includes a permanenturlfield that never expires, useful for previewing uploads or verifying that the file transferred correctly.
Handling validation_warnings before publishing
When media does not perfectly match a platform's requirements (e.g. aspect ratio outside the optimal range), the post creation response includes a validation_warnings object keyed by platform. The post is still created, but the platform may crop or resize the media.
result = requests.post(f"{BASE}/posts", headers=headers, json={
"content": "New photo!",
"account_ids": [2280, 2281],
"media_ids": [media_id],
}).json()
# Always check for validation_warnings — they are non-blocking but important
warnings = result["data"].get("validation_warnings", {})
for platform, message in warnings.items():
print(f"Warning ({platform}): {message}")
Example warning:
{
"data": {
"post_id": "f47ac10b-...",
"status": "scheduled",
"validation_warnings": {
"instagram": ["Image aspect ratio 2.5:1 is outside Instagram's supported range (4:5 to 1.91:1). The image will be cropped."]
}
}
}
Your agent should surface these warnings to the user so they can adjust media for future posts.
After publishing, check again. Each destination in GET /v1/posts/{id}/results has a warnings array: a post can go out and still carry one (text cut to fit, a setting the platform did not apply). Report those too, not only failures. Other things the API tells you rather than silently fixing:
- A caption over a platform's real limit is refused with
400 content_too_long(see the limits). - A first comment (
firstCommentorfirst_comment) works on X only. Sent for other platforms it is removed and the responsenotessay so. request_approval: truewithoutdraft: truereturns400 request_approval_needs_draftand creates nothing.- Deleting media that a pending post uses returns
409 media_in_use;?force=truedeletes it and marks those posts failed.
Examples for LangChain, CrewAI and the OpenAI SDK
OpenClaw / Custom Python Agent
import requests
class PostEverywhereAgent:
def __init__(self, api_key):
self.base = "https://app.posteverywhere.ai/api/v1"
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "MyAgent/1.0"
}
def get_accounts(self):
r = requests.get(f"{self.base}/accounts", headers=self.headers)
return r.json()["data"]["accounts"]
def post(self, content, account_ids, scheduled_for=None, media_ids=None):
body = {"content": content, "account_ids": account_ids}
if scheduled_for:
# `scheduled_for` is canonical — `scheduled_at` is deprecated.
body["scheduled_for"] = scheduled_for
if media_ids:
# The only accepted media field name is `media_ids`.
body["media_ids"] = media_ids
r = requests.post(f"{self.base}/posts", headers=self.headers, json=body)
return r.json()
def check_results(self, post_id):
r = requests.get(f"{self.base}/posts/{post_id}/results", headers=self.headers)
return r.json()["data"]["results"]
# Usage
agent = PostEverywhereAgent("pe_live_...")
accounts = agent.get_accounts()
x_account = next(a for a in accounts if a["platform"] == "x")
result = agent.post("Daily update from my AI agent!", [int(x_account["id"])])
LangChain Tool
from langchain.tools import tool
@tool
def schedule_social_post(content: str, platforms: str, scheduled_for: str = None) -> str:
"""Schedule a social media post to specified platforms.
Args:
content: The post text content.
platforms: Comma-separated platform names (instagram, x, linkedin, etc.).
scheduled_for: Optional ISO 8601 UTC datetime to schedule for, e.g.
"2026-04-15T14:30:00Z". Omit for immediate publish. Always send UTC.
"""
import requests
headers = {
"Authorization": f"Bearer {os.environ['POSTEVERYWHERE_API_KEY']}",
"Content-Type": "application/json"
}
# Get accounts matching requested platforms
accounts = requests.get(
"https://app.posteverywhere.ai/api/v1/accounts",
headers=headers
).json()["data"]["accounts"]
target_platforms = [p.strip() for p in platforms.split(",")]
account_ids = [int(a["id"]) for a in accounts if a["platform"] in target_platforms]
if not account_ids:
return f"No connected accounts for: {platforms}"
body = {"content": content, "account_ids": account_ids}
if scheduled_for:
# Canonical field name — `scheduled_at` is deprecated.
body["scheduled_for"] = scheduled_for
result = requests.post(
"https://app.posteverywhere.ai/api/v1/posts",
headers=headers,
json=body
).json()
if result.get("error"):
err = result["error"]
return f"Error [{err.get('code')}]: {err.get('message')}"
return f"Post created: {result['data']['post_id']} (status: {result['data']['status']})"
Handling errors inside an agent loop
def safe_post(content, account_ids, max_retries=3):
for attempt in range(max_retries):
r = requests.post(f"{BASE}/posts", headers=headers, json={
"content": content,
"account_ids": account_ids
})
if r.status_code in (200, 201):
return r.json()
elif r.status_code == 429:
wait = int(r.headers.get("Retry-After", 60))
time.sleep(wait)
elif r.status_code >= 500:
time.sleep(2 ** attempt)
else:
return r.json() # Client error, don't retry
raise Exception("Max retries exceeded")
Pattern: an agent that posts every day
import schedule
import time
agent = PostEverywhereAgent("pe_live_...")
def daily_post():
content = generate_content() # Your AI content generation
accounts = agent.get_accounts()
account_ids = [int(a["id"]) for a in accounts]
result = agent.post(content, account_ids)
print(f"Posted: {result['data']['post_id']}")
schedule.every().day.at("10:00").do(daily_post)
while True:
schedule.run_pending()
time.sleep(60)
Every error code an agent may receive
Every PostEverywhere error response follows the envelope:
{
"data": null,
"error": {
"message": "Human-readable explanation",
"code": "machine_readable_snake_case",
"details": { }
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-04-12T10:00:00Z"
}
}
Always log meta.request_id when a call fails: it is what support will ask for first. These are the codes your agent should handle distinctly:
| HTTP | error.code |
What it means | What the agent should do |
|---|---|---|---|
400 |
invalid_field_name |
You sent media, media_id, mediaId or attachments instead of media_ids. error.details.field_mistakes[] carries {wrong, right, hint}. Other unknown fields (for example schedule_for) are silently ignored rather than rejected, so a mistyped schedule field publishes immediately. |
Read the hint, fix the field name, resend. Never retry the same body. |
400 |
validation_error |
A required field is missing or a value has the wrong type (e.g. account_ids contained a string instead of an integer). A past scheduled_for returns past_schedule_time; a malformed one returns invalid_datetime. |
Fix the body client-side and resend. |
400 |
media_not_found |
A media_ids entry does not exist or does not belong to your organization. |
Re-upload the media or correct the UUID. |
400 |
media_not_ready |
A media_ids entry exists but has not finished processing (status is not ready). |
Poll GET /v1/media/{id} until media_status is ready, then retry. |
400 |
file_not_uploaded |
POST /v1/media/{id}/complete was called but the file was never uploaded to the presigned URL. |
Upload the file bytes to the presigned URL first, then call /complete again. |
401 |
invalid_api_key / api_key_revoked / api_key_expired |
The Authorization header is missing, malformed, or the key was revoked. |
Do not retry. Surface to the user. |
403 |
insufficient_scope |
Your API key is missing a required scope (e.g. calling /ai/generate-image without the ai scope). |
Do not retry. Ask the user to generate a new key with the right scopes. |
403 |
storage_quota_exceeded |
Media upload rejected because the organization's storage quota is full. | Do not retry. Ask the user to delete unused media or upgrade their plan. |
404 |
not_found |
Post, media, or account ID does not exist (or is not in your organization). | Do not retry. Re-list the resource. |
429 |
rate_limit_exceeded |
API or plan rate limit exceeded. The Retry-After header carries the wait in seconds; error.details has limit, remaining and reset_at. |
Sleep for Retry-After seconds, then retry. Use exponential backoff for repeat 429s. |
500 / 502 / 503 / 504 |
internal_error / service_unavailable |
Server-side issue or upstream platform hiccup. | Retry with exponential backoff (max ~3 attempts). Include request_id if you escalate. |
Related: webhook events for agents · testing an agent safely · the best AI agents for social media · AI agent frameworks compared · PostEverywhere for AI agents · the Claude walkthrough
Full reference: Error Handling.
Related agent and API pages
- Agent System Prompt: Drop-in system prompt for Claude/GPT/Gemini agents that call PostEverywhere
- API Reference: Full endpoint documentation
- MCP Server: Use with Claude Code and Cursor
- SDKs & CLI: Node.js SDK and CLI tools
- Authentication: API key setup
- Rate Limits: Request quotas per plan
- Error Handling: Full error envelope and codes
Agents can also connect social accounts entirely from the API, no dashboard session needed.