# PostEverywhere API — Full Documentation > This is the complete PostEverywhere API documentation as a single markdown file. Designed to be pasted into an LLM context window so an agent has the full reference available. Generated automatically from https://developers.posteverywhere.ai. Last update: 2026-08-19T17:23:23.951Z --- # Getting Started # Social Media Post API Documentation > Schedule and publish social media content across Instagram, TikTok, YouTube, LinkedIn, X, Facebook, Threads, Pinterest, Bluesky, Telegram, and Discord from a single API. # PostEverywhere: The Social Media Post API Schedule and publish social media content across **12 platforms** (11 social networks plus WordPress blogs) from a single API. Connect your social accounts, create posts with platform-specific customizations, upload media, schedule for optimal times, and monitor publishing results — all through one unified interface. :::tip The OpenAPI spec is the canonical reference Building with an AI agent or code generator? Point it at [developers.posteverywhere.ai/openapi.json](https://developers.posteverywhere.ai/openapi.json). It is generated from the live API and always current, and its field names are authoritative: the upload request takes `size` (not `file_size`), and media readiness lives in `media_status` (not `status`). ::: ## Supported Platforms | Platform | Post Types | Media | |----------|-----------|-------| | **[Instagram](/platforms/instagram)** | Feed posts, Stories, Reels | Images, video, carousels | | **[TikTok](/platforms/tiktok)** | Videos, photo carousels | Video, images (up to 35) | | **[YouTube](/platforms/youtube)** | Videos, Shorts | Video | | **[LinkedIn](/platforms/linkedin)** | Posts, articles | Images, video, PDF carousels | | **[X (Twitter)](/platforms/x-twitter)** | Posts, threads | Images, video | | **[Facebook](/platforms/facebook)** | Posts, Reels | Images, video | | **[Threads](/platforms/threads)** | Posts | Images, video | | **[Pinterest](/platforms/pinterest)** | Pins | Images | | **[Bluesky](/platforms/bluesky)** | Posts, threads | Images, video | | **[Telegram](/platforms/telegram)** | Channel posts | Images, video, PDF, carousels | | **[Discord](/platforms/discord)** | Server posts via webhook | Images, video | ## What You Can Do - **[Schedule posts](/api/create-post)** to multiple platforms simultaneously - **Customize content** per platform (hashtags, titles, privacy settings) - **Save drafts** with `draft: true`, review them, then schedule with `POST /v1/posts/{id}/schedule` — ideal for human-in-the-loop agent workflows - **[Upload media](/api/init-media-upload)** (images and video) with automatic processing - **Schedule many posts** by calling [Create Post](/api/create-post) in a loop with different `scheduled_for` times - **[Monitor results](/api/get-post-results)** with per-platform publishing status - **Retry failed posts** automatically or on demand - **[Generate AI images](/api/generate-image)** from text prompts ## Two status vocabularies (read this before polling) A post and each of its destinations use **different** status names. Mixing them up is the most common integration mistake, so they are listed here in full. **Post status** (`post_status` on a post, and the `status` filter on `GET /posts`) is derived live from the destinations underneath it: `draft` · `scheduled` · `publishing` · `published` · `partially_failed` · `failed` **Destination status** (`destination_status` on each entry in a post's `destinations` array) is the real lifecycle of one platform target: | Value | Meaning | | --- | --- | | `queued` | Waiting for its scheduled time | | `preparing`, `uploading`, `publishing`, `verifying` | In flight right now | | `retry_scheduled` | Failed once, will be retried automatically | | `done` | **Published successfully** | | `failed` | Terminal failure, see the `error` field | :::warning A destination is never "published" At the destination level, success is **`done`**. `published` exists only in the post-level vocabulary above. Filtering destinations on `status == "published"` matches nothing. ::: ## Base URL ``` https://app.posteverywhere.ai/api/v1 ``` ## Getting Started 1. [Create a PostEverywhere account](https://app.posteverywhere.ai/signup) if you don't have one 2. Go to **Settings > Developers** in your dashboard to generate an API key 3. Follow the [Quick Start guide](/quick-start) to make your first API call ## SDKs & Tools ### Node.js SDK ```bash npm install @posteverywhere/sdk ``` ```javascript const client = new PostEverywhere({ apiKey: 'pe_live_...' }); const post = await client.posts.create({ content: 'Hello from the API!', account_ids: [123, 456, 789], scheduled_for: '2026-04-01T10:00:00Z', }); ``` ### Command line (CLI) Log in, connect every platform, and publish straight from your terminal: ```bash npm install -g @posteverywhere/cli posteverywhere login # browser approve, key saved locally posteverywhere connect instagram # OAuth opens; the account is auto-detected posteverywhere post -c "Hello from the CLI!" -a 123,456 ``` Every command supports `--json` for AI agents. Full guide: [CLI](/cli). ### Claude Code (MCP) Easiest — connect to the hosted endpoint, nothing to install: ```bash claude mcp add --transport http posteverywhere \ https://mcp.posteverywhere.ai \ --header "Authorization: Bearer pe_live_YOUR_KEY" ``` Or run it locally via `npx`: ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` Full guide: [MCP Server](/integrations/mcp). ### cURL ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer pe_live_..." \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from the API!", "account_ids": [2280, 2281, 2282], "scheduled_for": "2026-04-15T14:30:00Z" }' ``` --- # API Authentication - API Keys & Bearer Tokens > How to authenticate with the PostEverywhere API using Bearer tokens. Generate API keys, manage scopes, and secure your integration. # Authentication All API requests require a Bearer token in the `Authorization` header. If you have not set up your account yet, start with the [Quick Start guide](/quick-start). ## Getting Your API Key 1. Sign in to [PostEverywhere](https://app.posteverywhere.ai) 2. Go to [**Settings > Developers**](https://app.posteverywhere.ai/settings?tab=developers) 3. Click **Generate API Key** 4. Copy your key — it starts with `pe_live_` :::caution Keep Your Key Secret Your API key grants full access to your account. Never expose it in client-side code, public repositories, or browser requests. ::: ## Using Your Key Include the key in every request: ```bash curl https://app.posteverywhere.ai/api/v1/accounts \ -H "Authorization: Bearer pe_live_abc123def456..." ``` Or with the Node.js SDK: ```javascript const client = new PostEverywhere({ apiKey: process.env.POSTEVERYWHERE_API_KEY, }); ``` ## Authenticated response format All responses follow a consistent envelope: ```json { "data": { }, "error": null, "meta": { "request_id": "req_abc123", "timestamp": "2026-04-01T10:00:00Z" } } ``` On error: ```json { "data": null, "error": { "code": "unauthorized", "message": "Invalid or expired API key", "details": { } }, "meta": { "request_id": "req_abc123", "timestamp": "2026-04-01T10:00:00Z" } } ``` All timestamps in responses are UTC. The `meta.request_id` value is what support will ask for if you need to debug a failed call — log it. ## Error Codes | Status | Code | Description | |--------|------|-------------| | `400` | `bad_request` | Invalid request body or parameters | | `401` | `unauthorized` | Missing or invalid API key | | `404` | `not_found` | Resource doesn't exist | | `429` | `rate_limited` | Too many requests (see [Rate Limits](/rate-limits)) | | `500` | `internal_error` | Server error — retry with backoff | ## Next Steps Once your API key is set up, explore the platform guides and API reference: - **[Create your first post](/api/create-post)** -- schedule content to any platform - **[Upload media](/api/init-media-upload)** -- attach images and videos to your posts - **[Rate Limits](/rate-limits)** -- understand request quotas and best practices - **Platform guides:** [Instagram](/platforms/instagram) | [TikTok](/platforms/tiktok) | [YouTube](/platforms/youtube) | [LinkedIn](/platforms/linkedin) | [X (Twitter)](/platforms/x-twitter) | [Facebook](/platforms/facebook) | [Threads](/platforms/threads) | [Pinterest](/platforms/pinterest) --- # Quick Start Guide > Get up and running with the PostEverywhere API in under 5 minutes. Create your first cross-platform social media post. # Quick Start Get your first cross-platform post scheduled in under 5 minutes. :::tip Prefer the terminal? The [CLI](/cli) does this whole flow end-to-end — `posteverywhere login`, `posteverywhere connect `, then `posteverywhere post`. Install: `npm install -g @posteverywhere/cli`. ::: ## Prerequisites - A [PostEverywhere account](https://app.posteverywhere.ai/signup) (7-day free trial) - At least one connected social media account (connect via the dashboard) - An API key (generate in **Settings > Developers**) ## Step 1: Get Your API Key Navigate to [Settings > Developers](https://app.posteverywhere.ai/settings?tab=developers) in your PostEverywhere dashboard and click **Generate API Key**. Your key starts with `pe_live_` — keep it secret. ```bash export POSTEVERYWHERE_API_KEY="pe_live_abc123..." ``` ## Step 2: List Your Connected Accounts ```bash curl https://app.posteverywhere.ai/api/v1/accounts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` Response: ```json { "data": { "accounts": [ { "id": 2280, "platform": "instagram", "account_name": "mybrand", "avatar_url": "https://...", "is_active": true } ] } } ``` Note the `id` values (integers) — you'll use these in `account_ids` when creating posts. ## Step 3: Create a Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Exciting news! We just launched our API.", "account_ids": [2280, 2281], "scheduled_for": "2026-04-01T10:00:00Z" }' ``` Response: ```json { "data": { "post_id": "d3e859bf-8b9e-4cd8-99e5-7f019ab95709", "content": "Exciting news! We just launched our API.", "status": "scheduled", "scheduled_for": "2026-04-01T10:00:00Z", "account_ids": [2280, 2281], "destinations": [ { "platform": "instagram", "account_id": 2280, "account_name": "mybrand", "status": "scheduled" }, { "platform": "linkedin", "account_id": 2281, "account_name": "My Brand Inc.", "status": "scheduled" } ] }, "error": null, "meta": { "request_id": "req_a1b2c3d4", "timestamp": "2026-04-01T09:55:12.341Z" } } ``` Notice that every field name in your request (`content`, `account_ids`, `scheduled_for`) also appears in the response with the same name and same meaning — this is the round-trip principle. You can take the response and POST its top-level fields straight back to create a clone. :::tip Save it as a draft instead Add `"draft": true` to save the post as a draft rather than scheduling it (`account_ids` is optional for drafts). Review it later, then schedule it with `POST /v1/posts/{id}/schedule` — sending `scheduled_for` or `publish_now: true`. This "draft → review → schedule" flow is ideal for AI agents that should get human approval before anything publishes. See [Building AI Agents](/integrations/agents). ::: ## Step 4: Check Results After the scheduled time, check how your post performed: ```bash curl https://app.posteverywhere.ai/api/v1/posts/post_789/results \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` ## Next Steps - [CLI](/cli) — log in, connect accounts, and publish from your terminal - [Authentication](/authentication) — API key scopes and security - [Platform Guides](/platforms/instagram) — Platform-specific features and requirements - [API Reference](/api/list-accounts) — Full endpoint documentation - [Rate Limits](/rate-limits) — Usage limits and best practices Next: the full [Create Post reference](/api/create-post), [schedule a draft](/api/schedule-post), or [connect more platforms](/connecting-accounts). --- # API Reference # Accounts > Accounts List and inspect connected accounts across all 12 platforms, including per-account publish health and reconnect status. ```mdx-code-block ``` --- # AI > AI Generate platform-tuned social media captions and AI images from the API, using your organization's AI credit allowance. ```mdx-code-block ``` --- # Analytics > Analytics Pull cross-platform performance summaries — impressions, likes, comments, and per-post results — for reporting and dashboards. ```mdx-code-block ``` --- # Auth > Auth Device-grant login and browser OAuth handoff — connect social accounts from CLIs, servers, and AI agents without a dashboard session. ```mdx-code-block ``` --- # Create up to 50 posts in one call > Each item is dispatched through the same validation as `createPost` Each item is dispatched through the same validation as `createPost` (sequentially, so posting rate limits stay deterministic). The bulk call counts as one API-rate-limit hit; per-post posting budgets still apply. Partial success is normal — iterate `results`. HTTP status: 201 all succeeded, 207 mixed, 422 all failed. --- # Campaigns > Campaigns Group posts into campaigns to tag, filter, and report on related content across platforms. ```mdx-code-block ``` --- # Finalize a media upload (step 3 of 3) > Call after uploading the file to the presigned `upload_url`. Verifies Call after uploading the file to the presigned `upload_url`. Verifies the file actually exists (`400 file_not_uploaded` when it does not) and that its magic bytes match the declared content type (`400 file_type_mismatch` — the media is then marked failed). Videos get a thumbnail generated in the background. Idempotent: calling on already-ready media returns 200. `400 upload_failed` when the media was previously marked failed. No request body is required. --- # Connect a Bluesky account (headless, app password) > Requires an API key. Use an App Password (Bluesky Settings → App Requires an API key. Use an App Password (Bluesky Settings → App Passwords), not the login password (`401 auth_failed` otherwise). Errors: `validation_error`, `account_limit_reached` (403), `already_connected` (409 — account belongs to another organization). --- # Connect telegram, discord, or bluesky directly with credentials > Connects a credential-based platform entirely through the API, no Connects a credential-based platform entirely through the API, no browser involved. Credentials are validated live against the platform before saving; invalid credentials return a 400 with the reason. Re-submitting credentials for an already-connected account updates it in place (this is also how these platforms reconnect). Required fields by platform: telegram needs `bot_token` (from @BotFather; the bot must be an admin of the channel) and `channel` (@username or numeric chat id); discord needs `webhook_url`; bluesky needs `handle` and `app_password` (an app password, never the main account password). --- # Connect a Discord channel (headless, webhook URL) > Requires an API key. Create the webhook in Discord under Server Requires an API key. Create the webhook in Discord under Server Settings → Integrations → Webhooks and paste its URL. Invalid URLs return `401 auth_failed`. --- # Connect a Telegram channel (headless, bot token) > Requires an API key. The bot (from @BotFather) must be an Requires an API key. The bot (from @BotFather) must be an administrator of the channel with Post Messages enabled (`400 bot_not_admin` otherwise, with setup instructions). `channel` accepts "@name" or a numeric chat ID. Provider errors are mapped to specific codes; upstream Telegram failures return 502. --- # Create a campaign > Campaigns group related posts (assign via `campaign_id` when listing Campaigns group related posts (assign via `campaign_id` when listing posts; the dashboard sets it on posts). Default color is `#3b82f6`. --- # Mint an OAuth connect link for a new social account > Returns a short-lived (10 minute) authorization URL for connecting a Returns a short-lived (10 minute) authorization URL for connecting a NEW account on an OAuth platform: `x`, `instagram`, `facebook`, `youtube`, `pinterest`, `threads`, `linkedin`, `tiktok`. Hand the URL to the account owner; they approve in any browser (logged in to PostEverywhere or not, identity is carried in the signed state), and the account lands in this organization. Poll `GET /accounts` until it appears. For `telegram`, `discord`, `bluesky` use `POST /accounts/connect-credential` instead, no browser needed. --- # Create, schedule, or draft a post > Omit `scheduled_for` to publish immediately; provide it (ISO 8601, Omit `scheduled_for` to publish immediately; provide it (ISO 8601, assumed UTC when no offset given) to schedule. Set `draft: true` to save a native draft instead (no destinations are created and nothing publishes until `POST /posts/{id}/schedule`). Attach media by id (see [media requirements per platform](/media-requirements)), and use `platform_content` for per-platform overrides — the [Platform Guides](/platforms/instagram) document every option. Field-name handling: camelCase aliases (`accountIds`, `mediaIds`, `scheduledFor`, `platformContent`) and the deprecated `scheduled_at` alias are accepted. Fields whose names imply a different shape (`media`, `media_id`, `mediaId`, `attachments`) are rejected with `400 invalid_field_name`. Content rules: `content` may be omitted only when `platform_content` provides at least one per-platform caption or media is attached. Max 10,000 characters. Max 50 `account_ids`. Instagram captions with more than 30 hashtags are rejected (`instagram_hashtag_limit_exceeded`). Media referenced by `media_ids` must exist in the workspace and be `ready` (`media_not_found` / `media_not_ready` otherwise; media stuck in `uploading` is auto-finalized via the `/complete` flow when the file was actually uploaded). Platform media requirements are validated pre-flight (`platform_requirement_violation`, e.g. video to Pinterest). A circuit breaker refuses a request body that has already failed 5+ times in 6 hours for the same key with `422 permanent_failure_circuit_breaker` — change any field to reset. Error codes: `invalid_json`, `wrong_content_type_for_endpoint`, `invalid_field_name`, `validation_error`, `content_too_long`, `account_ids_required`, `too_many_accounts`, `invalid_timezone`, `invalid_accounts`, `instagram_hashtag_limit_exceeded`, `invalid_datetime`, `past_schedule_time`, `media_not_found`, `media_not_ready`, `platform_requirement_violation`, `post_creation_failed`, `rate_limit_exceeded`, `permanent_failure_circuit_breaker`. --- # Mint an OAuth link to repair an existing broken account > Returns a short-lived (10 minute) authorization URL that repairs an Returns a short-lived (10 minute) authorization URL that repairs an EXISTING OAuth account whose token died (`needs_reconnection` or `token_expired` from the health endpoint). The owner must approve while logged in to the platform as that same profile; matching is by the platform's account id, so authorizing as a different profile creates a new account instead. Verify with `GET /accounts/{id}/health` afterwards. Credential platforms (telegram, discord, bluesky) reconnect by re-submitting credentials via `POST /accounts/connect-credential`. --- # Create a webhook subscription > PostEverywhere POSTs event payloads to your URL, signed with PostEverywhere POSTs event payloads to your URL, signed with HMAC-SHA256 in the `X-PostEverywhere-Signature` header (`sha256=` over the raw body using the returned `secret`). Delivery headers also include `X-PostEverywhere-Event`, `X-PostEverywhere-Event-Id`, `X-PostEverywhere-Timestamp` and `X-PostEverywhere-Delivery-Id`. The `secret` is returned ONLY on creation. URLs are SSRF-validated (no localhost/private/metadata addresses). Max 25 webhooks per organization (`400 limit_reached`). Unknown events → `400 invalid_event_type`. --- # Delete a campaign > Posts in the campaign survive — their campaign_id is set to null. Posts in the campaign survive — their campaign_id is set to null. --- # Delete a media file > If the media is attached to scheduled posts the delete is refused with If the media is attached to scheduled posts the delete is refused with `409 media_in_use` unless `?force=true`, in which case those pending destinations are cancelled (marked failed with a cancellation note) and `cancelled_post_count` reports how many. Deletion is permanent and frees storage. Fires the `media.deleted` webhook event. --- # Delete a post and all its destinations > Permanent. Fires the `post.deleted` webhook event. Permanent. Fires the `post.deleted` webhook event. --- # Delete a webhook subscription > Cascades to stored deliveries. Cascades to stored deliveries. --- # Generate social media captions with AI > Generates 1-5 captions for a topic, styled per platform/tone/length. Generates 1-5 captions for a topic, styled per platform/tone/length. Costs 1 AI credit per caption returned; credits are reserved atomically before generation and refunded for captions that fail. `402 insufficient_credits` when the plan's monthly credit budget (plus bonus credits) cannot cover the request; `502 ai_no_content` when the model returns nothing. Unknown platform/tone/length values silently fall back to defaults. --- # Generate an image with AI and store it in the media library > Generates one image via FAL AI, stores it in the media library and Generates one image via FAL AI, stores it in the media library and returns a ready `media_id` for `createPost`. Credit cost depends on the model; credits are reserved atomically and refunded on failure. Free-trial organizations without a paid subscription get `402 upgrade_required`. Prompts are safety-filtered (`400 prompt_rejected`). Additional AI burst limits apply on top of the API-key limit. Error codes: `validation_error`, `prompt_rejected`, `upgrade_required`, `insufficient_credits`, `rate_limit_exceeded`, `generation_failed`, `internal_error`. --- # Get a single connected social account > Account IDs are integers (as returned by `GET /accounts`), not UUIDs. Account IDs are integers (as returned by `GET /accounts`), not UUIDs. Non-numeric IDs return `400 invalid_id`. --- # Get a detailed health report for an account > Tells clients whether an account is currently usable (token alive, no Tells clients whether an account is currently usable (token alive, no reconnection required, recent publish activity). `status` verdicts: `healthy` (post away), `warning` (`recent_permanent_failures` or `token_expiring_soon` — posting still allowed), `broken` (`account_inactive`, `needs_reconnection` or `token_expired` — `can_post` is false). --- # Aggregate post counters and engagement metrics for a time window > Buckets posts created in the window by derived status, breaks down Buckets posts created in the window by derived status, breaks down destinations per platform, and sums engagement metrics of published destinations. Periods are UTC-anchored rolling windows. Errors: `invalid_period`, `invalid_range` (custom period missing/invalid from/to). --- # Get a campaign > Fetch one campaign by id — name, description, color, status, and Fetch one campaign by id — name, description, color, status, and its `post_count`. --- # Get current API key context, organization, quota and usage > Returns who the key belongs to, its scopes, the organization's Returns who the key belongs to, its scopes, the organization's subscription state, quota usage (accounts, AI credits, storage, seats) and post counts. Lets SDK/MCP clients self-configure without being handed `organization_id` etc. --- # Get media status and details > `media_status` stays `uploading` until you call `/complete` — polling `media_status` stays `uploading` until you call `/complete` — polling this endpoint alone will never flip it to `ready`. `action_required` tells clients the next step when not ready. `url` is a permanent viewable URL, populated once ready. --- # Get platform composer rules > Server-authoritative composer rules for every supported platform: Server-authoritative composer rules for every supported platform: character limits, image/video constraints, and feature support (threads, carousels, reels, alt text). Check before composing for an unfamiliar platform — limits span 300 characters (Bluesky) to 63,206 (Facebook), and some constraints are enforced platform-side only. Served with an ETag; a newly supported platform appears here with no client update. Live since 2026-08-17. --- # Get a post with destination statuses > For drafts, the chosen accounts and per-platform content are surfaced For drafts, the chosen accounts and per-platform content are surfaced from the draft data and `scheduled_for` is `null` unless pre-set. --- # Get per-platform publish results for a post > Poll this after an immediate publish. Each destination's status moves Poll this after an immediate publish. Each destination's status moves from `queued` → `publishing` → `published` or `failed`; `platform_post_url` is set after a successful publish. --- # Get a webhook subscription (without secret) > Fetch one webhook subscription — URL, subscribed events, active Fetch one webhook subscription — URL, subscribed events, active state, and delivery stats. The signing secret is only returned at creation time. --- # Upload media (direct or presigned) > TWO modes, selected by the request Content-Type. TWO modes, selected by the request Content-Type. **DIRECT mode (images only, recommended for API clients).** Send `multipart/form-data` with the file in a field named `file` (optional `filename` field to override the name). The upload completes in this single request and the response returns `media_status: "ready"` with `upload_required: false`, no `upload_url`, no `/complete` call. Max 25 MB. Videos sent this way get `415` with instructions. **PRESIGNED mode (all types).** Send file METADATA as JSON. Returns an `upload_url` plus `upload_method` describing how to send the bytes: - images → POST `multipart/form-data`, field name `file` (Cloudflare Images direct upload) - videos (MP4 only) and PDFs → PUT with the declared `Content-Type` (R2 presigned URL) Then you MUST call `POST /media/{media_id}/complete`, or the media stays `uploading` forever otherwise. `expires_at` is authoritative (image URLs last 6 hours, video/PDF URLs 1 hour). Size caps: 20 MB images/PDFs, 500 MB video. Upload quota is only consumed at `/complete`, so retries here are free. **Presigned image caveat**: the image upload host runs an edge bot filter outside our control. Send any custom `User-Agent` header; Python stdlib's default (`Python-urllib/x`) is rejected with HTTP 403 error 1010. `python-requests`, `curl`, node, Go and empty User-Agents all pass. DIRECT mode has no such restriction from any client. Error codes: `validation_error`, `unsupported_media_type`, `file_too_large` (413), `length_required` (411, direct mode), `storage_quota_exceeded` (403), `rate_limit_exceeded`, `service_unavailable`. --- # Introspection > Introspection Ask the API who you are — key scopes, organization, quota, and usage. Lets SDKs, CLIs, and AI agents self-configure from a bare API key. ```mdx-code-block ``` --- # List connected social accounts > Returns all active social accounts in the organization with a brief Returns all active social accounts in the organization with a brief health block per account. Not paginated. Facebook-flow Instagram rows that have an Instagram-direct sibling are excluded (they are hidden in the dashboard too). --- # List campaigns in the workspace > Returns every campaign in the workspace with name, color, status, Returns every campaign in the workspace with name, color, status, and post counts — use `status` to filter active vs archived. --- # List media library items > Newest first. `pagination` echoes `limit`/`offset` only (no total). Newest first. `pagination` echoes `limit`/`offset` only (no total). The convenience `media_ids` array can be copied straight into `createPost`. Viewable URLs are not included in the list response — use `getMedia` for a per-item `url`. --- # List posts with filters > Post status is derived live from per-destination statuses (the raw Post status is derived live from per-destination statuses (the raw `posts.post_status` column is frozen at creation). Multi-value filters (`status`, `platform`) accept comma-separated lists. Invalid filter values are silently dropped, not rejected. --- # List webhook subscriptions > The signing secret is never included — it is returned only on creation. The signing secret is never included — it is returned only on creation. --- # Media > Media Upload images, video, and PDFs — direct, presigned three-step, or imported straight from a URL — then attach them to posts on any platform. ```mdx-code-block ``` --- # Platform Rules > Platform Rules Per-platform composer rules — character limits, media constraints, and feature support — so clients can validate before publishing. ```mdx-code-block ``` --- # Poll a device login and mint the API key on approval > UNAUTHENTICATED. Poll every 5 s with the `device_code`. Non-terminal UNAUTHENTICATED. Poll every 5 s with the `device_code`. Non-terminal states return 200 with `status` of `pending` or `slow_down` (includes `interval`). Terminal: `expired`, `denied`, or `authorized`. On the first poll after browser approval the response carries the plaintext `api_key` (scopes read/write/ai, no expiry) EXACTLY ONCE — persist it immediately; subsequent polls return `expired`. --- # Poll a connect session until the browser OAuth completes > Requires the SAME API key that started the session. Resolves to Requires the SAME API key that started the session. Resolves to `completed` (with the connected `account`) once an account for the session's platform appears (or, for reconnect, the target account's token refreshes) after the session started. Other statuses: `pending`, `expired`, `cancelled`. --- # PostEverywhere API Reference > Public v1 API for PostEverywhere — schedule and publish content across Public v1 API for PostEverywhere — schedule and publish content across social platforms (X/Twitter, Instagram, Facebook, LinkedIn, YouTube, TikTok, Threads, Pinterest, Bluesky, Telegram, Discord). New here? Start with the [Quick Start](/quick-start) (first post in 5 minutes) and [Authentication](/authentication). Platform-specific behavior lives in the [Platform Guides](/platforms/instagram), and media constraints in [Media Requirements](/media-requirements). ## Authentication All endpoints (except the device-login pair under `/auth/device`) accept an API key in the `Authorization` header: ``` Authorization: Bearer pe_live_... ``` Keys are created in the dashboard (Developers) or minted by the CLI device flow (`POST /auth/device` → `POST /auth/device/token`). Keys carry scopes (`read`, `write`, `ai`); each operation's required scope is noted via `x-scope`. A missing scope returns `403 insufficient_scope`. A browser session cookie also works as a fallback for interactive testing; scopes are then implicitly `read, write, ai` and API-key rate limits are skipped. Auth failures return `401` with codes `invalid_api_key`, `api_key_revoked`, or `api_key_expired`. Organizations without an active subscription receive `402 subscription_required` (the error object additionally carries `subscription_status`). ## Response envelope Every endpoint responds with the same JSON envelope: ```json { "data": { ... }, "error": null, "meta": { "request_id": "1a2b3c4d", "timestamp": "2026-07-08T09:00:00.000Z" } } ``` On failure `data` is `null` and `error` is populated: ```json { "data": null, "error": { "message": "...", "code": "validation_error", "retryable": false, "details": { } }, "meta": { "request_id": "1a2b3c4d", "timestamp": "..." } } ``` `error.retryable` is `true` for transient failures (HTTP 5xx and 429) and `false` for permanent ones — clients should not blindly retry 4xx. Exception: the auth layer's own 401/402 responses omit `retryable` and `meta.request_id` (they carry only `meta.timestamp`). ## Rate limiting API keys are limited to 60 requests/minute and 1,000 requests/hour. When the API-key limit is hit the `429` response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) headers. Every `429` (including posting-budget and AI-budget limits, which are separate from the API-key limit) includes a `Retry-After` header (seconds, default 30). Posting budgets (per user): immediate publishes 20/min, 200/hour, 1,000/day; scheduled posts 100/min, 1,000/hour with no daily cap. Media uploads: 60/hour, 300/day (counted at `/media/{id}/complete` or `/media/upload-from-url`, not at presign). ## Timestamps All timestamps are ISO 8601. `scheduled_for` request values without an explicit offset are interpreted as UTC. The `timezone` field is display metadata only — it never changes when a post fires.

Contact

