Skip to main content

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:

ActionMethodEndpointDescription
List accountsGET/api/v1/accountsGet connected social accounts and their IDs
Create a postPOST/api/v1/postsCreate, schedule, or immediately publish a post
Check resultsGET/api/v1/posts/{id}/resultsSee per-platform publish status
Retry failuresPOST/api/v1/posts/{id}/retryRetry failed platform destinations
Upload media from URLPOST/api/v1/media/upload-from-urlOne-call image import (preferred when source is a public URL — server fetches it)
Upload media (3-step)POST/api/v1/media/uploadGet presigned URL for image/video upload (use for local files or videos)
Complete uploadPOST/api/v1/media/{id}/completeFinalize upload (verifies file exists)
Get mediaGET/api/v1/media/{id}Check media status and get permanent URL
List postsGET/api/v1/postsList existing posts with status filter

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.

Common Mistakes (the ones LLMs keep making)
  • Endpoint: Create a post via POST /api/v1/posts, NOT /api/v1/publish. There is no /api/v1/schedule endpoint.
  • scheduled_for, not scheduled_at. The old name scheduled_at is a deprecated alias and still accepted, but every new integration must use scheduled_for. Responses always return the value under scheduled_for.
  • media_ids, never media / media_id / mediaId / attachments. The API returns 400 invalid_field_name with a did 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 integer id from the GET /accounts response.
  • 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. The timezone field 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}}. Parse error.code (machine-readable) and error.message (human-readable) and echo the meta.request_id back 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()
Verify before attaching

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:

HTTPerror.codeWhat it meansWhat the agent should do
400invalid_field_nameYou 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.
400validation_errorA 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.
400media_not_foundA media_ids entry does not exist or does not belong to your organization.Re-upload the media or correct the UUID.
400media_not_readyA media_ids entry exists but has not finished processing (status is not ready).Poll GET /v1/media/{id} until status is ready, then retry.
400file_not_uploadedPOST /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.
401unauthorized / invalid_api_keyThe Authorization header is missing, malformed, or the key was revoked.Do not retry. Surface to the user.
403forbidden / scope_missingYour 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.
403storage_quota_exceededMedia upload rejected because the organization's storage quota is full.Do not retry. Ask the user to delete unused media or upgrade their plan.
404not_foundPost, media, or account ID does not exist (or is not in your organization).Do not retry. Re-list the resource.
429rate_limited / rate_limit_exceededAPI 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 / 504internal_error / service_unavailableServer-side issue or upstream platform hiccup.Retry with exponential backoff (max ~3 attempts). Include request_id if you escalate.

Full reference: Error Handling.