Works with all AI agents

Connect your AI agent

Choose your AI agent and connect in seconds.

Add to Claude

Works on every Claude plan, including free. Full Claude guide

  1. 1

    Add to Claude

    One click from Claude’s connector directory.

  2. 2

    Sign up or sign in

    Takes seconds, then you’re sent straight back.

  3. 3

    Ask Claude

    “Plan and schedule my posts for next week.”

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_photo and use_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/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:

{
  "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 after content, 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 get content only.
  • 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.videoCoverTimestamp in milliseconds.
  • use_queue: true lets the workspace posting queue pick the next free slot. Do not send it with scheduled_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 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.

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 (firstComment or first_comment) works on X only. Sent for other platforms it is removed and the response notes say so.
  • request_approval: true without draft: true returns 400 request_approval_needs_draft and creates nothing.
  • Deleting media that a pending post uses returns 409 media_in_use; ?force=true deletes 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.

Agents can also connect social accounts entirely from the API, no dashboard session needed.