PostEverywhere Developers: URL: [https://developers.posteverywhere.ai](https://developers.posteverywhere.ai)
--- # Posts > Posts Create, schedule, draft, retry, and inspect posts across 12 platforms (11 social networks plus WordPress blogs) — single posts, bulk batches of 50, and per-platform content overrides. ```mdx-code-block ``` --- # Bulk-retry failed destinations by filter > Retries every failed destination matching the filter (at least one Retries every failed destination matching the filter (at least one filter is required — the API refuses to retry the entire failure history, `400 no_filter`). Matched destinations are reset to `queued` with attempts zeroed and `scheduled_for = now`. `post_ids` is capped at 200 (`bulk_limit_exceeded`). `failed_after` / `failed_before` compare against the destination's last update time. Counts against the immediate-publish posting budget. --- # Retry all failed destinations of one post > Resets every `failed` destination of the post to `queued` with a fresh Resets every `failed` destination of the post to `queued` with a fresh attempt budget and `scheduled_for = now` (publishes on the next scheduler cycle). Returns `400 no_failed_destinations` when nothing is in a failed state. Counts against the immediate-publish posting budget. --- # Schedule (publish) a draft > Turns a draft into a live scheduled/publishing post: flips the post to Turns a draft into a live scheduled/publishing post: flips the post to `scheduled`, clears draft data, and creates one queued destination per account. Provide either `scheduled_for` (at least 1 minute in the future) or `publish_now: true` (queued to fire within ~1 scheduler cycle). `account_ids` overrides the draft's saved targets; required if the draft has none. Non-drafts return `409 not_a_draft` — use `PATCH /posts/{id}` to move an already-scheduled post. Plan post limits are enforced here (`429 post_limit_reached` / `daily_limit_reached`). --- # AccountHealthReport --- # AnalyticsSummary --- # BulkResultEnvelope --- # Campaign --- # ConnectCompletedEnvelope --- # ConnectedAccountResult --- # CreatePostRequest > Body for createPost (and each item of bulkCreatePosts). camelCase Body for createPost (and each item of bulkCreatePosts). camelCase aliases (accountIds, mediaIds, scheduledFor, platformContent, coverPhoto) and the deprecated scheduled_at alias are also accepted. --- # DestinationMetrics > Engagement metrics fetched from the platform (null until first fetch; only some platforms have metric fetchers) Engagement metrics fetched from the platform (null until first fetch; only some platforms have metric fetchers) --- # DraftCreated > createPost response when draft: true createPost response when draft: true --- # ErrorBody --- # ErrorEnvelope --- # GeneratedImage > generateImage response — the image is already in the media library generateImage response — the image is already in the media library --- # Me --- # MediaCompleted > completeMediaUpload response completeMediaUpload response --- # MediaDetail --- # MediaItem > Media library item (listMedia shape) Media library item (listMedia shape) --- # MediaStatus > uploading until /complete is called; it never flips to ready on its own uploading until /complete is called; it never flips to ready on its own --- # MediaUploadTicket > initMediaUpload response — where and how to send the file bytes initMediaUpload response — where and how to send the file bytes --- # Meta > Response metadata. request_id is an 8-char correlation id (absent on auth-layer 401/402). Response metadata. request_id is an 8-char correlation id (absent on auth-layer 401/402). --- # Pagination --- # PlatformRule > Composer rules for one platform Composer rules for one platform --- # Post > Post as returned by listPosts / getPost Post as returned by listPosts / getPost --- # PostCreated > createPost response for a scheduled/immediate post createPost response for a scheduled/immediate post --- # PostDestination > One platform/account target of a post One platform/account target of a post --- # PostResult > Per-destination publish result (getPostResults) Per-destination publish result (getPostResults) --- # PostStatus > Derived from live destination statuses (the stored post_status column is frozen at creation) Derived from live destination statuses (the stored post_status column is frozen at creation) --- # SocialAccount --- # UpdatedPost > Post as returned by updatePost (a slimmer shape than getPost) Post as returned by updatePost (a slimmer shape than getPost) --- # Webhook --- # WebhookEventType --- # Start a device-grant login (RFC 8628 style) > UNAUTHENTICATED. Show the returned `user_code` and UNAUTHENTICATED. Show the returned `user_code` and `verification_uri` to the user; they approve in a logged-in browser. Then poll `POST /auth/device/token` every `interval` seconds. Codes expire after `expires_in` seconds (15 min). Per-IP rate limited. --- # Start a browser OAuth connect session > Requires an API key (session fallback rejected with Requires an API key (session fallback rejected with `401 api_key_required`). For the browser-OAuth platforms (instagram, facebook, threads, x, linkedin, tiktok, youtube, pinterest) this returns the dashboard's OAuth-start `authorize_url` to open in a logged-in browser, plus a `session_id` to poll. Headless platforms (bluesky, telegram, discord) are rejected with `400 use_headless_endpoint` — POST credentials to their dedicated endpoints instead. `mode: reconnect` requires `account_id`. --- # Send a signed test payload to a webhook URL > Sends a synthetic ping (payload has `data.test: true`) so you can Sends a synthetic ping (payload has `data.test: true`) so you can verify signature validation end-to-end. The delivery result is returned inline; no delivery row is stored. Always responds 200 — check `data.ok` for the delivery outcome. --- # Update a campaign > At least one of name, description, color, status is required. At least one of name, description, color, status is required. --- # Update a scheduled or draft post > Only posts whose derived status is `scheduled` or `draft` can be Only posts whose derived status is `scheduled` or `draft` can be edited (`400 invalid_post_status` otherwise). At least one field is required. Changing `account_ids` rebuilds all destinations; changing only `scheduled_for` shifts every queued destination by the same delta. Same alias and wrong-field-name rules as `createPost`. Error codes: `validation_error`, `invalid_post_status`, `content_too_long`, `too_many_accounts`, `invalid_timezone`, `invalid_datetime`, `past_schedule_time`, `invalid_accounts`, `invalid_field_name`, `invalid_id`, `not_found`. --- # Update a webhook subscription > At least one of url, events, name, description, is_active is required. At least one of url, events, name, description, is_active is required. Setting `is_active: true` on a disabled webhook clears the auto-disabled marker and resets its failure counter. URL changes are re-validated with the same SSRF rules as creation. --- # One-shot import of a public image or MP4 video URL > Fetches the URL server-side and stores it — no presign/PUT/complete Fetches the URL server-side and stores it — no presign/PUT/complete dance. **Images** (JPEG/PNG/GIF/WebP/HEIC/HEIF, max 25 MB) import synchronously: the response `media_id` is ready immediately. **Videos** (MP4 only, max 500 MB) import ASYNCHRONOUSLY: the response returns instantly with `media_status: "uploading"` while the file streams in server-side. Poll `GET /media/{id}` until `media_status` is `ready` (typically seconds), then attach via `media_ids`. If it becomes `failed`, the poll response's `error_message` states exactly why (too large, not actually an MP4, storage quota, source URL died). Oversize videos with a Content-Length return `413` up front; storage quota returns `403 storage_quota_exceeded`. 30 s fetch timeout to first byte, SSRF-guarded including redirects (no localhost/private addresses, `400 invalid_url`). Unreachable or slow URLs return `502`/`504` `fetch_failed`. Consumes the media upload quota. --- # Webhooks > Webhooks Subscribe to real-time post lifecycle events (published, failed, updated) with signed deliveries and automatic retries. ```mdx-code-block ``` --- # Integrations # Agent System Prompt — Drop-in Template for Any LLM Agent > A copy-paste system prompt that teaches any LLM agent (Claude, GPT, Gemini, open-source) how to call the PostEverywhere API correctly. Covers field names, the round-trip principle, UTC handling, and the most common LLM mistakes. # Agent System Prompt Use this as a drop-in system prompt (or part of one) for any LLM agent that calls the PostEverywhere API. It heads off the most common mistakes LLMs make with our API — wrong field names, scope confusion, timezone bugs, and silent error handling. :::tip Why this exists LLM agents tend to invent plausible-but-wrong field names if you don't tell them the canonical ones. We discovered this the hard way: a customer's agent silently used `scheduled_at` instead of `scheduled_for` and our v1 API silently dropped it. We've since made `scheduled_for` the canonical field name, with `scheduled_at` accepted as a deprecated alias — and the API now rejects other variants with helpful 400 errors. The cleanest fix is teaching the agent the right names from the start. ::: ## How to use - **Claude / Anthropic API:** paste this into the `system` parameter of your `messages.create` call. - **OpenAI / GPT / Codex:** paste into the `system` role message (or your Codex agent instructions). - **LangChain / CrewAI / AutoGen:** use as the system prompt for the agent that has tool access to PostEverywhere. - **MCP (Claude Code/Desktop, Cursor, ChatGPT, OpenAI Codex, …):** the [PostEverywhere MCP server](/integrations/mcp) already encodes most of this, but you can include the principles in your assistant instructions. For ChatGPT, connect via the hosted connector on the [agents page](https://posteverywhere.ai/agents). ## The prompt ````markdown You are connecting to the PostEverywhere API to schedule and publish social media posts. Read these rules carefully — they're written specifically to head off the most common mistakes LLM agents make with this API. ## Base URL https://app.posteverywhere.ai/api/v1 ## Authentication Send `Authorization: Bearer pe_live_` on every request. ## CANONICAL FIELD NAMES — use these EXACTLY | Field | Type | Notes | |---|---|---| | `content` | string | Post body text | | `account_ids` | integer[] | **Array of integers**, not strings. Get them from `GET /v1/accounts`. Example: `[2280, 2281]` | | `scheduled_for` | string (ISO 8601 UTC) | When the post should publish. **Always send UTC.** Example: `"2026-04-15T14:30:00Z"`. Omit to publish immediately. | | `timezone` | string | IANA timezone for display. Defaults to `"UTC"`. **Does NOT change when the post fires** — that's controlled entirely by `scheduled_for`. | | `media_ids` | string[] (UUID) | **Plural**, **array of UUID strings**. Upload media first — use the one-call URL upload (next section) when the image is already at a public URL, or the 3-step flow for local files/videos. Example: `["a1b2c3d4-..."]` | | `platform_content` | object | Optional per-platform overrides keyed by platform name. | | `draft` | boolean | Optional. Send `true` to save the post as a **draft** instead of publishing or scheduling it. With `draft: true`, `account_ids` is **optional** (you can pick targets later). Schedule it afterwards with `POST /v1/posts/{id}/schedule`. | ## ⚠️ COMMON MISTAKES — DO NOT DO THESE The API will return a clear `400 invalid_field_name` error with a "Did you mean…" hint if you make any of these mistakes, but it's faster to get it right the first time: | ❌ Wrong | ✅ Right | Why | |---|---|---| | `scheduled_at` | `scheduled_for` | `scheduled_at` is a deprecated alias. Use `scheduled_for`. | | `schedule_for` | `scheduled_for` | Note the **`d`** in "scheduled". | | `media` | `media_ids` | `media` is the response field (full hydrated objects). Requests use `media_ids`. | | `media_id` (singular) | `media_ids` (plural array) | Always plural array, even for one item. | | `attachments` | `media_ids` | Twitter SDK terminology, not ours. | | `accountIds` (camelCase) | `account_ids` (snake_case) | API is snake_case canonical (camelCase aliases work but snake_case is preferred). | | `"account_ids": ["2280"]` (strings) | `"account_ids": [2280]` (integers) | Account IDs are integers, not strings. | | Local time without timezone | UTC with `Z` suffix | All timestamps must be UTC. | ## Time handling — ALWAYS UTC `scheduled_for` must be a UTC ISO 8601 timestamp. If you want to schedule "9 AM Eastern", convert to UTC in your code BEFORE sending: ```python from datetime import datetime from zoneinfo import ZoneInfo local = datetime(2026, 4, 15, 9, 0, tzinfo=ZoneInfo("America/New_York")) scheduled_for = local.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z") # → "2026-04-15T13:00:00Z" ``` ## Round-trip principle Every field name in a REQUEST also appears in the corresponding RESPONSE with the same meaning. So you can take the response from GET /v1/posts/{id} and POST it straight back to clone the post — no field renaming required. Always pattern-match field names from responses into your next request. ## Step-by-step post creation flow ### Schedule a post (no media) ```python import requests response = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={ "Authorization": "Bearer pe_live_...", "Content-Type": "application/json", }, json={ "content": "Excited to share our launch!", "account_ids": [2280, 2281], "scheduled_for": "2026-04-15T14:30:00Z", } ).json() post_id = response["data"]["post_id"] ``` ### Publish immediately (omit scheduled_for) ```python response = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, json={ "content": "Posting right now", "account_ids": [2280], } ).json() ``` ### Save a draft for review, then schedule it (human-in-the-loop) When a human (or a reviewing agent) should approve content before it goes out, create it as a DRAFT, let it be reviewed, then schedule it in a second step. Send `draft: true` to save without publishing or scheduling. `account_ids` is OPTIONAL for a draft — you can pick targets now or in the schedule call. ```python # Step 1 — create the draft (nothing publishes yet) draft = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, json={ "content": "Proposed launch announcement — please review.", "draft": True, # account_ids optional here; can also be supplied at schedule time }, ).json()["data"] post_id = draft["post_id"] assert draft["status"] == "draft" # draft["next_steps"] points at POST /v1/posts/{id}/schedule # Step 2 — review drafts (returns account_ids + platform_content so the # reviewer can see exactly what each draft will publish) drafts = requests.get( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, params={"status": "draft"}, ).json()["data"] # Step 3 — once approved, schedule it (or publish it now). # This endpoint ONLY works on drafts (409 otherwise). scheduled = requests.post( f"https://app.posteverywhere.ai/api/v1/posts/{post_id}/schedule", headers={...}, json={ "scheduled_for": "2026-04-15T14:30:00Z", # OR: "publish_now": True "account_ids": [2280, 2281], # optional: set/override targets "timezone": "America/New_York", # optional: display only }, ).json()["data"] assert scheduled["status"] in ("scheduled", "publishing") ``` To publish a draft immediately instead of scheduling it, send `{"publish_now": true}` in place of `scheduled_for`. Supply `account_ids` in the schedule call if the draft didn't already have targets. ### Verifying a reschedule — check the DESTINATION time, not just the post A post fans out to one or more **destinations** (one per target account), and **each destination has its own `scheduled_for` — that is the time the post actually fires.** The post-level `scheduled_for` is the headline value; the scheduler publishes by the destination time. When you `PATCH /v1/posts/{id}` to reschedule, **don't trust only the echoed post-level `scheduled_for`.** Re-fetch with `GET /v1/posts/{id}` (or `/results`) and confirm every `destinations[].scheduled_for` matches the new time: ```python post = requests.get(f"{BASE}/posts/{post_id}", headers=headers).json()["data"] assert all(d["scheduled_for"] == post["scheduled_for"] for d in post["destinations"]), \ "Reschedule didn't propagate to all destinations — they fire at different times!" ``` (For most posts all destinations share one time. Multi-day content series are the exception — there, destinations legitimately differ.) ### Post with media — one-call URL upload If your image is already at a public URL (web search result, OG image, hosted screenshot), this is the fastest path. One call returns a ready-to-attach `media_id`. Image-only, 25 MB cap. ```python upload = requests.post( "https://app.posteverywhere.ai/api/v1/media/upload-from-url", headers={...}, json={"url": "https://example.com/hero.webp"}, ).json()["data"] media_id = upload["media_id"] # already in `ready` status — attach immediately response = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, json={ "content": "Post text", "account_ids": [2280], "media_ids": [media_id], }, ).json() ``` ### Post with a local image, single request (direct upload) For a local IMAGE up to 25 MB, skip the multi-step flow entirely: POST the file itself to `/v1/media/upload` as `multipart/form-data` with the field name `file`. The response comes back with `media_status: "ready"`, so the media_id can be attached to a post immediately. No presigned URL, no `/complete` call, and it works from any HTTP client with any User-Agent. ```python with open("photo.jpg", "rb") as f: upload = requests.post( "https://app.posteverywhere.ai/api/v1/media/upload", headers={...}, # same Bearer auth, do NOT set Content-Type manually files={"file": ("photo.jpg", f, "image/jpeg")}, ).json()["data"] media_id = upload["media_id"] # already "ready", attach immediately response = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, json={ "content": "Post text", "account_ids": [2280], "media_ids": [media_id], }, ).json() ``` ### Post with media — 3-step upload flow (videos, PDFs, images over 25 MB) For videos, PDFs, or images over 25 MB, use the multi-step flow. Media must be uploaded and confirmed ready BEFORE attaching to a post. Upload rate limits and storage quotas apply. The API returns clear 429/403 errors with details when limits are reached. CAVEAT for the image branch of this flow: the presigned image host runs an edge bot filter outside our control. Send any custom User-Agent header; Python stdlib's default (`Python-urllib/x`) is rejected with HTTP 403 error 1010. `python-requests` (used below), curl, and node all pass. The direct upload above avoids this entirely. ```python # Step 1: get a presigned upload URL upload = requests.post( "https://app.posteverywhere.ai/api/v1/media/upload", 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 # IMPORTANT: you MUST actually upload the file before calling /complete. # /complete verifies the file exists and will return 400 if it's missing. with open("photo.jpg", "rb") as f: if upload["upload_method"]["method"] == "POST": # Image storage — multipart POST, field name "file" requests.post(upload["upload_url"], files={"file": f}) else: # File storage (videos, PDFs) — PUT with Content-Type header requests.put( upload["upload_url"], data=f, headers={"Content-Type": "image/jpeg"}, ) # Step 3: finalize — confirms the file was received complete = requests.post( f"https://app.posteverywhere.ai/api/v1/media/{media_id}/complete", headers={...}, ).json() # Returns {"data": {"status": "ready"}} on success, or 400 if file missing. # Optional: verify media is ready and get its URL media = requests.get( f"https://app.posteverywhere.ai/api/v1/media/{media_id}", headers={...}, ).json()["data"] assert media["status"] == "ready" print(f"Media URL: {media['url']}") # Step 4: attach to a post # media_ids must reference media that exists and has status "ready". response = requests.post( "https://app.posteverywhere.ai/api/v1/posts", headers={...}, json={ "content": "Look at this", "account_ids": [2280], "media_ids": [media_id], }, ).json() # Note: a 201 response may include validation_warnings for # aspect ratio issues (e.g. image too wide for Instagram Reels). # The post is still created, but check warnings to avoid # platform-side cropping or rejection. if response.get("validation_warnings"): for w in response["validation_warnings"]: print(f"Warning: {w['message']}") ``` ## Per-platform content overrides ```python { "content": "Default text for all platforms", "account_ids": [2280, 2281, 2282], "platform_content": { "instagram": { "content": "Instagram-specific caption with #hashtags", "contentType": "Reels" }, "x": {"content": "Short version for X (280 char limit)"}, "linkedin": {"content": "Professional version for LinkedIn"} } } ``` Supported platform keys: `instagram`, `tiktok`, `youtube`, `linkedin`, `x`, `facebook`, `threads`. ## Common LLM mistakes to avoid These are the patterns we see most often when LLM agents call the API incorrectly. Check your agent's behaviour against this list: 1. **Using `scheduled_at` instead of `scheduled_for`.** `scheduled_for` is canonical. `scheduled_at` still works as a deprecated alias, but your agent should always use `scheduled_for`. 2. **Calling `/complete` without actually uploading the file first.** The `/complete` endpoint verifies the file exists at the upload URL. If you skip Step 2 (the actual file upload), `/complete` returns a 400 error. Always upload bytes THEN call `/complete`. 3. **Not checking media status before creating a post.** `media_ids` in a create-post request must reference media that exists and has `status: "ready"`. If you pass a `media_id` that hasn't been completed (or failed to upload), the post creation will fail. Use `GET /v1/media/{id}` to verify status if unsure. 4. **Ignoring `validation_warnings` in 201 responses.** A post can be created successfully (201) but still include `validation_warnings` — for example, aspect ratio mismatches that will cause cropping or rejection on specific platforms. Always log or surface these warnings. 5. **Sending local timestamps without converting to UTC.** `scheduled_for` must be UTC with a `Z` suffix. Sending `"2026-04-15T09:00:00"` (no timezone) or `"2026-04-15T09:00:00-04:00"` (offset) will cause unexpected scheduling times. ## Error handling The API uses standard HTTP status codes. Error responses always have the shape: ```json { "data": null, "error": { "message": "Human-readable description", "code": "machine_readable_code", "details": { ... } }, "meta": { "request_id": "...", "timestamp": "..." } } ``` ### Retry behaviour **The simplest correct rule: only retry when `error.retryable === true`.** Every error response includes this boolean and it tells you exactly what to do. Do NOT infer retry-ability from the status code — use the field. If you must use status codes, the mapping is: - **429** (rate limited): `retryable: true`. Wait for the `Retry-After` header value (in seconds), then retry. - **500/502/503** (server error): `retryable: true`. Retry with exponential backoff (1s, 2s, 4s, 8s). Cap at ~5 attempts. - **422** (circuit breaker): `retryable: false`. The same exact request body has failed 5+ times; we will not accept it again until you **modify the request body** (or wait 6 hours). Inspect the prior 4xx responses for `validation_errors` — those tell you what to fix. - **400** (validation): `retryable: false`. Read `error.message`, `error.code`, and `error.details.validation_errors` to fix the request. - **401** (auth): `retryable: false`. The API key is invalid, expired, or revoked. - **404** (not found): `retryable: false`. The resource doesn't exist. ### CRITICAL: Do not retry the same payload after a 4xx If a 4xx response says the request is invalid (over character limit, missing field, unsupported media type, etc.), retrying the same payload will produce the same error. Always either fix the payload or stop. This is what `error.retryable: false` is telling you. We will circuit-break runaway loops automatically: after 5 identical failures from the same API key, we return **422 `permanent_failure_circuit_breaker`** instead of forwarding the request, and refuse all further attempts of that exact body for 6 hours. Do not hit this — it indicates broken retry logic on your side. ### When you get a 400 with `code: "invalid_field_name"` The error response includes `details.field_mistakes: [{wrong, right, hint}]`. Read it. Fix the wrong field name and retry once. ## Checking post results After creating a post, monitor publishing status: ```python results = requests.get( f"https://app.posteverywhere.ai/api/v1/posts/{post_id}/results", headers={...}, ).json() for dest in results["data"]["results"]: print(f"{dest['platform']} ({dest['account_name']}): {dest['status']}") if dest["status"] == "published": print(f" Live at: {dest['platform_post_url']}") elif dest["status"] == "failed": print(f" Error: {dest['error']}") ``` Status values per destination: `queued` → `publishing` → `published` (success) or `failed` (terminal). ```` ## Customizing the prompt The template above is a starting point. You'll want to: 1. **Replace `pe_live_...`** with a placeholder reference like `{{POSTEVERYWHERE_API_KEY}}` so your agent knows to read the key from its environment, not hard-code it. 2. **Add your specific business context** — what kind of posts the agent should write, what platforms it has access to, what tone, what hashtag strategy. 3. **Add safety rails** — e.g. "always preview a post before scheduling", "never post to platforms outside this list", "never schedule more than 3 posts per day per account". 4. **Add your own examples** — if your agent does specific things repeatedly (e.g. weekly newsletter announcements), give it example payloads it can pattern-match. ## Related - [Building AI Agents](/integrations/agents) — Framework-agnostic patterns (Python, LangChain, OpenClaw, CrewAI) - [Using PostEverywhere with Claude](/integrations/claude) — Anthropic-specific setup (Claude Code, Claude Desktop, Anthropic API) - [Create Post API](/api/create-post) — Full endpoint reference - [Error Handling](/errors) — Complete error code reference and retry strategies - [Rate Limits](/rate-limits) — Per-minute, per-hour, per-day limits --- # Build AI Agents with PostEverywhere API > 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. # Building AI Agents Use the PostEverywhere API to give your AI agent the ability to schedule and publish social media posts across 12 platforms (11 social networks plus WordPress blogs). ## 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, 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 | | 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_` :::tip 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`. ::: :::caution 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 — 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: ```json { "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. ## Drafts & the human-in-the-loop pattern 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], }) ``` ## Step-by-Step Agent Flow ### 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 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 ```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") ``` ## Platform-Specific Content 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", "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:** ```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 — 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 / >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`. ```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": {"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() ``` :::tip 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. ```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. ## Framework Examples ### 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"]["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 ```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']})" ``` ## Error Handling for Agents ```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 == 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 ```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) ``` ## Error Code Reference 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 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](/errors). ## Related - [Agent System Prompt](/integrations/agent-system-prompt) — Drop-in system prompt for Claude/GPT/Gemini agents that call PostEverywhere - [API Reference](/api/create-post) — Full endpoint documentation - [MCP Server](/integrations/mcp) — Use with Claude Code and Cursor - [SDKs & CLI](/integrations/sdk) — Node.js SDK and CLI tools - [Authentication](/authentication) — API key setup - [Rate Limits](/rate-limits) — Request quotas per plan - [Error Handling](/errors) — Full error envelope and codes Agents can also [connect social accounts entirely from the API](/connecting-accounts) — no dashboard session needed. --- # Use PostEverywhere with Claude - AI Social Media Scheduling > Schedule and publish social media posts using Claude AI. Connect PostEverywhere to Claude Code, Claude Desktop, and Claude API for AI-powered social media management. # Using PostEverywhere with Claude There are three ways to use Claude with PostEverywhere, depending on your use case: MCP for interactive use in Claude Code or Claude Desktop, or the Anthropic API for building your own automated agent. :::tip Easiest setup — hosted MCP, no install Skip local config entirely: connect to our hosted endpoint at **`https://mcp.posteverywhere.ai`** with your API key as a Bearer header. One command in Claude Code: ```bash claude mcp add --transport http posteverywhere \ https://mcp.posteverywhere.ai \ --header "Authorization: Bearer pe_live_YOUR_KEY" ``` See [MCP Server → Hosted MCP](/integrations/mcp) for every client. The `npx` configs below are the local alternative. ::: :::tip The round-trip principle Every top-level field name in a PostEverywhere request body also appears in the response with the same name and meaning. You can take a post you got from `GET /v1/posts/{id}` and POST its fields straight back to clone it — no renaming required. The canonical request fields are: `content`, `account_ids`, `scheduled_for`, `timezone`, `media_ids`, `platform_content`, and `draft` (optional). All timestamps are UTC. When building a Claude tool definition, use the exact canonical names from the [Create Post reference](/api/create-post). Claude will happily invent plausible-but-wrong field names (`scheduled_at`, `media`, `mediaId`, `attachments`) if you leave the tool schema vague — be explicit. ::: :::info Building a full agent? Read [Building AI Agents](/integrations/agents) for framework-agnostic patterns (Python, LangChain, OpenClaw) and the canonical agent system-prompt template in [Agent System Prompt](/integrations/agent-system-prompt). ::: :::tip Draft first, schedule after approval (human-in-the-loop) For a review step before anything publishes, have Claude create posts as **drafts** (`create_post` with `draft: true`), let you review them (`list_posts` with `status: "draft"` returns each draft's target accounts and per-platform content), then `schedule_post` once approved — passing `scheduled_for` or `publish_now: true`. `schedule_post` only works on drafts. Both tools are defined below. ::: ## Option 1: Claude Code (MCP) [Claude Code](https://docs.anthropic.com/en/docs/claude-code) is Anthropic's CLI tool for agentic coding. With the PostEverywhere MCP server, Claude Code can manage your social media directly from your terminal. Add this to your Claude Code MCP config: ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` Then ask Claude to manage your social media in natural language: > "Schedule a LinkedIn post for tomorrow at 9am announcing our Series A funding" See the full [MCP Server setup guide](/integrations/mcp) for detailed instructions, troubleshooting, and configuration for other tools. ## Option 2: Claude Desktop (MCP) [Claude Desktop](https://claude.ai/download) supports MCP servers through its configuration file. This lets you manage social media from the Claude chat interface without any coding. **macOS:** Edit `~/Library/Application Support/Claude/claude_desktop_config.json` **Windows:** Edit `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` Restart Claude Desktop after saving. You'll see a tools icon in the chat input indicating the MCP server is connected. Now you can chat with Claude and ask it to schedule posts, check account status, or review publishing results. ## Option 3: Claude API (Build Your Own Agent) Use the [Anthropic SDK](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview) with tool use to build a custom agent that manages social media automatically. This is ideal for workflows like automated content pipelines, scheduled reporting, or AI-driven social media strategies. ### Install Dependencies ```bash npm install @anthropic-ai/sdk posteverywhere ``` ### Define PostEverywhere as a Tool ```typescript const anthropic = new Anthropic(); const pe = new PostEverywhere({ apiKey: process.env.POSTEVERYWHERE_API_KEY }); const tools: Anthropic.Tool[] = [ { name: 'list_accounts', description: 'List all connected social media accounts', input_schema: { type: 'object' as const, properties: {}, required: [], }, }, { name: 'upload_media_from_url', description: 'PREFERRED for images already at a public URL. Fetches the URL server-side and stores it ' + 'in the PostEverywhere media library. Returns a ready-to-use media_id you can pass directly ' + 'to create_post via media_ids — no separate complete step. Images (JPEG, PNG, GIF, WebP, HEIC, ' + 'HEIF, 25 MB) are ready immediately; MP4 videos (up to 500 MB) import asynchronously — poll ' + 'get_media until media_status is "ready" before attaching. For local files, use upload_media (the 3-step flow).', input_schema: { type: 'object' as const, properties: { url: { type: 'string', description: 'Public HTTPS URL pointing to the image. Must be reachable from the public internet.', }, filename: { type: 'string', description: 'Optional filename to record in the library. If omitted, derived from the URL path.', }, }, required: ['url'], }, }, { name: 'upload_media', description: 'Upload a media file (image or video) to the PostEverywhere media library using the 3-step ' + 'flow — use this for local files or videos. (If the image is already at a public URL, ' + 'prefer upload_media_from_url which is a single call.) ' + 'Returns an upload_url and a media_id. You must upload the file bytes to the ' + 'upload_url, then call complete_media to finalize. Only after completing can you ' + 'pass the media_id into create_post via the media_ids array.', input_schema: { type: 'object' as const, properties: { filename: { type: 'string', description: 'Original filename including extension, e.g. "photo.jpg".', }, content_type: { type: 'string', description: 'MIME type, e.g. "image/jpeg", "video/mp4".', }, size: { type: 'integer', description: 'File size in bytes.', }, }, required: ['filename', 'content_type', 'size'], }, }, { name: 'complete_media', description: 'Finalize a media upload after uploading the file to the presigned URL. ' + 'Call this AFTER uploading bytes to the upload_url from upload_media. ' + 'Returns the media object with status, dimensions, url, and aspect_ratio. ' + 'The media_id can then be used in media_ids when creating a post.', input_schema: { type: 'object' as const, properties: { media_id: { type: 'string', description: 'The UUID returned by upload_media.', }, }, required: ['media_id'], }, }, { name: 'create_post', description: 'Create and schedule a social media post. Omit scheduled_for to publish immediately. ' + 'Set draft: true to save it as a draft instead (then schedule it later with schedule_post). ' + 'All timestamps are UTC. The response has the same top-level fields as the request (round-trip).', input_schema: { type: 'object' as const, properties: { content: { type: 'string', description: 'The post text content.', }, account_ids: { type: 'array', items: { type: 'integer' }, description: 'Account IDs (integers) to post to. Get them from list_accounts. ' + 'These are NOT usernames or strings — always integers like 2280. ' + 'Required unless draft is true (drafts can pick targets later).', }, scheduled_for: { type: 'string', description: 'ISO 8601 datetime in UTC when the post should publish, e.g. "2026-04-15T14:30:00Z". ' + 'Omit to publish immediately. Always send UTC — convert from local time in your code if needed. ' + 'Do NOT use scheduled_at (that is a deprecated alias).', }, media_ids: { type: 'array', items: { type: 'string' }, description: 'Optional array of media UUIDs returned from upload_media_from_url (preferred for URLs) or upload_media (for local files). ' + 'The only accepted media field name is media_ids — NOT media, media_id, mediaId, or attachments.', }, draft: { type: 'boolean', description: 'Optional. Set true to save the post as a DRAFT — it is neither published nor scheduled. ' + 'account_ids is optional when draft is true. Schedule it later with the schedule_post tool. ' + 'Use this for human-in-the-loop review: draft, let the user approve, then schedule.', }, }, required: ['content'], }, }, { name: 'schedule_post', description: 'Schedule (or immediately publish) an existing DRAFT post. ' + 'Provide scheduled_for to schedule, or publish_now: true to publish right now. ' + 'Only works on drafts — calling it on an already-scheduled or published post returns a 409. ' + 'Use after create_post with draft: true once the draft has been reviewed/approved.', input_schema: { type: 'object' as const, properties: { post_id: { type: 'string', description: 'The post_id of the draft to schedule (from create_post or list_posts).', }, scheduled_for: { type: 'string', description: 'ISO 8601 UTC datetime to schedule the draft for, e.g. "2026-04-15T14:30:00Z". ' + 'Provide this OR publish_now — not both.', }, publish_now: { type: 'boolean', description: 'Set true to publish the draft immediately instead of scheduling. Provide this OR scheduled_for.', }, account_ids: { type: 'array', items: { type: 'integer' }, description: 'Optional. Target account IDs (integers). Sets or overrides the draft’s targets. ' + 'Required only if the draft was created without account_ids.', }, timezone: { type: 'string', description: 'Optional IANA timezone for display only (e.g. "America/New_York"). Does not change when the post fires.', }, }, required: ['post_id'], }, }, ]; ``` :::caution Field-name pitfalls Claude tends to invent Claude (and most other LLMs) will guess plausible-but-wrong names if the tool schema is loose. The API rejects all of these with `400 invalid_field_name`: | What Claude invents | Correct field | Notes | |---|---|---| | `scheduled_at` | `scheduled_for` | `scheduled_at` is a **deprecated alias** — still silently accepted, but responses always return `scheduled_for`. Never describe `scheduled_at` as a tool property. New integrations must use `scheduled_for`. | | `schedule_time`, `publish_at`, `post_at` | `scheduled_for` | Never accepted. | | `schedule_for` | `scheduled_for` | Note the **`d`** in "scheduled". | | `media`, `media_id`, `mediaId`, `attachments` | `media_ids` | All rejected. The only accepted name is `media_ids` (array of UUID strings from the upload flow). | | `accountIds` | `account_ids` | camelCase aliases exist for back-compat but prefer snake_case `account_ids`. | Spell out these pitfalls in your tool description so Claude stays on the rails. ::: :::info New response fields to handle **`POST /v1/posts` — `validation_warnings`:** When media doesn't perfectly match a platform's requirements (e.g. aspect ratio outside optimal range), the response includes a `validation_warnings` object keyed by platform. The post still publishes, but the platform may crop or resize. Your agent should surface these to the user so they can improve future content. ```json { "data": { "post_id": "...", "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." } } } ``` **`GET /v1/media/:id` — `url`:** The media status response includes a permanent `url` field with a viewable/downloadable URL. URLs never expire — safe for scheduled posts. ::: :::note Storage quotas and rate limits Upload rate limits and storage quotas are enforced per plan. Handle 429 and 403 responses. See [Rate Limits](/rate-limits) for headers and retry strategies. Build exponential backoff into your tool handler. ::: ### The 3-Step Media Upload Flow Attaching media to a post requires three steps: upload, transfer bytes, complete. ```typescript // Step 1 — Start the upload (get a presigned URL) const upload = await handleToolCall('upload_media', { filename: 'product-launch.jpg', content_type: 'image/jpeg', size: 2048576, }); // Step 2 — Upload file bytes to the presigned URL // upload_media returns upload_url and upload_method. // Images use multipart POST (field: "file"), videos use PUT with Content-Type. await fetch(upload.uploadUrl, { method: 'PUT', body: fileBytes, headers: { 'Content-Type': 'image/jpeg' }, }); // Step 3 — Finalize (confirms file was received, returns url + dimensions) const media = await handleToolCall('complete_media', { media_id: upload.mediaId, }); // media.url → viewable URL, media.media_status → "ready" // Now use it in a post await handleToolCall('create_post', { content: 'Check this out!', account_ids: [123], media_ids: [media.media_id], }); ``` ### Handle Tool Calls in a Loop ```typescript async function handleToolCall( name: string, input: Record ): Promise { switch (name) { case 'list_accounts': { const accounts = await pe.accounts.list(); return JSON.stringify(accounts); } case 'upload_media_from_url': { // One-call import — server fetches the URL, stores it, returns a ready media_id. const media = await pe.media.uploadFromUrl({ url: input.url, filename: input.filename, }); return JSON.stringify(media); } case 'upload_media': { const upload = await pe.media.upload({ fileName: input.filename, contentType: input.content_type, size: input.size, }); return JSON.stringify(upload); // Next: upload file bytes to upload.uploadUrl, then call complete_media } case 'complete_media': { const media = await pe.media.complete(input.media_id); // Response includes: media_id, media_status, url, dimensions, aspect_ratio return JSON.stringify(media); } case 'create_post': { const post = await pe.posts.create({ content: input.content, account_ids: input.account_ids, scheduled_for: input.scheduled_for, // undefined → publish immediately media_ids: input.media_ids, // undefined → text-only post draft: input.draft, // true → save as draft, schedule later }); // Response may include validation_warnings (non-blocking) when media // doesn't perfectly match platform requirements (e.g. aspect ratio). // For a draft, post.status === 'draft' and post.next_steps points at schedule. return JSON.stringify(post); } case 'schedule_post': { // Turns an existing draft into a scheduled (or immediately-publishing) post // via POST /v1/posts/{id}/schedule. Send scheduled_for OR publish_now. const res = await fetch( `https://app.posteverywhere.ai/api/v1/posts/${input.post_id}/schedule`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.POSTEVERYWHERE_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scheduled_for: input.scheduled_for, // OR publish_now: true publish_now: input.publish_now, account_ids: input.account_ids, // optional: set/override targets timezone: input.timezone, // optional: display only }), }, ); return JSON.stringify(await res.json()); } default: return JSON.stringify({ error: `Unknown tool: ${name}` }); } } async function runAgent(userMessage: string) { const messages: Anthropic.MessageParam[] = [ { role: 'user', content: userMessage }, ]; while (true) { const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 1024, tools, messages, }); // If Claude is done (no more tool calls), return the text if (response.stop_reason === 'end_turn') { const textBlock = response.content.find((b) => b.type === 'text'); return textBlock ? textBlock.text : ''; } // Process tool calls const assistantContent = response.content; const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of assistantContent) { if (block.type === 'tool_use') { const result = await handleToolCall( block.name, block.input as Record ); toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result, }); } } messages.push({ role: 'assistant', content: assistantContent }); messages.push({ role: 'user', content: toolResults }); } } ``` ### Run It ```typescript const result = await runAgent( 'List my social accounts, then schedule a post to all active Instagram ' + 'and LinkedIn accounts for tomorrow at 10am saying "Big announcement coming soon!"' ); console.log(result); ``` The agent will call `list_accounts` to discover your connected accounts, filter for active Instagram and LinkedIn accounts, then call `create_post` with the right account IDs and schedule time. ## Example Workflow: A Week of Content Whether using MCP or the API, Claude can help you plan and schedule an entire week of social media content. Here's an example conversation with Claude via MCP: **You:** > Create a week of social media posts for my SaaS product launch. Post to LinkedIn and X every weekday next week at 9am EST. Day 1: teaser, Day 2: problem we solve, Day 3: feature highlight, Day 4: customer quote, Day 5: launch announcement with link to example.com/launch. **Claude:** > I'll create 5 posts scheduled across next week. Let me first check your connected accounts... > > I found your LinkedIn account (@yourcompany) and X account (@yourcompany). Here's what I've scheduled: > > - **Mon 9am**: "Something big is coming next week. Stay tuned." (LinkedIn + X) > - **Tue 9am**: "Teams waste 4 hours/week switching between social media platforms..." (LinkedIn + X) > - **Wed 9am**: "One API call. 12 platforms. Zero context-switching..." (LinkedIn + X) > - **Thu 9am**: "'PostEverywhere cut our social media publishing time by 80%' — Sarah, Head of Marketing at Acme" (LinkedIn + X) > - **Fri 9am**: "It's here. PostEverywhere is now live..." (LinkedIn + X) > > All 5 posts are scheduled. You can review or edit them in your PostEverywhere dashboard. ## Next Steps - [Agent System Prompt](/integrations/agent-system-prompt) — Drop-in system prompt for any Claude agent that calls PostEverywhere - [Building AI Agents](/integrations/agents) — Framework-agnostic patterns (Python, LangChain, OpenClaw) - [MCP Server Setup](/integrations/mcp) — Detailed MCP configuration and troubleshooting - [SDKs & CLI](/integrations/sdk) — Node.js SDK and CLI reference - [Create Post API](/api/create-post) — Full endpoint documentation - [Rate Limits](/rate-limits) — Request limits and retry strategies --- # MCP Server - Use PostEverywhere with Claude, Cursor & AI Agents > Connect PostEverywhere to Claude Code, Cursor, Windsurf, and other AI tools via MCP (Model Context Protocol). Schedule social media posts from your IDE. # MCP Server Integration [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard that lets AI assistants call external tools. The PostEverywhere MCP server gives any MCP-compatible AI tool — Claude Code, Claude Desktop, Cursor, Windsurf, ChatGPT, OpenAI Codex, and others — direct access to your social media accounts, letting you draft, schedule, and publish posts through natural language. ## Two ways to connect | | **Hosted (recommended)** | Local (npm) | |---|---|---| | Setup | Paste one URL + your API key | Install Node, edit a config file | | Runs on | Our servers | Your machine | | Requires | Just an API key | Node.js 18+ | ## Hosted MCP (recommended) Connect to our hosted endpoint — nothing to install, no Node, nothing running on your machine. The URL is: ``` https://mcp.posteverywhere.ai ``` In **Claude Code**, one command does it: ```bash claude mcp add --transport http posteverywhere \ https://mcp.posteverywhere.ai \ --header "Authorization: Bearer pe_live_YOUR_KEY" ``` For any client that supports a **remote/HTTP MCP server with a custom header**: - **URL:** `https://mcp.posteverywhere.ai` - **Header:** `Authorization: Bearer pe_live_YOUR_KEY` Get your API key from **Settings → Developers** in the [dashboard](https://app.posteverywhere.ai). Your key is passed straight through to the PostEverywhere API on every request, which enforces the same authentication, tenancy, and rate limits as a direct API call — the hosted MCP server stores nothing. ### Connect from the claude.ai website (browser) You can also add PostEverywhere as a **custom connector** in the [claude.ai](https://claude.ai) web app — no API key to paste, nothing to install. This flow uses a one-time sign-in (OAuth) instead of a header. 1. In claude.ai, open **Settings → Connectors**. 2. Under **Custom connectors**, click **Add custom connector**. 3. In the URL field, enter `https://mcp.posteverywhere.ai` 4. Click **Add**. You can leave *Advanced settings* empty — a Client ID and Secret are **not** needed, because Claude registers itself automatically. 5. Claude opens a PostEverywhere sign-in page. Log in, then click **Approve** on the **"Connect Claude to PostEverywhere"** screen. That's it — Claude can now list your accounts and draft, schedule, and publish posts. Approving creates a revocable access key that you can view or delete any time under **Settings → Developers** in the dashboard. :::note Custom connectors are a beta feature in Claude, so claude.ai shows an "unverified" notice during setup — that's expected for every custom connector. Available on all Claude plans (Free is limited to one connector; Pro, Max, Team, and Enterprise allow more). ::: The hosted URL also works today in Claude Code, Claude Desktop, Cursor, ChatGPT, OpenAI Codex, and any client that supports a Bearer header (above), and the local option below works everywhere. ### Connect from ChatGPT ChatGPT connects to the hosted MCP server through a **custom connector**, too. The simplest path is to open the [PostEverywhere agents page](https://posteverywhere.ai/agents) and follow the **Connect ChatGPT** flow — it walks you through adding `https://mcp.posteverywhere.ai` as a connector and authorizing it, no API key to paste. Once connected, ChatGPT can list your accounts and draft, schedule, and publish posts. **OpenAI Codex** uses the same hosted endpoint via its MCP configuration with a Bearer header (above). ## Local MCP (npm) Prefer to run the server on your own machine? The `@posteverywhere/mcp` npm package runs via `npx` — the configs below set that up. ## Prerequisites - A [PostEverywhere account](https://app.posteverywhere.ai/signup) with at least one connected social account - An API key from **Settings > Developers** (see [Authentication](/authentication)) - Node.js 18+ installed ## Setup for Claude Code Add this to your Claude Code MCP configuration (`~/.claude/claude_code_config.json`, or the project-level `.claude/claude_code_config.json`): ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` Restart Claude Code after saving. You should see PostEverywhere listed in your available tools. ## Setup for Cursor Open Cursor's MCP settings (**Settings > MCP**) and add a new server with this configuration: ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` Alternatively, create a `.cursor/mcp.json` file in your project root with the same content. ## Setup for Windsurf In Windsurf, go to **Settings > Extensions > MCP** and add: ```json { "mcpServers": { "posteverywhere": { "command": "npx", "args": ["-y", "@posteverywhere/mcp"], "env": { "POSTEVERYWHERE_API_KEY": "pe_live_..." } } } } ``` ## What You Can Do Once connected, you can manage your social media entirely through natural language: - **List accounts** — see all connected social accounts and their status - **Create posts** — content for any combination of platforms - **Save drafts** — create a post as a draft (`create_post` with `draft: true`) for review before it goes out; accounts can be chosen now or later - **Review drafts** — list and inspect drafts (with their target accounts and per-platform content) before committing them - **Schedule a draft** — turn a draft into a scheduled or immediately-publishing post with `schedule_post` - **Schedule posts** — set a specific date and time for publishing - **Upload media** — attach images or videos to your posts - **Check results** — see which platforms published successfully and which failed ### Draft → review → schedule (human-in-the-loop) The MCP server supports an approval workflow that's ideal when a human (or a second agent) should sign off before anything publishes: 1. **`create_post` with `draft: true`** — saves the post as a draft. `account_ids` is optional at this stage; you can decide targets later. 2. **`list_posts` with `status: "draft"`** (or `get_post`) — review the draft. The response includes the draft's `account_ids` and `platform_content` so you can see exactly what will publish. 3. **`schedule_post`** — once approved, pass the `post_id` plus either `scheduled_for` (ISO 8601 UTC) or `publish_now: true`. You can also supply `account_ids` here to set or override the draft's targets, and an optional `timezone` for display. `schedule_post` only works on drafts. ## Example Prompts Here are prompts you can use with any MCP-compatible AI tool: **Schedule a post:** > "Schedule a post to Instagram and LinkedIn for tomorrow at 10am saying 'We just launched our new feature! Check it out at example.com'" **Cross-platform content:** > "Create a post announcing our Q2 results. Put it on X, LinkedIn, and Threads. Schedule it for Monday at 9am EST. Keep X under 280 characters but make the LinkedIn version longer and more professional." **Check your accounts:** > "Show me all my connected social media accounts and their status" **Review scheduled posts:** > "What posts do I have scheduled for this week?" **Upload and post media:** > "Upload the image at ./campaign-banner.png and schedule it to Instagram and Facebook for Friday at noon" **Draft for review, then schedule:** > "Draft three LinkedIn posts about our new pricing — don't schedule them yet, just save them as drafts so I can review." > "Show me my draft posts." … then … "The second one looks great — schedule it to LinkedIn for Tuesday at 9am EST." ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `POSTEVERYWHERE_API_KEY` | Yes | Your API key starting with `pe_live_`. Generate at **Settings > Developers**. | ## Troubleshooting ### "Tool not found" or MCP server not connecting 1. Make sure Node.js 18+ is installed: `node --version` 2. Test the package is accessible: `npx @posteverywhere/mcp --help` 3. Confirm your configuration file is valid JSON 4. Restart your AI tool after changing MCP configuration ### "Unauthorized" errors - Double-check your API key is correct and starts with `pe_live_` - Make sure the key hasn't been revoked in **Settings > Developers** - Verify there are no extra spaces or quotes around the key in your config ### Posts failing to publish - Confirm the target accounts are still connected and healthy in your dashboard - Check platform-specific requirements (e.g., TikTok requires video, YouTube requires a title) - See the [platform guides](/platforms/instagram) for media and content requirements ### Rate limit errors The MCP server respects the same [rate limits](/rate-limits) as direct API calls. If you're making many requests in quick succession, wait a moment and try again. ## FAQ **What AI tools support MCP?** MCP is supported by Claude Code, Claude Desktop, Cursor, Windsurf, ChatGPT, OpenAI Codex, Cline, Continue, and a growing list of AI assistants. Any tool that implements the MCP client specification will work with the PostEverywhere MCP server. For ChatGPT, connect via the hosted connector on the [agents page](https://posteverywhere.ai/agents). **Is my API key secure in the MCP config?** Your API key is stored locally in your MCP configuration file and passed as an environment variable to the MCP server process. It is never sent to the AI model itself — only the MCP server uses it to authenticate with the PostEverywhere API. Treat your config file like any other file containing secrets: don't commit it to public repositories. **Can I use MCP to schedule many posts at once?** Yes. Ask your AI assistant to create multiple posts in a single conversation — it will call the [Create Post](/api/create-post) tool once per post with different `scheduled_for` times. Scheduled posts don't count against the per-minute posting rate limit, so bulk scheduling works smoothly. **Does it work with self-hosted or team accounts?** The MCP server uses your personal API key, which is scoped to your organization. Any accounts connected to your PostEverywhere organization are accessible through MCP. ## Next Steps - [Authentication](/authentication) — API key setup and security - [Quick Start](/quick-start) — Make your first API call - [Create Post API](/api/create-post) — Full endpoint reference - [Using PostEverywhere with Claude](/integrations/claude) — Claude-specific workflows - [SDKs & CLI](/integrations/sdk) — Node.js SDK and CLI tools The MCP server can also [connect social accounts entirely from the API](/connecting-accounts) — no dashboard session needed. --- # PostEverywhere SDKs & CLI Tools > Official PostEverywhere Node.js SDK and CLI for scheduling social media posts. Install via npm, authenticate, and start publishing. # SDKs & CLI PostEverywhere provides a Node.js SDK, a CLI, and REST API access for any language. Pick the approach that fits your workflow. ## Node.js SDK ### Installation ```bash npm install @posteverywhere/sdk ``` ### Initialization ```typescript const client = new PostEverywhere({ apiKey: process.env.POSTEVERYWHERE_API_KEY, // pe_live_... }); ``` :::caution Never hard-code your API key. Use environment variables or a secrets manager. See [Authentication](/authentication) for details. ::: ### List Connected Accounts ```typescript const { accounts } = await client.accounts.list(); for (const account of accounts) { console.log(`${account.platform} — @${account.username} (${account.status})`); } ``` ### Create and Schedule a Post ```typescript const post = await client.posts.create({ content: 'Just shipped a major update! Check it out at example.com', account_ids: [123, 456], scheduled_for: '2026-04-07T14:00:00Z', // canonical — always UTC }); console.log(`Post ${post.id} scheduled for ${post.scheduled_for}`); ``` :::tip Round-trip safe The response from `client.posts.create()` has the same top-level field names as the request, so you can take any post and create a clone by passing its fields straight back in. `scheduled_for`, `account_ids`, `media_ids`, `platform_content`, `timezone`, `content` — all round-trip. ::: ### Save a Draft, Then Schedule It Pass `draft: true` to save a post without publishing or scheduling it (`account_ids` is optional for a draft). Review it later, then schedule it with `POST /v1/posts/{id}/schedule` — sending either `scheduled_for` or `publish_now: true`. The schedule endpoint only works on drafts. ```typescript // 1. Save a draft (nothing publishes yet) const draft = await client.posts.create({ content: 'Proposed announcement — review before sending.', draft: true, // account_ids optional for drafts }); // 2. ...review it (e.g. client.posts.list({ status: 'draft' }))... // 3. Schedule (or publish now) the approved draft await fetch( `https://app.posteverywhere.ai/api/v1/posts/${draft.post_id}/schedule`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.POSTEVERYWHERE_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ scheduled_for: '2026-04-15T14:30:00Z', // OR: publish_now: true account_ids: [123, 456], // optional if the draft already had targets }), }, ); ``` This "draft → review → schedule" flow is the recommended human-in-the-loop pattern for AI agents — see [Building AI Agents](/integrations/agents). ### Platform-Specific Content Customize content per platform using `platform_content`: ```typescript const post = await client.posts.create({ content: 'Default content for all platforms', account_ids: [123, 456, 789], platform_content: { x: { content: 'Short version for X (under 280 chars)', }, linkedin: { content: 'Longer, more detailed version for LinkedIn with professional tone...', }, instagram: { content: 'Visual-first caption with #hashtags', }, }, scheduled_for: '2026-04-07T14:00:00Z', }); ``` ### Upload Media ```typescript // Start an upload const upload = await client.media.upload({ fileName: 'product-launch.jpg', contentType: 'image/jpeg', }); // Upload the file to the pre-signed URL await fetch(upload.uploadUrl, { method: 'PUT', body: fs.readFileSync('./product-launch.jpg'), headers: { 'Content-Type': 'image/jpeg' }, }); // Complete the upload const media = await client.media.complete(upload.mediaId); // Use it in a post await client.posts.create({ content: 'Our new product is here!', account_ids: [123], media_ids: [media.id], }); ``` ### Schedule a Week of Posts Loop through your posts and call `create()` for each with a future `scheduled_for`: ```typescript const posts = [ { content: 'Monday motivation: ship fast, learn faster', scheduled_for: '2026-04-07T09:00:00Z' }, { content: 'Tuesday tip: automate your social media with APIs', scheduled_for: '2026-04-08T09:00:00Z' }, { content: 'Wednesday win: our users scheduled 10,000 posts last week', scheduled_for: '2026-04-09T09:00:00Z' }, { content: 'Thursday thought: the best content strategy is consistency', scheduled_for: '2026-04-10T09:00:00Z' }, { content: 'Friday launch: new API endpoints are live!', scheduled_for: '2026-04-11T09:00:00Z' }, ]; const results = await Promise.allSettled( posts.map((p) => client.posts.create({ content: p.content, account_ids: [123, 456], scheduled_for: p.scheduled_for, }) ) ); const created = results.filter((r) => r.status === 'fulfilled').length; const failed = results.filter((r) => r.status === 'rejected').length; console.log(`Scheduled ${created} posts, ${failed} failed`); ``` ### Check Post Results ```typescript const results = await client.posts.results('post_789'); // `results` is a rich per-platform array with account_id, account_name, // status, platform_post_url, error (if failed), and published_at. for (const dest of results.results) { console.log(`${dest.platform} (@${dest.account_name}): ${dest.status}`); if (dest.status === 'failed' && dest.error) { console.log(` [${dest.error.code}] ${dest.error.message}`); } else if (dest.status === 'published') { console.log(` ${dest.platform_post_url}`); } } ``` ## CLI The [`@posteverywhere/cli`](https://www.npmjs.com/package/@posteverywhere/cli) package manages posts and accounts from your terminal — or from any AI agent that can run shell commands. Every command prints structured **JSON** to stdout (errors go to stderr with a non-zero exit), so it's easy to pipe into `jq` or parse programmatically. Nothing to install: run it with `npx`. ### Authentication The CLI reads your API key from an environment variable — there is no separate login step: ```bash export POSTEVERYWHERE_API_KEY="pe_live_..." # Settings → Developers ``` ### Common commands ```bash # Verify the key — shows the account, plan, and quota npx @posteverywhere/cli whoami # List connected accounts (you need the numeric `id`s to post) npx @posteverywhere/cli accounts # Publish now to accounts 123 and 456 npx @posteverywhere/cli post -c "Just shipped v2.0! 🚀" -a 123,456 # Schedule (ISO-8601, UTC) — add -s npx @posteverywhere/cli post -c "Big announcement" -a 123,456 -s 2026-07-01T09:00:00Z # Import an image or MP4 video by URL, then attach the returned media_id # (videos import async — poll get_media until media_status is "ready") npx @posteverywhere/cli upload https://example.com/banner.jpg # → { "media_id": "..." } npx @posteverywhere/cli post -c "New drop" -a 123 -m # Inspect posts and per-platform results npx @posteverywhere/cli posts --status published --limit 10 npx @posteverywhere/cli results npx @posteverywhere/cli retry # retry failed platforms # Why can't an account post? (needs reconnect, quota, etc.) npx @posteverywhere/cli account:health # AI captions + analytics summary npx @posteverywhere/cli caption -t "summer sale" --platform instagram --tone playful npx @posteverywhere/cli analytics --period month ``` Run `npx @posteverywhere/cli help` for the full command list. Prefer a global install? `npm i -g @posteverywhere/cli` lets you drop the `npx @posteverywhere/cli` prefix and just call `posteverywhere `. ### CLI in CI/CD pipelines Use the CLI in GitHub Actions or other CI to announce releases as part of your pipeline: ```yaml # .github/workflows/release.yml - name: Announce release on social media env: POSTEVERYWHERE_API_KEY: ${{ secrets.POSTEVERYWHERE_API_KEY }} run: | npx @posteverywhere/cli post \ -c "v${{ github.event.release.tag_name }} is live! ${{ github.event.release.html_url }}" \ -a 123,456 ``` ### Built for AI agents The package ships a `SKILL.md` that teaches agents (Claude, Cursor, ChatGPT, OpenAI Codex, and others) the command surface for auto-discovery. If you'd rather connect over MCP than shell out, see the [MCP Server guide](/integrations/mcp) — the hosted endpoint exposes the same capabilities with no install. (For ChatGPT, connect via the hosted connector on the [agents page](https://posteverywhere.ai/agents).) ## Python A dedicated Python SDK is not yet available. In the meantime, you can call the REST API directly with `requests`: ```python import requests import os API_KEY = os.environ["POSTEVERYWHERE_API_KEY"] BASE_URL = "https://app.posteverywhere.ai/api/v1" headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", } # List accounts accounts = requests.get(f"{BASE_URL}/accounts", headers=headers).json() print(accounts["data"]) # Create a scheduled post post = requests.post( f"{BASE_URL}/posts", headers=headers, json={ "content": "Hello from Python!", "account_ids": [123, 456], "scheduled_for": "2026-04-07T14:00:00Z", # canonical — UTC }, ).json() print(f"Post {post['data']['id']} scheduled for {post['data']['scheduled_for']}") ``` ## cURL For quick one-off requests or shell scripts: ```bash # List accounts curl https://app.posteverywhere.ai/api/v1/accounts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" # Create a post curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from cURL!", "account_ids": [123], "scheduled_for": "2026-04-07T14:00:00Z" }' # Upload media — one-call from a public URL (preferred when source is online) curl -X POST https://app.posteverywhere.ai/api/v1/media/upload-from-url \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hero.webp"}' # Upload media — 3-step flow (for local files) # Step 1: Get presigned URL. Required fields: filename, content_type, size. curl -X POST https://app.posteverywhere.ai/api/v1/media/upload \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "photo.jpg", "content_type": "image/jpeg", "size": 2048576}' # Step 2: PUT/POST the bytes to the returned upload_url # Step 3: POST /api/v1/media/{media_id}/complete to finalise ``` ## Next Steps - [Quick Start](/quick-start) — Make your first API call in 5 minutes - [Authentication](/authentication) — API key scopes and security - [MCP Server](/integrations/mcp) — Use PostEverywhere from Claude, Cursor, or Windsurf - [API Reference](/api/create-post) — Full endpoint documentation --- # Use PostEverywhere with Zapier - No-Code Social Media Automation > Connect Zapier to PostEverywhere to publish posts from any of 7,000+ apps. Includes the working raw-JSON setup, the array-as-string gotcha, and the exact action and field formats. # Using PostEverywhere with Zapier Zapier doesn't have a native PostEverywhere integration yet, but you can connect any of Zapier's 7,000+ apps to PostEverywhere through the **Webhooks by Zapier** action. This guide covers the exact setup that works, plus three pitfalls that cause silent failures. :::caution Quick context PostEverywhere has a REST API. Zapier sends data to it via a webhook. The most common reason these Zaps fail is that Zapier's default form-style action serializes JSON arrays as **strings**, but our API requires real arrays. Use **Custom Request** (raw JSON), not **POST** (form-fields). Details below. ::: ## Before you start 1. **Get an API key** — Sign in to PostEverywhere, open **Developers**, and click **Generate API Key**. Copy it (you only see it once). Keys start with `pe_live_`. 2. **Find your account IDs** — On the same **Developers** page, the **Your account IDs** card lists every connected social account with a copyable integer ID. You'll use these in `account_ids`. 3. **Connect at least one social account** in PostEverywhere before testing — the API rejects requests for accounts you haven't connected. ## Step 1: Add the Webhooks by Zapier action In your Zap, add an action and search for **Webhooks by Zapier**. **Choose `Custom Request`** — not `POST`. Custom Request lets you send a raw JSON body, which is what PostEverywhere expects. The form-style `POST` action wraps every field as a separate URL-encoded form field and stringifies arrays — both will break the request. ## Step 2: Configure the Custom Request | Field | Value | |---|---| | **Method** | `POST` | | **URL** | `https://app.posteverywhere.ai/api/v1/posts` | | **Data Pass-Through?** | `false` | | **Data** | Leave empty (we use the JSON body below) | | **Unflatten** | `no` | | **Headers** | `Content-Type: application/json`
`Authorization: Bearer pe_live_yourkeyhere` | | **Wrap Request in Array** | `no` | Then paste this as the **Data (JSON / Raw)** body — replace the account IDs with your real ones from the dashboard: ```json { "content": "Hello from Zapier!", "account_ids": [6409, 6413], "scheduled_for": "2026-06-04T18:30:00Z" } ``` When you map Zapier trigger fields into the body, keep `account_ids` as a real JSON array in your body — don't wrap the array in quotes. The array must contain integers, not strings. ## Step 3: Test the Zap Click **Test action**. A successful response looks like this: ```json { "data": { "post_id": "post_abc123", "status": "scheduled", "scheduled_for": "2026-06-04T18:30:00Z", "account_ids": [6409, 6413] }, "error": null, "meta": { "request_id": "a1b2c3d4", "timestamp": "2026-06-04T01:23:45Z" } } ``` If you get a `400` error, see the troubleshooting section below — the message in the error body almost always tells you exactly what to fix. ## Three pitfalls that account for ~80% of Zapier failures ### Pitfall 1: `account_ids` arrives as a string ``` 400 account_ids_required "account_ids is required and must be a non-empty array." ``` **Why:** You used the **POST** action (form-fields) or your trigger app surfaced the IDs as a string like `"[6409, 6413]"`. Our API rejects strings — `account_ids` must be a real JSON array. **Fix:** Use the **Custom Request** action and put the array in the raw JSON body as `[6409, 6413]` — no surrounding quotes. If you're computing the array dynamically (e.g. from a spreadsheet column), use a **Code by Zapier** step to `JSON.parse(input)` the value before the webhook step. ### Pitfall 2: `scheduled_for` is not ISO 8601 UTC ``` 400 invalid_timezone ``` or ``` 400 validation_error "scheduled_for must be ISO 8601" ``` **Why:** Zapier formats datetimes in lots of ways depending on the source. `2026-06-04 6:30 PM CST`, `06/04/2026`, or `Jun 4` will all fail. The API needs strict ISO 8601 in UTC. **Fix:** Use Zapier's **Formatter → Date / Time → Format** step to convert your datetime to `YYYY-MM-DDTHH:mm:ss[Z]`. Example: `2026-06-04T18:30:00Z`. To schedule for a specific local time, convert it to UTC first. ### Pitfall 3: Your Filter step blocks the run but the webhook still fires If you see a 400 with a body that contains `_zap_data_filter_summary`, the request was sent without your content fields — the filter blocked the run but the webhook step ran on the filter metadata, not your actual post data. **Fix:** Add the Filter step **before** the webhook step in your Zap (not after) so a failed filter prevents the webhook entirely. ## Stale account IDs after a reconnect If you reconnect a social account in PostEverywhere (e.g. when its token expires), it sometimes gets a **new** integer ID — even if the username didn't change. **Symptom:** Your Zap starts returning `400 invalid_accounts: No valid social accounts found for the given account_ids`. **Fix:** Re-check the **Your account IDs** card on the **Developers** page in PostEverywhere and update the array in your Zapier action. (Or add a step that calls `GET /v1/accounts` first and uses the IDs from that response — slower but auto-heals.) ## Where to go next - [Create Post API reference](/api/create-post) — every field PostEverywhere accepts - [Errors](/errors) — full list of error codes and what they mean - [Authentication](/authentication) — how API keys, scopes, and revocation work - [Rate Limits](/rate-limits) — per-minute and per-hour caps If you get stuck, contact **support@posteverywhere.ai** with the request body and the full error response — those two are usually enough to spot the issue. --- # Platforms # Post to Bluesky via API - Schedule & Publish > Bluesky posting API: publish and schedule text, images, video, threads, and quote posts to the AT Protocol network via REST. # Post to Bluesky via API Publish text posts, image posts, video posts, and threads to [Bluesky](https://bsky.app) (the AT Protocol network). PostEverywhere handles the AT Protocol session, rich-text facets, link cards, and [media attachments](/api/init-media-upload) through a single call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Bluesky Account Bluesky uses an **app password**, not OAuth. In your PostEverywhere dashboard, go to **Accounts → Connect Bluesky** and enter: - Your **handle** (e.g. `yourname.bsky.social`, or a custom domain handle) - An **app password** created at [bsky.app → Settings → App Passwords](https://bsky.app/settings/app-passwords) (format `xxxx-xxxx-xxxx-xxxx`) Use an app password — never your main account password. You can revoke it from Bluesky at any time without affecting your login. ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain text post, up to 300 characters. | | **Image post** | Image(s) | Post with up to 4 attached images. | | **Video post** | Video (MP4) | Post with a single video attachment. | | **Thread** | None / media | A chain of connected posts (each up to 300 characters). | Bluesky is short-form: posts are capped at **300 characters**. Links, `@mentions`, and `#hashtags` in your text are detected automatically and turned into rich-text facets — you don't need to format them. When your text contains a URL, Bluesky renders an external link preview card automatically. ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Post text length | 300 characters (Unicode grapheme-aware) | | Images per post | Up to 4 | | Image formats | JPEG, PNG, WebP, GIF | | Image handling | Automatically recompressed to ≤ 1 MB per image if needed | | Video format | MP4 (H.264) | | Video max file size | 100 MB | | Video duration | Up to 3 minutes | | Links | Auto-detected; rendered as a preview card | | Mentions / hashtags | Auto-detected from `@handle` / `#tag` in text | ## Example: Create a Post ### Schedule a Bluesky Text Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Cross-posting from PostEverywhere — links like https://posteverywhere.ai and #hashtags are detected automatically.", "account_ids": [2295], "scheduled_for": "2026-06-01T12:00:00Z", "timezone": "UTC" }' ``` ### Schedule a Bluesky Post with Images Upload your images first (see [Upload Media](/api/init-media-upload)), then attach the returned IDs: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Four shots from launch day 🦋", "account_ids": [2295], "media_ids": [ "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444" ] }' ``` ### Post a Thread Use `thread_posts` to publish a connected chain. Each entry becomes a reply to the one before it: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "A short thread on why we built a unified posting API 🧵", "account_ids": [2295], "thread_posts": [ "1/ Managing 9 platforms by hand does not scale.", "2/ One API call fans out to all of them, with per-platform overrides.", "3/ Bluesky was the latest to join. Try it: https://posteverywhere.ai" ] }' ``` ## Tips - **Keep it under 300 characters.** Bluesky counts by Unicode grapheme, so emoji and combined characters count as one. If you need more room, use a thread. - **Don't pre-shorten links or build markdown.** Bluesky link cards and `@mention` / `#hashtag` facets are generated automatically from the raw text — paste the URL or handle directly. - **Images are recompressed for you.** Bluesky caps each uploaded blob at ~1 MB; PostEverywhere downsizes large images automatically so you can send full-resolution files. ## Frequently Asked Questions ### How do I connect a Bluesky account? Bluesky uses an app password rather than OAuth. Create one at [bsky.app → Settings → App Passwords](https://bsky.app/settings/app-passwords), then add your handle and that app password under **Accounts → Connect Bluesky** in the PostEverywhere dashboard. ### What is the character limit for Bluesky posts? 300 characters, counted by Unicode grapheme. Emoji and combined characters count as a single character. For longer content, split it across a thread using `thread_posts`. ### Can I attach images or video to a Bluesky post? Yes. [Upload your media](/api/init-media-upload) and pass the returned UUIDs in `media_ids`. Bluesky supports up to 4 images (JPEG, PNG, WebP, GIF) or a single MP4 video up to 100 MB and 3 minutes. Large images are automatically recompressed to fit Bluesky's per-blob size limit. ### Do links and mentions need special formatting? No. Include the raw URL, `@handle`, or `#hashtag` in your `content` and PostEverywhere generates the correct AT Protocol rich-text facets and link preview card automatically. ### Can I edit a Bluesky post after it's published? No. The AT Protocol does not expose a post-edit operation — Bluesky's official model is delete-and-repost. `PATCH /v1/posts/:id` only accepts posts in `scheduled` or `draft` status. To change a published Bluesky post, delete it (in the Bluesky app or via repository tooling) and recreate it from PostEverywhere. ## Related Platforms - **[X (Twitter)](/platforms/x-twitter)** — similar short-form text and threading model - **[Threads](/platforms/threads)** — short-form conversational posts by Meta - **[LinkedIn](/platforms/linkedin)** — longer-form posts with PDF/document support ## Further Reading - [API Reference — Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) --- # Post to Discord via API - Publish to Servers > Discord post API: publish and schedule messages to server channels via webhook. Text, image embeds, and media in one REST call. # Post to Discord via API Publish text and media to a Discord server channel via an Incoming Webhook. PostEverywhere posts to the webhook URL you provide at setup — there is no OAuth, no bot, and no app review process. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Discord Account Discord uses an **Incoming Webhook URL**. A webhook is bound to one specific channel — if you want to post to multiple channels, create multiple webhooks and connect each separately. 1. In Discord, open your server. Go to **Server Settings → Integrations → Webhooks**. 2. Click **New Webhook**, give it a name, and select the target channel. 3. Click **Copy Webhook URL**. The URL looks like `https://discord.com/api/webhooks//`. 4. In your PostEverywhere dashboard, go to **Accounts → Connect Discord** and paste the webhook URL. We validate the URL by fetching the webhook metadata before saving. If Discord rejects the URL (404 / deleted webhook), reconnect after recreating it. ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain message, up to 2,000 characters. | | **Image post** | Image(s) | Up to 10 images, each rendered as an embed. | | **Video post** | Video URL | Video URL appended to the message — Discord renders it inline. | Discord webhooks render attached images as **embeds**, not native attachments. This means images appear as preview cards rather than uploaded files; the visual result is similar but the underlying mechanism is different from native bot uploads. ## Platform-Specific Settings Discord has no platform-specific settings beyond per-platform content override. The webhook's display name and avatar are configured in Discord at the webhook level, not per-post. ```json { "platform_content": { "discord": { "content": "Discord-specific message (up to 2,000 chars)" } } } ``` ## Example: Create a Post ### Schedule a Discord Text Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Release v2.4.0 is live! Patch notes: https://example.com/changelog", "account_ids": [3105], "scheduled_for": "2026-06-15T17:00:00Z", "timezone": "America/Los_Angeles" }' ``` ### Schedule a Discord Post with Multiple Image Embeds ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Concept art drop for the new map", "account_ids": [3105], "media_ids": [ "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333" ] }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Message length | 2,000 characters | | Image embeds per message | Up to 10 | | Image formats | JPEG, PNG, WebP, GIF | | Image max file size | 25 MB (Discord webhook limit) | | Video | Sent as URL — Discord renders the link inline | | Channel binding | One webhook = one channel | | Webhook URL must be valid | Must be a current Discord webhook (not deleted/revoked) | :::note Webhook deletion = silent failures If the webhook is deleted on Discord, every subsequent publish fails with 404. Recreate the webhook in Discord and reconnect the account in PostEverywhere. ::: ## Tips - **Webhook = one channel.** To post to multiple channels in the same server, create one webhook per channel and connect each as a separate "account" in PostEverywhere. Use `account_ids` to fan out. - **Images are embeds, not attachments.** Up to 10 image URLs are passed as embed objects. Discord fetches each URL — if your media is behind a private store, ensure the URL is publicly accessible (PostEverywhere's media URLs are). - **Video is appended as a link, not uploaded.** Discord webhooks don't accept direct video uploads — instead, we append the video URL to the message body and Discord renders the embed automatically. This works well for MP4s served from a CDN. ## Frequently Asked Questions ### How do I connect a Discord server? Open the server in Discord, go to **Server Settings → Integrations → Webhooks**, create a new webhook for the channel you want to post to, copy the webhook URL, then paste it under **Accounts → Connect Discord** in your PostEverywhere dashboard. ### Why did my Discord webhook stop working? The most common cause is that someone deleted the webhook on Discord, or the channel itself was deleted. Recreate the webhook in Discord and reconnect the account in PostEverywhere — the URL changes whenever a webhook is recreated. ### Can I post to a Discord channel that requires a role? A webhook posts as the webhook itself, bypassing role checks for posting permission. However, if the channel is read-restricted by role, members without that role won't see the post. The webhook itself can post regardless of channel permissions, as long as the webhook still exists. ### Can I edit a Discord post after it's published? No. Discord webhooks support `PATCH /webhooks///messages/`, but `PATCH /v1/posts/:id` only accepts `scheduled` or `draft` posts on our API. Once published, edits return `400 invalid_post_status` — edit the message manually in Discord if needed. ## Related Platforms - **[Telegram](/platforms/telegram)** — bot-token connection model, similar low-friction setup - **[Bluesky](/platforms/bluesky)** — app-password model, also no OAuth - **[Threads](/platforms/threads)** — Meta's short-form text platform ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) --- # Post to Facebook via API - Schedule & Publish > Post to Facebook via API: publish and schedule Page posts, Reels, and link shares with one REST call. Video types, previews, examples. # Post to Facebook via API Publish text posts, image posts, Reels, video posts, and link shares to Facebook Pages. PostEverywhere handles Facebook's Graph API interactions, [media uploads](/api/init-media-upload), and post creation through a single endpoint. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Facebook Account Connect Facebook from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain text status update on your Page. | | **Image post** | Image(s) | Post with one or more attached images. | | **Reel** | Video (MP4, 9:16) | Vertical short-form video. Default for video uploads. | | **Video post** | Video (MP4) | Standard video post with any aspect ratio. Set `videoType` to `"video"`. | | **Link share** | None | Post with a URL that generates a link preview card. | ## Platform-Specific Settings Use the `platform_content.facebook` object to customise your Facebook post: ```json { "platform_content": { "facebook": { "content": "Facebook post text", "settings": { "videoType": "reel", "link": "https://example.com", "firstComment": "First comment text" } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Override the default text with a Facebook-specific version. | | `settings.videoType` | string | `"reel"` | Video format: `"reel"` (vertical, 9:16) or `"video"` (any aspect ratio). | | `settings.link` | string | — | URL to share. Facebook generates a preview card with title, description, and thumbnail. | | `settings.firstComment` | string | — | Text posted as the first comment after publishing. | ### Video Types | Value | Aspect Ratio | Description | |-------|-------------|-------------| | `reel` | 9:16 (vertical) | Facebook Reel. Appears in the Reels tab and feed. Default for video uploads. | | `video` | Any | Standard video post. Supports landscape, square, or portrait aspect ratios. | ## Example: Create a Post ### Schedule a Facebook Reel ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default text for other platforms", "account_ids": [2278], "media_ids": ["c9d0e1f2-a3b4-5678-9012-cdef01234567"], "scheduled_for": "2026-04-16T10:00:00Z", "timezone": "America/New_York", "platform_content": { "facebook": { "content": "3 things I wish I knew before starting my business 👇", "settings": { "videoType": "reel", "firstComment": "What would you add to this list? Drop it below!" } } } }' ``` ### Schedule a Link Share Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out our new blog post", "account_ids": [2278], "scheduled_for": "2026-04-16T14:00:00Z", "timezone": "Europe/London", "platform_content": { "facebook": { "content": "We just published a deep dive into social media scheduling for 2026.\n\n5 strategies that actually work (based on data from 10,000+ posts).\n\nLink in the comments!", "settings": { "firstComment": "Read the full article: https://example.com/blog/scheduling-strategies" } } } }' ``` ### Schedule a Standard Video Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Product demo", "account_ids": [2278], "media_ids": ["d0e1f2a3-b4c5-6789-0123-def012345678"], "platform_content": { "facebook": { "content": "Full product walkthrough — 5 minutes, no fluff.", "settings": { "videoType": "video" } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Post text length | 63,206 characters | | Image formats | JPEG, PNG, WebP, GIF | | Image max file size | 20 MB | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Reel duration | 3 - 90 seconds | | Reel aspect ratio | 9:16 (vertical, 1080x1920) | | Standard video duration | Up to 240 minutes | | Recommended image ratio | 1.91:1 (landscape) or 1:1 (square) | | Link preview | Auto-generated from Open Graph tags on the URL | ## Tips - **Use `"videoType": "reel"` for maximum reach.** Facebook heavily promotes Reels in the feed and Reels tab. If your video is vertical (9:16), use the default `"reel"` type for significantly better organic distribution than standard video posts. - **Put links in `firstComment` instead of the post body.** Similar to LinkedIn, Facebook deprioritizes posts with external links in the main text. Use `firstComment` to share your URL after publishing to protect your organic reach. - **Use the `link` setting for intentional link shares.** When you explicitly want a link preview card (with title, image, and description), use the `link` setting. This tells Facebook to generate the preview from the URL's Open Graph meta tags. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### What is the difference between a Facebook Reel and a standard video post? Reels are vertical (9:16) short-form videos between 3 and 90 seconds. They appear in the dedicated Reels tab and get significantly more organic reach. Standard video posts support any aspect ratio and can be up to 240 minutes long. Set `videoType` to `"reel"` or `"video"` in `platform_content.facebook.settings` when [creating a post](/api/create-post). ### How do I create a link share post with a preview card? Use the `settings.link` field in `platform_content.facebook` with a valid HTTPS URL. Facebook generates a preview card automatically from the URL's Open Graph meta tags (title, description, image). You do not need to attach any media -- the preview is generated from the linked page. ### Can I post to Facebook Groups via the API? No. The PostEverywhere API currently supports posting to Facebook Pages only. Facebook's API does not allow third-party apps to post to Groups. Connect your Page as a social account and use its `account_id` when creating posts. ### Should I put links in the post body or in a first comment? Use the `firstComment` setting for external links. Facebook's algorithm deprioritizes posts that contain links in the main text. By placing your link in the first comment (posted automatically after publishing), you preserve organic reach while still driving traffic. ### Will my posts show "Published by PostEverywhere" branding? Not to your audience. Facebook shows a small "Published by [name] · PostEverywhere" attribution line on Page posts made through any third-party tool, but it is visible **only to admins and editors of that Page**. Your followers and the public see a completely normal post with no tool branding anywhere; you can verify this yourself by viewing the post in an incognito window while logged out. The same line appears for Buffer, Hootsuite, and every other scheduler, it cannot be removed (Facebook attaches it to whichever tool published), and it has zero effect on the post's appearance or reach. ### Can I edit a Facebook post after it's published? No. `PATCH /v1/posts/:id` is only valid for posts in `scheduled` or `draft` status. Once Facebook accepts the publish, the post status becomes `published` and our API returns `400 invalid_post_status`. You can edit a live Page post's text directly in Facebook's UI, but media and Reel covers cannot be changed after publishing. ## Related Platforms - **[Instagram](/platforms/instagram)** -- owned by Meta, supports cross-posting of Reels and image content - **[LinkedIn](/platforms/linkedin)** -- similar post formats with text, image, video, and link support - **[Threads](/platforms/threads)** -- Meta's text-first companion platform ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [Facebook Scheduler on PostEverywhere](https://posteverywhere.ai/facebook-scheduler) --- # Post to Instagram via API - Reels, Stories & Carousels > Post to Instagram via API: publish and schedule feed posts, Reels, Stories, Trial Reels, and carousels with one REST call. # Post to Instagram via API Publish Instagram Feed Posts, Reels, Stories, and Trial Reels through a single API call. PostEverywhere handles [media uploads](/api/init-media-upload), aspect ratio validation, and Instagram's content publishing API so you can focus on your content. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Instagram Account Connect Instagram from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Post** | Image(s) | Standard feed post. Default when an image is attached. Supports up to 10 images for carousels. | | **Reels** | Video | Short-form video (up to 90 seconds). Default when a video is attached. | | **Story** | Image or video | Disappears after 24 hours. Must be set explicitly via `contentType`. | | **Trial Reel** | Video | Reel shared with a smaller audience first to test engagement. | ## Platform-Specific Settings Use the `platform_content.instagram` object to customise your Instagram post: ```json { "platform_content": { "instagram": { "content": "Instagram-specific caption with #hashtags", "contentType": "Story", "settings": { "altText": "Image description for accessibility", "firstComment": "First comment posted automatically after publishing" } } } } ``` ### Settings Reference | Setting | Type | Description | |---------|------|-------------| | `content` | string | Override the default caption with an Instagram-specific version. | | `contentType` | string | One of `"Post"`, `"Reels"`, `"Story"`, or `"Trial Reel"`. If omitted, images default to `"Post"` and videos default to `"Reels"`. | | `settings.altText` | string | Accessibility description for the image. Max 100 characters. | | `settings.firstComment` | string | Text posted as the first comment after publishing. Useful for hashtag stacking. | | `settings.coverPhotoTimestamp` | number | Timestamp in seconds to use as the video cover photo. | ## Example: Create a Post ### Schedule an Instagram Reel ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Check out our latest product walkthrough!", "account_ids": [2280], "media_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"], "scheduled_for": "2026-04-15T14:00:00Z", "timezone": "America/New_York", "platform_content": { "instagram": { "content": "New product walkthrough is live! Watch to the end for a surprise 🎁\n\n#productlaunch #tutorial #newfeature", "contentType": "Reels", "settings": { "firstComment": "#socialmedia #marketing #contentcreator #smallbusiness #growthhacking", "coverPhotoTimestamp": 3.5 } } } }' ``` ### Schedule an Instagram Story ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes today!", "account_ids": [2280], "media_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"], "platform_content": { "instagram": { "contentType": "Story" } } }' ``` ### Schedule an Instagram Trial Reel Trial Reels let you test a Reel with non-followers first. Instagram only shows it to your existing followers if it performs well — useful for A/B testing or de-risking new content directions. ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Testing a new content angle 🎬", "account_ids": [2280], "media_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"], "platform_content": { "instagram": { "contentType": "Trial Reel" } } }' ``` Under the hood, PostEverywhere passes Instagram's documented `trial_params` (`graduation_strategy: SS_PERFORMANCE`) to the Graph API. The Reel is shown only to non-followers initially and graduates to your follower feed automatically if engagement metrics clear Instagram's thresholds. ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Caption length | 2,200 characters | | Hashtags per post | 30 maximum | | Alt text length | 100 characters | | Image formats | JPEG, PNG, WebP, HEIC | | Image max file size | 20 MB | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration (Reels) | 3 - 90 seconds | | Video duration (Story) | Up to 60 seconds | | Carousel images | 2 - 10 images | | Recommended image ratio | 1:1 (square), 4:5 (portrait), 1.91:1 (landscape) | | Recommended Reels ratio | 9:16 (vertical) | ## Tips - **Use `firstComment` for hashtags.** Moving hashtags to the first comment keeps your caption clean while maintaining discoverability. The comment is posted automatically within seconds of publishing. - **Set `contentType` explicitly for Stories.** Without it, images default to Feed Post and videos default to Reels. Stories must be requested via `"contentType": "Story"`. - **Test with Trial Reels first.** Use `"contentType": "Trial Reel"` to share a Reel with a smaller audience before committing to a full publish. This is useful for A/B testing content. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### Can I schedule Instagram Reels via the API? Yes. Set `contentType` to `"Reels"` in the `platform_content.instagram` object when [creating a post](/api/create-post). Attach a video file (MP4, up to 90 seconds for Reels) using the [Upload Media endpoint](/api/init-media-upload). ### What image sizes does Instagram support? Instagram supports images from 320x320 to 1440x1800 pixels. Square (1:1), landscape (1.91:1), and portrait (4:5) aspect ratios are accepted. Images are automatically resized if they fall outside these dimensions. ### Can I post Instagram carousels via the API? Yes. Attach multiple images (2 to 10) to your post using the `media_ids` array. Set `contentType` to `"Post"` or omit it -- images default to a feed post, and Instagram automatically creates a carousel when multiple images are attached. ### How do I add hashtags without cluttering my caption? Use the `firstComment` setting. PostEverywhere automatically posts your hashtags as the first comment within seconds of publishing, keeping your caption clean while maintaining full discoverability. ### How do I publish a Trial Reel via the API? Set `contentType` to `"Trial Reel"` inside `platform_content.instagram`. See the [Trial Reel example](#schedule-an-instagram-trial-reel) above for the full request shape. Trial Reels require a video (3-90 seconds, MP4) and a Reel-eligible Instagram account. ### Do you support `share_mode` or `TRIAL_REELS_DONT_SHARE_TO_FOLLOWERS`? We support the **functionality** these names describe (a Reel shown to non-followers first), but not under those names — they aren't part of Instagram's documented Graph API. The Meta-supported way to publish a Trial Reel is via `trial_params` with `graduation_strategy: SS_PERFORMANCE`, which is exactly what PostEverywhere sends when you set `contentType: "Trial Reel"`. The `TRIAL_REELS_DONT_SHARE_TO_FOLLOWERS` identifier you may have seen elsewhere is an internal Meta constant, not a public API parameter. ### Can I edit an Instagram post after it's published? No. `PATCH /v1/posts/:id` rejects edits on `published` posts with `400 invalid_post_status` — only `scheduled` and `draft` posts can be modified. Instagram's Graph API also doesn't expose endpoints for editing a published feed post's caption or media. To change a live post, edit the caption directly in the Instagram app, or delete and recreate. ## Related Platforms - **[TikTok](/platforms/tiktok)** -- short-form vertical video, similar to Reels - **[Facebook](/platforms/facebook)** -- cross-post Reels and image content to Facebook Pages - **[Threads](/platforms/threads)** -- text-first companion platform by Meta ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [Instagram Scheduler on PostEverywhere](https://posteverywhere.ai/instagram-scheduler) --- # Post to LinkedIn via API - Schedule & Publish > Post to LinkedIn via API: publish and schedule text, images, video, and PDF carousels to profiles and company pages with one REST call. # Post to LinkedIn via API Publish text posts, image posts, videos, and PDF carousel documents to LinkedIn profiles and company pages. PostEverywhere handles LinkedIn's multi-step [media upload](/api/init-media-upload) and UGC post creation through a single API call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a LinkedIn Account Connect LinkedIn from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain text post with no media attachments. | | **Image post** | Image(s) | Post with one or more images. Supports JPEG, PNG, WebP, and HEIC. | | **Video post** | Video (MP4) | Native video post uploaded directly to LinkedIn. | | **PDF carousel** | Document (PDF) | A PDF uploaded as a document post. LinkedIn displays each page as a swipeable carousel slide. | LinkedIn is one of the most versatile platforms, supporting images, videos, and PDF documents in a single API. ## Platform-Specific Settings Use the `platform_content.linkedin` object to customise your LinkedIn post: ```json { "platform_content": { "linkedin": { "content": "LinkedIn-specific text (max 3000 chars)", "settings": { "visibility": "PUBLIC", "firstComment": "First comment text" } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Override the default text with a LinkedIn-specific version. Max 3,000 characters. | | `settings.visibility` | string | `"PUBLIC"` | Who can see the post. See values below. | | `settings.firstComment` | string | — | Text posted as the first comment after publishing. Useful for adding links or CTAs. | ### Visibility Options | Value | Description | |-------|-------------| | `PUBLIC` | Visible to everyone on LinkedIn, including non-connections. | | `CONNECTIONS` | Visible only to your 1st-degree connections. | | `LOGGED_IN_MEMBERS` | Visible to any logged-in LinkedIn user, but not in public search. | ## Example: Create a Post ### Schedule a LinkedIn Post with a PDF Carousel ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default caption for other platforms", "account_ids": [2275], "media_ids": ["a7b8c9d0-e1f2-3456-7890-abcdef012345"], "scheduled_for": "2026-04-16T08:30:00Z", "timezone": "America/New_York", "platform_content": { "linkedin": { "content": "I spent the last 6 months analyzing what makes content go viral on LinkedIn.\n\nHere are the 10 patterns I found (swipe through the carousel):\n\n1. Hook in the first line\n2. Use data, not opinions\n3. One idea per slide\n...\n\nDrop a comment if you want the full PDF.", "settings": { "visibility": "PUBLIC", "firstComment": "Download the full report here: https://example.com/report" } } } }' ``` ### Schedule a Connections-Only Text Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Thinking about our next product direction.", "account_ids": [2275], "scheduled_for": "2026-04-16T12:00:00Z", "timezone": "America/Chicago", "platform_content": { "linkedin": { "content": "I have been reflecting on what our customers actually need vs. what we assumed they wanted.\n\nThe gap is bigger than expected.\n\nMore soon — would love your thoughts in the comments.", "settings": { "visibility": "CONNECTIONS" } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Post text length | 3,000 characters | | First comment length | 1,250 characters | | Image formats | JPEG, PNG, WebP, HEIC | | Image max file size | 20 MB | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration | 3 seconds - 10 minutes | | Document format | PDF | | Document max file size | 20 MB | | PDF carousel pages | Up to 300 pages (practical limit ~20 for engagement) | | Recommended image ratio | 1.91:1 (landscape) or 1:1 (square) | ## Tips - **Use PDF carousels for high engagement.** LinkedIn's algorithm favors carousel documents because they increase dwell time. Upload a multi-page PDF and each page becomes a swipeable slide. Keep it to 8-12 slides for optimal engagement. - **Put links in `firstComment`, not the post body.** LinkedIn's algorithm deprioritizes posts with external links in the main text. Use `firstComment` to add your link after publishing — this keeps your reach high while still driving traffic. - **Write longer, value-driven posts.** LinkedIn rewards posts between 1,000-2,000 characters. Use the full 3,000-character limit to tell a story or share insights. The first line is your hook — make it count. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### How do I create a LinkedIn PDF carousel via the API? [Upload a PDF file](/api/init-media-upload) using the Upload Media endpoint, then pass the returned UUID in the `media_ids` array in your [Create Post](/api/create-post) request. LinkedIn automatically displays each page of the PDF as a swipeable carousel slide. Keep carousels to 8-12 slides for best engagement. ### Can I post to LinkedIn company pages, not just personal profiles? Yes. Connect your LinkedIn company page as a social account in PostEverywhere, then use the company page's `account_id` when creating a post. The API supports both personal profiles and company pages. ### What is the `firstComment` setting used for on LinkedIn? The `firstComment` field posts a reply to your own post immediately after publishing. This is commonly used to add external links or CTAs. LinkedIn's algorithm deprioritizes posts that contain links in the main body, so placing links in the first comment preserves your organic reach. ### What is the maximum character limit for LinkedIn posts? LinkedIn posts support up to 3,000 characters. Posts between 1,000 and 2,000 characters tend to perform best. The first line of your post is your hook -- it appears before the "see more" fold, so make it count. ### Why didn't my LinkedIn post get a rich link card? A rich card requires four things to land in this order: (1) we extract a URL from your post body, (2) the URL responds with HTML, (3) we find an Open Graph title in that HTML, and (4) we successfully fetch the `og:image`, convert it to JPEG, and upload it to LinkedIn under 5 MB. If any step fails, we fall back to a plain text post that still contains the URL. Common failures: the page blocks bots (returns 403/empty body to our user-agent), has no `og:image`, or serves an image that fails to download. Carousels and image posts bypass the card pipeline entirely — LinkedIn shows the attached media instead. ### Are bare URLs (without `https://`) detected for link cards? Yes. We detect both explicit URLs (`https://example.com/blog/foo`) and bare URLs (`example.com/blog/foo`) as long as the host has at least one dot, the TLD is 2-10 alphabetic characters, and at least one path segment follows the host. Only the **first** URL in the post becomes the card (LinkedIn renders one card per post). ### Can I edit a LinkedIn post after it's published? No. `PATCH /v1/posts/:id` only accepts posts in `scheduled` or `draft` status. Once LinkedIn has accepted the post, it becomes `published` and the API returns `400 invalid_post_status`. LinkedIn's own UGC API does not allow third-party tools to edit a published post's text — you must edit it on LinkedIn directly, or delete and recreate. ## Related Platforms - **[X (Twitter)](/platforms/x-twitter)** -- professional commentary and thought leadership - **[Facebook](/platforms/facebook)** -- similar post formats with image, video, and link support - **[Threads](/platforms/threads)** -- short-form text posts for conversation ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [LinkedIn Scheduler on PostEverywhere](https://posteverywhere.ai/linkedin-scheduler) --- # Post to Pinterest via API - Schedule & Publish Pins > Pinterest post API: create and schedule Pins via REST with titles, descriptions, board selection, and destination links. # Post to Pinterest via API Create and schedule Pins to Pinterest boards. PostEverywhere handles pin creation, [image uploads](/api/init-media-upload), and board targeting so you can publish visual content alongside your other social platforms in a single API call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Pinterest Account Connect Pinterest from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Image Pin** | Image | Standard Pin with an image, title, description, and optional destination link. | Pinterest is an image-first platform. Every Pin requires at least one image attachment. ## Platform-Specific Settings Use the `platform_content.pinterest` object to customise your Pin: ```json { "platform_content": { "pinterest": { "content": "Pin description with keywords", "settings": { "title": "Pin Title", "link": "https://example.com/page", "boardId": "board-id-from-your-account" } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Pin description. Used for search discovery on Pinterest. Include relevant keywords naturally. | | `settings.title` | string | — | Pin title displayed above the image. Max 100 characters. | | `settings.link` | string | — | Destination URL. When users click the Pin, they are directed to this URL. | | `settings.boardId` | string | — | The Pinterest board to pin to. If omitted, the Pin is saved to your profile's default board. | | `settings.altText` | string | — | Accessibility description for the pin image. Max 500 characters. Improves screen-reader access and is used by Pinterest for visual search indexing. | ## Example: Create a Post ### Schedule a Pin with a Destination Link ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default text for other platforms", "account_ids": [2300], "media_ids": ["f2a3b4c5-d6e7-8901-2345-f01234567890"], "scheduled_for": "2026-04-16T09:00:00Z", "timezone": "America/New_York", "platform_content": { "pinterest": { "content": "10 social media scheduling tips for small businesses. Save this for later and visit our blog for the full guide.", "settings": { "title": "10 Social Media Scheduling Tips for 2026", "link": "https://example.com/blog/scheduling-tips" } } } }' ``` ### Schedule a Pin to a Specific Board ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New recipe posted!", "account_ids": [2300], "media_ids": ["a3b4c5d6-e7f8-9012-3456-012345678901"], "scheduled_for": "2026-04-16T11:00:00Z", "timezone": "America/Los_Angeles", "platform_content": { "pinterest": { "content": "Easy 30-minute weeknight dinner recipe. Creamy garlic pasta with roasted vegetables. Perfect for busy families. Full recipe on the blog.", "settings": { "title": "30-Minute Creamy Garlic Pasta", "link": "https://example.com/recipes/garlic-pasta", "boardId": "your-recipes-board-id" } } } }' ``` ### Cross-Post to Pinterest and Instagram ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New product just dropped!", "account_ids": [2280, 2300], "media_ids": ["b4c5d6e7-f8a9-0123-4567-123456789012"], "scheduled_for": "2026-04-17T10:00:00Z", "timezone": "UTC", "platform_content": { "instagram": { "content": "NEW: Our spring collection just dropped! Link in bio 🛍\n\n#newcollection #spring2026 #fashion", "settings": { "firstComment": "#style #outfitinspo #shopnow #newarrivals" } }, "pinterest": { "content": "Spring 2026 collection now available. Shop the latest styles with free shipping over $50.", "settings": { "title": "Spring 2026 Collection", "link": "https://example.com/shop/spring-2026" } } } }' ``` ### Fix the Board or Link on an Already-Scheduled Pin Scheduled a Pin without a board or destination link? You do not need to delete and recreate it. Send a `PATCH` with only the settings you want to change. They merge into the post before it publishes. ```bash curl -X PATCH https://app.posteverywhere.ai/api/v1/posts/{post_id} \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform_content": { "pinterest": { "settings": { "boardId": "your-board-id", "link": "https://example.com/page" } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Pin description length | 500 characters | | Pin title length | 100 characters | | Image formats | JPEG, PNG, WebP | | Image max file size | 20 MB | | Recommended image ratio | 2:3 (1000x1500 px) | | Minimum image width | 600 px | | Maximum image ratio | 1:2.1 (very tall images may be cropped) | | Link URL | Must be a valid HTTPS URL | ## Tips - **Use 2:3 vertical images (1000x1500 px).** Pinterest is a vertical-scrolling platform. Taller images take up more space in the feed, which means more visibility. The ideal ratio is 2:3 — wide or square images get less engagement. - **Write keyword-rich descriptions.** Pinterest functions as a visual search engine. Include relevant keywords naturally in your Pin description (not hashtags — Pinterest uses natural language search, not hashtag discovery). Think about what someone would search for to find your content. - **Always include a destination link.** Pinterest is one of the best platforms for driving website traffic. Every Pin should link to a relevant page — a blog post, product page, or landing page. Pins without links are missed opportunities for conversion. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### How do I pin to a specific Pinterest board? Set `settings.boardId` in the `platform_content.pinterest` object when [creating a post](/api/create-post). If you omit `boardId`, the Pin is saved to your profile's default board. You can find board IDs by listing your connected Pinterest account's boards via the API. Already scheduled the Pin? Send the same `platform_content` object in a `PATCH` to [update the post](/api/update-post) — see the example above. ### Does Pinterest support video Pins via the API? Yes. Upload a video with the [Upload Media endpoint](/api/init-media-upload) and attach it to your post. Image Pins work the same way (JPEG, PNG, or WebP, up to 20 MB). For best results, use vertical 2:3 media (1000x1500 px). ### Should I use hashtags in Pinterest descriptions? No. Unlike Instagram or TikTok, Pinterest uses natural language search rather than hashtag discovery. Write keyword-rich descriptions that read naturally. Think about what someone would type into Pinterest search to find your content, and include those phrases in your description. ### Can I cross-post the same image to Pinterest and Instagram in one API call? Yes. Include both account IDs in the `account_ids` array and use `platform_content` to customize the caption for each platform. See the cross-posting example above for a complete request that posts to both [Instagram](/platforms/instagram) and Pinterest simultaneously. ### Can I edit a Pinterest pin after it's published? Not via the PostEverywhere API. Pinterest's v5 API allows editing pin titles, descriptions, board placement, and destination links, but we don't propagate edits to already-published pins. `PATCH /v1/posts/:id` only works while a post is in `scheduled` or `draft` status — once Pinterest has accepted the pin, the post becomes `published` and is read-only from our side. To change a live pin, edit it directly on Pinterest, or delete and recreate the pin from PostEverywhere. ### How do I set alt text on a Pinterest pin? Set `settings.altText` inside `platform_content.pinterest`. Pinterest uses alt text both for accessibility (screen readers) and for its visual search index, so descriptive alt text can help discovery. Max 500 characters; we truncate above that. Alt text is separate from `content` (the pin description) and `settings.title` — all three appear in different places on the pin. ### Does Pinterest auto-extract a URL from my pin description? No. Unlike Facebook and LinkedIn (which automatically build a link card from URLs in the post body), Pinterest does **not** auto-extract a destination URL from your description. If you want the pin to link somewhere when clicked, you must set `settings.link` explicitly. Without it, clicking the pin opens Pinterest's internal pin-detail view rather than redirecting to your site. ## Related Platforms - **[Instagram](/platforms/instagram)** -- visual-first platform ideal for cross-posting image content - **[Facebook](/platforms/facebook)** -- image and link sharing for broader social reach - **[LinkedIn](/platforms/linkedin)** -- PDF carousels and image posts for professional audiences ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [Pinterest Scheduler on PostEverywhere](https://posteverywhere.ai/social-media-scheduler) --- # Post to Telegram via API - Publish to Channels > Telegram post API: publish and schedule channel posts via your bot. Text, images, carousels, and video through one REST call. # Post to Telegram via API Publish text, images, image carousels, video, and PDF documents to a Telegram channel through your bot. PostEverywhere uses the Telegram Bot API to deliver content; the bot you connected at setup is the credential — there is no OAuth flow. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Telegram Account Telegram uses a **bot token** plus a **target channel**. There is no OAuth flow. 1. Open a chat with [@BotFather](https://t.me/BotFather) on Telegram and create a bot. Copy the bot token it gives you. 2. Add the bot as an **administrator** of the channel you want to post to. Enable the **Post Messages** permission. 3. In your PostEverywhere dashboard, go to **Accounts → Connect Telegram** and paste: - The **bot token** from BotFather (format: `123456:ABC-DEF1234...`) - The **channel** — either `@channelusername`, a `t.me/channelusername` link, or a numeric chat ID. We validate the bot token, confirm the bot can see the channel, and check it has post permission before saving the account. If any of those fail, we surface the exact error so you can fix the setup. ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain text message, up to 4,096 characters. | | **Image post** | Image | Single image with optional caption (up to 1,024 chars). | | **Image carousel** | Images (2-10) | Media group with up to 10 images. | | **Video post** | Video (MP4) | Single video with optional caption. | | **Document post** | PDF | PDF sent as a document attachment. | When the post text exceeds Telegram's 1,024-character caption limit but media is attached, we send the long text as a separate message first, then send the media without a caption — so nothing gets truncated. ## Platform-Specific Settings Telegram has no platform-specific settings object — `platform_content.telegram.content` lets you override the caption / text per platform, but there are no privacy, comment-control, or reply-restriction fields. ```json { "platform_content": { "telegram": { "content": "Telegram-specific caption (up to 4,096 chars text, 1,024 chars caption)" } } } ``` ## Example: Create a Post ### Schedule a Telegram Text Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "New blog post is live — read it here: https://example.com/blog/foo", "account_ids": [3100], "scheduled_for": "2026-06-15T12:00:00Z", "timezone": "UTC" }' ``` ### Schedule a Telegram Image Carousel ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Launch day photos", "account_ids": [3100], "media_ids": [ "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333" ] }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Text-only message length | 4,096 characters | | Caption length (with media) | 1,024 characters | | Images per carousel | 2 - 10 (media group) | | Image formats | JPEG, PNG, WebP | | Image max file size | 10 MB (Telegram limit) | | Video format | MP4 (H.264) | | Video max file size | 50 MB via bot URL (Telegram limit) | | Document formats | PDF | | Document max file size | 50 MB | | Bot must be channel admin | Yes — with "Post Messages" permission | :::caution Bot must have "Post Messages" enabled If you remove the bot from your channel or revoke its post permission, every subsequent publish will fail with "bot can't post to chat". Reconnect via **Accounts → Connect Telegram** after fixing the permission on Telegram. ::: ## Tips - **Telegram fetches our URLs.** We pass media to Telegram as URLs to our storage; Telegram pulls the file. This means very large files (over Telegram's 50 MB ceiling) cannot be sent via the Bot API. - **Long text + media gets split automatically.** If your post text is longer than 1,024 characters but you've attached media, we send the text as a separate message before the media (with no caption on the media). This avoids silent truncation. - **The bot's display name is the post author.** Posts appear authored by the channel, but Telegram shows that the message was sent via a bot. Pick a bot name that makes sense for your channel. ## Frequently Asked Questions ### How do I connect a Telegram channel? Create a bot via [@BotFather](https://t.me/BotFather), copy the token, add the bot to your channel as an administrator with "Post Messages" enabled, then go to **Accounts → Connect Telegram** in your PostEverywhere dashboard. Enter the bot token and the channel (`@channelname` or a numeric chat ID). ### Why did my Telegram post fail with "bot is not an admin"? The bot was removed from the channel, demoted, or had its "Post Messages" permission revoked. Re-add the bot as an administrator with post permissions, then re-validate the connection by clicking **Reconnect** on the account in your dashboard. ### Can I post to a private Telegram group via the API? Yes — the bot must be an administrator of the group. We support channels, supergroups, and private groups. Direct messages to users are not supported (Telegram's Bot API restricts that to opted-in users only). ### Can I edit a Telegram post after it's published? No. Telegram's Bot API supports `editMessageText` and `editMessageCaption`, but `PATCH /v1/posts/:id` only accepts `scheduled` or `draft` posts. Edits via our API on published posts return `400 invalid_post_status`. Edit directly in Telegram if needed. ## Related Platforms - **[Discord](/platforms/discord)** — webhook-based posting to a server channel - **[Bluesky](/platforms/bluesky)** — app-password connection model, similar low-friction setup - **[X (Twitter)](/platforms/x-twitter)** — short-form text with thread support ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) --- # Post to Threads via API - Schedule & Publish > Threads API post guide: publish and schedule Threads posts via REST. Reply controls, auto first comments, images and video. # Post to Threads via API Publish text posts, image posts, and video posts to Threads (by Meta). PostEverywhere handles Threads' Publishing API to create posts with reply controls, first comments, and [media attachments](/api/init-media-upload) through a single call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a Threads Account Connect Threads from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Plain text post, up to 500 characters. | | **Image post** | Image(s) | Post with one or more attached images. | | **Video post** | Video (MP4) | Post with a single video attachment. | Threads is designed for short-form text conversations. Posts are limited to 500 characters, making concise writing essential. ## Platform-Specific Settings Use the `platform_content.threads` object to customise your Threads post: ```json { "platform_content": { "threads": { "content": "Threads post (max 500 chars)", "settings": { "replyControl": "everyone", "firstComment": "First comment text" } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Override the default text with a Threads-specific version. Max 500 characters. | | `settings.replyControl` | string | `"everyone"` | Who can reply to this post. See values below. | | `settings.firstComment` | string | — | Text posted as the first reply after publishing. | ### Reply Controls | Value | Description | |-------|-------------| | `everyone` | Anyone on Threads can reply. | | `following` | Only accounts you follow can reply. | | `mentioned` | Only accounts mentioned in the post can reply. | ## Example: Create a Post ### Schedule a Threads Text Post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default text for other platforms", "account_ids": [2295], "scheduled_for": "2026-04-15T12:00:00Z", "timezone": "America/New_York", "platform_content": { "threads": { "content": "Hot take: the best social media strategy is being genuinely helpful.\n\nNo growth hacks. No engagement bait. Just answer questions and share what you know.\n\nAgree or disagree?", "settings": { "replyControl": "everyone" } } } }' ``` ### Schedule a Threads Post with Image and First Comment ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our new feature just shipped!", "account_ids": [2295], "media_ids": ["e1f2a3b4-c5d6-7890-1234-ef0123456789"], "scheduled_for": "2026-04-15T18:00:00Z", "timezone": "UTC", "platform_content": { "threads": { "content": "We just shipped bulk scheduling to PostEverywhere.\n\nSchedule up to 100 posts in a single API call.\n\nFull details below:", "settings": { "replyControl": "everyone", "firstComment": "Docs: https://developers.posteverywhere.ai/quick-start\n\nFree 7-day trial." } } } }' ``` ### Schedule a Restricted-Reply Announcement ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Scheduled maintenance notice", "account_ids": [2295], "platform_content": { "threads": { "content": "Scheduled maintenance tonight 11pm-1am ET.\n\nAll systems will be back online by 1am.\n\nFollow @posteverywhere for updates.", "settings": { "replyControl": "mentioned" } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Post text length | 500 characters | | Image formats | JPEG, PNG, WebP, HEIC | | Image max file size | 20 MB | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration | Up to 5 minutes | | Recommended image ratio | 1:1 (square) or 4:3 | | Links in post | Supported (auto-previewed by Threads) | ## Tips - **Write for conversation.** Threads is built for dialogue. Posts that ask questions, share opinions, or invite debate get significantly more engagement than announcements. End with a question or a take that people want to respond to. - **Use `replyControl` strategically.** Set replies to `"mentioned"` for announcements where you do not want noise (maintenance notices, official statements). Use `"everyone"` for posts designed to spark conversation. - **Keep it under 500 characters.** Unlike other platforms where longer posts perform well, Threads rewards brevity. Get to the point quickly. If you need more space, use `firstComment` to add context without bloating the main post. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### Can I attach images or video to a Threads post? Yes. [Upload your media](/api/init-media-upload) using the Upload Media endpoint, then pass the returned UUID in the `media_ids` array in your [Create Post](/api/create-post) request. Threads supports images (JPEG, PNG, WebP, HEIC) up to 20 MB and video (MP4) up to 500 MB and 5 minutes long. ### How do I restrict replies on a Threads post? Set `settings.replyControl` in the `platform_content.threads` object. Options are `"everyone"` (default), `"following"` (only accounts you follow), or `"mentioned"` (only accounts mentioned in the post). Use `"mentioned"` for announcements where you want to limit noise. ### What is the character limit for Threads posts? Threads posts are limited to 500 characters. Unlike LinkedIn or Facebook where longer posts perform well, Threads rewards brevity. If you need more space, use the `firstComment` setting to add context or links in a reply posted automatically after publishing. ### Does Threads support link previews? Yes. When you include a URL in your Threads post text, Threads automatically generates a link preview card. You do not need a separate `link` setting like on Facebook or Pinterest -- just include the URL directly in the `content` field. ### Can I edit a Threads post after it's published? No. The Threads Publishing API does not let third-party apps modify a live post, and `PATCH /v1/posts/:id` only accepts `scheduled` or `draft` status. To change a published post, edit it directly in the Threads app, or delete and recreate. ## Related Platforms - **[X (Twitter)](/platforms/x-twitter)** -- similar short-form text post format and conversational style - **[Instagram](/platforms/instagram)** -- owned by Meta, visual-first companion platform - **[LinkedIn](/platforms/linkedin)** -- longer-form professional text posts ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [Threads Scheduler on PostEverywhere](https://posteverywhere.ai/threads-scheduler) --- # Post to TikTok via API - Schedule & Publish Videos > TikTok posting API: publish and schedule videos via REST. Privacy levels, duets and stitches, cover thumbnails - no 2-6 week audit. # Post to TikTok via API Publish videos to TikTok with full control over privacy settings, interaction permissions, and cover thumbnails. PostEverywhere uses TikTok's Content Posting API to handle [video uploads](/api/init-media-upload) and publishing on your behalf. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a TikTok Account Connect TikTok from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Video** | Video (MP4) | Standard TikTok video. Video is always required for TikTok posts. | TikTok is a video-only platform. Every post must include at least one video attachment. ## Platform-Specific Settings Use the `platform_content.tiktok` object to configure privacy and interaction settings: ```json { "platform_content": { "tiktok": { "content": "TikTok caption (max 150 chars)", "settings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": true, "allowStitch": true, "videoCoverTimestamp": 1000 } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Override the default caption. TikTok captions are limited to 150 characters. | | `settings.privacyLevel` | string | `"PUBLIC_TO_EVERYONE"` | Who can view the video. See values below. | | `settings.allowComments` | boolean | `true` | Whether viewers can comment on the video. | | `settings.allowDuet` | boolean | `true` | Whether other users can create Duets with this video. | | `settings.allowStitch` | boolean | `true` | Whether other users can Stitch this video. | | `settings.videoCoverTimestamp` | number | `1000` | Timestamp in **milliseconds** for the cover thumbnail frame. | ### Privacy Levels | Value | Description | |-------|-------------| | `PUBLIC_TO_EVERYONE` | Visible to all TikTok users. | | `MUTUAL_FOLLOW_FRIENDS` | Visible only to mutual followers (friends). | | `FOLLOWER_OF_CREATOR` | Visible only to your followers. | | `SELF_ONLY` | Only visible to you. Useful for drafts or testing. | ## Example: Create a Post ### Schedule a Public TikTok Video ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Quick tip for your morning routine!", "account_ids": [2285], "media_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"], "scheduled_for": "2026-04-15T17:00:00Z", "timezone": "America/Los_Angeles", "platform_content": { "tiktok": { "content": "Morning routine hack you NEED to try #morningroutine #lifehack #tips", "settings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": true, "allowStitch": false, "videoCoverTimestamp": 2500 } } } }' ``` ### Schedule a Private Test Video ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Testing new content format", "account_ids": [2285], "media_ids": ["d4e5f6a7-b8c9-0123-def0-234567890123"], "platform_content": { "tiktok": { "settings": { "privacyLevel": "SELF_ONLY", "allowComments": false, "allowDuet": false, "allowStitch": false } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Caption length | 150 characters | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration | 3 seconds - 10 minutes | | Video resolution | Minimum 720p recommended | | Recommended aspect ratio | 9:16 (vertical, 1080x1920) | | Cover timestamp | Specified in milliseconds | ## Tips - **Set `videoCoverTimestamp` to a visually compelling frame.** The cover thumbnail is the first thing users see when scrolling. Pick a frame that teases the content — default is 1000ms (1 second). - **Use `SELF_ONLY` for testing.** Schedule a private video first to verify the upload looks correct on TikTok before changing the privacy level and reposting publicly. - **Keep captions short and hashtag-heavy.** TikTok's 150-character limit means every character counts. Put your hook in the first few words and use trending hashtags for discoverability. ## Analytics TikTok reports views, likes, comments and shares per post. They appear on `GET /v1/analytics/summary` and on the analytics page in the dashboard, refreshed periodically after publishing (engagement takes time to accrue, so a post checked minutes after going live will legitimately read zero). :::note Accounts connected before Jul 3 2026 need one reconnect Per-post analytics needs TikTok's `video.list` scope, which we were granted on Jul 3 2026. Accounts connected before that date hold tokens without it and will keep returning no metrics until reconnected once from **Settings > Accounts**. Reconnecting does not affect scheduled posts. ::: Private and self-only posts have no analytics: TikTok withholds the public post id for them, and its analytics API cannot see them either. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### Can I control who can Duet or Stitch my TikTok video? Yes. Set `allowDuet` and `allowStitch` to `true` or `false` in the `platform_content.tiktok.settings` object. Both default to `true`. You can disable them individually to prevent other users from creating Duets or Stitches with your content. ### What privacy levels are available for TikTok posts? TikTok supports four privacy levels: `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS` (mutual followers only), `FOLLOWER_OF_CREATOR` (your followers only), and `SELF_ONLY` (only you). Set the `privacyLevel` field when [creating a post](/api/create-post). ### How do I choose the cover thumbnail for my TikTok video? Use the `videoCoverTimestamp` setting, specified in milliseconds. For example, `2500` selects the frame at 2.5 seconds. If omitted, TikTok uses the frame at 1000ms (1 second) by default. ### What video format does TikTok require? TikTok requires MP4 (H.264) video files. Videos can be 3 seconds to 10 minutes long, up to 500 MB. The recommended aspect ratio is 9:16 vertical (1080x1920) with a minimum resolution of 720p. [Upload your video](/api/init-media-upload) before creating the post. ### Can I post a photo carousel to TikTok via the API? Yes. Attach 1-35 image `media_ids` to your post and TikTok publishes a Photo Mode carousel. Video and photo carousels are mutually exclusive — you cannot mix them in the same post. Custom covers (`coverPhotoUrl`) and frame-pick (`videoCoverTimestamp`) are ignored on photo carousels; TikTok uses the first image as the cover automatically. Max **35 images per carousel**. ### Can I cross-post a TikTok video to X (Twitter) in the same call? Yes, but watch the duration limits. TikTok accepts videos up to 10 minutes, but X caps at 2 minutes 20 seconds. If you cross-post a longer video, the X destination will fail with a duration error while TikTok still publishes. To avoid partial publishes, keep cross-posted videos under 2:20, or schedule them as separate posts. ### Can I edit a TikTok post after it's published? No. `PATCH /v1/posts/:id` only accepts posts in `scheduled` or `draft` status. After TikTok accepts the upload, the post status becomes `published` and our API rejects edit attempts with `400 invalid_post_status`. TikTok's Content Posting API does not let third-party apps edit a live post's caption, privacy, or duet/stitch settings — edit on TikTok directly or delete and recreate. ## Related Platforms - **[Instagram](/platforms/instagram)** -- Reels share the same vertical video format (9:16) - **[YouTube](/platforms/youtube)** -- YouTube Shorts are a similar short-form vertical video format - **[Facebook](/platforms/facebook)** -- Facebook Reels also use 9:16 vertical video ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [TikTok Scheduler on PostEverywhere](https://posteverywhere.ai/tiktok-scheduler) --- # Post to Twitter (X) via API - Schedule & Publish > Twitter post API for X: publish and schedule tweets and threads via REST. Reply permissions, alt text, and auto first comments. # Post to Twitter (X) via API Publish tweets, image posts, video posts, and multi-tweet threads to X (formerly Twitter). PostEverywhere handles [media uploads](/api/init-media-upload), reply settings, and thread composition through a single API call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a X (Twitter) Account Connect X (Twitter) from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Text post** | None | Standard tweet, up to 280 characters. | | **Image post** | Image(s) | Tweet with up to 4 attached images. | | **Video post** | Video (MP4) | Tweet with a single video attachment. | | **Thread** | Optional | Multi-tweet thread using the `threadPosts` parameter. | ## Platform-Specific Settings Use the `platform_content.x` object to customise your tweet: ```json { "platform_content": { "x": { "content": "Tweet text (max 280 chars)", "settings": { "replySettings": "everyone", "mediaAltText": "Image description for accessibility", "firstComment": "Reply text posted as first reply" } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Override the default text. Max 280 characters. | | `settings.replySettings` | string | `"everyone"` | Who can reply to this tweet. See values below. | | `settings.mediaAltText` | string | — | Accessibility description for attached images. Max 1,000 characters. | | `settings.firstComment` | string | — | Text posted as a reply to your tweet immediately after publishing. | ### Reply Settings | Value | Description | |-------|-------------| | `everyone` | Anyone can reply. | | `following` | Only accounts you follow can reply. | | `mentionedUsers` | Only users mentioned in the tweet can reply. | ## Example: Create a Post ### Schedule a Tweet with an Image ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default text for other platforms", "account_ids": [2270], "media_ids": ["b8c9d0e1-f2a3-4567-8901-bcdef0123456"], "scheduled_for": "2026-04-15T13:00:00Z", "timezone": "America/New_York", "platform_content": { "x": { "content": "We just shipped something big.\n\nDetails in the thread below 👇", "settings": { "replySettings": "everyone", "mediaAltText": "Screenshot of the new dashboard feature showing real-time analytics" } } } }' ``` ### Schedule a Tweet with a First Reply ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Launching our new API today.", "account_ids": [2270], "platform_content": { "x": { "content": "We just launched our public API.\n\nSchedule posts to 12 platforms from one endpoint.\n\nFree 7-day trial.", "settings": { "firstComment": "Full docs: https://developers.posteverywhere.ai\n\nNode.js SDK: npm install @posteverywhere/sdk" } } } }' ``` ## Threads To create a multi-tweet thread, use the `threadPosts` array at the top level of your request (not inside `platform_content`). Each entry becomes a separate tweet in the thread: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Here are 5 things I learned this quarter:", "account_ids": [2270], "scheduled_for": "2026-04-15T15:00:00Z", "timezone": "UTC", "threadPosts": [ { "content": "1/ Focus on one metric at a time. Spreading attention across 10 dashboards means you optimize nothing." }, { "content": "2/ Ship weekly. Monthly releases create anxiety. Weekly releases create momentum." }, { "content": "3/ Talk to customers before writing code. 80% of our best features came from support tickets." }, { "content": "4/ Delete more than you add. We removed 3 features this quarter and NPS went up." }, { "content": "5/ Document decisions, not just outcomes. Future-you will thank present-you.\n\nWhat would you add to this list?" } ] }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Tweet text length | 280 characters | | Alt text length | 1,000 characters | | Images per tweet | Up to 4 | | Image formats | JPEG, PNG, WebP, GIF | | Image max file size | 20 MB | | GIF max file size | 15 MB | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration | Up to 2 minutes 20 seconds | | Thread length | No hard limit (practical limit ~25 tweets) | | X daily posting limit | Varies by plan (Trial: 10, Starter: 50, Growth: 150, Pro: 500) | ## Tips - **Use `firstComment` to add links.** Tweets with links get less algorithmic reach on X. Post the tweet without a link, then use `firstComment` to reply with the URL. Your followers still see the link, but the original tweet gets better distribution. - **Always add `mediaAltText` for images.** Alt text improves accessibility and can boost search visibility. X supports up to 1,000 characters per image — use it to describe what is in the image for screen reader users. - **Mind the daily posting limits.** X imposes per-day posting limits that vary by your PostEverywhere plan. Check the limits above and spread your scheduled posts across the day to avoid hitting the cap. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### How do I create a Twitter thread via the API? Use the `threadPosts` array at the top level of your [Create Post](/api/create-post) request. Each entry in the array becomes a separate tweet in the thread. The main `content` field is the first tweet, and `threadPosts` contains the replies. There is no hard limit, but threads beyond 25 tweets are impractical. ### Can I attach multiple images to a single tweet? Yes. Include up to 4 `media_ids` in your post request. [Upload each image](/api/init-media-upload) first using the Upload Media endpoint, then pass all returned media IDs in the `media_ids` array. Note that you cannot mix images and video in a single tweet. ### How do I restrict who can reply to my tweet? Set `settings.replySettings` in the `platform_content.x` object. Options are `"everyone"` (default), `"following"` (only accounts you follow), or `"mentionedUsers"` (only accounts mentioned in the tweet). ### What are the daily posting limits on X? X imposes per-day posting limits that vary by your PostEverywhere subscription plan: Trial has 10 posts/day, Starter has 50, Growth has 150, and Pro has 500. Spread your scheduled posts across the day to avoid hitting the cap. Check [Rate Limits](/rate-limits) for API request limits. ### Can I edit a tweet after it's published? X allows post owners to edit tweets in their own UI for a limited window (the "Edit tweet" feature on Premium), but their API does not expose tweet editing to third-party tools. `PATCH /v1/posts/:id` only accepts posts in `scheduled` or `draft` status — once a tweet publishes, our API returns `400 invalid_post_status`. To change a tweet's text, delete it (in X) and recreate via the API. ## Related Platforms - **[Threads](/platforms/threads)** -- short-form text posts with a similar conversational style - **[LinkedIn](/platforms/linkedin)** -- professional text posts and thought leadership - **[Facebook](/platforms/facebook)** -- cross-post announcements and link shares ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [X (Twitter) Scheduler on PostEverywhere](https://posteverywhere.ai/x-scheduler) --- # Upload to YouTube via API - Schedule & Publish Videos > YouTube upload API: publish and schedule videos and Shorts via REST. Titles, descriptions, privacy, tags, and categories. # Upload to YouTube via API Upload and schedule YouTube videos with titles, descriptions, tags, privacy settings, and category assignments. PostEverywhere handles the multi-step YouTube [upload process](/api/init-media-upload) and metadata configuration through a single API call. For a general overview of all supported platforms, see the [Introduction](/). ## Connecting a YouTube Account Connect YouTube from the dashboard with OAuth, or headlessly from code: mint a [connect link](/api/create-connect-link) and hand it to the account owner - no dashboard session needed. Both flows are covered in [Connecting Accounts](/connecting-accounts). ## Supported Content Types | Content Type | Media Required | Description | |-------------|---------------|-------------| | **Video** | Video (MP4) | Standard YouTube video. A title is required. | | **Short** | Video (MP4) | Vertical video (9:16) under 60 seconds is automatically treated as a Short by YouTube. | YouTube is a video-only platform. Every post must include a video attachment. :::note Community posts are not supported **YouTube Community posts** — the text, image, and poll posts on a channel's Community tab — cannot be created via PostEverywhere, because **YouTube does not provide an API to create them.** YouTube's Data API supports video and Shorts uploads, playlists, comments, and channel data, but has never exposed a Community-post endpoint. Community posts can only be created in YouTube's own app or website. If YouTube releases a Community Posts API, we'll add support. ::: ## Platform-Specific Settings Use the `platform_content.youtube` object to set video metadata: ```json { "platform_content": { "youtube": { "content": "Full video description with links and chapters", "settings": { "title": "My Video Title (required)", "privacyStatus": "public", "tags": ["tag1", "tag2", "tag3"], "categoryId": "22", "madeForKids": false } } } } ``` ### Settings Reference | Setting | Type | Default | Description | |---------|------|---------|-------------| | `content` | string | — | Video description. Supports links, chapters, and multi-line text. | | `settings.title` | string | **Required** | Video title. Max 100 characters. | | `settings.privacyStatus` | string | `"public"` | Visibility setting. See values below. | | `settings.tags` | string[] | `[]` | Tags for search discovery. YouTube allows up to 500 characters total across all tags. | | `settings.categoryId` | string | `"22"` | YouTube category ID. See common categories below. | | `settings.madeForKids` | boolean | `false` | Whether the video is made for children. Affects comments and personalized ads. | ### Privacy Statuses | Value | Description | |-------|-------------| | `public` | Visible to everyone. Appears in search results and subscriptions. | | `unlisted` | Only accessible via direct link. Does not appear in search or on your channel. | | `private` | Only visible to you and users you explicitly share with. | ### Common Category IDs | ID | Category | |----|----------| | `1` | Film & Animation | | `2` | Autos & Vehicles | | `10` | Music | | `15` | Pets & Animals | | `17` | Sports | | `20` | Gaming | | `22` | People & Blogs | | `23` | Comedy | | `24` | Entertainment | | `25` | News & Politics | | `26` | Howto & Style | | `27` | Education | | `28` | Science & Technology | ## Example: Create a Post ### Schedule a YouTube Video ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Default description for other platforms", "account_ids": [2290], "media_ids": ["e5f6a7b8-c9d0-1234-ef01-345678901234"], "scheduled_for": "2026-04-15T16:00:00Z", "timezone": "Europe/London", "platform_content": { "youtube": { "content": "In this video, I walk through the complete setup process.\n\n0:00 Intro\n0:30 Installation\n2:15 Configuration\n4:00 First Run\n\nLinks:\nhttps://example.com/docs", "settings": { "title": "Complete Setup Guide - Step by Step Tutorial", "privacyStatus": "public", "tags": ["tutorial", "setup guide", "getting started", "walkthrough"], "categoryId": "28", "madeForKids": false } } } }' ``` ### Schedule an Unlisted Video for Review ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Review draft", "account_ids": [2290], "media_ids": ["f6a7b8c9-d0e1-2345-f012-456789012345"], "platform_content": { "youtube": { "content": "Draft video for team review — do not share publicly.", "settings": { "title": "Q2 Product Update (DRAFT)", "privacyStatus": "unlisted", "madeForKids": false } } } }' ``` ## Requirements & Limits | Requirement | Limit | |-------------|-------| | Title length | 100 characters | | Description length | 5,000 characters | | Tags total length | 500 characters | | Video format | MP4 (H.264) | | Video max file size | 500 MB | | Video duration | Up to 12 hours (standard uploads) | | Shorts duration | Up to 60 seconds | | Recommended video resolution | 1080p (1920x1080) or higher | | Recommended Shorts ratio | 9:16 (1080x1920) | | Thumbnail | Auto-generated by YouTube from video frames | ## Tips - **Always provide a `title`.** It is the only required setting for YouTube. Without it, the post will fail to publish. Keep titles under 70 characters for best search result display. - **Use `content` for rich descriptions with chapters.** YouTube parses timestamps in the format `0:00 Label` as video chapters. Structure your description with chapter markers to improve viewer retention and search ranking. - **Schedule as `unlisted` first for team review.** Upload with `"privacyStatus": "unlisted"` to let your team review the video on YouTube before making it public. Update the privacy setting directly on YouTube when ready. ## Frequently Asked Questions {/* FAQ schema can be added via a Docusaurus plugin or custom component wrapping these Q&A pairs as JSON-LD FAQPage structured data. */} ### How do I schedule a YouTube Short via the API? Upload a vertical video (9:16 aspect ratio) under 60 seconds using the [Upload Media endpoint](/api/init-media-upload), then [create a post](/api/create-post) normally. YouTube automatically classifies vertical videos under 60 seconds as Shorts -- no special setting is needed. ### Is the video title required for YouTube posts? Yes. The `settings.title` field is mandatory for YouTube. Posts without a title will fail to publish. Keep titles under 70 characters for optimal display in search results, though the maximum is 100 characters. ### Can I set the YouTube video category via the API? Yes. Use the `settings.categoryId` field with one of YouTube's standard category IDs. For example, `"28"` is Science & Technology and `"22"` is People & Blogs (the default). See the full list of category IDs in the Settings Reference above. ### How do I add chapters to my YouTube video description? Include timestamps in the `content` field using the format `0:00 Label`. YouTube parses these as video chapters. The first timestamp must be `0:00`. For example: `"0:00 Intro\n0:30 Setup\n2:15 Demo"`. ### Why did some of my YouTube tags get dropped? YouTube's 500-character tag limit is counted in the **serialized** form — tags with spaces are wrapped in quotes (each adds 2 chars), and tags are joined by `, ` (each separator adds 2 chars). We count the same way YouTube does and trim from the tail until your tags fit. We also reserve 11 characters for the `, "Shorts"` tag we auto-append on vertical sub-60s uploads. If you saw tags in your request that didn't end up on the published video, you exceeded the serialized budget — shorten or remove a few and resubmit. ### Can I edit a YouTube video's title, description, or tags after upload? Not via the PostEverywhere API. `PATCH /v1/posts/:id` only works on `scheduled` or `draft` posts. Once YouTube has accepted the upload (post status becomes `published`), the API returns `400 invalid_post_status`. To change a video's metadata after upload, edit it directly in YouTube Studio. ## Related Platforms - **[TikTok](/platforms/tiktok)** -- short-form vertical video, similar to YouTube Shorts - **[Instagram](/platforms/instagram)** -- Instagram Reels for short-form vertical video - **[LinkedIn](/platforms/linkedin)** -- professional video content with native video support ## Further Reading - [API Reference -- Create Post](/api/create-post) - [Upload Media](/api/init-media-upload) - [Quick Start Guide](/quick-start) - [YouTube Scheduler on PostEverywhere](https://posteverywhere.ai/youtube-scheduler) --- # Reference # API Changelog > PostEverywhere API changelog and version history. Track breaking changes, new endpoints, and improvements. # API Changelog All notable changes to the PostEverywhere API. ## 2026-06-14 — Drafts via API & MCP: create-review-schedule for agents You can now create a post as a **draft** through the API and MCP, review it, and schedule it in a separate step. This unlocks the human-in-the-loop approval pattern: an agent drafts posts, a person (or a second agent) reviews them, and only then are they scheduled or published. All changes are **additive** — existing integrations are unaffected. ### Draft creation on `POST /v1/posts` - **`POST /v1/posts` now accepts `draft: true`** — saves the post as a draft instead of publishing or scheduling it. The response returns `status: "draft"`, the `post_id`, and a `next_steps` hint pointing at the schedule endpoint. - **`account_ids` is optional for a draft.** You can save a draft now and choose its target accounts later (either when editing the draft or in the schedule call). For a non-draft post, `account_ids` is still required. ### New endpoint: `POST /v1/posts/{id}/schedule` - **Turns a draft into a scheduled (or immediately-publishing) post.** Body: `{ "scheduled_for": "" }` to schedule, or `{ "publish_now": true }` to publish immediately. Optional `account_ids` (to set/override the draft's targets) and `timezone` (display metadata). - **Only works on drafts.** Calling it on a post that is already scheduled, publishing, or published returns **409** — re-scheduling an existing scheduled post is still done with `PATCH /v1/posts/{id}`. ### Richer drafts on `GET /v1/posts` and `GET /v1/posts/{id}` - **`GET /v1/posts?status=draft`** and **`GET /v1/posts/{id}`** now return each draft's target `account_ids` and `platform_content`, so an agent (or a reviewer) can inspect exactly what a draft will publish before scheduling it. ### MCP server - **`create_post` gains a `draft` boolean** — set `draft: true` to save a draft instead of publishing/scheduling. - **New `schedule_post` tool** — params: `post_id`, plus `scheduled_for` (ISO 8601 UTC) **or** `publish_now: true`, and optional `account_ids` and `timezone`. The full agent flow is now `create_post(draft: true)` → review with `list_posts(status: "draft")` / `get_post` → `schedule_post`. ### ChatGPT & OpenAI Codex - The hosted MCP server and the [agent docs](/integrations/agents) now call out **ChatGPT** (via the hosted connector at [posteverywhere.ai/agents](https://posteverywhere.ai/agents)) and **OpenAI Codex** alongside Claude Code, Claude Desktop, and Cursor as supported AI clients. ## 2026-06-11 — Major API upgrade: introspection, webhooks, campaigns, bulk ops The largest single API expansion since launch. Eleven new endpoints + a comprehensive enhancement of `GET /v1/posts`. All changes are **additive** — existing integrations continue to work unchanged. ### New endpoints - **[`GET /v1/me`](/introspection)** — introspection. Returns your API key context, scopes, organization, quota (accounts/AI credits/storage), and plan limits in one call. Call this first when initializing an integration so you don't have to hardcode `organization_id`. - **[`GET /v1/analytics/summary`](/analytics-summary)** — aggregate counters for a time window (today / week / month / all / custom). Returns posts-by-status, per-platform breakdown, total views/likes/comments/shares/impressions/clicks, and AI credits used. One call instead of listing every post and counting client-side. - **Campaigns CRUD** ([`GET`/`POST` `/v1/campaigns`, `GET`/`PATCH`/`DELETE` `/v1/campaigns/:id`](/campaigns)) — group related posts under named campaigns. Tag posts via `campaign_id` on `POST /v1/posts` and filter via `?campaign_id=…` on the list endpoint. - **[`POST /v1/posts/bulk`](/bulk-operations#bulk-create--post-v1postsbulk)** — create up to 50 posts in one call. Counts as ONE API-rate-limit hit instead of N. Returns per-item success/error so you can handle partial failures (HTTP 207 Multi-Status for mixed outcomes). - **[`POST /v1/posts/retry-failed`](/bulk-operations#bulk-retry--post-v1postsretry-failed)** — bulk retry every failed destination matching a filter (account_id, platform, date range, post_ids, max_attempts). Refuses when no filter is provided to avoid "retry the entire failure history" footguns. - **`GET /v1/accounts/:id/health`** — token expiry, last successful publish, recent failure count, `needs_reconnection` flag, `can_post` boolean, and an overall `status` (`healthy`/`warning`/`broken`). Use this BEFORE publishing to detect a dead token in advance. - **`POST /v1/accounts/:id/reconnect`** — generate a signed dashboard URL that opens the platform OAuth reconnect flow for an account. 30-minute single-use token. - **`POST /v1/ai/generate-caption`** — generate 1-5 caption variants from topic + tone + platform. Respects per-platform character limits (X: 280, Bluesky: 300, LinkedIn: 3000, IG: 2200) and hashtag conventions. Costs 1 AI credit per returned caption. - **[Webhooks](/webhooks)** — `POST`/`GET` `/v1/webhooks`, `GET`/`PATCH`/`DELETE` `/v1/webhooks/:id`, `POST /v1/webhooks/:id/test`. Subscribe to event streams instead of polling. 12 event types (`post.published`, `post.failed`, `post.partially_failed`, `account.reconnect_needed`, `media.uploaded`, …). HMAC-SHA256 signing, exponential-backoff retries (30s → 2min → 10min → 1h → 6h → 24h, 6 attempts), auto-disable after 20 consecutive failures. ### Enhanced `GET /v1/posts` All new query params are optional and additive — the old single-value `?status=` and `?platform=` keep working. - **`status`** now accepts comma-separated values, and adds `failed`, `partially_failed`, `publishing` (was: only `scheduled` / `published` / `draft`). Example: `?status=failed,partially_failed`. - **`platform`** now accepts comma-separated values. Example: `?platform=instagram,tiktok`. - **`account_id`** — filter to one social account. - **`campaign_id`** — filter to a campaign. - **Date range filters:** `created_after`, `created_before`, `scheduled_after`, `scheduled_before`, `published_after`, `published_before`, `updated_after` (the last one for incremental sync polling). - **`search`** — full-text search on post content (`ILIKE`). - **`sort`** — `created_at` (default) | `scheduled_for` | `published_at` | `updated_at`. - **`order`** — `asc` | `desc` (default). - **Response includes `pagination.total` + `pagination.has_more`** so clients know if more pages exist without fetching the next one. - **Response includes per-destination `metrics`** (views/likes/comments/shares/impressions/saves/clicks) inline — no more N+1 fetch pattern. - **Response includes `campaign_id`** on each post for round-trip + filtering. ### MCP server expansion The Claude Code MCP server now exposes the new endpoints as tools: `get_me`, `get_analytics_summary`, `list_campaigns`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `bulk_create_posts`, `retry_failed_posts`, `get_account_health`, `reconnect_account`, `generate_caption`, `list_webhooks`, `create_webhook`, `get_webhook`, `update_webhook`, `delete_webhook`, `test_webhook`, `list_posts_advanced`. Existing tools (`list_posts`, `create_post`, etc) are unchanged. ### SDK `@posteverywhere/sdk` v1.3.0 publishes companion methods: `client.getMe()`, `client.getAnalyticsSummary()`, `client.listCampaigns()`, `client.bulkCreatePosts()`, `client.retryFailedPosts()`, `client.getAccountHealth()`, `client.getAccountReconnectUrl()`, `client.generateCaption()`, `client.listWebhooks()`, `client.createWebhook()`, `client.listPostsAdvanced()`. All new methods are TypeScript-typed. ### Backwards compatibility - All previously documented params and response fields continue to work and contain the same shape. - `?status=scheduled` (single value) still works — comma-separated is opt-in. - `?platform=instagram` (single value) still works. - Old `pagination: { limit, offset }` shape unchanged; new fields (`total`, `has_more`) are added. - New response fields (`campaign_id`, `metrics`, etc) are appended; nothing was removed. - New endpoints are net-new — they don't collide with anything in v1. ## 2026-05-29 — Bluesky support, accurate post status, media auto-finalize **Bluesky (AT Protocol) is now a supported platform** — the 9th. Connect a Bluesky account with a handle + app password, then publish text, images (up to 4), video (MP4), and threads. Links, `@mentions`, and `#hashtags` are turned into rich-text facets automatically, and URLs render as link cards. See the [Bluesky guide](/platforms/bluesky). - **`post_status` now reflects real publishing progress.** Previously a `publish_now` post could report `scheduled` indefinitely because the value was fixed at creation time. The status is now derived from per-destination state and can be `scheduled`, `publishing`, `published`, `partially_failed`, or `failed` — consistent across `POST /v1/posts`, `GET /v1/posts/{id}`, the list endpoint, and `/results`. The `?status=` list filter now filters on the same real status. - **Posts auto-finalize uploaded media.** If you attach `media_ids` for media that was uploaded but not yet finalized with `POST /v1/media/{id}/complete`, `POST /v1/posts` now completes it for you (verifying the file exists) instead of returning `media_not_ready`. The explicit `/complete` call is still supported and recommended for tighter control. - **Clearer errors.** Posting `multipart/form-data` to `/v1/posts` now returns guidance on the correct JSON shape and the upload → `media_ids` flow. The media status response exposes `ready` and `action_required` fields so polling clients know a `/complete` call is required. ## 2026-04-12 — `scheduled_for` rename, strict field validation, scope visibility **`scheduled_for` is now the canonical request field for scheduling posts.** The old name `scheduled_at` was a misnomer — it read as a past-tense timestamp (like `created_at` / `updated_at`) even though it represented a *future* moment when the post will publish. `scheduled_for` makes the intent explicit. - **`scheduled_for` is the canonical request field name** (was `scheduled_at`). The response also returns the value under `scheduled_for`, completing the round-trip. - **`scheduled_at` is deprecated but still accepted as an alias.** Existing integrations built before 2026-04-12 will continue to work unchanged. New integrations should use `scheduled_for`. - **Strict `400` validation for unknown media field names.** If you send `media`, `media_id`, `mediaId`, or `attachments` in a request body, the API now returns `400 invalid_field_name` with a helpful `did you mean media_ids?` hint. The only accepted media field on requests is `media_ids` (an array of UUID strings). - **OAuth scope visibility.** When a user reconnects a social account and the platform drops scopes from the grant, we now record the drop and alert on the mismatch, so silently-broken integrations surface quickly. - **Body logging for v1 endpoints.** The v1 API now logs sanitized request bodies (with API keys and tokens redacted) for debug visibility, making it much easier to trace bad requests when you email developer support. - **Round-trip principle.** Every top-level field name in a request body also appears in the response with the same name and meaning. You can take a post you got from `GET /v1/posts/{id}` and POST its fields straight back to clone it — no renaming. ## v1.0 — March 2026 - Initial public API release - 18 endpoints across Accounts, Posts, Media, and AI - Support for 8 platforms: Instagram, TikTok, YouTube, LinkedIn, X, Facebook, Threads, Pinterest - Bulk scheduling (up to 100 posts per request) - AI image generation - MCP server integration for Claude Code - Node.js SDK --- Subscribe to API updates by emailing **developers@posteverywhere.ai**. **Resources:** - [Introduction](/) - [API Reference](/api/list-accounts) --- # Error Handling & Error Codes > Complete reference of PostEverywhere API error codes, HTTP status codes, and retry strategies. Handle errors gracefully in your integration. # Error Handling The API uses conventional HTTP status codes and returns a consistent JSON error envelope so you can handle failures programmatically. ## Error Response Format Every error response follows the same structure: ```json { "data": null, "error": { "message": "Free X accounts are limited to 280 characters per post (currently 282).", "code": "post_creation_failed", "retryable": false, "details": { "validation_errors": [...] } }, "meta": { "request_id": "req_01HXYZ...", "timestamp": "2026-04-12T14:30:00Z" } } ``` | Field | Type | Description | |-------|------|-------------| | `data` | null | Always `null` on error responses. | | `error.message` | string | Human-readable explanation of what went wrong. | | `error.code` | string | Machine-readable error code (see table below). | | `error.retryable` | boolean | **Whether retrying this exact request might succeed.** `true` for transient failures (5xx, 429); `false` for permanent failures (most 4xx). Use this instead of inferring from the status code. | | `error.details` | object | Optional. Additional context about the error (e.g. failed validation fields, circuit breaker info). | | `meta.request_id` | string | Unique ID for this request. Include when contacting support. | | `meta.timestamp` | string | ISO 8601 timestamp of when the response was generated. | The HTTP status code is returned in the response status line — there is no `error.status` field in the body (removed 2026-04-12). :::tip Always check `error.retryable` The simplest robust retry logic: **retry if `error.retryable === true`, abort otherwise.** This is more reliable than inferring from the status code and protects you from edge cases like our circuit breaker (see [422 below](#422-unprocessable-entity)). ::: ## Error Codes by HTTP Status ### 400 Bad Request Validation or input errors. Do **not** retry these -- fix the request first. | Code | Description | |------|-------------| | `validation_error` | Request body failed schema validation. Check required fields and types. | | `invalid_json` | The request body is not valid JSON. Common cause: unescaped newlines/tabs inside a string value — use `\n` / `\t`, not raw control characters. | | `content_too_long` | Post text exceeds the platform's character limit. | | `too_many_accounts` | The `account_ids` array exceeds the maximum number of accounts per request. | | `invalid_timezone` | The `timezone` value is not a valid IANA timezone string. | | `invalid_accounts` | One or more account IDs do not exist or do not belong to your organization. | | `invalid_datetime` | The `scheduled_for` value is not a valid ISO 8601 datetime string. | | `past_schedule_time` | The `scheduled_for` time is in the past. Schedule at least 5 minutes ahead. | | `invalid_post_status` | The post is in a status that does not allow the requested action. | | `no_failed_destinations` | Retry was called on a post with no failed platform destinations. | | `unsupported_media_type` | The uploaded file MIME type is not supported. See [Media Requirements](/media-requirements). | | `file_too_large` | The uploaded file exceeds the size limit (20 MB images, 500 MB videos). | | `upload_failed` | The file upload could not be processed. Try uploading again. | | `file_not_uploaded` | The media ID references a file that has not finished uploading or does not exist. | | `file_type_mismatch` | The uploaded file's actual type does not match the declared MIME type. | | `bulk_limit_exceeded` | `POST /v1/posts/bulk` received more than 50 posts in one call. Split into multiple calls. Also fires for `posts/retry-failed` with `post_ids > 200`. | | `no_filter` | `POST /v1/posts/retry-failed` was called with no filter. At least one of `post_ids`, `account_id`, `platform`, `failed_after`, `failed_before` is required. | | `invalid_period` | `GET /v1/analytics/summary` received an invalid `period` value. Must be `today`, `week`, `month`, `all`, or `custom`. | | `invalid_range` | `GET /v1/analytics/summary?period=custom` is missing `from`/`to` or they're not valid ISO timestamps. | | `invalid_event_type` | `POST /v1/webhooks` received an unknown event name. The response `details.valid_events` lists every accepted event. | | `limit_reached` | The org has reached the per-org cap (e.g. 25 webhooks). Contact support to raise. | | `misconfig` | A server-side config required for the endpoint is missing (e.g. `NEXTAUTH_SECRET` for webhook signing). Should not happen in production — contact support. | ### 401 Unauthorized Authentication failures. Check your API key. | Code | Description | |------|-------------| | `invalid_api_key` | The API key in the `Authorization` header is not valid. | | `api_key_revoked` | The API key has been revoked. Generate a new key in **Settings > Developers**. | | `api_key_expired` | The API key has expired. Generate a new key in **Settings > Developers**. | ### 402 Payment Required Billing or entitlement issues. | Code | Description | |------|-------------| | `insufficient_credits` | Your plan's AI credits have been exhausted for this billing period. | | `upgrade_required` | Your current plan does not include this feature. [Upgrade your plan](https://app.posteverywhere.ai/choose-plan). | ### 403 Forbidden Permission or scope errors. | Code | Description | |------|-------------| | `insufficient_scope` | Your API key does not have the required scope for this endpoint. See [Scopes](/scopes). | | `prompt_rejected` | The AI content generation request was rejected by the safety filter. | ### 404 Not Found | Code | Description | |------|-------------| | `not_found` | The requested resource does not exist or does not belong to your organization. | ### 422 Unprocessable Entity | Code | Description | |------|-------------| | `permanent_failure_circuit_breaker` | This exact request body has failed 5+ times in the last 6 hours and the circuit breaker is now open for it. Modify any field of the request body (the `content_hash` in `details` will change) — or wait 6 hours — to retry. Triggered when an integration ignores 4xx responses and retries the same payload in a loop. | When the circuit breaker is open, the response includes: ```json { "data": null, "error": { "message": "This exact request has failed 5 times in the last 6 hours...", "code": "permanent_failure_circuit_breaker", "retryable": false, "details": { "circuit_breaker_open": true, "content_hash": "a1b2c3d4e5f6g7h8", "fail_count": 5, "threshold": 5, "ttl_seconds": 21600, "hint": "The previous failures should have included `validation_errors` explaining what to fix..." } }, "meta": { "request_id": "req_01HXYZ...", "timestamp": "..." } } ``` To clear the breaker before its 6-hour TTL, change *any* field of the request body — even adding an `idempotency_key` you weren't using before will produce a new content hash and reset the counter to 0. The breaker exists specifically to protect badly-written retry loops from burning rate-limit budget on requests that will never succeed. If you're hitting it, **inspect the `validation_errors` from the previous 4xx responses** — those tell you exactly what to fix. ### 429 Too Many Requests | Code | Description | |------|-------------| | `rate_limit_exceeded` | You have exceeded the per-minute or per-hour rate limit. See [Rate Limits](/rate-limits). | ### 500 Internal Server Error | Code | Description | |------|-------------| | `generation_failed` | An AI content generation request failed internally. Safe to retry. | | `service_unavailable` | A downstream service (platform API, media processing) is temporarily unavailable. Safe to retry. | | `internal_error` | An unexpected server error occurred. Safe to retry with backoff. | ## Which Errors to Retry **Recommended:** check `error.retryable` (boolean) on the response — it tells you directly whether retrying makes sense, without needing to map status codes yourself. | Status | `retryable` | Reason | |--------|-------------|--------| | 400 | `false` | Fix the request. The same payload will always fail. | | 401 | `false` | Fix your API key or generate a new one. | | 402 | `false` | Upgrade your plan or wait for credits to reset. | | 403 | `false` | Check your API key scopes or modify the request. | | 404 | `false` | The resource does not exist. | | **422** | **`false`** | Circuit breaker open — **modify the request body** before retrying (see above). Retrying the same payload will fail until the 6-hour TTL expires. | | **429** | **`true`** | Wait for the `Retry-After` header value, then retry. | | **500** | **`true`** | Retry with exponential backoff (see below). | ## Retry Strategy For retryable errors (429 and 5xx), use exponential backoff with jitter: ```javascript async function callWithRetry(fn, maxRetries = 5) { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fn(); if (response.ok) { return response.json(); } const status = response.status; // Don't retry client errors (except 429) if (status >= 400 && status < 500 && status !== 429) { const body = await response.json(); throw new Error(`${body.error.code}: ${body.error.message}`); } if (attempt === maxRetries) { throw new Error(`Request failed after ${maxRetries + 1} attempts`); } // For 429, respect the Retry-After header if present const retryAfter = response.headers.get("Retry-After"); if (retryAfter) { await sleep(parseInt(retryAfter, 10) * 1000); continue; } // Exponential backoff: 1s, 2s, 4s, 8s, 16s + random jitter const baseDelay = Math.pow(2, attempt) * 1000; const jitter = Math.random() * 1000; await sleep(baseDelay + jitter); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } ``` ```bash # Example: a 429 response with rate-limit headers HTTP/1.1 429 Too Many Requests Content-Type: application/json X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711929660 Retry-After: 30 { "data": null, "error": { "message": "Rate limit exceeded. Try again in 30 seconds.", "code": "rate_limit_exceeded" }, "meta": { "request_id": "req_01HXYZ...", "timestamp": "2026-04-12T14:30:00Z" } } ``` ## Best Practices 1. **Always check the `error.code` field** -- not just the HTTP status. Multiple error codes can share the same status. 2. **Log the full error response** for debugging. The `message` field provides useful context. 3. **Set a retry budget.** Cap retries at 5 attempts to avoid infinite loops. 4. **Handle 429 specifically.** Respect the `Retry-After` header when present instead of using your own backoff timer. 5. **Distinguish transient from permanent errors.** Only retry 429 and 5xx codes. ## Related - [Authentication](/authentication) -- API key setup and usage - [Rate Limits](/rate-limits) -- per-minute and per-hour limits, response headers - [Scopes](/scopes) -- API key permission scopes --- # Media Requirements by Platform > Image dimensions, video duration limits, file sizes, aspect ratios, and format requirements for every platform supported by the PostEverywhere API. # Media Requirements Each social platform enforces its own limits on file sizes, dimensions, video duration, and formats. This page covers every constraint so you can validate media before uploading. ## Upload Limits The PostEverywhere API accepts files up to these sizes: | Type | Maximum Size | |------|-------------| | Images | 20 MB | | Videos | 500 MB | | PDFs | 20 MB | ## Accepted MIME Types ``` image/jpeg image/png image/gif image/webp image/heic image/heif video/mp4 application/pdf ``` Only `video/mp4` is accepted for video uploads. If your source video is in another format, transcode it to H.264 MP4 before uploading. ## File Size Limits by Platform The API enforces the PostEverywhere upload limits above, but individual platforms may reject media that exceeds their own limits. Ensure your files stay within these bounds: | Platform | Image Max | Video Max | Other | |----------|-----------|-----------|-------| | Instagram | 8 MB | 100 MB | -- | | X / Twitter | 5 MB | 512 MB | -- | | LinkedIn | 20 MB | 5 GB | 100 MB (PDF) | | Facebook | 4 MB | 1 GB | -- | | TikTok | 2 MB | 500 MB | -- | | YouTube | -- | 256 GB | -- | | Threads | 8 MB | 100 MB | -- | | Pinterest | 20 MB | 2 GB | -- | :::tip PostEverywhere's own 500 MB video upload limit is more restrictive than some platforms (YouTube, LinkedIn). We process and re-upload your media to each platform. ::: ## Image Dimensions | Platform | Minimum | Maximum / Recommended | |----------|---------|----------------------| | Instagram | 320 x 320 | 1080 x 1350 (4:5 portrait recommended) | | X / Twitter | 200 x 200 | 4096 x 4096 | | LinkedIn | 552 x 368 | 4000 x 4000 | | Facebook | 200 x 200 | 2048 x 2048 | | TikTok | 720 x 1280 | 1080 x 1920 | | Threads | 320 x 320 | 1080 x 1350 | | Pinterest | 200 x 200 | 1000 x 1500 (2:3 recommended) | :::tip Oversized images are resized for you You do not need to pre-resize images to fit the table above. Media is served to each platform through Cloudflare Images, and where a platform enforces a hard ceiling we request a scaled-down variant automatically — TikTok's Photo API rejects anything over 1080 px, so a 4032 x 3024 phone photo is delivered to TikTok at 1080 x 810 with the aspect ratio preserved. Images already within a platform's limits are passed through untouched at full resolution. ::: ## Video Duration Limits | Platform | Minimum | Maximum | |----------|---------|---------| | Instagram Reels | 3 seconds | 15 minutes | | X / Twitter | 0.5 seconds | 2 min 20 sec | | LinkedIn | 3 seconds | 10 minutes | | Facebook | 1 second | 4 hours | | TikTok | 1 second | 10 minutes | | YouTube | 1 second | 12 hours | | Threads | 1 second | 5 minutes | | Pinterest | 4 seconds | 15 minutes | ## Aspect Ratios Aspect ratio requirements vary significantly by platform. Getting these wrong is one of the most common causes of publishing failures. | Platform | Accepted Ratios | Notes | |----------|----------------|-------| | **TikTok** | **9:16 only** | Vertical video is mandatory. Other ratios are rejected. | | Instagram | 4:5 to 1.91:1 | 4:5 portrait performs best. Square (1:1) is safe. | | X / Twitter | 1:3 to 3:1 | Wide range accepted. 16:9 is standard. | | LinkedIn | 1:2.4 to 2.4:1 | 1:1 or 16:9 recommended for feed. | | Facebook | 9:16 to 16:9 | 4:5 recommended for feed. | | YouTube | 16:9 | Other ratios are letterboxed or pillarboxed. | | Threads | 4:5 to 1.91:1 | Same as Instagram. | | Pinterest | 2:3 to 1:1 | 2:3 vertical pins perform best. | :::caution TikTok requires 9:16 TikTok strictly rejects any video that is not in 9:16 portrait orientation. Always verify the aspect ratio before publishing to TikTok. ::: ## Media Count Limits Maximum number of media attachments per post: | Platform | Images | Videos | PDF | |----------|--------|--------|-----| | Instagram | 10 (carousel) | 1 (Reels) | -- | | X / Twitter | 4 | 1 | -- | | LinkedIn | 20 | 1 | 1 | | Facebook | 10 | 1 | -- | | TikTok | 1 | 1 | -- | | YouTube | -- | 1 | -- | | Threads | 10 | 1 | -- | | Pinterest | 1 | 1 | -- | :::note You cannot mix images and videos in the same post on most platforms. Instagram carousels are image-only. ::: ## Video Codec Requirements All platforms require H.264-encoded MP4 files. Details vary: | Platform | Codec | Frame Rate | Max Bitrate | |----------|-------|-----------|-------------| | Instagram | H.264 | 30 fps | 25 Mbps | | X / Twitter | H.264 | 60 fps | 25 Mbps | | LinkedIn | H.264 | 30 fps | 30 Mbps | | Facebook | H.264 | 30 fps | 16 Mbps | | TikTok | H.264 | 30 fps | 20 Mbps | | YouTube | H.264 / H.265 | 60 fps | 128 Mbps | | Threads | H.264 | 30 fps | 25 Mbps | | Pinterest | H.264 | 25 fps | 20 Mbps | Audio should be AAC, 128 kbps or higher. PostEverywhere transcodes uploaded videos automatically, but starting with a compliant file avoids quality loss. ## Upload Flow Two options depending on whether the file is already on the public web: ### Option A: Already on a public URL (one call) If your image is already hosted somewhere publicly fetchable, use `POST /v1/media/upload-from-url`: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/media/upload-from-url \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hero.webp" }' ``` Response is ready-to-use immediately (`media_status: "ready"`): ```json { "data": { "media_id": "a1b2c3d4-...", "media_ids": ["a1b2c3d4-..."], "media_status": "ready", "type": "image", "url": "https://imagedelivery.net/.../img_.../public", "content_type": "image/webp", "size": 14914 } } ``` Images (25 MB cap) import instantly; MP4 videos (500 MB cap) import asynchronously — poll `GET /media/{id}` until `media_status` is `ready`. For local files, use Option B. ### Option B: Local file (3-step flow) For local files, the upload is a 3-step process: request a presigned URL, upload bytes to it, then finalise. #### Step 1: Request a presigned upload URL `POST /v1/media/upload` takes JSON describing the file you're about to upload — **not** the file itself. Field names are `filename`, `content_type`, `size` (not `file_name`, `mime_type`, `file_size`). ```bash curl -X POST https://app.posteverywhere.ai/api/v1/media/upload \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{ "filename": "photo.jpg", "content_type": "image/jpeg", "size": 2048000 }' ``` The response includes a `media_id`, an `upload_url`, and an `upload_method` describing how to upload: ```json { "data": { "media_id": "a1b2c3d4-...", "upload_url": "https://upload.imagedelivery.net/...", "upload_method": { "method": "POST", "content_type": "multipart/form-data", "field_name": "file" }, "media_status": "uploading" } } ``` #### Step 2: Upload the bytes to `upload_url` :::warning Image upload URLs are not S3-style Sending `PUT` with raw bytes to an **image** `upload_url` returns `400 {"code":100,"message":"method is not supported"}`. Image URLs are Cloudflare direct-upload endpoints: use `POST` with `multipart/form-data` and the file in the field named by `upload_method.field_name` (usually `file`). Only **video and PDF** URLs accept `PUT` with raw bytes. ::: For images, send a multipart POST with the field name from `upload_method.field_name` (usually `file`): ```bash curl -X POST "" \ -F "file=@photo.jpg" ``` For videos, use the PUT method with the correct `Content-Type` header (`upload_method.method` will be `PUT` and `upload_method.headers` tells you the header to send). #### Step 3: Finalise This step is **required** — without it the media stays in `media_status: "uploading"` and `POST /v1/posts` will reject attempts to attach it. ```bash curl -X POST https://app.posteverywhere.ai/api/v1/media/{media_id}/complete \ -H "Authorization: Bearer pe_live_abc123..." ``` After Step 3 the media is `ready` and you can attach it via `media_ids` in a post. ### Polling for video processing (Option B videos only) Videos go through additional processing for thumbnail extraction. After Step 3 returns `ready`, the file is uploadable but the thumbnail may still be generating. Poll `GET /v1/media/{id}` if you need the `thumbnail_url` field populated. ### Step 3: Attach to a post ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{ "content": "Check out this photo!", "account_ids": [123], "media_ids": ["550e8400-e29b-41d4-a716-446655440000"], "scheduled_for": "2026-04-15T10:00:00Z" }' ``` Always confirm the media status is `ready` before attaching it to a post. See [Upload Media](/api/init-media-upload) for the full endpoint reference. ## Related - [Upload Media](/api/init-media-upload) -- upload endpoint reference - [Schedule a Post](/api/schedule-post) -- attach media to posts - [Error Handling](/errors) -- media-related error codes (`unsupported_media_type`, `file_too_large`, `file_type_mismatch`) --- # API Pagination - limit & offset Parameters > How to paginate through PostEverywhere API results. Offset-based pagination with limit and offset parameters. # Pagination List endpoints return paginated results using offset-based pagination. You control the page size with `limit` and skip results with `offset`. ## Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `limit` | integer | `20` | Number of items to return. Minimum `1`, maximum `100`. | | `offset` | integer | `0` | Number of items to skip before returning results. | ## Example: paginated list request Fetch the second page of 10 posts: ```bash curl "https://app.posteverywhere.ai/api/v1/posts?limit=10&offset=10" \ -H "Authorization: Bearer pe_live_abc123..." ``` ## Paginated response format Paginated endpoints include a `pagination` object alongside the results: ```json { "data": { "posts": [ { "id": 1, "content": "..." }, { "id": 2, "content": "..." } ], "pagination": { "limit": 10, "offset": 10 } }, "error": null, "meta": { "request_id": "req_abc123", "timestamp": "2026-03-01T12:00:00Z" } } ``` The `pagination` object contains: | Field | Type | Description | |-------|------|-------------| | `limit` | integer | The `limit` value used for this request. | | `offset` | integer | The `offset` value used for this request. | | `total` | integer | (Since 2026-06-11 on `/v1/posts` + `/v1/campaigns`) The total matching count — lets you know if there are more pages without fetching. | | `has_more` | boolean | (Since 2026-06-11 on `/v1/posts` + `/v1/campaigns`) `true` if more pages exist beyond this one. | ## Iterating Through All Results The recommended pattern (using `has_more`): ```javascript const client = new PostEverywhere({ apiKey: process.env.POSTEVERYWHERE_API_KEY }); async function fetchAllPosts() { const allPosts = []; const limit = 50; let offset = 0; while (true) { const { posts, pagination } = await client.posts.list({ limit, offset }); allPosts.push(...posts); if (!pagination.has_more) break; offset += limit; } return allPosts; } ``` The older pattern (fallback for endpoints that don't yet return `has_more`) — keep iterating until the response returns fewer items than the requested `limit`: ```javascript const client = new PostEverywhere({ apiKey: process.env.POSTEVERYWHERE_API_KEY, }); async function fetchAllPosts() { const allPosts = []; const limit = 50; let offset = 0; while (true) { const response = await client.posts.list({ limit, offset }); const posts = response.data.posts; allPosts.push(...posts); // If fewer results than the limit, we've reached the last page if (posts.length < limit) { break; } offset += limit; } return allPosts; } ``` ## Paginated Endpoints The following list endpoints support `limit` and `offset` parameters: - **[List Posts](/api/list-posts)** -- filter by status and platform - **[List Media](/api/list-media)** -- filter by media type --- # API Rate Limits - 60/min, 1,000/hour > PostEverywhere API rate limits, headers, and best practices for handling 429 responses. # Rate Limits The API enforces rate limits per minute and per hour to ensure fair usage. ## Rate limit tiers (per API key) | Window | Limit | |--------|-------| | Per minute | 60 requests | | Per hour | 1,000 requests | Limits are applied per API key. ## Response Headers Every response includes rate limit headers: ``` X-RateLimit-Limit: 60 X-RateLimit-Remaining: 58 X-RateLimit-Reset: 1711929600 ``` | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Maximum requests allowed in the current window | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp when the window resets | ## Handling 429 Responses When you exceed the limit, the API returns `429 Too Many Requests`: ```json { "data": null, "error": { "code": "rate_limited", "message": "Rate limit exceeded. Retry after 32 seconds.", "details": { "retry_after": 32 } }, "meta": { "request_id": "req_abc123", "timestamp": "2026-04-12T10:00:00Z" } } ``` The `Retry-After` header tells you how long to wait (in seconds). ## Bulk endpoint exemption `POST /v1/posts/bulk` (create up to 50 posts) counts as **one** API rate-limit hit instead of N. This is the recommended path for high-volume scheduling: ```bash # Instead of 50 separate POST /v1/posts calls (eats your per-minute budget): curl -X POST https://app.posteverywhere.ai/api/v1/posts/bulk \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"posts": [ ... up to 50 ... ]}' ``` Per-post **publishing-rate-limits** (POSTS_PER_DAY etc.) still apply individually — the bulk endpoint just collapses the API-key budget. See [Bulk Operations](/bulk-operations). ## Webhooks Webhook deliveries from PostEverywhere to your URL are **not subject** to your API rate limit (we're talking to you, not the other way around). The retry schedule + 20-failure auto-disable is documented in the [Webhooks guide](/webhooks). ## Best Practices - **Use bulk endpoints when N > 5** — `/v1/posts/bulk` for create, `/v1/posts/retry-failed` for retries - **Use webhooks instead of polling** — saves you from burning the API budget on "is it done yet?" loops - **Schedule in parallel** — scheduled posts (with a future `scheduled_for`) bypass the per-minute posting limit - **Cache account data** — `/accounts` and `/me` rarely change, cache for 5-10 minutes - **Implement exponential backoff** — on 429 or 5xx responses - **Monitor headers** — check `X-RateLimit-Remaining` before making requests ```javascript // Example: exponential backoff async function fetchWithRetry(url, options, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const res = await fetch(url, options); if (res.status === 429) { const retryAfter = res.headers.get('Retry-After') || Math.pow(2, i); await new Promise(r => setTimeout(r, retryAfter * 1000)); continue; } return res; } throw new Error('Max retries exceeded'); } ``` ## Platform & Plan Limits In addition to API rate limits, posting volume is capped per plan. X (Twitter) has the most restrictive per-day limits due to platform API restrictions. Other platforms have higher or unlimited daily caps. | Plan | Daily Posts (X/Twitter) | AI Credits | Social Accounts | |------|------------------------|------------|-----------------| | Trial | 10/day | 50/month | 10 | | Starter | 50/day | 50/month | 10 | | Growth | 150/day | 500/month | 25 | | Pro | 500/day | 2,000/month | 40 | When a posting limit is exceeded, the API returns a `429` response with a specific error message: ```json { "data": null, "error": { "message": "Rate limit exceeded. Check Retry-After header for wait time.", "code": "rate_limit_exceeded", "details": { "retry_after": 60, "limit": 50, "window": "day" } } } ``` For bulk uploads, plan limits are validated before scheduling. If the batch would exceed your X daily limit or subscription limit, the response includes `batchErrors` describing which limits were hit. ## Next Steps - **[Quick Start](/quick-start)** -- make your first API call - **[Authentication](/authentication)** -- set up your API key and understand error codes - **[Create Post](/api/create-post)** -- schedule content to any platform - **Platform guides:** [Instagram](/platforms/instagram) | [TikTok](/platforms/tiktok) | [YouTube](/platforms/youtube) | [LinkedIn](/platforms/linkedin) | [X (Twitter)](/platforms/x-twitter) | [Facebook](/platforms/facebook) | [Threads](/platforms/threads) | [Pinterest](/platforms/pinterest) --- # API Key Scopes - read, write, ai > PostEverywhere API key scopes and permissions. Control access to accounts, posts, media, and AI features. # API Key Scopes Scopes control which parts of the API your key can access. When you generate an API key, you choose which scopes to enable. ## The three scopes: read, write, ai | Scope | Endpoints | Description | |-------|-----------|-------------| | **`read`** (always included) | All `GET` endpoints | List accounts, posts, media, campaigns, webhooks, analytics, and the introspection endpoint `/me`. | | **`write`** (always included) | All mutating endpoints | Create/update/delete posts, media, campaigns, webhooks. Bulk operations and retry. | | **`ai`** (opt-in) | `/v1/ai/*` | Generate images AND captions with AI. Counts against AI credit quota. | ## Requesting Scopes When generating an API key from **Settings > Developers** in the PostEverywhere dashboard: 1. Click **Generate API Key** 2. Select the scopes you need -- default access is always included 3. Enable the **ai** scope if you plan to use the [Generate Image](/api/generate-image) endpoint 4. Click **Create** and copy your key You can generate multiple API keys with different scopes. For example, create one key with default access for your scheduling service and a separate key with the `ai` scope for your content generation pipeline. ## Scope Details ### `read` Scope (always included) Grants access to all GET endpoints: - **Accounts** -- List and view connected social media accounts, [check account health](/account-health) - **Posts** -- List and view scheduled and published posts (including [advanced filters](/api/list-posts)) - **Media** -- View media library - **Campaigns** -- List campaigns - **Webhooks** -- List webhook subscriptions - **Analytics** -- [Aggregate metrics](/analytics-summary) - **Introspection** -- [`/v1/me`](/introspection) — current API key context, quota, plan ### `write` Scope (always included) Grants mutating access: - **Posts** -- Create, update, delete, retry, [bulk create](/bulk-operations), [bulk retry](/bulk-operations#bulk-retry--post-v1postsretry-failed) - **Media** -- Upload, complete, delete - **Campaigns** -- Create, update, delete - **Webhooks** -- [Create](/webhooks), update, delete, test ### `ai` Scope (opt-in) Required for AI-powered endpoints. Grants access to: - **[Generate Image](/api/generate-image)** -- Create images from text prompts using models like `nano-banana-pro`, `ideogram-v2`, `gemini-3-pro`, and `flux-schnell` - **[Generate Caption](/generate-caption)** -- Create AI captions tuned to the target platform's character limits + style Both AI endpoints count against your monthly AI credit allowance. Requests without the `ai` scope return a `401 Unauthorized` error. :::tip AI Credits AI image generation consumes credits from your plan's monthly allowance. Different models cost different amounts of credits. See [Generate Image](/api/generate-image) for the full model and pricing table. ::: ## Further Reading - [Authentication](/authentication) -- how to use your API key - [Generate Image](/api/generate-image) -- AI image generation endpoint --- # Testing Your API Integration - Sandbox & Strategies > How to test your PostEverywhere API integration safely — dedicated test accounts, schedule-then-delete, and verifying media before attaching. # Testing & Sandbox PostEverywhere does not currently have a separate sandbox environment or a bulk dry-run endpoint. The strategies below let you test your integration safely without publishing live posts to your real audience. ## Testing Strategies ### 1. Create Dedicated Test Accounts Connect social accounts that you use exclusively for testing on each platform. For example, create a private X account, a test Instagram business account, and a test LinkedIn page. Test posts publish to accounts your audience never sees, so mistakes are invisible. ### 2. Schedule-Then-Delete Create posts scheduled far in the future (e.g. one year from now), inspect them via the API, then delete them before they publish. This exercises the full create / read / delete flow without any real publishing risk: ```bash # Schedule a post for next year curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{ "content": "Test post - will be deleted", "account_ids": [123], "scheduled_for": "2027-04-15T10:00:00Z" }' # Inspect the post (use the id returned above) curl https://app.posteverywhere.ai/api/v1/posts/{post_id} \ -H "Authorization: Bearer pe_live_abc123..." # Delete before it publishes curl -X DELETE https://app.posteverywhere.ai/api/v1/posts/{post_id} \ -H "Authorization: Bearer pe_live_abc123..." ``` ### 3. Verify Media Before Attaching Upload media and confirm it processes successfully before using it in posts: ```bash # Option A: One-call URL import (images ready instantly; MP4 video imports async — poll until ready) curl -X POST https://app.posteverywhere.ai/api/v1/media/upload-from-url \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hero.webp"}' # Option B: 3-step flow (for local files) # Step 1: Request presigned URL — body is JSON, NOT multipart. Required: filename, content_type, size curl -X POST https://app.posteverywhere.ai/api/v1/media/upload \ -H "Authorization: Bearer pe_live_abc123..." \ -H "Content-Type: application/json" \ -d '{"filename": "test-image.jpg", "content_type": "image/jpeg", "size": 524288}' # Step 2: PUT/POST the bytes to the returned upload_url # Step 3: POST /api/v1/media/{media_id}/complete to mark it ready # Check status (poll until "ready") — for videos which may still be processing thumbnails curl https://app.posteverywhere.ai/api/v1/media/{media_id} \ -H "Authorization: Bearer pe_live_abc123..." # Response when ready: # { "id": "{media_id}", "status": "ready", "mime_type": "image/jpeg", ... } ``` Only attach media to posts once the status is `ready`. See [Media Requirements](/media-requirements) for file size and dimension limits. ### 4. Validate Your Payload Client-Side First Before sending to the API, check your payload for the common mistakes that cause `400` errors: - `content` is present and non-empty - `account_ids` is a non-empty array of integers (not strings) - `scheduled_for` (if set) is a valid ISO 8601 datetime in UTC and at least 5 minutes in the future. Never use `scheduled_at` — it is a deprecated alias and new integrations should use `scheduled_for`. - `media_ids` (if set) are UUIDs from your media uploads. The only accepted media field name is `media_ids`. Sending `media`, `media_id`, `mediaId`, or `attachments` returns `400 invalid_field_name`. - `timezone` (if set) is a valid IANA name like `America/New_York`. It is display metadata only and does not affect when the post actually fires. All field names use `snake_case`. `accountIds`, `mediaIds`, and `platformContent` are also accepted as camelCase aliases for back-compat, but `snake_case` is canonical. ## Rate Limits Apply to All Calls Test requests count against your rate limits. Scheduled posts (with a future `scheduled_for`) only count against the hourly and daily limits. Immediate posts (no `scheduled_for`) also count against the per-minute limit. Plan your test loops accordingly to avoid hitting `rate_limit_exceeded` errors. If you are running automated test suites, add a short delay between requests: ```javascript // Simple delay between test API calls async function testWithDelay(calls, delayMs = 1100) { for (const call of calls) { await call(); await new Promise((resolve) => setTimeout(resolve, delayMs)); } } ``` See [Rate Limits](/rate-limits) for the full details on limits and headers. ## Pinterest Sandbox Pinterest offers a dedicated sandbox environment for API testing. If you are integrating with Pinterest specifically, you can use the sandbox to test pin creation without publishing to live boards. Contact support at **developers@posteverywhere.ai** if you need Pinterest sandbox access enabled for your account. ## Production Readiness Checklist Before going live, verify each of these: - [ ] **Authentication works.** Your API key returns a `200` on `GET /accounts`. - [ ] **Error handling is implemented.** Your code handles 400, 401, 429, and 500 errors distinctly (see [Error Handling](/errors)). - [ ] **Retry logic uses exponential backoff.** Retries only fire for 429 and 5xx responses. - [ ] **Rate limits are respected.** You are not exceeding your per-minute limit. - [ ] **Media uploads are validated.** You check that media status is `ready` before attaching to posts. - [ ] **Scheduling times are in the future.** All `scheduled_for` values are at least 5 minutes ahead and expressed in UTC. - [ ] **Field names are `snake_case` and canonical.** `content`, `account_ids`, `scheduled_for`, `media_ids`, `platform_content`, `timezone`. Never `scheduled_at`, `media`, `media_id`, `mediaId`, or `attachments`. - [ ] **Round-trip works.** A post you fetch via `GET /v1/posts/{id}` can be POSTed back as-is (top-level fields) to create a clone. - [ ] **Post results are polled.** You track publishing results via [Get Post Results](/api/get-post-results) and handle failures. - [ ] **Scopes are correct.** Your API key has the minimum scopes needed (see [Scopes](/scopes)). ## Related - [Quick Start](/quick-start) — first API call walkthrough - [Error Handling](/errors) — error codes and retry strategies - [Rate Limits](/rate-limits) — request limits and headers --- # Webhooks API - Real-Time Post Status Events > Receive real-time HTTPS notifications when posts publish or fail, accounts disconnect, or media uploads complete. Includes HMAC-SHA256 signing, retry policy, and verification examples. # Webhooks Webhooks let you receive **real-time HTTPS notifications** the moment something interesting happens in PostEverywhere — instead of polling `/v1/posts/{id}` every few seconds waiting for a publish to finish. Subscribe once, and PostEverywhere will POST a signed JSON payload to your endpoint every time a matching event occurs. ## Quick start ```bash # 1. Create a webhook subscription curl -X POST https://app.posteverywhere.ai/api/v1/webhooks \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/posteverywhere", "events": ["post.published", "post.failed", "account.reconnect_needed"], "name": "Production" }' ``` Response (the `secret` is shown **once only** — save it now): ```json { "id": "9f3c4d12-...", "url": "https://your-app.example.com/webhooks/posteverywhere", "events": ["post.published", "post.failed", "account.reconnect_needed"], "secret": "whsec_abc123...64chars", "secret_warning": "Save this secret now — it is shown only once.", "is_active": true, "created_at": "2026-06-11T...", ... } ``` ## Event types | Event | Fires when | |-------|------------| | `post.scheduled` | A new post is created (after `POST /v1/posts`) | | `post.publishing` | A destination has started its publish request | | `post.published` | A destination has successfully published | | `post.failed` | A destination has failed after all retries | | `post.partially_failed` | A post group ends with some destinations succeeded + some failed | | `post.updated` | A post is modified (`PATCH /v1/posts/:id`) | | `post.deleted` | A post is deleted | | `account.connected` | A new social account is connected | | `account.disconnected` | A social account is disconnected by the user | | `account.reconnect_needed` | A social account's token is detected dead (user must reconnect) | | `media.uploaded` | A media upload completes processing (status=ready) | | `media.deleted` | A media item is deleted | Subscribe to one or many. New events are added over time without breaking existing subscriptions. ## Payload shape Every delivery has this envelope: ```json { "event": "post.published", "event_id": "9c4d12e0-...", "created_at": "2026-06-11T05:42:18.234Z", "organization_id": "56cb4496-...", "data": { ... event-specific fields ... } } ``` The `data` shape depends on the event. Example for `post.published`: ```json { "event": "post.published", "event_id": "9c4d12e0-...", "created_at": "2026-06-11T05:42:18.234Z", "organization_id": "56cb4496-...", "data": { "post_id": "a808f10f-...", "destination_id": "b9070ed5-...", "platform": "instagram", "account_id": 5356, "account_name": "taxrefundonspot.com.au", "published_at": "2026-06-11T05:42:17.000Z", "platform_post_id": "17841451231344189_..." } } ``` Unknown fields **may be added** over time. Receivers should treat the payload as **forward-compatible** — ignore fields you don't recognise. ## Request headers Every delivery includes these headers: | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `User-Agent` | `PostEverywhere-Webhook/1.0` | | `X-PostEverywhere-Event` | The event name (e.g. `post.published`) | | `X-PostEverywhere-Event-Id` | Stable UUID — use as a dedup key (we may deliver at-least-once) | | `X-PostEverywhere-Delivery-Id` | Per-attempt UUID | | `X-PostEverywhere-Timestamp` | Unix epoch seconds (anti-replay) | | `X-PostEverywhere-Signature` | `sha256=` | | `X-PostEverywhere-Attempt` | Attempt number (1-6) | ## Verifying the signature **You MUST verify the signature on every incoming webhook.** Without verification, anyone could POST to your URL and pretend to be PostEverywhere. ### Node.js / TypeScript ```ts function verifyWebhook(rawBody: string, signatureHeader: string, secret: string): boolean { if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false; const provided = signatureHeader.slice(7); // strip "sha256=" const expected = crypto.createHmac("sha256", secret) .update(rawBody) // ⚠️ MUST be the raw body before any parsing .digest("hex"); // Constant-time compare to prevent timing attacks. try { return crypto.timingSafeEqual( Buffer.from(provided, "hex"), Buffer.from(expected, "hex"), ); } catch { return false; } } ``` ### Python ```python import hmac import hashlib def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: if not signature_header or not signature_header.startswith("sha256="): return False provided = signature_header[7:] expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(provided, expected) ``` ### Ruby ```ruby require "openssl" def verify_webhook(raw_body, signature_header, secret) return false unless signature_header&.start_with?("sha256=") provided = signature_header.sub("sha256=", "") expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body) Rack::Utils.secure_compare(provided, expected) end ``` ### Important verification gotchas - **Use the RAW request body** — not a re-serialised JSON object. JSON re-encoding may reorder keys or change whitespace and the HMAC will not match. - **Use a constant-time compare** (`crypto.timingSafeEqual` / `hmac.compare_digest`). Naive `==` leaks timing information. - **Optionally check `X-PostEverywhere-Timestamp`** — reject deliveries older than ~5 minutes to prevent replay of a captured payload. - **Dedupe by `X-PostEverywhere-Event-Id`** — delivery is at-least-once. Idempotent processing prevents double-acting on rare retries. ## Retry policy If your endpoint returns anything other than `2xx` within 10 seconds, the delivery is retried with **exponential backoff**: | Attempt | Delay before next attempt | |---------|--------------------------| | 1 | immediate | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 10 minutes | | 5 | 1 hour | | 6 | 6 hours | After **6 failed attempts**, the delivery is marked `dead` and not retried again. After **20 consecutive failed deliveries on the same webhook**, the webhook is **auto-disabled**. You can re-enable it via `PATCH /v1/webhooks/:id` with `{"is_active": true}` — that also resets the failure counter. ## Testing your endpoint Before flipping a webhook to production, send a synthetic ping: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID/test \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` Response shows the receiver's HTTP status + duration: ```json { "ok": true, "status": 200, "duration_ms": 87, "message": "Test webhook delivered successfully. Verify your endpoint received the X-PostEverywhere-Signature header and validated it against your saved secret." } ``` The test payload includes `data.test: true` so you can detect and ignore it in your handler if you want. ## Managing subscriptions Full reference: [List](/api/list-webhooks) · [Create](/api/create-webhook) · [Get](/api/get-webhook) · [Update](/api/update-webhook) · [Delete](/api/delete-webhook) · [Test](/api/test-webhook). ```bash # List your webhooks curl https://app.posteverywhere.ai/api/v1/webhooks \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" # Update events / URL / active state curl -X PATCH https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"events": ["post.published","post.failed","post.partially_failed"]}' # Disable temporarily curl -X PATCH https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"is_active": false}' # Delete (also cascades delivery history) curl -X DELETE https://app.posteverywhere.ai/api/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` ## Webhook limits - **25 webhooks per organization** (contact support to raise) - **HTTPS only** in production (HTTP refused). Localhost / private IPs refused (SSRF protection). - **10-second timeout** per delivery - **2-KB response body capture** for debugging (longer responses are truncated) ## Frequently asked questions ### Where is the signing secret? It's not in the GET response. The signing secret is returned **once only** in the `POST /v1/webhooks` create response. There is no API to retrieve it later. If you lost it, delete the webhook and create a new one. ### What if my endpoint is slow? You have **10 seconds** to respond with a `2xx` status. If your processing is slow, return `200` immediately and process the event asynchronously in your worker / queue. Idempotent processing keyed on `event_id` makes retries safe. ### How do I prevent replay attacks? Verify the `X-PostEverywhere-Timestamp` header is within the last 5 minutes. Combined with HMAC verification, this prevents an attacker who captures a payload from re-submitting it later. ### What's a sensible processing pattern? ```ts // 1. Read raw body BEFORE parsing. const rawBody = await req.text(); // 2. Verify signature. if (!verifyWebhook(rawBody, req.headers.get('x-posteverywhere-signature') || '', secret)) { return new Response('Invalid signature', { status: 401 }); } // 3. Dedupe. const eventId = req.headers.get('x-posteverywhere-event-id'); if (await alreadyProcessed(eventId)) return new Response('OK', { status: 200 }); // 4. Parse. const event = JSON.parse(rawBody); // 5. Enqueue + return fast. await queue.add('posteverywhere-event', event); return new Response('OK', { status: 200 }); ``` ### How do I subscribe to all events? Pass the full event list explicitly. There's no `*` wildcard — this is intentional, so adding a new event type in the future doesn't silently start firing to your endpoint without you opting in. --- # Other # Account Health > Check whether a connected social account is currently usable (token alive, no reconnection required, recent publish history) before publishing. # Account Health — `GET /v1/accounts/:id/health` Tells you whether a connected social account is currently **usable for publishing** — token alive, OAuth grant still valid, no recent terminal failures. Call this BEFORE a critical publish so you can surface a "reconnect needed" prompt instead of letting a publish fail. ## Request: `GET /v1/accounts/:id/health` ```bash curl https://app.posteverywhere.ai/api/v1/accounts/$ACCOUNT_ID/health \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` Read-only. Requires `read` scope. ## Response: the health verdict ```json { "account_id": 5356, "platform": "instagram", "account_name": "taxrefundonspot.com.au", "platform_account_id": "17841451231344189", "status": "healthy", // healthy | warning | broken "can_post": true, // boolean — short-circuit for client code "reasons": [], // empty when healthy; reason codes otherwise "is_active": true, "needs_reconnection": false, "needs_reconnection_since": null, "token": { "expires_at": "2026-07-15T...", "last_refresh": "2026-06-10T...", "expired": false, "expiring_soon": false // true if expires within 7 days }, "activity": { "last_published_at": "2026-06-11T05:42:17Z", "failures_last_7d": 2, "permanent_failures_last_7d": 0, "queued_count": 12 }, "connected_at": "2025-11-01T..." } ``` ## Status verdict The `status` field gives a single answer to "should my client trust this account?": | Status | `can_post` | What it means | |--------|-----------|---------------| | `healthy` | `true` | Token alive, no recent terminal failures. Publish normally. | | `warning` | `true` | Account works but watch out — token expires soon OR several recent permanent failures. | | `broken` | `false` | Don't publish. Either the account is inactive, the token expired, or `needs_reconnection` is set. User must reconnect. | ## Reason codes (in `reasons[]`) | Code | Triggers | |------|----------| | `account_inactive` | `is_active = false` | | `needs_reconnection` | `needs_reconnection_since` is set (token detected dead) | | `token_expired` | `token.expires_at` is in the past | | `token_expiring_soon` | `token.expires_at` is within 7 days | | `recent_permanent_failures` | ≥3 permanent failures in last 7 days | The first matching reason determines status (broken > warning > healthy). ## Typical workflow ```ts // Before publishing, check all selected accounts. const checks = await Promise.all( accountIds.map(id => client.accounts.health(id)) ); const broken = checks.filter(c => !c.can_post); if (broken.length > 0) { showToast( `Some accounts need attention: ${broken.map(b => b.account_name).join(', ')}` ); return; // Don't proceed. } await client.posts.create({ content, account_ids: accountIds }); ``` Full reference: [GET /v1/accounts/:id/health](/api/get-account-health). --- # Analytics Summary > Aggregate counters and per-platform totals for the current workspace — one call instead of listing every post. # Analytics Summary — `GET /v1/analytics/summary` Returns aggregate counts for a time window. Answers questions like "how many posts have I published this month?" or "which platform has the most failures this week?" without listing every post and counting client-side. ## Request: `GET /v1/analytics/summary` ```bash curl "https://app.posteverywhere.ai/api/v1/analytics/summary?period=week" \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` ## Parameters | Param | Values | Default | |-------|--------|---------| | `period` | `today` (24 h), `week` (7 d), `month` (30 d), `all`, `custom` | `month` | | `from` | ISO timestamp (required if `period=custom`) | — | | `to` | ISO timestamp (required if `period=custom`) | — | ## Response: totals, per-platform, and metrics coverage ```json { "period": "week", "range": { "from": "2026-06-04T05:30:00Z", "to": "2026-06-11T05:30:00Z" }, "posts": { "total": 214, "draft": 3, "scheduled": 168, "publishing": 2, "published": 33, "partially_failed": 4, "failed": 4 }, "by_platform": [ { "platform": "instagram", "total": 67, "scheduled": 55, "published": 11, "failed": 1 }, { "platform": "tiktok", "total": 41, "scheduled": 38, "published": 0, "failed": 3 }, { "platform": "linkedin", "total": 38, "scheduled": 30, "published": 8, "failed": 0 } ], "metrics": { "views": 12483, "likes": 872, "comments": 143, "shares": 29, "impressions": 41208, "clicks": 518 }, "ai_credits": { "used_this_period": 138, "bonus": 0 } } ``` ## Metric coverage per platform - **`posts.total`** counts unique post groups. Multi-platform posts count as **one** here, not one per destination. - **`by_platform.total`** counts **destinations**, so a 5-platform post contributes 5 rows total. - **`metrics`** aggregates only the `published` destinations. Failed destinations don't have metrics. - Status counts use the **derived** post status, the same as `GET /v1/posts`'s status filter (`scheduled` / `publishing` / `published` / `partially_failed` / `failed` / `draft`). ## Status derivation Post status is derived live from the destinations underneath it. Note the vocabulary shift: a **destination** that published successfully is `done`, never `published`. See [the two status vocabularies](/#two-status-vocabularies-read-this-before-polling). - `published` — every destination is `done` - `failed` — at least 1 destination `failed`, none `done`, none queued - `partially_failed` — at least 1 destination `done` AND at least 1 `failed` - `publishing` — at least 1 destination is in flight (`preparing` / `uploading` / `publishing` / `verifying`) - `scheduled` — at least 1 destination `queued` and nothing published yet - `draft` — `posts.post_status = 'draft'` ## Which metrics each platform actually returns The `metrics` block is only as complete as what each platform's API gives us back, and that varies a lot. Read this before building a dashboard on it, so a zero reads as "this platform does not report it" rather than "this post got nothing". | Platform | Views | Likes | Comments | Shares | Impressions | Notes | | --- | :-: | :-: | :-: | :-: | :-: | --- | | YouTube | yes | yes | yes | - | - | Most complete source | | TikTok | yes | yes | yes | yes | - | Live since Jul 28 2026. Accounts connected before Jul 3 2026 must reconnect once to grant the analytics scope | | Pinterest | yes | yes | - | yes | yes | Saves reported as shares | | LinkedIn | - | yes | yes | - | yes | LinkedIn exposes no view count for posts | | Instagram | - | yes | yes | - | - | Insights pending Meta App Review | | X | - | yes | - | yes | yes | Refreshed on demand only (each read costs API credits), press Refresh on the analytics page | | Bluesky | - | yes | - | - | - | | | Facebook | - | - | - | - | - | Blocked on `pages_read_engagement` Advanced Access, pending Meta App Review | | Threads | - | - | - | - | - | Blocked on `threads_manage_insights`, pending Meta App Review | | Telegram, Discord | - | - | - | - | - | No per-post analytics available for these integrations | A field we do not receive is returned as `0` in the aggregate and `null` on the individual destination, so `null` is the honest signal for "not reported". :::info Reach and follower growth are not available The API exposes no reach metric and no follower counts or follower growth, on any platform. `impressions` is the closest available measure and is not the same thing. If you need either, tell us at support@posteverywhere.ai so we can weight the work. ::: Full reference: [GET /v1/analytics/summary](/api/get-analytics-summary). --- # Bulk Posting API - Create 50 Posts in One Call > Create up to 50 posts in one call and bulk-retry failed destinations by filter — counts as ONE API-rate-limit hit instead of N. # Bulk Operations Two endpoints for working with many posts in one call: - [`POST /v1/posts/bulk`](/api/bulk-create-posts) — create up to 50 posts in a single request - [`POST /v1/posts/retry-failed`](/api/retry-failed-posts) — retry every failed destination matching a filter Both count as **one** API-rate-limit hit (vs N if you'd called the single-item endpoint in a loop). Per-post posting-rate-limits (POSTS_PER_DAY etc) still apply individually. ## Bulk create — `POST /v1/posts/bulk` ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts/bulk \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "posts": [ { "content": "Morning post", "account_ids": [123, 456], "scheduled_for": "2026-08-01T09:00:00Z" }, { "content": "Afternoon post", "account_ids": [123, 456], "scheduled_for": "2026-08-01T14:00:00Z" }, { "content": "Evening post", "account_ids": [123, 456], "scheduled_for": "2026-08-01T19:00:00Z" } ] }' ``` ### Bulk create response ```json { "summary": { "total": 3, "succeeded": 2, "failed": 1 }, "results": [ { "index": 0, "ok": true, "post": { "post_id": "...", ... } }, { "index": 1, "ok": true, "post": { "post_id": "...", ... } }, { "index": 2, "ok": false, "error": { "code": "validation_error", "message": "scheduled_for must be in the future" } } ] } ``` ### Partial-success HTTP semantics - **All succeeded** → `201 Created` - **All failed** → `422 Unprocessable Entity` - **Mixed** → `207 Multi-Status` Always iterate `results` — don't assume `200` means everything worked. ### Bulk request limits - **Max 50 posts per call.** Exceeding returns `400 bulk_limit_exceeded`. - Each post inside the array is validated independently — one bad entry doesn't kill the others. - Posts are processed **sequentially** (not in parallel) to keep posting-rate-limits deterministic. ## Bulk retry — `POST /v1/posts/retry-failed` Retry every failed destination matching a filter. Avoids the "loop over N failed posts and retry each" anti-pattern. ```bash # Retry every TikTok failure from yesterday curl -X POST https://app.posteverywhere.ai/api/v1/posts/retry-failed \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "tiktok", "failed_after": "2026-06-10T00:00:00Z", "failed_before":"2026-06-11T00:00:00Z" }' ``` ### Filter fields (at least one required) | Field | Type | Description | |-------|------|-------------| | `post_ids` | string[] | Up to 200 specific post UUIDs | | `account_id` | number | Only failures on this social account | | `platform` | string | One of `instagram`, `facebook`, `x`, `linkedin`, `youtube`, `tiktok`, `threads`, `pinterest`, `bluesky`, `telegram`, `discord` | | `failed_after` | ISO timestamp | Only failures with `updated_at >= this` | | `failed_before` | ISO timestamp | Only failures with `updated_at <= this` | | `max_attempts` | number | Skip destinations whose attempt_count is already `>= this` | If **no filter** is supplied, the endpoint returns `400 no_filter` — refusing to retry the entire failure history is intentional. ### Bulk retry response ```json { "retried_count": 12, "destinations": [ { "destination_id": "...", "post_id": "...", "platform": "tiktok", "new_status": "queued" }, ... ], "message": "12 destinations queued for retry." } ``` Retried destinations have their `attempts` reset to 0, `last_error` cleared, and `scheduled_for` set to `NOW()` for immediate publish. ## Use cases ### Schedule a 30-day content calendar ```ts const posts = generateContentPlan(30); // 30 post objects const batches = chunk(posts, 50); // Split into 50-post batches for (const batch of batches) { await client.bulkCreatePosts(batch); } ``` ### Resurrect everything that failed during an outage ```ts const outageStart = "2026-05-29T08:00:00Z"; const outageEnd = "2026-05-29T20:30:00Z"; const result = await client.retryFailedPosts({ failed_after: outageStart, failed_before: outageEnd, }); console.log(`Retrying ${result.retried_count} destinations`); ``` ### Recover one account after reconnecting ```ts // User just reconnected their Instagram. Retry recent IG failures. await client.retryFailedPosts({ account_id: 5356, failed_after: "2026-06-08T00:00:00Z", }); ``` --- # Campaigns API - Group, Filter & Report on Posts > Group related posts under named campaigns ("Q3 Launch", "Holiday 2026"). Filter and report on them as a unit. # Campaigns Campaigns let you **group related posts** under a name + color so you can filter, list, and report on them as a unit. Useful for marketing pushes ("Q3 Launch"), recurring series ("Tip Tuesday"), or seasonal content ("Holiday 2026"). ## Endpoints Full reference: [List](/api/list-campaigns) · [Create](/api/create-campaign) · [Get](/api/get-campaign) · [Update](/api/update-campaign) · [Delete](/api/delete-campaign). ``` GET /v1/campaigns list campaigns POST /v1/campaigns create GET /v1/campaigns/:id fetch one (includes post_count) PATCH /v1/campaigns/:id update name/description/color/status DELETE /v1/campaigns/:id delete (posts survive — campaign_id set NULL) ``` ## Create a campaign ```bash curl -X POST https://app.posteverywhere.ai/api/v1/campaigns \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Q3 Product Launch", "description": "Cross-platform rollout for the August launch", "color": "#3b82f6", "status": "active" }' ``` Response: ```json { "id": 42, "name": "Q3 Product Launch", "description": "Cross-platform rollout for the August launch", "color": "#3b82f6", "status": "active", "post_count": 0, "created_at": "2026-06-11T...", "updated_at": "2026-06-11T..." } ``` ## Tag posts to a campaign Pass `campaign_id` when creating a post: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/posts \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Big news today...", "account_ids": [123, 456], "scheduled_for": "2026-08-01T10:00:00Z", "campaign_id": 42 }' ``` ## Filter posts by campaign ```bash curl "https://app.posteverywhere.ai/api/v1/posts?campaign_id=42" \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` ## Validation | Field | Type | Constraint | |-------|------|------------| | `name` | string | 1-100 chars, required | | `description` | string | max 500 chars | | `color` | string | hex `#RRGGBB` (e.g. `#3b82f6`); defaults to `#3b82f6` | | `status` | string | `active` or `archived`; defaults to `active` | ## Behavior & constraints - **Workspace-scoped.** Campaigns belong to a workspace, not a user — visible to all workspace members. - **Deleting doesn't delete posts.** When you delete a campaign, posts that referenced it have their `campaign_id` set to `NULL`. The posts themselves are untouched. - **`status='archived'`** is a soft-hide. Archived campaigns still exist for posts that reference them but are filtered out of the default list view (pass `?status=archived` to see them). --- # Social Media CLI - Post from the Terminal > Log in, connect all 12 platforms (11 social networks plus WordPress blogs), and publish from the terminal with the PostEverywhere CLI (@posteverywhere/cli). Built for developers and AI agents. # PostEverywhere CLI The `@posteverywhere/cli` lets you (or your AI agent) **log in, connect every platform, and publish** entirely from the terminal — no dashboard navigation required. ```bash npm install -g @posteverywhere/cli # or: npx @posteverywhere/cli ``` Source: [github.com/posteverywhere/cli](https://github.com/posteverywhere/cli) ## Install as an agent skill The CLI ships with a `SKILL.md` that teaches an agent the whole command surface. One command installs it: ```bash npx skills add posteverywhere/cli ``` That auto-detects your agent — Claude Code, Cursor, Codex and 70+ others. Then just ask: > *"schedule a post to all my accounts for tomorrow at 9am"* Claude Code users can instead install it as a plugin, which brings auto-updates: ```bash claude plugin marketplace add posteverywhere/cli ``` Either way the agent learns to connect accounts, check per-platform limits before composing, schedule, and read back per-destination results. Every command emits JSON, so it can act on what just happened rather than guessing. ## 90-second setup ```bash posteverywhere login # opens your browser, saves a key to ~/.posteverywhere posteverywhere connect instagram # opens the OAuth flow; auto-detects the connected account posteverywhere accounts # list connected accounts (+ ids & health) posteverywhere post -c "Hello 🚀" -a 123,456 ``` ## How `login` works A device-grant flow (RFC 8628, like the GitHub CLI): 1. The CLI calls `POST /v1/auth/device` and prints a short code (e.g. `GUPK-HM3G`) plus a URL. 2. It opens `https://app.posteverywhere.ai/device` in your browser. You confirm the code (you must be signed in). 3. The CLI polls `POST /v1/auth/device/token` until you approve, then receives a scoped API key (`read`, `write`, `ai`) and saves it to `~/.posteverywhere/config.json` (`chmod 600`). The key is a normal API key — it shows up in **Settings → Developers** alongside dashboard-generated keys, and you can revoke it there anytime. The plaintext key is shown/saved exactly once and never stored on our servers in plaintext. ## How `connect` works ```bash posteverywhere connect posteverywhere reconnect # refresh an expired account's token ``` - **Browser platforms** — `instagram`, `facebook`, `threads`, `x`, `linkedin`, `tiktok`, `youtube`, `pinterest`. The CLI calls `POST /v1/auth/social-connect/start`, opens the normal OAuth flow in your browser, and **watches for the account** to appear, then resolves automatically. No copy-paste of tokens. - **Bluesky** — prompts for your handle + an **App Password** (Settings → App Passwords). No browser. - **Telegram** — prompts for a **bot token** (@BotFather) + your **channel** (add the bot to it as an admin first). No browser. - **Discord** — prompts for a channel **webhook URL** (Server Settings → Integrations → Webhooks). No browser. ## Commands | Command | What it does | |---|---| | `login` / `logout` | Device-flow login / remove saved credentials | | `whoami` | Show the authed account, plan & quota | | `accounts` | List connected accounts (+ ids & health) | | `platform-rules [platform]` | Character limits, media constraints & supported features | | `connect ` | Connect a new account | | `reconnect ` | Re-authorize an account whose token expired | | `account:health ` | Detailed health for one account | | `post -c -a [-s ] [-m ]` | Publish now (omit `-s`) or schedule (`-s` = ISO-8601 UTC) | | `posts [--status x] [--platform y] [--limit n]` | List posts | | `results ` | Per-platform publish results | | `retry ` | Retry failed destinations | | `upload ` | Import an image or MP4 video by URL → `media_id` (videos import async — poll until ready) | | `caption -t [--platform x] [--tone y]` | AI captions | | `analytics [--period week\|month\|all]` | Analytics summary | | `campaigns` | List campaigns | ## For AI agents Every command accepts `--json` (auto-on when output is piped) and emits structured JSON, so Claude, Cursor, and other agents can parse results. Install the bundled skill with `npx skills add posteverywhere/cli` and the agent learns the command surface without you explaining it. Two habits are worth encouraging in your own prompts: - **`accounts` before `post`** — account ids should never be guessed. - **`platform-rules` before composing for an unfamiliar platform** — the limits range from 300 characters on Bluesky to 63,206 on Facebook, and TikTok enforces a pixel ceiling server-side. One call is cheaper than a rejected publish. For CI or fully headless agents, skip `login` and set the key directly: ```bash export POSTEVERYWHERE_API_KEY=pe_live_... # Settings → Developers posteverywhere accounts --json ``` Prefer MCP? Use the hosted endpoint `https://mcp.posteverywhere.ai/mcp` or `npx -y @posteverywhere/mcp` — see [MCP](./integrations/mcp). ## Troubleshooting - **"Not logged in"** — run `posteverywhere login`, or set `POSTEVERYWHERE_API_KEY`. - **`connect` times out** — the browser window must be signed in to the same account your key belongs to. Finish the OAuth there; the CLI resolves within a few seconds. - **An account "needs reconnect"** — `posteverywhere reconnect `. - **Auth precedence** — `POSTEVERYWHERE_API_KEY` env var wins over the saved `login` key. --- # Connecting Accounts > Connect social accounts from the API, an AI agent, or the terminal. Mint OAuth connect links for X, Instagram, Facebook, YouTube, Pinterest, Threads, LinkedIn, TikTok, or pass credentials directly for Telegram, Discord, Bluesky. # Connecting Accounts from the API Everything else in this API assumes a connected social account. This page is how you get one **without touching the dashboard** — from a script, a CLI, or an AI agent (the MCP tools `create_connect_link`, `create_reconnect_link`, and `connect_credential_account` wrap these endpoints). There are two connection styles, decided by the platform: | Style | Platforms | Browser needed? | | --- | --- | --- | | OAuth connect link | `x`, `instagram`, `facebook`, `youtube`, `pinterest`, `threads`, `linkedin`, `tiktok` | One click by the account owner (the platform's own consent screen; that hop is imposed by the platforms, not us) | | Direct credentials | `telegram`, `discord`, `bluesky` | None. Fully in-API | ## OAuth platforms: `POST /v1/accounts/connect-link` ```bash curl -X POST https://app.posteverywhere.ai/api/v1/accounts/connect-link \ -H "Authorization: Bearer pe_live_..." \ -H "Content-Type: application/json" \ -d '{"platform": "instagram"}' ``` ```json { "data": { "platform": "instagram", "url": "https://www.instagram.com/oauth/authorize?...", "expires_in_seconds": 600, "instructions": "Have the instagram account owner open this URL in any browser within 10 minutes..." }, "error": null } ``` The flow, `gh auth login` style: 1. Mint the link (requires the `write` scope; expires in **10 minutes**). 2. Hand it to the account owner. They can open it in **any** browser — they do not need to be logged in to PostEverywhere there; the link carries a signed, single-org grant of who initiated it. 3. They approve on the platform's consent screen. 4. The account lands in your organization. **Poll `GET /v1/accounts`** until the new account appears (typically seconds after approval), then post to it. Security properties: the link is HMAC-signed and bound to your organization and workspace, expires after 10 minutes, and can only ever ADD an account to the organization that minted it. It grants the clicker nothing. Treat it like any invite link: send it to the account owner directly, not somewhere public. ## Credential platforms: `POST /v1/accounts/connect-credential` No browser, validated live against the platform before saving: ```bash # Telegram: bot from @BotFather, bot must be an ADMIN of the channel curl -X POST .../v1/accounts/connect-credential \ -H "Authorization: Bearer pe_live_..." -H "Content-Type: application/json" \ -d '{"platform": "telegram", "bot_token": "123456:ABC...", "channel": "@mychannel"}' # Discord: Server Settings > Integrations > Webhooks -d '{"platform": "discord", "webhook_url": "https://discord.com/api/webhooks/..."}' # Bluesky: Settings > App Passwords (never the main account password) -d '{"platform": "bluesky", "handle": "me.bsky.social", "app_password": "xxxx-xxxx-xxxx-xxxx"}' ``` Bad credentials return a `400` with the platform's reason, so agents can self-correct. Re-submitting credentials for an already-connected account updates it in place — that is also how these platforms reconnect. ## Fixing a dead token: `POST /v1/accounts/{id}/reconnect` When `GET /v1/accounts/{id}/health` reports `needs_reconnection` or `token_expired`, mint a repair link: ```bash curl -X POST https://app.posteverywhere.ai/api/v1/accounts/1234/reconnect \ -H "Authorization: Bearer pe_live_..." ``` Same mechanics as connect-link, with one rule: the owner must approve **while logged in to the platform as that same profile**. Matching is by the platform's account id — authorizing as a different profile connects a new account instead of repairing this one. Verify with the health endpoint afterwards. ## Notes for agents - `list_accounts` before and after is the confirmation loop: mint link, wait for the human, poll until the platform appears, then proceed to posting. - A connect link that expires unclicked costs nothing; mint a fresh one. - Connecting an account someone ELSE's PostEverywhere workspace already holds is blocked by an anti-hijack guard; the human will see an explanatory error and should contact support if they believe they own the account. ## API reference Every connect flow, headless: [Create connect link](/api/create-connect-link) · [Create reconnect link](/api/create-reconnect-link) · [Connect credential account](/api/connect-credential-account) · [Start social connect](/api/start-social-connect) · [Poll social connect](/api/poll-social-connect). --- # AI Caption Generator API > AI-generated social media captions tuned per-platform — respects character limits, hashtag conventions, and emoji style. # Generate Caption — `POST /v1/ai/generate-caption` Generates **1-5 caption variants** for a social media post from a topic + tone + platform. Companion to [`/v1/ai/generate-image`](/api/generate-image) — together they let an MCP agent compose an entire post in two API calls. Captions respect per-platform character limits and follow each platform's hashtag conventions automatically: | Platform | Hard limit | Hashtag style | |----------|-----------|---------------| | X / Twitter | 280 chars | 1-3 hashtags at end | | Bluesky | 300 chars | 1-3 hashtags | | Threads | 500 chars | 1-3 hashtags | | Pinterest | 500 chars | **No hashtags** (uses natural-language search) | | YouTube | 1,000 chars | 1-3 hashtags | | Instagram | 2,200 chars | 5-10 hashtags at end | | TikTok | 2,200 chars | 5-10 hashtags | | LinkedIn | 3,000 chars | 2-3 hashtags (LinkedIn discourages heavy use) | | Facebook | 5,000 chars | 1-3 hashtags | ## Request: `POST /v1/ai/caption` ```bash curl -X POST https://app.posteverywhere.ai/api/v1/ai/generate-caption \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "topic": "Black Friday sale on running shoes — 30% off through Monday", "platform": "instagram", "tone": "enthusiastic", "length": "medium", "count": 3 }' ``` Requires the `ai` scope. Costs **1 AI credit per returned caption**. ## Parameters | Field | Type | Default | Description | |-------|------|---------|-------------| | `topic` | string (required) | — | What the post should be about (max 1,000 chars). | | `platform` | string | none | If set, applies platform-specific length + style rules. Omit for generic. | | `tone` | string | `professional` | `professional` \| `casual` \| `witty` \| `enthusiastic` \| `urgent` \| `inspirational` | | `length` | string | `medium` | `short` (~80 chars) \| `medium` (~180 chars) \| `long` (~400 chars) | | `include_hashtags` | boolean | `true` | Set `false` to omit hashtags entirely. | | `include_emojis` | boolean | `true` | Set `false` for emoji-free output. | | `count` | integer | `1` | Number of variants to return (1-5). | ## Response: the generated caption ```json { "captions": [ "🏃 30% OFF until Monday! Time to lace up. Our best running shoes — half a closet, all the comfort. Free shipping over $50. Link in bio.\n\n#blackfriday #runningshoes #sale #fitness #running", "BLACK FRIDAY MADNESS 🛍️ ...", "..." ], "platform": "instagram", "tone": "enthusiastic", "length": "medium", "count_requested": 3, "count_returned": 3, "credits_used": 3, "credits_remaining": 197 } ``` ## Errors | Code | HTTP | When | |------|------|------| | `validation_error` | 400 | Missing `topic` or `topic` > 1000 chars. | | `insufficient_credits` | 402 | Not enough AI credits for the requested count. Response includes `needed` and `available`. | | `ai_no_content` | 502 | OpenAI returned no content. Rephrase the topic and retry. | ## Typical workflow with `generate-image` ```ts const me = await client.me.get(); if (me.quota.ai_credits.limit - me.quota.ai_credits.used < 6) { showUpgrade(); return; } // 1 image (~5 credits) + 1 caption (1 credit) = 6 credits. const [image, captions] = await Promise.all([ client.ai.generateImage({ prompt: 'Vibrant running shoes on a track', model: 'gemini-3-pro' }), client.ai.generateCaption({ topic: 'Black Friday — 30% off running shoes', platform: 'instagram', tone: 'enthusiastic', count: 1, }), ]); await client.posts.create({ content: captions.captions[0], account_ids: [igAccountId], media_ids: [image.media_id], scheduled_for: '2026-11-29T10:00:00Z', }); ``` ## Credits, models, and limits - **Tone influences word choice.** `professional` produces measured copy with full sentences. `witty` introduces wordplay. `enthusiastic` uses exclamation marks and energy words. Match tone to brand voice. - **Multi-platform posting.** If you're posting to multiple platforms with different character limits, generate ONE caption sized for the shortest (X/Bluesky). Or call this endpoint per-platform. - **Hashtag placement.** Hashtags always land at the end of the caption (Instagram convention), unless you set `include_hashtags: false`. - **Pinterest** is special — `include_hashtags` is ignored. Pinterest uses natural-language search, not hashtags, so they're omitted. Full reference: [POST /v1/ai/caption](/api/generate-caption) and [POST /v1/ai/image](/api/generate-image). --- # API Introspection (/v1/me) > Get the current API key context — organization, scopes, quota, plan, and usage stats — in a single call. # Introspection — `GET /v1/me` Returns everything an SDK or MCP client needs to know about itself: which organization it belongs to, what scopes its API key has, what plan the org is on, and where it stands against the plan's limits. **Call this first** when initializing your integration. It eliminates the need to hardcode `organization_id` in config and lets your app surface "you've used 38 / 50 AI credits this month" without listing every generation. ## Request: `GET /v1/me` ```bash curl https://app.posteverywhere.ai/api/v1/me \ -H "Authorization: Bearer $POSTEVERYWHERE_API_KEY" ``` Requires `read` scope. Counts against the API rate limit. ## Response: key, organization, and quota ```json { "api_key": { "id": "fb382fa0-...", "key_prefix": "pe_live_4f3c", "name": "Production server", "scopes": ["read", "write", "ai"], "last_used_at": "2026-06-11T05:30:00Z", "created_at": "2026-04-01T...", "expires_at": null }, "scopes": ["read", "write", "ai"], "user": { "id": 8476, "email": "info@example.com", "name": "Netanel" }, "organization": { "id": "56cb4496-...", "name": "Sweeppy Inc.", "subscription_plan": "GROWTH", "subscription_status": "active", "subscription_source": "stripe", "trial_end": null, "current_period_end": "2026-07-11T...", "cancel_at_period_end": false, "entitled": true }, "workspace": { "id": "ea0589e2-...", "name": "Marketing" }, "quota": { "accounts": { "used": 6, "limit": 25 }, "ai_credits": { "used": 138, "limit": 500, "bonus": 0 }, "storage_bytes": { "used": 4283392104, "limit": 26843545600 }, "team_seats": { "limit": 3 } }, "stats": { "posts_last_30d": 214, "total_posts": 832 } } ``` ## Notable fields - **`organization.entitled`** — derived boolean. `true` when `subscription_status` is `active` or `trialing`. Use this as the single source of truth for "should I let this user post?" - **`organization.subscription_source`** — `stripe`, `revenuecat` (iOS IAP), or `admin_comp` (comped account). `admin_comp` accounts don't have Stripe billing; their entitlement is granted directly. - **`quota.ai_credits.bonus`** — credits purchased via the credit-pack flow, added on top of the monthly allotment. - **`api_key.expires_at`** — `null` for keys that don't expire. ## Common patterns ### Check before generating ```ts const me = await client.getMe(); if (me.quota.ai_credits.limit - me.quota.ai_credits.used < 5) { showUpgradePrompt(); return; } await client.generateImage({ prompt: "..." }); ``` ### Surface entitlement state ```ts const me = await client.getMe(); if (!me.organization.entitled) { // Subscription expired / canceled / past_due redirect("/billing"); } ``` ### Auto-discover organization_id ```ts // In your SDK init — no need to ask the user for org_id. const me = await client.getMe(); this.organizationId = me.organization.id; ``` Full reference: [GET /v1/me](/api/get-me). ---