# 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. **Source:** https://posteverywhere.ai/docs/integrations/agents **Section:** Integrations **API reference:** https://posteverywhere.ai/docs/api/reference --- 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 | **Base URL:** `https://app.posteverywhere.ai/api/v1` **Authentication:** `Authorization: Bearer pe_live_` > **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 top-level `/api/v1/schedule` endpoint for *creating* a post: to schedule, send `scheduled_for` on `POST /api/v1/posts`. (There IS a `POST /api/v1/posts/{id}/schedule` endpoint, but it only schedules an existing **draft**, see below.) > - **`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, 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: ```json { "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. ```python 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. ```python 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). ```python # 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 ```python 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 ```python # 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 ```python # 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 ```python # 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 ```python # 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`: ```python 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() ``` ## Uploading media from an agent in three steps **If your image is already at a public URL, use the one-call import:** ```python 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 500 MB) 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`. ```python 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 confirm `media_status` is `"ready"` (or `ready` is `true`) 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` 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. ```python 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: ```json { "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. ## Examples for LangChain, CrewAI and the OpenAI SDK ### OpenClaw / Custom Python Agent ```python 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 ```python 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 ```python 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 ```python 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: ```json { "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](/docs/webhooks) · [testing an agent safely](/docs/testing) · [the best AI agents for social media](/blog/best-ai-agents-for-social-media) · [AI agent frameworks compared](/blog/ai-agent-frameworks-for-social-media) · [PostEverywhere for AI agents](/agents) · [the Claude walkthrough](/docs/integrations/claude) Full reference: [Error Handling](/docs/errors). ## Related agent and API pages - [Agent System Prompt](/docs/integrations/agent-system-prompt): Drop-in system prompt for Claude/GPT/Gemini agents that call PostEverywhere - [API Reference](/docs/api/create-post): Full endpoint documentation - [MCP Server](/docs/integrations/mcp): Use with Claude Code and Cursor - [SDKs & CLI](/docs/integrations/sdk): Node.js SDK and CLI tools - [Authentication](/docs/authentication): API key setup - [Rate Limits](/docs/rate-limits): Request quotas per plan - [Error Handling](/docs/errors): Full error envelope and codes Agents can also [connect social accounts entirely from the API](/docs/connecting-accounts), no dashboard session needed.