Building AI Agents
Use the PostEverywhere API to give your AI agent the ability to schedule and publish social media posts across 8 platforms.
Quick Reference for 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, or immediately publish a post |
| 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 or videos) |
| 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 |
| List posts | GET | /api/v1/posts | List existing posts with status filter |
Base URL: https://app.posteverywhere.ai/api/v1
Authentication: Authorization: Bearer pe_live_<your-key>
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.
- Endpoint: Create a post via
POST /api/v1/posts, NOT/api/v1/publish. There is no/api/v1/scheduleendpoint. 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 — support will ask for it.
The next_step Hint
Where it makes sense, endpoint responses include a next_step field that tells your agent the recommended next action. 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": {
"action": "poll_results",
"endpoint": "/v1/posts/f47ac10b-58cc-4372-a567-0e02b2c3d479/results",
"after": "2026-04-15T14:35:00Z",
"message": "Post is scheduled. Poll this endpoint after the scheduled time to check which platforms published successfully."
}
}
}
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.
Step-by-Step Agent Flow
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 rich `destinations` 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"]["destinations"]:
status = dest["status"]
label = f"{dest['platform']} (@{dest.get('account_name', dest['account_id'])})"
if status == "published":
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")
Platform-Specific Content
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",
"settings": {"altText": "Image description"}
},
"x": {
"content": "Short version for X (280 chars)"
},
"linkedin": {
"content": "Professional version for LinkedIn"
}
}
}).json()
Media Upload Flow (3-Step)
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
Image-only (JPEG/PNG/GIF/WebP/HEIC/HEIF), 25 MB cap. For local files or videos, use the 3-step flow below.
3-step flow (local files / videos / >25 MB images):
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": {"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["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()
Use GET /v1/media/{id} to confirm status is "ready" before passing the media ID to a post. The response includes a permanent url field that never expires — useful for previewing uploads or verifying that the file transferred correctly.
Handling validation_warnings
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.
Framework Examples
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"]["destinations"]
# 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']})"
Error Handling for Agents
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 == 200:
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")
Daily Posting Agent Pattern
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)
Error Code Reference
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 a field the API does not recognise, e.g. scheduled_at typo'd as schedule_at, or media / media_id / mediaId / attachments instead of media_ids. error.details.hint carries a "did you mean…" suggestion. | 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, or scheduled_for was in the past). | 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 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 | unauthorized / invalid_api_key | The Authorization header is missing, malformed, or the key was revoked. | Do not retry. Surface to the user. |
403 | forbidden / scope_missing | 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_limited / rate_limit_exceeded | API or plan rate limit exceeded. error.details.retry_after and the Retry-After header carry the wait time in seconds. | 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. |
Full reference: Error Handling.
Related
- 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