# postpeg docs > postpeg is one REST API to publish, schedule, read analytics, and answer comments and DMs on X, Instagram, Facebook, LinkedIn, TikTok, YouTube, Threads, Pinterest, Bluesky and Google Business, through each network’s official API. Base URL: https://api.postpeg.com. Every request under `/v1` needs an API key as a bearer token, except `GET /v1/platforms`. Bodies are JSON. The browsable reference with every schema is at https://api.postpeg.com/docs. Canonical HTML docs: https://postpeg.com/docs. ## Quickstart 1. **Get a key.** Start the 7-day trial in the dashboard and create a key. It’s shown once. Keys look like `pp_live_` or `pp_test_` followed by 32 hex characters. `GET /v1/me` checks it works. 2. **Create a profile.** A profile is one brand or one of your end users; accounts are connected to a profile. ```http POST https://api.postpeg.com/v1/profiles Authorization: Bearer $POSTPEG_KEY Content-Type: application/json { "name": "Acme Coffee", "external_id": "customer_4821" } ``` ```json { "id": "prof_mfz0b7q2k9x4c1v8n3m6a0s5d2", "name": "Acme Coffee", "external_id": "customer_4821", "created_at": "2026-09-24T13:58:40.021Z" } ``` 3. **Connect an account.** Ask for a connect link, send the account’s owner to it, and they approve on the network’s own screen, then land on your `redirect_url` with `?status=success&account_id=…&platform=…`. The link is valid for 15 minutes. Bluesky connects with a handle and app password instead (`POST /v1/accounts/bluesky`). ```http POST https://api.postpeg.com/v1/accounts/connect Authorization: Bearer $POSTPEG_KEY Content-Type: application/json { "profile_id": "prof_mfz0b7q2k9x4c1v8n3m6a0s5d2", "platform": "instagram", "redirect_url": "https://example.com/settings/social" } ``` 4. **Publish a post.** List the account ids, write the post once, attach media by public https URL. Leave out `scheduled_at` to publish now. ```http POST https://api.postpeg.com/v1/posts Authorization: Bearer $POSTPEG_KEY Content-Type: application/json Idempotency-Key: changelog-2026-10-01 { "account_ids": [ "acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f", "acc_mfz20b5n1s8f4j7xv2p9r6hu1c" ], "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic", "media": [ { "url": "https://example.com/changelog.png", "type": "image" } ], "scheduled_at": "2026-10-01T09:00:00Z" } ``` ```json { "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3", "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic", "media": [ { "url": "https://example.com/changelog.png", "type": "image" } ], "platform_options": {}, "status": "scheduled", "scheduled_at": "2026-10-01T09:00:00.000Z", "targets": [ { "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "platform": "x", "status": "pending", "platform_post_id": null, "url": null, "error": null, "attempts": 0, "published_at": null }, { "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f", "platform": "linkedin", "status": "pending", "platform_post_id": null, "url": null, "error": null, "attempts": 0, "published_at": null }, { "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c", "platform": "instagram", "status": "pending", "platform_post_id": null, "url": null, "error": null, "attempts": 0, "published_at": null } ], "created_at": "2026-09-24T14:02:11.418Z" } ``` 5. **Check its status.** `GET /v1/posts/{id}`. Publishing happens in the background, usually within seconds. Each target has its own `status`; `url` links to the live post and `error` says why a target failed. ```json { "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3", "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic", "media": [ { "url": "https://example.com/changelog.png", "type": "image" } ], "platform_options": {}, "status": "published", "scheduled_at": null, "targets": [ { "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "platform": "x", "status": "published", "platform_post_id": "1971234567890123456", "url": "https://x.com/acme/status/1971234567890123456", "error": null, "attempts": 1, "published_at": "2026-10-01T09:00:04.112Z" }, { "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f", "platform": "linkedin", "status": "published", "platform_post_id": "urn:li:share:7378123456789012345", "url": "https://www.linkedin.com/feed/update/urn:li:share:7378123456789012345", "error": null, "attempts": 1, "published_at": "2026-10-01T09:00:05.870Z" }, { "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c", "platform": "instagram", "status": "published", "platform_post_id": "18061234567890123", "url": "https://www.instagram.com/p/DPq7xYzAbCd/", "error": null, "attempts": 1, "published_at": "2026-10-01T09:00:09.304Z" } ], "created_at": "2026-09-24T14:02:11.418Z" } ``` ## Authentication Create keys in the dashboard; only a hash is stored. Send `Authorization: Bearer pp_live_…`. A missing, malformed or revoked key gets `401 unauthorized`. Keys belong to your postpeg account and can act on every profile in it, so keep them on your server. - **Live and test keys:** the `pp_live_` / `pp_test_` prefix records which environment the key is for, and `GET /v1/me` reports it. Both behave the same today: a test key publishes for real. Use them to keep development and production credentials apart. - **`GET /v1/me`:** the calling key, your plan (`account_limit`, `trial_ends_at`) and `usage.connected_accounts`. - **Rate limits** (per minute): every authenticated request 120 per API key (per user in the dashboard); writes (POST and DELETE: publish, schedule, connect, reply, send, cancel, create profile) 30 per key, counted on top of the general limit; requests without a key (`GET /v1/platforms`, the OpenAPI document and reference, account-connect pages) 60 per client IP; failed API-key attempts 30 per IP, then `429` for 60 seconds; the site’s free AI tools 10 per visitor plus a daily cap. Successful authenticated responses carry `RateLimit-Policy: 120;w=60` (`30;w=60` on writes). Over a limit: `429 rate_limited` problem JSON with `Retry-After: 60` and `retry_after` in the body; back off for that long, and retry `POST /v1/posts` with the same `Idempotency-Key`. Network-side limits on analytics, comments or messages return the same `429` shape. - **Trial:** 7 days, up to 3 connected accounts, every feature, no card. See Billing. ## Profiles and accounts - `POST /v1/profiles` `{ name (1–100), external_id? (1–200, unique) }` → `201`. `GET /v1/profiles`, `GET /v1/profiles/{id}`, `DELETE /v1/profiles/{id}` (`204`; disconnect its accounts first or get `409`). - `POST /v1/accounts/connect` `{ profile_id, platform, redirect_url }` → `{ url, expires_at }`. `platform` is one of `x`, `instagram`, `facebook`, `linkedin`, `tiktok`, `youtube`, `threads`, `pinterest`, `google_business`. On error the redirect carries `status=error&error=…`: `account_limit` (plan full), `connect_failed`, `account_not_found`, `missing_account`, or the network’s own reason. Fetch the account with `GET /v1/accounts/{account_id}` rather than trusting the query string. Connecting the same social account to the same profile again refreshes it and keeps its id. - `POST /v1/accounts/bluesky` `{ profile_id, handle, app_password }` → `201` with the account. The app password looks like `xxxx-xxxx-xxxx-xxxx` (Bluesky Settings → Privacy and security → App passwords); never the real password. ```json { "id": "acc_mfz21c8w3e6r0t4y2u9i5o1p7a", "profile_id": "prof_mfz0b7q2k9x4c1v8n3m6a0s5d2", "platform": "bluesky", "username": "acmecoffee.bsky.social", "display_name": "Acme Coffee", "avatar_url": null, "provider": "native", "status": "active", "connected_at": "2026-09-24T14:00:12.530Z" } ``` - `GET /v1/accounts`: connected accounts, newest first; filter with `profile_id` and `platform`. `provider` is informational. - Statuses: `active` (working), `reconnect_required` (the network rejected the saved login; new posts are refused with `400` and due posts fail; run the connect flow again and the account keeps its id), `disconnected` (not listed, doesn’t count to the plan). - `DELETE /v1/accounts/{id}` → `204`. Posts still scheduled for it fail with “The account was disconnected”. - Plan limits on connected accounts: 3 on the trial, 10 on Starter, 50 on Pro, unlimited on Scale (fair use 150). Going over returns `402 account_limit`. ## Publishing One request publishes or schedules a post to up to 50 accounts on any mix of networks. postpeg checks it against each network first, then publishes each account separately. `POST /v1/posts` fields: - `account_ids` (string[], 1–50, required): account ids from `GET /v1/accounts`; each must be `active`. - `content` (string): the text, counted in graphemes. Each network’s limit applies. - `media` (object[], up to 20): `{ url, type, alt_text? }`. `url` is a public https URL; `type` is `image` or `video`; `alt_text` up to 2,000 characters. - `scheduled_at` (ISO 8601 with offset): up to a year ahead. Leave out to publish now. - `platform_options` (object): per-network settings, keyed by network. Unknown keys are rejected. A post needs text, media, or both. Without `scheduled_at` (or with a past time) the post is `publishing` and targets `queued`; with a future time it is `scheduled` and targets wait as `pending`. Scheduled posts go out within a minute of their time. ### Validation Before anything is stored or sent, the post is checked against every target network: text length, required media, video-only networks, image and video counts, and required options. Every problem comes back at once in `issues`; nothing is published on any network until the request is valid. This response is computed by the same validator the API runs: ```json { "type": "https://postpeg.com/errors/invalid_request", "title": "Invalid request", "status": 400, "detail": "X: text is 304 characters, the limit is 280; TikTok: accepts video only; YouTube: accepts video only; YouTube: `platform_options.youtube.title` is required", "code": "invalid_request", "request_id": "8f14e45f-ceea-467a-9575-1f2a5c3e6b0d", "issues": [ { "path": "content", "message": "X: text is 304 characters, the limit is 280" }, { "path": "media", "message": "TikTok: accepts video only" }, { "path": "media", "message": "YouTube: accepts video only" }, { "path": "platform_options.youtube.title", "message": "YouTube: `platform_options.youtube.title` is required" } ] } ``` ### Platform options - `youtube.title` (1–100, required for YouTube), `youtube.privacy` (`public` | `unlisted` | `private`, default `public`) - `pinterest.board_id` (required for Pinterest), `pinterest.title` (up to 100), `pinterest.link` - `tiktok.privacy` (`public` | `friends` | `private`, default `public`) - `google_business.call_to_action` (`learn_more` | `book` | `order` | `shop` | `sign_up` | `call`), `google_business.link` ### Statuses Target statuses: `pending` (waiting for its time or a retry), `queued` (due), `publishing` (being sent; videos can stay here while the network processes them), `published` (`url` and `platform_post_id` set), `failed` (`error` says why), `canceled`. Post status is derived from its targets: `scheduled`, `publishing`, `published` (every target), `partially_published`, `failed` (none published), `canceled` (every target). ### Retries, cancelling, idempotency, listing - Failures that may pass (timeout, outage, rate limit) send the target back to `pending`: up to 5 attempts, waiting 2, 4, 8 and 16 minutes, or longer if the network asks. A refusal that won’t change fails straight away. If a publish is interrupted after the request may have reached the network, the target is marked `failed` rather than retried: a duplicate is worse than a missing post. - `DELETE /v1/posts/{id}` cancels every target that hasn’t started publishing and returns the post; `409` if nothing is left to cancel. - `Idempotency-Key` header (up to 255 characters) on `POST /v1/posts`: a repeat with the same key returns the original post with `200` instead of `201`, and nothing new is published. - `GET /v1/posts`: newest first, `limit` 1–100 (default 20), filter by `status`, paginate with `cursor` = the previous `next_cursor` until it is `null`. ## Analytics Available on X, Instagram, Facebook, LinkedIn, TikTok, YouTube and Pinterest. - `GET /v1/posts/{id}/analytics`: one entry per target with `status` `ready` (`metrics` filled, `updated_at` set), `syncing` (ask again later) or `unavailable` (`reason` says why). Cached 15 minutes per target (2 minutes while syncing). - Metrics: `impressions`, `reach`, `views`, `likes`, `comments`, `shares`, `saves`, `clicks`, `engagement_rate` (a percentage). A metric the network doesn’t report is `null`, never `0`. - `GET /v1/accounts/{id}/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD`: `followers`, `follower_growth`, `follower_growth_percent` and a daily `series`. Default range is the last 30 days, at most 366. A network without analytics returns `400`. ## Comments Available on Instagram, Facebook, LinkedIn and Threads, for posts published through postpeg. - `GET /v1/posts/{id}/comments`: top-level comments with inline replies; paginate with `cursor`. `author.is_owner` marks the connected account’s own comments. - `POST /v1/posts/{id}/comments` `{ text (1–8000), reply_to?, account_id? }` → `201`. Send an `Idempotency-Key` so a retry never posts twice. - `POST /v1/posts/{id}/comments/{comment_id}/hide` `{ hidden: true | false }` → `204`. Works on Instagram, Facebook and Threads; LinkedIn doesn’t support hiding. - If the post went to several accounts that support comments, pass `account_id` (query when listing, body when replying or hiding), or get a `400` listing the choices. Naming an account whose target hasn’t published returns `409`. ## Messages Available on Instagram accounts. - `GET /v1/accounts/{id}/conversations`: the account’s conversations; paginate with `cursor`. - `GET /v1/accounts/{id}/conversations/{conversation_id}/messages`: newest first, `direction` `incoming` or `outgoing`. Attachment URLs are signed and expire; don’t store them. - `POST /v1/accounts/{id}/conversations/{conversation_id}/messages` `{ text? (1–1000), attachment_url? }` → `201` `{ id, conversation_id }` (`id` may be `null`). Send an `Idempotency-Key`. - Instagram only allows a business to reply within 24 hours of the person’s last message; outside it you get `502 upstream_error` with Instagram’s reason. Messages are for replies only: no cold or bulk messages. ## Integrations Every integration calls this API with an API key; requests, statuses and errors are the same. ### MCP server Hosted at https://mcp.postpeg.com/mcp (Streamable HTTP). Send `Authorization: Bearer pp_live_…`. Same key, plan and rate limits as the REST API. Tools that act in public (`create_post`, `reply_to_comment`, `send_message`) tell the assistant to show the user exactly what will be sent and get an explicit go-ahead first; `create_post` without `scheduled_at` publishes immediately. Test keys publish for real, so use `check_post` (never publishes) to try it. **Claude Code.** Run in a terminal. Add `--scope user` to use it in every project. ```bash claude mcp add --transport http postpeg https://mcp.postpeg.com/mcp \ --header "Authorization: Bearer pp_live_your_key" ``` **Claude Desktop.** Settings → Developer → Edit Config, then add this to `claude_desktop_config.json` and restart Claude Desktop. ```json { "mcpServers": { "postpeg": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.postpeg.com/mcp", "--header", "Authorization:${POSTPEG_AUTH}" ], "env": { "POSTPEG_AUTH": "Bearer pp_live_your_key" } } } } ``` **Cursor.** Add to `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project). ```json { "mcpServers": { "postpeg": { "url": "https://mcp.postpeg.com/mcp", "headers": { "Authorization": "Bearer pp_live_your_key" } } } } ``` **VS Code.** Add to `.vscode/mcp.json` in your workspace, or run “MCP: Open User Configuration”. ```json { "servers": { "postpeg": { "type": "http", "url": "https://mcp.postpeg.com/mcp", "headers": { "Authorization": "Bearer pp_live_your_key" } } } } ``` | Tool | What it does | Changes anything? | | --- | --- | --- | | `list_platforms` | The ten networks, what each supports and its text limit. | Read only | | `get_me` | The calling key, your plan and connected accounts. | Read only | | `list_profiles` | Your profiles (brands or end users). | Read only | | `create_profile` | Create a profile to connect accounts to. | Changes your postpeg account | | `list_accounts` | Connected accounts and their status, filtered by profile or network. | Read only | | `connect_account` | A hosted OAuth link the account’s owner opens to connect it. | Changes your postpeg account | | `connect_bluesky` | Connect a Bluesky account with a handle and app password. | Changes your postpeg account | | `check_post` | Check a post against every target network’s rules without publishing. | Read only | | `create_post` | Publish now or schedule a post to one or more accounts. | Acts in public | | `get_post` | A post and each account’s status, link or error. | Read only | | `list_posts` | Recent posts, filtered by status. | Read only | | `cancel_post` | Cancel a scheduled post’s targets that haven’t started. | Acts in public | | `get_post_analytics` | Impressions, reach, likes and more per account. | Read only (asks the network) | | `get_account_analytics` | Followers and daily growth for an account. | Read only (asks the network) | | `list_comments` | Comments on a published post. | Read only (asks the network) | | `reply_to_comment` | Reply to a comment, in public. | Acts in public | | `hide_comment` | Hide or unhide a comment. | Acts in public | | `list_conversations` | Instagram DM conversations. | Read only (asks the network) | | `list_messages` | Messages in a conversation. | Read only (asks the network) | | `send_message` | Reply to an Instagram DM within the 24-hour window. | Acts in public | | `get_billing` | Your plan, subscription and the plans on offer. | Read only | ### Agent Skill A skill that teaches coding agents the API. Written, not yet published; no install command yet. ### No-code: n8n, Make, Zapier The native apps are built but not published. Each platform’s own HTTP step works today. Always send an `Idempotency-Key` built from something unique to the item, escape mapped text so the JSON stays valid, and don’t re-post failed targets (postpeg retries on its own). The body every guide builds: ```json { "account_ids": [ "acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f" ], "content": "New on the blog: how we cut build times in half https://example.com/blog/builds" } ``` ### n8n (https://postpeg.com/integrations/n8n) Native app: coming soon. Use **HTTP Request node** today (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/). The HTTP Request node is a core node in every n8n edition. You can also paste a curl command from our docs with Import cURL. Create a post: - Method: POST - URL: https://api.postpeg.com/v1/posts - Authentication: Generic Credential Type → Header Auth. Name: Authorization, Value: Bearer pp_live_your_key - Send Headers: On. Idempotency-Key = rss-{{ $json.guid || $json.link }} - Send Body: On. Body Content Type: JSON, Specify Body: Using JSON - JSON: The body below ``` { "account_ids": ["acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f"], "content": {{ JSON.stringify($json.title + " " + $json.link) }} } ``` Wrap mapped text in `JSON.stringify(…)` without quotes around it, as above, so quotes and line breaks in a title can’t break the JSON. List accounts: Method: GET; URL: https://api.postpeg.com/v1/accounts; Authentication: The same Header Auth credential; Query Parameters: platform = linkedin (optional). Paging: List endpoints return `{ data, next_cursor }`. Under Options → Pagination choose “Update a Parameter in Each Request”: Type Query, Name `cursor`, Value `{{ $response.body.next_cursor }}`, and stop when `{{ !$response.body.next_cursor }}` is true. Set a page limit so a loop can’t run away. Recipes: Publish every new RSS item to X and LinkedIn (RSS Feed Trigger: new item → HTTP Request: POST /v1/posts with the title and link); Schedule a Google Sheets row as a post (Google Sheets Trigger: row added → HTTP Request: POST /v1/posts with scheduled_at from the row → Google Sheets: write the post id back to the row); Post new Shopify products to Instagram (Shopify Trigger: product created → HTTP Request: POST /v1/posts with media [{ url, type: "image" }]); Alert Slack when a post fails (Schedule Trigger: every 15 minutes → HTTP Request: GET /v1/posts?status=failed → Remove Duplicates: skip ids seen in earlier runs → Slack: send each target’s error); Reply to comments with an AI step (Schedule Trigger: hourly → HTTP Request: GET /v1/posts/{id}/comments → AI Agent: draft a reply → Slack: approve, then HTTP Request: POST /v1/posts/{id}/comments). ### Make (https://postpeg.com/integrations/make) Native app: coming soon. Use **HTTP → Make a request** today (https://apps.make.com/http). HTTP is one of Make’s built-in apps. Create a post: - URL: https://api.postpeg.com/v1/posts - Method: POST - Headers: Authorization: Bearer pp_live_your_key; Idempotency-Key: (map the item’s ID or URL) - Body Content Type: application/json - Body Content: The body below - Parse Response: Yes ``` { "account_ids": ["acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f"], "content": {{2.json}} } ``` Put a JSON → Transform to JSON module (2) before the request with the text you want to post, and map its output without quotes around it, as above. Mapping raw text straight into the body breaks on the first double quote or line break. List accounts: URL: https://api.postpeg.com/v1/accounts; Method: GET; Headers: Authorization: Bearer pp_live_your_key; Parse Response: Yes. Paging: List endpoints return `{ data, next_cursor }`; map `data` into an Iterator. One request is one page (up to `limit=100` posts). For more, request again with `cursor` set to the previous `next_cursor` until it is empty. Recipes: Publish every new RSS item to X and LinkedIn (RSS: Watch RSS feed items → JSON: Transform to JSON → HTTP: Make a request); Schedule a Google Sheets row as a post (Google Sheets: Watch New Rows → HTTP: Make a request (POST /v1/posts) → Google Sheets: Update a Row with the post id); Post new Shopify products to Instagram (Shopify: Watch Products → HTTP: Make a request (POST /v1/posts with media)); Alert Slack when a post fails (Schedule: every 15 minutes → HTTP: Make a request (GET /v1/posts?status=failed) → Iterator over data, filtered to ids not in a data store → Slack: Create a Message); Reply to comments with an AI step (HTTP: GET /v1/posts/{id}/comments → OpenAI: draft a reply → Slack approval, then HTTP: POST /v1/posts/{id}/comments). ### Zapier (https://postpeg.com/integrations/zapier) Native app: coming soon. Use **Webhooks by Zapier → Custom Request** today (https://help.zapier.com/hc/en-us/articles/8496326446989-Send-webhooks-in-Zaps). Webhooks by Zapier is not on Zapier’s Free plan; it needs Professional, Team or Enterprise. Create a post: - Method: POST - URL: https://api.postpeg.com/v1/posts - Data: The body below - Headers: Authorization | Bearer pp_live_your_key; Content-Type | application/json; Idempotency-Key | (map the trigger item’s ID) ``` { "account_ids": ["acc_mfz1x0k27d4q8vbn3c5s0a1p9e", "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f"], "content": "(map the text here)" } ``` Custom Request sends Data as you type it, so a mapped value with a double quote or line break makes invalid JSON. If your text can contain them, build the body in a Code by Zapier step with `JSON.stringify` and map that output into Data. List accounts: Method: GET; URL: https://api.postpeg.com/v1/accounts; Headers: Authorization | Bearer pp_live_your_key. Paging: One request is one page (up to `limit=100` posts). To watch for new items, use the Webhooks by Zapier Retrieve Poll trigger: URL `/v1/posts?status=failed`, Key `data`, the same Authorization header; it deduplicates on `id`. Recipes: Publish every new RSS item to X and LinkedIn (RSS by Zapier: New Item in Feed → Webhooks by Zapier: Custom Request); Schedule a Google Sheets row as a post (Google Sheets: New Spreadsheet Row → Webhooks by Zapier: Custom Request (POST /v1/posts) → Google Sheets: Update Spreadsheet Row with the post id); Post new Shopify products to Instagram (Shopify: New Product → Webhooks by Zapier: Custom Request with media); Alert Slack when a post fails (Webhooks by Zapier: Retrieve Poll on /v1/posts?status=failed → Slack: Send Channel Message); Reply to comments with an AI step (Webhooks by Zapier: Retrieve Poll on /v1/posts/{id}/comments → ChatGPT: draft a reply → Human approval, then Custom Request: POST /v1/posts/{id}/comments). ## Errors Every error is JSON in the RFC 9457 problem details format, served as `application/problem+json`, with a stable `code` to branch on. ```json { "type": "https://postpeg.com/errors/invalid_request", "title": "Invalid request", "status": 400, "detail": "X: text is 312 characters, the limit is 280", "code": "invalid_request", "request_id": "8f14e45f-ceea-467a-9575-1f2a5c3e6b0d", "issues": [ { "path": "content", "message": "X: text is 312 characters, the limit is 280" } ] } ``` - `code` is stable and only ever gains new values. Branch on it, not on `detail`. - `detail` is written for a person: show it, log it, don’t parse it. - `issues` appears on `invalid_request` and lists every problem found, each with the `path` of the field. - `retry_after` appears on `rate_limited`, in seconds, matching the `Retry-After` header. - `request_id` matches the `X-Request-Id` response header. Send your own `X-Request-Id` to correlate with your logs. | Code | Status | What to do | | --- | --- | --- | | `invalid_request` | 400 | The body, query or a platform rule is wrong. Read `issues`: it lists every problem with its path. Fix the request; retrying it unchanged fails the same way. | | `unauthorized` | 401 | The key is missing, malformed or revoked. Send `Authorization: Bearer pp_live_…` with a key from the dashboard. | | `account_limit` | 402 | Connecting one more account would go over your plan. Disconnect an account or upgrade. | | `subscription_required` | 402 | The trial or subscription has ended. Only `GET /v1/me` and `/v1/billing` still work: start a checkout to continue. | | `forbidden` | 403 | The network refused because the account’s login has expired. The account is now `reconnect_required`: run the connect flow again. | | `not_found` | 404 | No such route, or no object with that id on your account (disconnected accounts count as gone). | | `conflict` | 409 | The object is in the wrong state for this request, for example cancelling a post that already went out. `detail` says which. | | `rate_limited` | 429 | Too many requests, from your key or on the network’s side. Wait `retry_after` seconds (also in the `Retry-After` header), then retry. | | `internal_error` | 500 | Something broke on our side. Retry with backoff, and quote `request_id` if it persists. | | `upstream_error` | 502 | The network failed or refused the request; `detail` carries its reason. Retry later if it didn’t respond; fix the request if it refused. | | `platform_unavailable` | 503 | That feature or network is not available on postpeg yet. Retrying won’t help until it is. | ### What to retry Retry `rate_limited` after `retry_after`, and `internal_error` and `upstream_error` with backoff when the network didn’t respond. Don’t retry `invalid_request`, `unauthorized`, `account_limit`, `subscription_required` or `platform_unavailable` unchanged. Send an `Idempotency-Key` on writes so a retry is always safe. Publishing itself retries failed targets in the background. ## Platforms The rules each network enforces, as postpeg checks them before publishing. Text is counted in characters as people see them (graphemes): an emoji or an accented letter is one. A post that breaks a rule is refused with `400` before anything is sent. | Network | Id | Max text | Media | Images | Videos | Required options | Connect with | | --- | --- | --- | --- | --- | --- | --- | --- | | X | `x` | 280 | Optional | 4 | 1 | None | OAuth | | Instagram | `instagram` | 2,200 | Required | 10 | 10 | None | OAuth | | Facebook | `facebook` | 63,206 | Optional | 10 | 1 | None | OAuth | | LinkedIn | `linkedin` | 3,000 | Optional | 20 | 1 | None | OAuth | | TikTok | `tiktok` | 2,200 | Video required | 0 | 1 | None | OAuth | | YouTube | `youtube` | 5,000 | Video required | 0 | 1 | `youtube.title` | OAuth | | Threads | `threads` | 500 | Optional | 20 | 20 | None | OAuth | | Pinterest | `pinterest` | 500 | Required | 1 | 1 | `pinterest.board_id` | OAuth | | Bluesky | `bluesky` | 300 | Optional | 4 | 0 | None | App password | | Google Business | `google_business` | 1,500 | Optional | 1 | 0 | None | OAuth | Images and video can be mixed in one post only on Instagram and Threads. Required options go in `platform_options`. ### Features | Network | Publish | Schedule | Analytics | Comments | Messages | | --- | --- | --- | --- | --- | --- | | X | Yes | Yes | Yes | No | No | | Instagram | Yes | Yes | Yes | Yes | Yes | | Facebook | Yes | Yes | Yes | Yes | No | | LinkedIn | Yes | Yes | Yes | Yes | No | | TikTok | Yes | Yes | Yes | No | No | | YouTube | Yes | Yes | Yes | No | No | | Threads | Yes | Yes | No | Yes | No | | Pinterest | Yes | Yes | Yes | No | No | | Bluesky | Yes | Yes | No | No | No | | Google Business | Yes | Yes | No | No | No | Every network also supports cancelling a scheduled post. Calling a feature a network doesn’t have returns a `400` naming it. `GET /v1/platforms` returns the same data as JSON with no key needed: `name`, `capabilities`, `max_text`, `media_required` and `connect` (`oauth` or `app_password`). ## Billing and plans Plans are priced by connected accounts, counted across all profiles. | Plan | Price | Connected accounts | Includes | | --- | --- | --- | --- | | Trial | free, 7 days | 3 | Every feature, no card | | Starter | $29 per month | 10 connected accounts | Unlimited posts, scheduling, analytics, comments and DMs | | Pro | $79 per month | 50 connected accounts | Unlimited posts, scheduling, analytics, comments and DMs | | Scale | $199 per month | Unlimited connected accounts (fair use: up to 150 accounts) | Unlimited posts, scheduling, analytics, comments and DMs | When the trial ends without a plan, every endpoint answers `402 subscription_required` except `GET /v1/me` and the billing endpoints. Connected accounts are kept for 3 days. A post scheduled for after the trial ends is not published if no plan is active when it’s due. - `GET /v1/billing`: your plan, the subscription state and the plans on offer (`account_limit` for Scale is the fair-use cap). - `POST /v1/billing/checkout` with `{ "plan": "starter" | "pro" | "scale", "success_url"? }`: returns `{ url, expires_at }` for a hosted checkout. With a subscription already active it returns `409`; change plan in the portal. - `POST /v1/billing/portal` with an optional `{ "return_url" }`: returns `{ url }` for the billing portal (change plan, card, invoices, cancel). Before the first checkout it returns `409`. ## Capabilities at a glance - **Publishing:** Post text, images and video to any mix of accounts in one request, checked against each network’s rules before anything is sent. (https://postpeg.com/social-media-posting-api) - **Scheduling:** Pass scheduled_at up to a year ahead. Posts go out within a minute of their time, and can be cancelled until they do. (https://postpeg.com/social-media-scheduling-api) - **Analytics:** Impressions, reach, likes, shares and follower growth per post and per account, with null where a network doesn’t report a number. (https://postpeg.com/social-media-analytics-api) - **Comments:** List the comments on posts you published, reply to them, and hide the ones you don’t want shown. (https://postpeg.com/social-media-comments-api) - **Messages:** Read direct-message conversations and reply to people who wrote first, within the 24-hour window. (https://postpeg.com/instagram-dm-api)