# Ampost API and MCP Reference This is the canonical LLM-readable documentation for Ampost, served at `/llms.txt` (the same text is at `/llms-full.txt`). It is generated from the same schemas as the API, so the shapes below are exact. The machine-readable contract is the OpenAPI 3.1 document at `/openapi.json`. Ampost is a unified social media publishing API for developers, SaaS tools and AI agents. It supports browser-assisted social-account connections, draft, scheduled and immediate posts, per-platform results, and an MCP server whose tools are the same operations as the REST API. ## Service Metadata - Base URL: `https://api.ampost.io` - Dashboard/UI host: `https://ampost.io` - REST API paths: `/api/platforms`, `/api/posts`, `/api/media/uploads`, `/api/accounts`, `/api/platform-sets`, `/api/analytics`, `/api/webhooks/endpoints`, `/api/docs/search`, `/api/feedback` (no version segment; Ampost is pre-production and changes the API in place) - MCP endpoint: `https://api.ampost.io/mcp` (Streamable HTTP) - Docs MCP endpoint (no key): `https://api.ampost.io/mcp/docs` (see Docs Search) - OpenAPI: `https://api.ampost.io/openapi.json` - This reference: `https://ampost.io/llms.txt` (also on `api.ampost.io`) - Publishing status and reliability: `https://ampost.io/status` - Auth for REST and MCP: `Authorization: Bearer amp_live_...` - Content type: `application/json` (`multipart/form-data` for file uploads) - Support: `support@ampost.io` ## Key Scopes Each operation needs one scope, shown next to it below (`docs_search` and `feedback_submit` need none); a key without it gets 403 `INSUFFICIENT_SCOPE` with `details.requiredScope`. MCP `tools/list` shows only the tools the key may call. Keys are created in the dashboard (`https://ampost.io/app/settings/api-keys`) as Full access (every scope), Read only (`posts:read`, `accounts:read`, `analytics:read`) or custom scopes. - `posts:read`: List and read posts and posting queues, and read platform rules - `posts:write`: Create, change, publish, retry, delete and import posts; edit posting queues - `accounts:read`: List and read accounts, platform sets and connection requests - `accounts:write`: Connect and disconnect accounts; create, rename and delete platform sets - `media:write`: Upload media for posts - `analytics:read`: Read post metrics, account overviews and follower history - `webhooks:write`: Create, change, test and delete webhooks, read their delivery logs, and redeliver events A key can also be restricted to some platform sets. Lists then show only those sets, their accounts and posts with an account in them (with only those accounts' results); an id from another set answers 404. Creating a post, connecting an account or changing a set outside the restriction (the default set included, when no set is named) answers 403 `PLATFORM_SET_NOT_ALLOWED`, and a restricted key cannot create platform sets. ## Plan Requirement The REST API and MCP are on every plan (Free, Pro, Max, Scale), Free included, with no credit card, so an agent can try Ampost right away. Free includes 3 social accounts, 50 posts a month, posts to several platforms at once and 60 read requests a minute; scheduling needs the Pro plan or higher. On Free a social account can be connected in one Ampost workspace only (also for 30 days after it is disconnected), and its 50 posts a month count per social account, whichever workspace publishes. A request outside the plan gets `403 UPGRADE_REQUIRED` (a feature) or `429 QUOTA_EXCEEDED` (the monthly posts), and a Free connect of a social account another workspace has gets `SOCIAL_ACCOUNT_IN_USE`. Plans: `https://ampost.io/pricing`. | | Free | Pro | Max | Scale | |---|---|---|---|---| | Price (monthly / yearly) | $0 | $29 / $290 | $49 / $490 | $99 / $990 | | Social accounts | 3 social accounts | 10 social accounts | 25 social accounts | 100 social accounts included, then $1 per account / month | | Posts a month | 50 | unlimited | unlimited | unlimited | | Multi-platform posts | yes | yes | yes | yes | | Scheduling | no | yes | yes | yes | | REST API and MCP | yes | yes | yes | yes | | X | no | no | yes | yes | ## Install for an Agent You are an agent asked to set up Ampost for your user. Warning: Config files are not shell scripts. If your client does not expand `${AMPOST_API_KEY}`, paste the key itself. Some clients (`npx` arguments, some Windows apps) do not expand variables: the header then arrives empty, and the client opens Ampost's sign-in page instead of using the key. 1. The user needs an Ampost account; the Free plan is enough to start (see Plan Requirement). 2. The user creates an API key in the dashboard: `https://ampost.io/app/settings/api-keys`. The key is shown once, and that page then shows the install commands below with the key filled in, so the user can run them without pasting the key into a chat. Never ask for the user's Ampost password or social media passwords; the API key (or the client's own sign-in, step 3) is the only credential, and social accounts are connected later in the browser (`accounts_connect`). 3. Add the server with the command for the client you run in (next section; the same guide for humans is `https://ampost.io/docs/mcp/install`): Claude Code `claude mcp add --transport http` (also when you run in the Code tab of the Claude desktop app or a Claude Code IDE extension; there `claude` may not be on PATH, see the Claude Code section), Cursor and VS Code a one-click link or `mcp.json`, Codex `codex mcp add --bearer-token-env-var`, chat in Claude (claude.ai, the desktop and mobile apps) and ChatGPT a custom connector with the server URL only: the user signs in with their Ampost account and picks the access, so step 2 is not needed there. 4. Most clients load new servers only after a restart or a new session. Then call `platforms_list` to check the connection; see If It Fails below. ## One Model for REST and MCP - Every REST endpoint is one MCP tool with the same arguments (path parameters and body or query fields in one object), the same result (`structuredContent`) and the same errors. - A single resource is returned as a plain object. A list is `{ "data": [...], "pagination": { "limit", "offset", "total", "hasMore" } }`; every list accepts `limit` (1-100, default 20) and `offset` (default 0). - An error is `{ "error": { "code", "message", "details" } }` with the HTTP status on REST and `isError: true` on MCP. Branch on `code`. - Ids are UUIDs. A malformed id is `400 VALIDATION_FAILED` with the field in `details.issues[].path`; a well-formed id that does not exist or is not yours is `404` with a `*_NOT_FOUND` code. - Unknown fields are rejected with `VALIDATION_FAILED`. - Every REST and MCP HTTP response carries `X-Request-Id`. Send your own (8-128 of `A-Z a-z 0-9 . _ : -`) to have it echoed; otherwise Ampost generates one. Quote it when you contact support. ## Idempotency `POST /api/posts` (`posts_create`), `POST /api/posts/bulk` (`posts_import_csv`), `POST /api/posts/{postId}/publish` (`posts_publish`), `POST /api/posts/{postId}/retry` (`posts_retry`), `POST /api/accounts/connect` (`accounts_connect`), `POST /api/platform-sets` (`platform_sets_create`), `POST /api/webhooks/endpoints` (`webhooks_create`) and `POST /api/feedback` (`feedback_submit`) accept an idempotency key: the `Idempotency-Key` header on REST, the `idempotencyKey` argument on MCP. Send a new key (a UUID) with every create, publish, retry and connect, and send the same key again when you retry that call after a timeout or network error. Without a key, a retried `posts_create` with `publishNow: true` creates and publishes a second post. - A retry with the same key and the same request within 24 hours returns the first response (same status and body) without running again; REST adds `Idempotent-Replayed: true`. - The same key with a different request (other operation, path or body): 409 `IDEMPOTENCY_KEY_REUSED`. Use a new key for a new action. - The same key while the first request is still running: 409 `IDEMPOTENCY_REQUEST_IN_PROGRESS`. Wait a few seconds and retry with the same key. - Only successes are kept. After an error (for example `VALIDATION_FAILED` or `RATE_LIMITED`) the key is free again, so fix the request and retry with the same key. - Keys belong to the Ampost user, so all of the user's API keys share them. A key is 1-255 visible ASCII characters. ## Platforms Platform differences are data. Call `GET /api/platforms` (MCP `platforms_list`) for each platform's limits, media rules, placements by media shape and the JSON Schema of its `options`. Summary: | Platform | Key | Max text | Media required | Media | Link | Placements | Required media fields | Pixel rules | Options | |---|---|---:|---|---|---|---|---|---|---| | Instagram | `instagram` | 2200 | yes | image, video (max 10) | ignored | feed, story, reel, carousel | – | – | text, firstComment, placement | | Facebook | `facebook` | 63206 | no | image, video (max 10) | yes | feed, story, reel | – | video (reel) 9:16 | text, firstComment, placement | | LinkedIn | `linkedin` | 3000 | no | image, video (max 20) | yes | – | – | – | text, firstComment, visibility, isReshareDisabledByAuthor, articleTitle, articleDescription, videoTitle | | TikTok | `tiktok` | 2200 | yes | image, video (max 35) | ignored | – | image.width, image.height, video.width, video.height, video.durationSeconds | image ≤ 1080x1080 px; video ≥ 360x360, ≤ 4096x4096 px | text, privacyLevel, disableComment, disableDuet, disableStitch, videoCoverTimestampMs, photoCoverIndex, autoAddMusic, brandContentToggle, brandOrganicToggle, isAigc | | Threads | `threads` | 500 | no | image, video (max 20) | yes | – | – | – | text, firstComment | | X | `twitter` | 280 | no | image, video (max 4) | yes | – | – | – | text, firstComment | | Discord | `discord` | 2000 | no | image, video (max 10) | yes | – | – | – | text | | Telegram | `telegram` | 4096 | no | image, video (max 10) | yes | – | – | – | text | | Bluesky | `bluesky` | 300 | no | image (max 4) | yes | – | – | – | text, firstComment | | YouTube | `youtube` | 5000 | yes | video (max 1) | yes | – | – | – | text, firstComment, title, privacyStatus, madeForKids, tags, categoryId, playlistId, notifySubscribers, containsSyntheticMedia | | Pinterest | `pinterest` | 800 | yes | image, video (max 5) | yes | – | – | – | text, boardId, title | ### Instagram - To post to Instagram, name `instagram` in `targets[].platforms`; its settings go in `options.instagram`. - Text up to 2200 characters. Links: `linkUrl` ignored. Scheduling: yes. - First comment: up to 2200 characters. - Media: required; image and video, up to 10 items. - Placements: `feed`, `story`, `reel`, `carousel` (`options.instagram.placement`). - Options: `text`, `firstComment`, `placement`. ### Facebook - To post to Facebook, name `facebook` in `targets[].platforms`; its settings go in `options.facebook`. - Text up to 63206 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 8000 characters. - Media: optional; image and video, up to 10 items, not mixed. - Placements: `feed`, `story`, `reel` (`options.facebook.placement`). - Pixel rules: video (reel) 9:16. - Options: `text`, `firstComment`, `placement`. - Facebook reels need one 9:16 video between 4 and 60 seconds. ### LinkedIn - To post to LinkedIn, name `linkedin` in `targets[].platforms`; its settings go in `options.linkedin`. - Text up to 3000 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 1250 characters. - Media: optional; image and video, up to 20 items, not mixed. - Options: `text`, `firstComment`, `visibility`, `isReshareDisabledByAuthor`, `articleTitle`, `articleDescription`, `videoTitle`. - LinkedIn is member-profile publishing only: no organization or Page posting or analytics. The first comment is posted as the member. ### TikTok - To post to TikTok, name `tiktok` in `targets[].platforms`; its settings go in `options.tiktok`. - Text up to 2200 characters. Links: `linkUrl` ignored. Scheduling: yes. - First comment: not supported. - Media: required; image and video, up to 35 items, not mixed. Required fields: `image.width`, `image.height`, `video.width`, `video.height`, `video.durationSeconds`. - Pixel rules: image ≤ 1080x1080 px; video ≥ 360x360, ≤ 4096x4096 px. - Options: `text`, `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `videoCoverTimestampMs`, `photoCoverIndex`, `autoAddMusic`, `brandContentToggle`, `brandOrganicToggle`, `isAigc`. - TikTok publishes only with the privacy levels the creator allows; they are in the TikTok account's `platformMetadata`. Private-only is a normal state while a TikTok app is in review. ### Threads - To post to Threads, name `threads` in `targets[].platforms`; its settings go in `options.threads`. - Text up to 500 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 500 characters. - Media: optional; image and video, up to 20 items. - Options: `text`, `firstComment`. ### X - To post to X, name `twitter` in `targets[].platforms`; its settings go in `options.twitter`. - Text up to 280 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 280 characters. - Media: optional; image and video, up to 4 items, not mixed. - Options: `text`, `firstComment`. - X (`twitter`) is on the Max and Scale plans only. Each X post uses units of a monthly X allowance: 1 for a post, 13 for a post with a link (bare domains count), plus 1 per image or video; there is also an hourly X post limit. When the allowance is used up, X posts fail with `QUOTA_EXCEEDED` until it resets; other platforms are unaffected. X counts every link as 23 characters. ### Discord - To post to Discord, name `discord` in `targets[].platforms`; its settings go in `options.discord`. - Text up to 2000 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: not supported. - Media: optional; image and video, up to 10 items. - Options: `text`. - Discord posts to one channel through a webhook: text up to 2000 characters and up to 10 attachments of 10 MB each. Mentions in the text never ping anyone. ### Telegram - To post to Telegram, name `telegram` in `targets[].platforms`; its settings go in `options.telegram`. - Text up to 4096 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: not supported. - Media: optional; image and video, up to 10 items. - Options: `text`. - Telegram posts as the user's own bot in a channel or group. Text up to 4096 characters; with media the caption holds 1024, and longer text follows the media as a second message. Up to 10 photos or videos as an album. ### Bluesky - To post to Bluesky, name `bluesky` in `targets[].platforms`; its settings go in `options.bluesky`. - Text up to 300 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 300 characters. - Media: optional; image, up to 4 items, not mixed. - Options: `text`, `firstComment`. - Bluesky turns `linkUrl` into a link card (with images, the link goes into the text instead). Text is limited to 300 characters; links, #tags and @handles become clickable. Images only, up to 4, each under 1 MB. ### YouTube - To post to YouTube, name `youtube` in `targets[].platforms`; its settings go in `options.youtube`. - Text up to 5000 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: up to 10000 characters. - Media: required; video, up to 1 items, not mixed. - Options: `text`, `firstComment`, `title`, `privacyStatus`, `madeForKids`, `tags`, `categoryId`, `playlistId`, `notifySubscribers`, `containsSyntheticMedia`. - YouTube takes exactly one video per post; Shorts are vertical videos up to 3 minutes through the same upload. The text plus `linkUrl` is the description (5,000 bytes, no `<` or `>`); `options.youtube.title` is the title (100 characters; without it the text's first line is used). `options.youtube.madeForKids` (true or false) is required. Optional: `privacyStatus` (`public`, `unlisted`, `private`; default `public`), `tags`, `categoryId`, `playlistId`, `notifySubscribers`, `containsSyntheticMedia`, and a custom thumbnail from `media[0].thumbnailUrl` (verified channels only). A refused thumbnail or playlist add leaves the video up and shows in `platformPosts[].warnings`. Until Google audits Ampost's API project, YouTube makes every upload private (also a warning). YouTube processes the video after the upload, so the account stays `processing` until it is ready. Uploads share one Google quota across Ampost: when it runs out, the account waits with `RATE_LIMITED` until midnight Pacific Time. ### Pinterest - To post to Pinterest, name `pinterest` in `targets[].platforms`; its settings go in `options.pinterest`. - Text up to 800 characters. Links: `linkUrl` supported. Scheduling: yes. - First comment: not supported. - Media: required; image and video, up to 5 items, not mixed. - Options: `text`, `boardId`, `title`. - Pinterest needs an image or a video: one image, a carousel of 2-5 images, or one video. The text is the Pin description (800 characters), `options.pinterest.title` the title (100), `linkUrl` the Pin's link and `media[].altText` the alt text (500). A Pin goes to the account's default board (`platformMetadata.boardId`), or to `options.pinterest.boardId`; list boards with accounts_list_boards and pick the default with accounts_set_board. Without either, the post fails. A video Pin stays `processing` until Pinterest has processed the video. Pinterest has no first comment. ## Posts A post has top-level `text`, `linkUrl` and `media` for every platform, and `options.` for per-platform changes. Every platform's options accept `text` (a text override); Instagram and Facebook add `placement`; LinkedIn and TikTok add their settings. - `firstComment` is text Ampost posts on each new post right after it publishes: a comment on Instagram, Facebook (as the Page), LinkedIn and YouTube, a reply on X, Threads and Bluesky. TikTok, Telegram, Discord and Pinterest take none (`VALIDATION_FAILED`); `firstComment.supported` and `firstComment.maxLength` in `GET /api/platforms` give each platform's rule. A top-level `firstComment` goes to every target platform, so every one must support it; `options..firstComment` sets it for one platform, and `""` turns it off there. Instagram and Facebook stories take no comments. An X reply is an X post and counts toward X usage like one. A post still processing gets its comment once it publishes. Each account's result is `platformPosts[].firstComment` (`status` `pending`, `published` or `failed`, `commentId`, `error`). A failed comment never fails the post: it stays `published`, and `posts_retry` (for that account, or without `accountIds`) posts only the comment again. `PERMISSION_MISSING` means the account was connected before Ampost asked for the comment permission; it must reconnect. - Without `placement`, Instagram uses `feed` for one image, `carousel` for 2+ images and `reel` for one video; Facebook uses `feed`. A `story` disappears after 24 hours, so send it only on purpose. The defaults are `placements.defaultByMediaShape` in `GET /api/platforms`. - `media[]` items are `{ "url", "type": "image" | "video", ...metadata }`. `type` is required and never guessed. `url` must be a public `https` URL: `http`, credentials, `localhost`, private, link-local and internal hosts fail with `VALIDATION_FAILED`. A URL Ampost cannot download (also after up to 3 checked redirects, or when it resolves to a private address) fails with `MEDIA_FETCH_FAILED`. Optional metadata: `mimeType` (inferred from the URL when omitted), `sizeBytes`, `width`, `height`, `durationSeconds`, `altText`, `thumbnailUrl`. - Without `scheduledFor` a new post is a `draft`. `publishNow: true` publishes it in the same call; `scheduledFor` (a future ISO 8601 time) schedules it; `useQueue: true` schedules it in the next free slot of its platform set's queue (see Posting Queues). Scheduled media is copied to Ampost storage when the post is created. - `targets` chooses which connected accounts publish: `[{ "platformSetId", "platforms" }]`, one entry per platform set. Omit `platformSetId` to use your default set. Each platform must have an active account in its set. A set holds one account per platform, so to post to several accounts of the same platform (two LinkedIn accounts, several clients' Instagram) list each account's set; it is still one post. - `platformPosts[]` has one result per account: `platform`, `accountId`, `platformSetId`, `status`, `publishedUrl`, `text` (the text live there after `posts_edit_published`, else null), `error`, `attempts`, `nextAttemptAt`, `firstComment` and `warnings` (what the platform did differently from what was asked while the post still went out, for example a YouTube thumbnail it refused or an upload it made private). Platform errors are stored there and do not fail the request. Ampost retries retryable failures by itself (see Automatic Retries and Platform Rate Limits); `posts_retry` retries the `failed` accounts and failed first comments, or only the `accountIds` you pass. - `posts_unpublish` deletes a published post from the platforms (every account where it is published, or only the `accountIds` you pass). Each deleted account turns `deleted`; an account the platform refused stays `published` with `error` (`UNPUBLISH_NOT_SUPPORTED` on Instagram and TikTok, which have no delete API; `PERMISSION_MISSING` for a Threads account connected before Ampost asked for `threads_delete`, which must reconnect; Telegram refuses messages older than 48 hours). The post turns `deleted` when no account has it. `unpublish.supported` in `GET /api/platforms` says which platforms can delete. - `posts_edit_published` replaces the text of a published post on the platforms (every account where it is published, or only the `accountIds` you pass); media and links stay. Each edited account's `platformPosts[].text` holds the new text (null means the post's text for that platform); an account the platform refused keeps its text with `error` (`EDIT_NOT_SUPPORTED` on Instagram, Threads, TikTok, Bluesky and X, and for Facebook stories). Facebook edits only posts Ampost published; a Telegram caption under media stays limited to 1024 characters. YouTube replaces the video description (the title stays) and Pinterest the Pin description; Pinterest keeps Pin edits in beta, so an app without access gets `EDIT_NOT_SUPPORTED`. `editPublished.supported` in `GET /api/platforms` says which platforms can edit. The post's own `text` and status stay as they were. - `metadata` is your own JSON object (at most 4096 bytes), such as your ids, set with `posts_create`. Ampost returns it on the post and in every webhook post event (see Webhooks). - `source` is where a post was created: `api` (REST with an API key), `mcp` (an MCP tool) or `dashboard`; null for posts created before sources were recorded. Ampost sets it; requests cannot. - Post statuses: `draft`, `scheduled`, `queued`, `posting`, `published`, `partial`, `failed`, `discarded`, `deleted` (deleted from every platform). Platform statuses: `draft` (the post is a draft; nothing publishes until you publish or schedule it), `pending` (waiting for its scheduled time, its turn in the queue, or an automatic retry at `nextAttemptAt`), `uploading`, `processing`, `publishing`, `published`, `failed`, `cancelled`, `deleted` (deleted from the platform with `posts_unpublish`). - `posts_update` merges `options` per platform: `options.facebook` replaces Facebook's options as a whole (send `text` again if you change `placement`), `null` removes them, and platforms you leave out keep theirs. `media` and `targets` replace the whole list. `options.` applies to every account of that platform. - A created post counts once against the monthly `postsPerMonth` quota, when it is created, whatever publishes it later. `publishNow` also takes from the hourly `publishPost` limit, like `posts_publish`. - `posts_import_csv` (`POST /api/posts/bulk`) creates one post per CSV row, each with the same checks as `posts_create`. Columns: `text`, `platforms` (or `accountIds`), `platformSetId`, `scheduledFor`, `useQueue`, `linkUrl`, `media` and `text_`; the dashboard's CSV export writes the same columns, so an exported file imports again. Call it with `dryRun: true` first: nothing is created and `rows[]` gives each row's `status` and `errors` (each with its `column`). Then import with an idempotency key; a bad row never stops the others, and resending the key returns the first result. A file with more rows than the plan allows fails with 400 `TOO_MANY_ROWS`. An import and a dry run each also take one token per row from the hourly `importPosts` limit, two runs of the plan's largest file an hour (e.g. a dry run and its import); a file over what is left fails with 429 `RATE_LIMITED` and creates nothing. More create examples: Text-only draft: ```json { "targets": [ { "platforms": [ "threads" ] } ], "text": "Hello from the Ampost API!" } ``` Scheduled with a link: ```json { "targets": [ { "platforms": [ "facebook", "threads" ] } ], "text": "Webinar starts in one hour.", "linkUrl": "https://example.com/webinar", "scheduledFor": "2026-10-08T15:00:00Z" } ``` TikTok video (TikTok needs width, height and durationSeconds on videos): ```json { "targets": [ { "platforms": [ "tiktok" ] } ], "text": "Behind the scenes #buildinpublic", "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21 } ], "options": { "tiktok": { "privacyLevel": "SELF_ONLY", "disableDuet": true, "isAigc": false } } } ``` Instagram story and Facebook feed placement: ```json { "targets": [ { "platforms": [ "instagram", "facebook" ] } ], "text": "New drop today", "media": [ { "url": "https://cdn.example.com/drop.jpg", "type": "image" } ], "options": { "instagram": { "placement": "story" }, "facebook": { "placement": "feed" } } } ``` The same LinkedIn post on two accounts (one per platform set), in one call: ```json { "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "linkedin" ] }, { "platformSetId": "9a4f2c7e-1d3b-4a6e-8c5f-2b7d9e1a3c50", "platforms": [ "linkedin" ] } ], "text": "We are hiring two engineers. Apply by Friday.", "linkUrl": "https://example.com/jobs", "publishNow": true } ``` A post whose TikTok publish failed: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "failed", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "tiktok" ] } ], "platforms": [ "tiktok" ], "text": "Behind the scenes #buildinpublic", "firstComment": null, "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "tiktok": { "privacyLevel": "PUBLIC_TO_EVERYONE" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "mcp", "metadata": null, "platformPosts": [ { "platform": "tiktok", "accountId": "8d3b6f1e-2c4a-4e7b-9f5d-1a6c8e3b2d70", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "failed", "platformPostId": null, "publishedUrl": null, "text": null, "error": { "code": "PRIVACY_LEVEL_UNAVAILABLE", "message": "TikTok privacy level \"PUBLIC_TO_EVERYONE\" is not available for this creator", "retryable": false, "platformCode": null, "platformSubcode": null }, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Automatic Retries and Platform Rate Limits - When a publish fails with `error.retryable: true` (the platform's rate limit, a timeout, a network error or a 5xx), Ampost publishes again by itself: up to 5 attempts in all, with exponential backoff and jitter (at most 1, 2, 4 and 8 minutes), and never before the platform's `Retry-After` or rate-limit reset. A platform that asks for a wait longer than a day fails the account at once. - While an account waits, its `platformPosts[]` entry stays `pending` with the last `error`, `attempts` (attempts so far) and `nextAttemptAt`; the post is `posting`. No webhook fires for a retry. After the last attempt the account turns `failed` and `post.failed` or `post.partial` fires as usual. A failure that does not pass on its own (`retryable: false`, such as `AUTH_EXPIRED` or invalid media) fails at once. - No duplicates: when the platform stopped answering after it got the publish (a timeout, a 5xx), Ampost looks for the post on the account before the next attempt, and publishes again only if it is not there. Instagram, Facebook feed posts, Threads, Bluesky, YouTube and Pinterest can be checked. On other platforms (Facebook stories and reels, LinkedIn, TikTok, X, Telegram, Discord) such an account fails with `PUBLISH_OUTCOME_UNKNOWN` instead: check the account, then call `posts_retry` only if the post is missing. - Ampost also spreads publishes per account and per platform app, inside each platform's limits (Meta's per-account publishing limits, X's posting window, LinkedIn's and TikTok's daily caps). A burst of posts due at once publishes in turn: an account held back waits `pending` with `nextAttemptAt` and is never `failed` for it, and it does not use an attempt. - `posts_retry` is for `failed` accounts. It starts `attempts` again from 0. - Live per-platform publishing status (from real publish outcomes, aggregates only) and a plain explanation of retries, queueing and duplicates: `https://ampost.io/status`. A post waiting for an automatic retry: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "posting", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram" ] } ], "platforms": [ "instagram" ], "text": "New drop at 9am", "firstComment": null, "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": {}, "scheduledFor": "2026-10-06T09:00:00.000Z", "queuePlatformSetId": null, "source": "api", "metadata": null, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "pending", "platformPostId": null, "publishedUrl": null, "text": null, "error": { "code": "RATE_LIMITED", "message": "Instagram rate limit reached: Application request limit reached", "retryable": true, "platformCode": 4, "platformSubcode": null }, "warnings": [], "attempts": 2, "nextAttemptAt": "2026-10-06T09:02:04.000Z", "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### List platforms and their capabilities `GET /api/platforms` · MCP tool `platforms_list` · scope `posts:read` · rate limit `apiRead` · success `200` List every supported platform with its rules: text length, link support, scheduling, unpublish (delete a published post), editPublished (edit a published post's text), firstComment (comment posted after publish, and its length), media types, counts, sizes and required metadata, placements by media shape, and the JSON Schema of its `options`. Read this instead of hardcoding platform differences. | Field | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | ### List posts `GET /api/posts` · MCP tool `posts_list` · scope `posts:read` · rate limit `apiRead` · success `200` List your posts, newest first, optionally filtered by status, platform, platform set (posts with an account in it) or text. | Field | In | Type | Required | Description | |---|---|---|---|---| | `status` | query | `draft` \| `scheduled` \| `queued` \| `posting` \| `published` \| `partial` \| `failed` \| `discarded` \| `deleted` | no | | | `platform` | query | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | | | `platformSetId` | query | string | no | | | `search` | query | string | no | Case-insensitive match on the post text | | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [], "pagination": { "limit": 20, "offset": 0, "total": 0, "hasMore": false } } ``` ### Create, schedule or publish a post `POST /api/posts` · MCP tool `posts_create` · scope `posts:write` · rate limit `createPost` · success `201` Create a post for one or more platforms. `targets` lists platform sets, each with its platforms; omit `platformSetId` for your default set. List several sets to reach several accounts of the same platform in one post (each set holds one account per platform); `platformPosts` then has one entry per account. Without `scheduledFor` it is a draft; set `publishNow` to publish it right away, or `scheduledFor` to publish later. `media` items are public URLs with a required `type` (REST also accepts multipart file uploads up to 4 MB per request; for larger files or files on the user's device use media_upload_create). Use `options.` for per-platform text and settings. `firstComment` is posted as a comment (a reply on X, Threads and Bluesky) on each new post right after it publishes; a failed comment leaves the post published with `platformPosts[].firstComment.status` failed. Call platforms_list (GET /api/platforms) for each platform's limits, placements and options. | Field | In | Type | Required | Description | |---|---|---|---|---| | `targets` | body | object[] | yes | Where the post goes: one entry per platform set (max 10). A set holds one account per platform, so to reach several accounts of one platform (two LinkedIn accounts, several clients' Instagram) list their sets. The post gets one platformPosts entry per account. | | `text` | body | string | no | | | `firstComment` | body | string \| null | no | | | `linkUrl` | body | string \| null | no | | | `media` | body | object[] | no | | | `options` | body | object | no | | | `scheduledFor` | body | string \| null | no | | | `publishNow` | body | boolean | no | Publish right after creating. Cannot be combined with `scheduledFor`. | | `useQueue` | body | boolean | no | Schedule the post in the next free slot of its platform set's queue (see queue_get) instead of at `scheduledFor`. Needs exactly one target and a queue with slots. Cannot be combined with `scheduledFor` or `publishNow`. | | `metadata` | body | object | no | | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21 } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "publishNow": true, "metadata": { "campaignId": "launch-2026" } } ``` Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Import posts from CSV `POST /api/posts/bulk` · MCP tool `posts_import_csv` · scope `posts:write` · rate limit `createPost` · success `200` Create many posts from a CSV file: one post per row, each checked like posts_create. Columns: `text`, `platforms` (space-separated, with an optional `platformSetId`) or `accountIds`, `scheduledFor` or `useQueue` (neither: a draft), `linkUrl`, `media` (space-separated public URLs; `.mp4`, `.m4v`, `.mov` and `.webm` are videos, the rest images) and `text_` for per-platform text. The dashboard's CSV export has the same columns, so an exported file imports again (its read-only columns are skipped). Send `dryRun: true` first: it checks every row without creating anything. A bad row does not stop the others; `rows[]` gives each row's `status` (`valid`, `created` or `error`) and `errors` with the column. Unknown columns are skipped with a warning. Plans import at most 50 (Free), 1,000 (Pro), 2,500 (Max) or 5,000 (Scale) rows per request. An import and a dry run each take one token per row from the hourly `importPosts` limit (two runs of the plan's largest file an hour, e.g. a dry run and its import); a file with more rows than are left fails with 429 `RATE_LIMITED` and creates nothing. Send an idempotency key with the import: a retry with the same key and file returns the first result and creates nothing new. REST also accepts multipart with the file in a part named `file`. | Field | In | Type | Required | Description | |---|---|---|---|---| | `csv` | body | string | yes | The CSV text: a header row, then one post per row. Columns: `text`, `platforms`, `platformSetId`, `accountIds`, `scheduledFor`, `useQueue`, `linkUrl`, `firstComment`, `media` and `text_` (per-platform text, such as `text_linkedin`). Each row needs `platforms` (with an optional `platformSetId`; default: your default set) or `accountIds`. List cells separate items with spaces. Unknown columns are skipped with a warning. | | `dryRun` | body | boolean | no | Check every row like posts_create does without creating anything. `valid` rows would be created. | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "csv": "text,platforms,scheduledFor,useQueue,media,text_threads\nLaunch day,linkedin threads,2026-10-20T09:00:00Z,,https://cdn.example.com/launch.jpg,Shorter for Threads\nBehind the scenes,instagram,,true,https://cdn.example.com/team.mp4,\nDraft for later,myspace,,,,", "dryRun": true } ``` Example response: ```json { "dryRun": true, "summary": { "rows": 3, "valid": 2, "created": 0, "errors": 1 }, "warnings": [], "rows": [ { "row": 2, "status": "valid", "postId": null, "scheduledFor": "2026-10-20T09:00:00Z", "errors": [] }, { "row": 3, "status": "valid", "postId": null, "scheduledFor": "2026-10-21T07:00:00.000Z", "errors": [] }, { "row": 4, "status": "error", "postId": null, "scheduledFor": null, "errors": [ { "code": "VALIDATION_FAILED", "message": "Invalid option: expected one of \"instagram\"|\"facebook\"|\"linkedin\"|\"tiktok\"|\"threads\"|\"twitter\"|\"discord\"|\"telegram\"|\"bluesky\"|\"youtube\"|\"pinterest\"", "column": "platforms" } ] } ] } ``` ### Get a post `GET /api/posts/{postId}` · MCP tool `posts_get` · scope `posts:read` · rate limit `apiRead` · success `200` Get one post with its result per account (`platformPosts`). | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Update a post `PATCH /api/posts/{postId}` · MCP tool `posts_update` · scope `posts:write` · rate limit `createPost` · success `200` Change a draft or scheduled post. Send only the fields to change; `media` and `targets` replace the whole list (accounts the post keeps keep their results). `options` is merged per platform: each `options.` you send replaces that platform's options as a whole, `null` removes them, and other platforms keep theirs. Set `scheduledFor` to null to turn a scheduled post back into a draft. To change the text of a published post, use posts_edit_published. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | | `targets` | body | object[] | no | Replaces all targets. Accounts the post keeps also keep their results. | | `text` | body | string | no | | | `firstComment` | body | string \| null | no | New first comment; null removes it | | `linkUrl` | body | string \| null | no | | | `media` | body | object[] | no | Replaces all media | | `options` | body | object | no | | | `scheduledFor` | body | string \| null | no | New publish time; null turns the post back into a draft | Example request: ```json { "text": "Launch day (updated): Ampost publishes everywhere from one API.", "scheduledFor": "2026-10-09T08:00:00Z" } ``` Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Delete a post `DELETE /api/posts/{postId}` · MCP tool `posts_delete` · scope `posts:write` · rate limit `createPost` · success `200` Discard a post that has not been published, including a scheduled one. The post stays readable with status `discarded`, and its media in Ampost storage is deleted. To take a published post down from the platforms, use posts_unpublish. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Publish a post now `POST /api/posts/{postId}/publish` · MCP tool `posts_publish` · scope `posts:write` · rate limit `publishPost` · success `200` Publish a draft or scheduled post to all its accounts now. Check `platformPosts` for each account's result; videos may stay `processing` for a while. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ### Unpublish a post `POST /api/posts/{postId}/unpublish` · MCP tool `posts_unpublish` · scope `posts:write` · rate limit `publishPost` · success `200` Take a published post down from the platforms: every account where it is published, or only the given `accountIds` (from `platformPosts[].accountId`). Each account's result is in `platformPosts`: `deleted`, or still `published` with `error` saying why (`UNPUBLISH_NOT_SUPPORTED` on Instagram and TikTok, which have no delete API; the platform's own refusal otherwise, such as Telegram's 48-hour limit for bots). The post becomes `deleted` once no account has it; it stays readable in Ampost. Call platforms_list for `unpublish.supported` per platform. Deleting from X costs X units like a post. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | | `accountIds` | body | string[] | no | Accounts to delete the post from (`platformPosts[].accountId`). Default: every account where it is published. | Example request: ```json { "accountIds": [ "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60" ] } ``` Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": null, "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": null, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": { "code": "UNPUBLISH_NOT_SUPPORTED", "message": "This platform has no API to delete a published post. Delete it in the platform's own app.", "retryable": false, "platformCode": null, "platformSubcode": null }, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "deleted", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": null, "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" } ``` ### Edit a published post's text `POST /api/posts/{postId}/edit-published` · MCP tool `posts_edit_published` · scope `posts:write` · rate limit `publishPost` · success `200` Replace the text of a published post on the platforms: every account where it is published, or only the given `accountIds` (from `platformPosts[].accountId`). Text only; media and links stay. Each account's result is in `platformPosts`: an edited account's `text` holds the new text, and an account the platform refused keeps its text with `error` (`EDIT_NOT_SUPPORTED` on Instagram, Threads, TikTok, Bluesky and X, and on Facebook stories; the platform's own refusal otherwise). Facebook edits only posts Ampost published; a Telegram caption under media stays limited to 1024 characters. YouTube and Pinterest replace the video or Pin description. Call platforms_list for `editPublished.supported` per platform. To change a draft or scheduled post, use posts_update. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | | `text` | body | string | yes | The new text. It replaces the text only; media and links stay. Each platform's `text.maxLength` applies. | | `accountIds` | body | string[] | no | Accounts to edit the post on (`platformPosts[].accountId`). Default: every account where it is published. | Example request: ```json { "text": "Launch day. Here is what we built and why (now with the pricing link in the comments).", "accountIds": [ "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60" ] } ``` Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": null, "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": null, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": { "code": "EDIT_NOT_SUPPORTED", "message": "This platform has no API to edit a published post. Edit it in the platform's own app.", "retryable": false, "platformCode": null, "platformSubcode": null }, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": "Launch day. Here is what we built and why (now with the pricing link in the comments).", "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": null, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:01.000Z" } ``` ### Retry failed accounts `POST /api/posts/{postId}/retry` · MCP tool `posts_retry` · scope `posts:write` · rate limit `publishPost` · success `200` Publish again to the accounts that failed, or only to the given `accountIds` (from `platformPosts[].accountId`). Ampost already retries retryable failures (rate limits, timeouts, platform errors) by itself, up to 5 attempts with backoff; an account waiting for that shows `pending` with `nextAttemptAt`. Call this for an account that is `failed`, after fixing the cause. It starts `attempts` again from 0. A published account whose first comment failed (`platformPosts[].firstComment.status` failed) posts only the comment again, without publishing the post twice or counting it again. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | path | string | yes | Post id | | `accountIds` | body | string[] | no | Accounts to retry (`platformPosts[].accountId`). A published account whose `firstComment.status` is failed retries only the comment; other accounts publish again. Default: every failed account and every failed first comment. | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "accountIds": [ "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80" ] } ``` Example response: ```json { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "instagram", "linkedin" ] } ], "platforms": [ "instagram", "linkedin" ], "text": "Launch day: Ampost publishes everywhere from one API.", "firstComment": "Pricing and docs: https://ampost.io", "linkUrl": null, "media": [ { "url": "https://cdn.example.com/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18200000, "width": 1080, "height": 1920, "durationSeconds": 21, "altText": null, "thumbnailUrl": null } ], "options": { "instagram": { "placement": "reel" }, "linkedin": { "text": "Launch day. Here is what we built and why.", "visibility": "PUBLIC" } }, "scheduledFor": null, "queuePlatformSetId": null, "source": "api", "metadata": { "campaignId": "launch-2026" }, "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "17895695668004550", "publishedUrl": "https://www.instagram.com/reel/C9xYz12AbCd/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "published", "commentId": "17858893269123456", "error": null, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" }, { "platform": "linkedin", "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "published", "platformPostId": "urn:li:share:7248123456789012345", "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:7248123456789012345/", "text": null, "error": null, "warnings": [], "attempts": 1, "nextAttemptAt": null, "firstComment": { "status": "failed", "commentId": null, "error": { "code": "PLATFORM_ERROR", "message": "LinkedIn comment failed: the service is temporarily unavailable", "retryable": true, "platformCode": null, "platformSubcode": null }, "updatedAt": "2026-10-06T09:00:04.000Z" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ], "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:04.000Z" } ``` ## LinkedIn `options.linkedin` accepts `text`, `visibility` (`PUBLIC` or `CONNECTIONS`), `isReshareDisabledByAuthor`, `articleTitle`, `articleDescription` and `videoTitle`. Alt text goes on each image as `media[].altText`. Article fields need `linkUrl`; an article cannot carry a video or more than one image; `videoTitle` needs a video; images and videos cannot be mixed. ```json { "targets": [ { "platforms": [ "linkedin" ] } ], "text": "Three screens from the new dashboard.", "media": [ { "url": "https://cdn.example.com/compose.png", "type": "image", "altText": "Compose screen" }, { "url": "https://cdn.example.com/posts.png", "type": "image", "altText": "Posts list" }, { "url": "https://cdn.example.com/sets.png", "type": "image", "altText": "Platform sets" } ], "options": { "linkedin": { "isReshareDisabledByAuthor": false } } } ``` ```json { "targets": [ { "platforms": [ "linkedin" ] } ], "text": "Our API reference is live.", "linkUrl": "https://ampost.io/docs", "options": { "linkedin": { "articleTitle": "Ampost API reference", "articleDescription": "Endpoints, options and limits for every platform.", "visibility": "PUBLIC" } } } ``` ## Media Upload `media[]` takes public URLs. When the user has a file on their device, or you have the file itself, get it a URL with a media upload: 1. `POST /api/media/uploads` (MCP `media_upload_create`). The result has `uploadPageUrl`, an Ampost page where the user picks files in a browser, and `uploadUrl` + `method` + `headers` for one direct upload. Both stop accepting files after 60 minutes (`expiresAt`). 2. Either show `uploadPageUrl` to the user (the part after `#` is a secret for this upload only; do not log or repeat it), or, if you can make HTTP requests and have the file, send it yourself: `PUT` the raw bytes to `uploadUrl` with every header in `headers` plus `x-content-type: `. 3. Poll `GET /api/media/uploads/{uploadId}` (MCP `media_upload_status`) every 3-5 seconds until the status is `completed`, `failed` or `expired`. `completed` comes with `files`, each already a `media[]` item (`url`, `type`, `mimeType`, `sizeBytes`, `width`, `height`, `durationSeconds`): pass them to `posts_create` as they are. Ampost checks each file's bytes and reads width, height and duration from them; images are re-saved without metadata (EXIF, GPS), so their URL differs from the one uploaded to. `failed` means the direct upload's content was not the type it was sent as (reason in `error`); create a new upload. Limits are in `limits`: up to 35 files on the page, JPEG, PNG, WebP, MP4, QuickTime and WebM, up to 200 MB per file. This is the way to send videos and any file over 4 MB. Files that no post uses are deleted 24 hours after the upload was created, by the daily cleanup; files used in a post stay with the post. Media in Ampost storage is deleted when its post is discarded, and 7 days after the post is published or has failed (a retry after that needs new media). ### Start a media upload `POST /api/media/uploads` · MCP tool `media_upload_create` · scope `media:write` · rate limit `mediaUpload` · success `201` Get a way to upload a local file for a post. Media in posts_create must be a public URL; use this when the user has the file on their device or you have the file itself. Returns `uploadPageUrl`, an Ampost page where the user uploads files in a browser (in MCP, a client that supports URL elicitation asks the user to open it itself and leaves `uploadPageUrl` out), and `uploadUrl` + `method` + `headers` to PUT one file directly. Both stop accepting files after 60 minutes. Then poll media_upload_status; when it is completed, its `files` are `media[]` items for posts_create. Files that no post uses are deleted 24 hours after the upload was created (by the daily cleanup). | Field | In | Type | Required | Description | |---|---|---|---|---| | `fileName` | body | string | no | File name for the direct upload, e.g. `launch.mp4`; it becomes the end of the file URL. Default `file`. Files uploaded on the page keep their own names. | Example request: ```json { "fileName": "launch.mp4" } ``` Example response: ```json { "id": "3c7e9a1b-5d2f-4e8a-9b6c-1f3d5a7e9c20", "status": "pending", "uploadPageUrl": "https://ampost.io/upload/3c7e9a1b-5d2f-4e8a-9b6c-1f3d5a7e9c20#token=one-time-token", "uploadUrl": "https://vercel.com/api/blob/?pathname=uploads%2F3c7e9a1b-5d2f-4e8a-9b6c-1f3d5a7e9c20%2Flaunch.mp4", "method": "PUT", "headers": { "authorization": "Bearer vercel_blob_client_...", "x-api-version": "12", "x-vercel-blob-access": "public", "x-add-random-suffix": "0", "x-allow-overwrite": "1" }, "limits": { "maxFiles": 35, "maxSizeBytes": 209715200, "mimeTypes": [ "image/jpeg", "image/png", "image/webp", "video/mp4", "video/quicktime", "video/webm" ] }, "createdAt": "2026-10-06T12:00:00.000Z", "expiresAt": "2026-10-06T13:00:00.000Z" } ``` ### Get a media upload `GET /api/media/uploads/{uploadId}` · MCP tool `media_upload_status` · scope `media:write` · rate limit `apiRead` · success `200` Read a media upload: `pending` until files arrive, `completed` with `files`, `failed` with `error` (the direct upload's content was not the type it was sent as), or `expired`. Poll every 3-5 seconds after media_upload_create. Each item in `files` is a `media[]` item (`url`, `type`, `mimeType`, `sizeBytes`, `width`, `height`, `durationSeconds`): pass them to posts_create unchanged. | Field | In | Type | Required | Description | |---|---|---|---|---| | `uploadId` | path | string | yes | Media upload id | Example response: ```json { "id": "3c7e9a1b-5d2f-4e8a-9b6c-1f3d5a7e9c20", "status": "completed", "files": [ { "url": "https://abc123.public.blob.vercel-storage.com/uploads/3c7e9a1b-5d2f-4e8a-9b6c-1f3d5a7e9c20/launch.mp4", "type": "video", "mimeType": "video/mp4", "sizeBytes": 18350112, "width": 1080, "height": 1920, "durationSeconds": 28 } ], "createdAt": "2026-10-06T12:00:00.000Z", "expiresAt": "2026-10-06T13:00:00.000Z", "completedAt": "2026-10-06T12:03:41.000Z", "error": null } ``` ### Multipart Post Create To upload files instead of passing URLs, send `POST /api/posts` as `multipart/form-data`: a `data` part with the JSON body and one part per file. A media item references its file with `{ "file": "" }`; type, MIME type, size, width, height and duration come from the file's bytes, and a file whose content is not its declared type gets `VALIDATION_FAILED` on `media[i].file`. Images are re-saved without metadata (EXIF, GPS). Every file part must be referenced exactly once. Accepted: JPEG, PNG, WebP, MP4, QuickTime and WebM. The whole multipart request must be at most 4 MB (all files and the `data` part together); a larger one gets 413 `PAYLOAD_TOO_LARGE`, or over 4.5 MB a plain-text 413 `FUNCTION_PAYLOAD_TOO_LARGE` from the hosting edge. That fits images, not most videos: for anything larger use a media upload (above), which takes files up to 200 MB, or pass a public URL in `media[]`. `data` part: ```json { "targets": [ { "platforms": [ "instagram" ] } ], "text": "Uploaded straight from my laptop", "media": [ { "file": "photo", "altText": "The new dashboard on a laptop" } ] } ``` ```bash curl -X POST https://api.ampost.io/api/posts \ -H "Authorization: Bearer amp_live_YOUR_KEY" \ -F 'data=' \ -F "photo=@launch.jpg" ``` ## Accounts Accounts are the connected social profiles. `GET /api/accounts` returns `active` accounts by default; pass `status=revoked` for disconnected accounts or `status=all` for both. A platform set's `accountCount` is the default list's total for that set. `GET /api/accounts/{accountId}` also returns revoked accounts (`status: revoked`); reconnect one with the browser-assisted flow below and its `accountId`, and it keeps its id. `DELETE /api/accounts/{accountId}` (MCP `accounts_disconnect`) disconnects an account: it deletes the stored tokens, sets `status: revoked` and asks the platform to revoke Ampost's access where it can: Facebook, LinkedIn, TikTok, X, YouTube (Google) and Discord (Ampost deletes the channel webhook). The result is the account plus `providerRevoked`, true when the platform confirmed the revoke, and `providerAccessSettings`: when `providerRevoked` is false, where the user removes Ampost in the platform itself; relay it. Instagram and Threads have no revoke endpoint, so they always return `providerRevoked: false`; the user removes Ampost in Instagram: Settings, Website permissions, Apps and websites, or in Threads: Settings, Account, Website permissions. Pinterest has no revoke for user tokens: the user removes Ampost in Pinterest: Settings, Security and logins, Apps. Telegram and Bluesky use the user's own bot and app password: the user removes the bot from the channel or deletes the app password. A second call returns the account with `providerRevoked: false`. Deleting a platform set does the same for its accounts. `PATCH /api/accounts/{accountId}/analytics` (MCP `accounts_set_analytics`, scope `accounts:write`) with `{ "enabled": true }` turns X analytics on for an X account; `false` turns them off. X bills every read, so X analytics are off until the user opts in, need the Max or Scale plan (403 `UPGRADE_REQUIRED` otherwise), and count against the plan's monthly X units: three post reads or one follower read cost one unit. Each account's `analytics` field says whether analytics are `available`, `optInRequired` and `enabled`. Other platforms need no opt-in and answer 400. A Pinterest Pin needs a board. `GET /api/accounts/{accountId}/boards` (MCP `accounts_list_boards`) lists the account's boards and its `defaultBoardId`; `PATCH /api/accounts/{accountId}/board` (MCP `accounts_set_board`, scope `accounts:write`) sets the default with `boardId`, or creates a board with `newBoardName` and makes it the default. A connect picks the board by itself when the account has exactly one; otherwise the account has the `BOARD_NOT_SELECTED` health warning until one is set, and each post must name `options.pinterest.boardId`. `health` says whether an account can publish and why not: `canPost` (active and no `error` issue), `needsReconnect` (there is an issue, and only a reconnect fixes it) and `issues[]`, each with a `code`, a `severity` (`error` blocks publishing, `warning` needs attention soon) and a `message` to relay. Codes: `ACCOUNT_REVOKED`, `TOKEN_EXPIRED` (the login expired and Ampost cannot renew it), `MISSING_SCOPE:` (for example `MISSING_SCOPE:w_member_social`), `REFRESH_TOKEN_EXPIRING` (the platform login ends within 7 days: TikTok, LinkedIn), `TOKEN_EXPIRING` (the token expires within 7 days and Ampost cannot renew it: LinkedIn without a refresh token, or an Instagram or Threads renewal that failed). Ampost renews logins by itself: Instagram and Threads 30 days before they expire, even when the account does not post, and short tokens with a refresh token (TikTok, X, LinkedIn, Bluesky, YouTube, Pinterest) on use, so an expired short token is no issue. Each issue shows up 7 days before the login lapses, and `account.needs_reconnect` fires then (once per lapse), not at the failed publish. `tokenHealth` is the bare token state: `healthy`, `non_expiring`, `expiring_soon`, `expired` or `inactive` (revoked). `platformMetadata` holds public details captured at connect time, a fixed list per platform. Facebook: `pageId`, `pageName`. LinkedIn: `displayName`, `locale`, `organizationPostingEnabled`. Instagram: `displayName`, `accountType`. Threads: `displayName`. TikTok: `creatorNickname`, `privacyLevelOptions`, `commentDisabled`, `duetDisabled`, `stitchDisabled`, `maxVideoPostDurationSec`. X: `displayName`. Bluesky: `displayName`. Telegram: `chatTitle`, `chatType`, `botUsername`. Discord: `guildName`, `channelId`. YouTube: `channelTitle`, `customUrl`. Pinterest: `displayName`, `accountType`, `boardId` and `boardName` (the default board; missing until one is picked). ### List connected accounts `GET /api/accounts` · MCP tool `accounts_list` · scope `accounts:read` · rate limit `apiRead` · success `200` List your social accounts, optionally only one platform or one platform set. `status` defaults to `active`, the accounts that can publish; pass `revoked` for disconnected accounts or `all` for both. The `active` total for a set equals that set's `accountCount` in platform_sets_list. `health` says whether each account can publish (`canPost`), whether only a reconnect helps (`needsReconnect`), and why (`issues[]` with codes such as `TOKEN_EXPIRED` or `MISSING_SCOPE:w_member_social`). Fix with accounts_connect and the account's id as `accountId`. `platformMetadata` has public per-platform details, such as the TikTok creator's allowed privacy levels. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platform` | query | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | | | `platformSetId` | query | string | no | | | `status` | query | `active` \| `revoked` \| `all` | no | `active` (default): accounts that can publish. `revoked`: disconnected accounts. `all`: both. | | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [], "pagination": { "limit": 20, "offset": 0, "total": 0, "hasMore": false } } ``` ### Get a connected account `GET /api/accounts/{accountId}` · MCP tool `accounts_get` · scope `accounts:read` · rate limit `apiRead` · success `200` Get one social account by id, including revoked ones (`status: revoked`). `health`: `canPost`, `needsReconnect` and `issues[]` (codes such as `ACCOUNT_REVOKED`, `TOKEN_EXPIRED`, `MISSING_SCOPE:`, each with a message to relay). When `needsReconnect` is true, call accounts_connect with `accountId` set to this id; the account keeps its id. `platformMetadata` has public per-platform details. | Field | In | Type | Required | Description | |---|---|---|---|---| | `accountId` | path | string | yes | Account id | Example response: ```json { "id": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformAccountId": "17841400000000000", "username": "ampost.io", "avatarUrl": null, "status": "active", "scopes": [ "instagram_business_basic", "instagram_business_content_publish" ], "tokenExpiresAt": "2026-12-05T09:00:00.000Z", "tokenHealth": "healthy", "health": { "canPost": true, "needsReconnect": false, "issues": [] }, "platformMetadata": { "displayName": "Ampost", "accountType": "BUSINESS" }, "analytics": { "available": true, "optInRequired": false, "enabled": true }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:00.000Z", "lastUsedAt": "2026-10-06T09:00:04.000Z" } ``` ### Disconnect an account `DELETE /api/accounts/{accountId}` · MCP tool `accounts_disconnect` · scope `accounts:write` · rate limit `connectPlatform` · success `200` Disconnect a social account: Ampost revokes its access at the platform where the platform allows it, deletes the stored tokens and sets `status: revoked`. Scheduled posts for the account will fail. The account keeps its id and can be reconnected later with accounts_connect and `accountId`. `providerRevoked` says whether the platform confirmed the revoke; when false, `providerAccessSettings` says where the user removes Ampost in the platform's settings. Instagram and Threads have no revoke endpoint, so they always return `providerRevoked: false`. Calling it again on a disconnected account returns the account unchanged with `providerRevoked: false`. | Field | In | Type | Required | Description | |---|---|---|---|---| | `accountId` | path | string | yes | Account id | Example response: ```json { "id": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformAccountId": "17841400000000000", "username": "ampost.io", "avatarUrl": null, "status": "revoked", "scopes": [], "tokenExpiresAt": null, "tokenHealth": "inactive", "health": { "canPost": false, "needsReconnect": true, "issues": [ { "code": "ACCOUNT_REVOKED", "severity": "error", "message": "The account is disconnected. Reconnect it with accounts_connect and its accountId." } ] }, "platformMetadata": { "displayName": "Ampost", "accountType": "BUSINESS" }, "analytics": { "available": true, "optInRequired": false, "enabled": true }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T12:20:00.000Z", "lastUsedAt": "2026-10-06T09:00:04.000Z", "providerRevoked": false, "providerAccessSettings": "Instagram: Settings > Website permissions > Apps and websites" } ``` ### Turn X analytics on or off `PATCH /api/accounts/{accountId}/analytics` · MCP tool `accounts_set_analytics` · scope `accounts:write` · rate limit `connectPlatform` · success `200` Opt an X account in to analytics, or out again. Every X read is billed, so X analytics are off until the account opts in, and the reads count against the plan's monthly X units like publishing (Max and Scale). When on, Ampost reads the metrics of the posts it published to the account during their first 29 days (daily for 3 days, then weekly) and the follower count once a day; three post reads or one follower read cost one X unit. The sync pauses when the units run out and resumes when they reset. Other platforms need no opt-in (`analytics.optInRequired` is false) and return 400. A plan without X returns 403 UPGRADE_REQUIRED. Returns the account with its `analytics` state. | Field | In | Type | Required | Description | |---|---|---|---|---| | `accountId` | path | string | yes | Account id | | `enabled` | body | boolean | yes | `true` starts the metered X analytics sync for the account; `false` stops it | Example request: ```json { "enabled": true } ``` Example response: ```json { "id": "9a4e2c7b-3d1f-4b8a-9c6e-5f2a8d1b7c40", "platform": "twitter", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformAccountId": "1580000000000000000", "username": "ampost_io", "avatarUrl": null, "status": "active", "scopes": [ "tweet.read", "tweet.write", "users.read", "media.write", "offline.access" ], "tokenExpiresAt": "2026-10-07T11:00:00.000Z", "tokenHealth": "healthy", "health": { "canPost": true, "needsReconnect": false, "issues": [] }, "platformMetadata": {}, "analytics": { "available": true, "optInRequired": true, "enabled": true }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z", "lastUsedAt": "2026-10-06T09:00:04.000Z" } ``` ### List a Pinterest account's boards `GET /api/accounts/{accountId}/boards` · MCP tool `accounts_list_boards` · scope `accounts:read` · rate limit `apiRead` · success `200` List the boards of an active Pinterest account (up to 250), read live from Pinterest. `defaultBoardId` is the board Pins go to when a post sets no `options.pinterest.boardId`; it is `null` until one is picked, and the account then has the `BOARD_NOT_SELECTED` health warning. Ask the user which board to use and call accounts_set_board. An account with a single board gets it as the default at connect. Other platforms return 400. | Field | In | Type | Required | Description | |---|---|---|---|---| | `accountId` | path | string | yes | Account id | Example response: ```json { "data": [ { "id": "813744226420795883", "name": "Product launches", "privacy": "PUBLIC", "isDefault": true }, { "id": "813744226420795901", "name": "Drafts", "privacy": "SECRET", "isDefault": false } ], "defaultBoardId": "813744226420795883" } ``` ### Pick a Pinterest account's default board `PATCH /api/accounts/{accountId}/board` · MCP tool `accounts_set_board` · scope `accounts:write` · rate limit `connectPlatform` · success `200` Set the board Pins go to when a post sets no `options.pinterest.boardId`: pass `boardId` (from accounts_list_boards), or `newBoardName` to create a public board and use it. Ampost checks the board with Pinterest first. The choice survives a reconnect. Returns the account; `platformMetadata.boardId` and `boardName` show the default. Other platforms return 400. | Field | In | Type | Required | Description | |---|---|---|---|---| | `accountId` | path | string | yes | Account id | | `boardId` | body | string | no | An existing board of the account, from accounts_list_boards. Pass this or `newBoardName` | | `newBoardName` | body | string | no | Creates a public board with this name and makes it the default. Pass this or `boardId` | Example request: ```json { "boardId": "813744226420795883" } ``` Example response: ```json { "id": "6b1f3d8a-4e2c-4a9b-8d7f-2c5e9a1b3d70", "platform": "pinterest", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformAccountId": "813744295139952108", "username": "ampost_io", "avatarUrl": null, "status": "active", "scopes": [ "user_accounts:read", "boards:read", "boards:write", "pins:read", "pins:write" ], "tokenExpiresAt": "2026-11-06T10:00:00.000Z", "tokenHealth": "healthy", "health": { "canPost": true, "needsReconnect": false, "issues": [] }, "platformMetadata": { "displayName": "Ampost", "accountType": "BUSINESS", "boardId": "813744226420795883", "boardName": "Product launches" }, "analytics": { "available": true, "optInRequired": false, "enabled": true }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z", "lastUsedAt": "2026-10-06T09:00:04.000Z" } ``` ### Start connecting an account `POST /api/accounts/connect` · MCP tool `accounts_connect` · scope `accounts:write` · rate limit `connectPlatform` · success `201` Create a short-lived Ampost-hosted link that connects one social account to a platform set. The user opens `connectUrl` in a browser and authorizes there; consent cannot happen in chat. In MCP, a client that supports URL elicitation asks the user to open the page itself, and the result then has no `connectUrl`. Then poll the connection request until it is connected, failed or expired. To reconnect an existing account (a revoked one, or an active one whose `health.needsReconnect` is true), pass its id as `accountId`; `platform` may then be omitted. For a new connection, `expectedUsername` or `expectedPlatformAccountId` makes sure the user signs in to the right account. If the user signs in to another account, the request fails with ACCOUNT_MISMATCH and nothing changes. A new request replaces the pending one for the same platform and set, also one the user opened but did not finish (it fails with SUPERSEDED), so call this again when the user lost or closed the page. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platform` | body | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | The platform to connect. Required unless `accountId` is given. | | `platformSetId` | body | string | no | | | `accountId` | body | string | no | | | `expectedUsername` | body | string | no | | | `expectedPlatformAccountId` | body | string | no | | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40" } ``` Example response: ```json { "id": "11111111-1111-4111-8111-111111111111", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "pending", "reconnectAccountId": null, "expectedPlatformAccountId": null, "expectedUsername": null, "connectUrl": "https://ampost.io/connect/11111111-1111-4111-8111-111111111111#token=one-time-token", "createdAt": "2026-10-06T12:00:00.000Z", "expiresAt": "2026-10-06T12:10:00.000Z" } ``` ### Get a connection request `GET /api/accounts/connect/{connectRequestId}` · MCP tool `accounts_connect_status` · scope `accounts:read` · rate limit `apiRead` · success `200` Read the status of an account connection request: pending, awaiting_selection, connected, failed or expired. Poll every 2-5 seconds. When connected, `accountId` is the account's id (a reconnected account keeps its id). When failed or expired, `error.message` says why and what the user should do next; relay it as is. `ACCOUNT_MISMATCH` means the user signed in to another account than `accountId` or the expected one; nothing was changed. `SOCIAL_ACCOUNT_IN_USE` means a Free workspace tried to connect a social account another Ampost workspace has (or had in the last 30 days); a paid plan can connect it. `SUPERSEDED` means a newer request for this platform replaced this one: poll the newer request instead. `USER_CANCELLED` means the user cancelled or declined consent. | Field | In | Type | Required | Description | |---|---|---|---|---| | `connectRequestId` | path | string | yes | Connection request id | Example response: ```json { "id": "11111111-1111-4111-8111-111111111111", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "status": "connected", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "reconnectAccountId": null, "expectedPlatformAccountId": null, "expectedUsername": null, "error": null, "createdAt": "2026-10-06T12:00:00.000Z", "expiresAt": "2026-10-06T12:10:00.000Z", "completedAt": "2026-10-06T12:02:14.000Z", "updatedAt": "2026-10-06T12:02:14.000Z" } ``` ### Browser-Assisted Account Connection `POST /api/accounts/connect` creates a ten-minute connection request; `GET /api/accounts/connect/{connectRequestId}` reads its status. Show the complete `connectUrl` to the user once and tell them to open it in a browser. The URL fragment is a one-time secret: never log, analyze, transform or repeat it after that handoff, and never ask the user to paste it back. Provider consent cannot be completed headlessly or inside the LLM conversation. The create response is the only result that contains `connectUrl`. To reconnect an account (revoked, or active with `health.needsReconnect`), pass its `accountId`; `platform` may be omitted. An active account gets fresh tokens and permissions and stays in its set; a revoked one is reactivated. For a new connection, `expectedUsername` (the `username` accounts_get would show; Facebook: the Page name; case and a leading @ ignored) or `expectedPlatformAccountId` pins the account the user must sign in to. If the user signs in to any other account, the request fails with `ACCOUNT_MISMATCH` and nothing is written. Poll every 2-5 seconds until the status is `connected` (then `accountId` is the account; a reconnected account keeps its id), `failed` or `expired`. On `failed` or `expired`, `error.message` says why and what the user should do next; relay it, and offer a new link only when it says so. `error.code` is one of `USER_CANCELLED` (the user cancelled or declined consent), `SUPERSEDED` (a newer request for this platform and set replaced this one; poll the newer one. A new request also replaces one the user opened but did not finish, so create one when the user lost or closed the page), `REQUEST_EXPIRED`, `OAUTH_DENIED`, `REQUIRED_PERMISSIONS_MISSING` (the platform did not grant a permission Ampost needs; if the app is not approved for it, retrying won't help), `ACCOUNT_MISMATCH`, `ACCOUNT_ALREADY_CONNECTED`, `PLATFORM_ALREADY_CONNECTED`, `ACCOUNT_LIMIT_REACHED`, `SOCIAL_ACCOUNT_IN_USE` (Free: the social account is connected in another Ampost workspace, or was disconnected from one less than 30 days ago; a paid plan can connect it), `PLATFORM_SET_NOT_FOUND`, `API_KEY_REVOKED` or `CONNECTION_FAILED`. Status results never include `connectUrl`, the fragment token, provider tokens, authorization codes, OAuth state or raw provider errors. Another user gets 404. There are no separate profiles (platform sets group accounts), customer callbacks, custom redirect URLs or headless authorization: the user always finishes in a browser. Facebook Page selection is hosted by Ampost in the browser; a reconnect or an expected account offers only that Page. Telegram and Bluesky have no consent screen: the `connectUrl` opens an Ampost page where the user enters their bot token and channel (Telegram) or handle and app password (Bluesky). Discord asks the user to pick the server and channel. X is on the Max and Scale plans only; on other plans `accounts_connect` fails with `UPGRADE_REQUIRED`. ### MCP Browser-Assisted Account Connection `accounts_connect` takes `platform` and an optional `platformSetId`, or `accountId` to reconnect, plus the optional `expectedUsername` / `expectedPlatformAccountId`, with the same service, `connectPlatform` rate limit, ten-minute expiry and errors as REST. Show the returned `connectUrl` to the user, then call `accounts_connect_status` with `connectRequestId` every 2-5 seconds. Never ask the user for an access token, OAuth authorization code, password, bot token, app password, state value, URL fragment or Facebook Page token: Telegram and Bluesky credentials are typed on the Ampost page, not in the conversation. ### Opening Links in the User's Browser (MCP) On MCP protocol revision 2026-07-28, a client that declares the `elicitation.url` capability does not get `connectUrl` (`accounts_connect`) or `uploadPageUrl` (`media_upload_create`) in the result. Ampost answers the call with a URL-mode elicitation instead: the client shows the user the page address, opens it in the browser after the user agrees, and retries the call. The result then has every other field, and its note says the page was opened. The link never reaches the model or the chat. If the user declines, or the client does not support URL elicitation (every 2025-era client, since this server keeps no sessions), the result has the link as before. REST is unchanged. ## Platform Sets A platform set groups connected accounts, at most one per platform. You can have up to 20 sets. Deleting a set revokes its accounts and keeps its disconnected accounts and posts without a set; a reconnect puts an account in the set you connect it to. Your last set cannot be deleted. ### List platform sets `GET /api/platform-sets` · MCP tool `platform_sets_list` · scope `accounts:read` · rate limit `apiRead` · success `200` List your platform sets with their active account counts, default set first. `accountCount` counts only active accounts, the same ones accounts_list returns by default; revoked accounts are not counted. A platform set groups at most one account per platform; every post targets one set. | Field | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [ { "id": "9a2c4e6b-1d3f-4a5c-8e7b-2f4d6a8c0e10", "name": "default", "isDefault": true, "accountCount": 3, "createdAt": "2026-10-01T08:00:00.000Z", "updatedAt": "2026-10-01T08:00:00.000Z" }, { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "name": "Client A", "isDefault": false, "accountCount": 2, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:00.000Z" } ], "pagination": { "limit": 20, "offset": 0, "total": 2, "hasMore": false } } ``` ### Create a platform set `POST /api/platform-sets` · MCP tool `platform_sets_create` · scope `accounts:write` · rate limit `createPost` · success `201` Create an empty platform set. You can have at most 20 sets. | Field | In | Type | Required | Description | |---|---|---|---|---| | `name` | body | string | yes | Set name, 1-50 characters | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "name": "Client A" } ``` Example response: ```json { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "name": "Client A", "isDefault": false, "accountCount": 2, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:00.000Z" } ``` ### Get a platform set `GET /api/platform-sets/{platformSetId}` · MCP tool `platform_sets_get` · scope `accounts:read` · rate limit `apiRead` · success `200` Get one platform set with its active account count (revoked accounts are not counted). | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | Example response: ```json { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "name": "Client A", "isDefault": false, "accountCount": 2, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:00.000Z" } ``` ### Rename a platform set `PATCH /api/platform-sets/{platformSetId}` · MCP tool `platform_sets_update` · scope `accounts:write` · rate limit `createPost` · success `200` Rename a platform set, including the default set. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | | `name` | body | string | yes | Set name, 1-50 characters | Example request: ```json { "name": "Client A" } ``` Example response: ```json { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "name": "Client A", "isDefault": false, "accountCount": 2, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T09:00:00.000Z" } ``` ### Delete a platform set `DELETE /api/platform-sets/{platformSetId}` · MCP tool `platform_sets_delete` · scope `accounts:write` · rate limit `createPost` · success `200` Delete a platform set and revoke its active accounts. Its disconnected accounts and its posts are kept without a set; reconnect an account with accounts_connect and its accountId to put it in another set. Your last remaining set cannot be deleted (409 LAST_PLATFORM_SET). 409 PLATFORM_SET_IN_USE means an account was connected to the set during the delete: retry. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | Example response: ```json { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "deleted": true } ``` ## Posting Queues Each platform set has one queue: weekly slots (`{ "day": "monday", "time": "09:00" }`) in an IANA time zone (default `UTC`). Slot times follow the zone's daylight saving changes: a time that happens twice when clocks go back is the first one, and a time that does not exist when clocks go forward moves forward by the gap (02:30 becomes 03:30). Send `useQueue: true` instead of `scheduledFor` to `posts_create` with exactly one target, and the post is scheduled in that set's next free slot at least a minute away. A slot is taken while a queued post (`queuePlatformSetId` on the Post) is scheduled at its time; rescheduling, moving to another set, publishing or deleting the post frees it. Changing the slots does not move posts already in the queue. Queues are scheduling, so they need the Pro plan or above; reading one works on every plan. `queue_slots` with `filter: free` shows where the next queued posts go. ### Get a posting queue `GET /api/platform-sets/{platformSetId}/queue` · MCP tool `queue_get` · scope `posts:read` · rate limit `apiRead` · success `200` Get the posting queue of a platform set. A set without a saved queue answers with no slots in UTC. Each platform set has one queue: weekly slots (weekday and HH:MM) in an IANA time zone, which follow its daylight saving changes. posts_create with `useQueue: true` schedules the post in the set's next free slot. A slot is taken while a queued post is scheduled at its time; rescheduling, publishing or deleting the post frees it. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | Example response: ```json { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "timezone": "Europe/Warsaw", "slots": [ { "day": "monday", "time": "09:00" }, { "day": "wednesday", "time": "12:30" }, { "day": "friday", "time": "17:00" } ], "updatedAt": "2026-10-06T09:00:00.000Z" } ``` ### Update a posting queue `PATCH /api/platform-sets/{platformSetId}/queue` · MCP tool `queue_update` · scope `posts:write` · rate limit `createPost` · success `200` Set the time zone or the weekly slots of a platform set's queue; `slots` replaces the whole list. Queues need the Pro plan or above (403 UPGRADE_REQUIRED). Posts already in the queue keep their time. Each platform set has one queue: weekly slots (weekday and HH:MM) in an IANA time zone, which follow its daylight saving changes. posts_create with `useQueue: true` schedules the post in the set's next free slot. A slot is taken while a queued post is scheduled at its time; rescheduling, publishing or deleting the post frees it. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | | `timezone` | body | string | no | | | `slots` | body | object[] | no | Replaces every slot (max 100); duplicates are merged. An empty list turns the queue off. | Example request: ```json { "timezone": "Europe/Warsaw", "slots": [ { "day": "monday", "time": "09:00" }, { "day": "wednesday", "time": "12:30" }, { "day": "friday", "time": "17:00" } ] } ``` Example response: ```json { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "timezone": "Europe/Warsaw", "slots": [ { "day": "monday", "time": "09:00" }, { "day": "wednesday", "time": "12:30" }, { "day": "friday", "time": "17:00" } ], "updatedAt": "2026-10-06T09:00:00.000Z" } ``` ### Preview queue slots `GET /api/platform-sets/{platformSetId}/queue/slots` · MCP tool `queue_slots` · scope `posts:read` · rate limit `apiRead` · success `200` The queue's next slot times from now on, each with the queued post holding it (`postId`, null when free). With `filter: free` the first slot is where posts_create with `useQueue` puts the next post. Each platform set has one queue: weekly slots (weekday and HH:MM) in an IANA time zone, which follow its daylight saving changes. posts_create with `useQueue: true` schedules the post in the set's next free slot. A slot is taken while a queued post is scheduled at its time; rescheduling, publishing or deleting the post frees it. | Field | In | Type | Required | Description | |---|---|---|---|---| | `platformSetId` | path | string | yes | Platform set id | | `count` | query | integer | no | How many slots, 1-50. Default 10. | | `filter` | query | `all` \| `free` | no | `free`: only free slots, the ones posts_create with `useQueue` fills next, in order. Default `all`: taken slots too, with their post. | Example response: ```json { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "timezone": "Europe/Warsaw", "slots": [ { "scheduledFor": "2026-10-07T10:30:00.000Z", "postId": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10" }, { "scheduledFor": "2026-10-09T15:00:00.000Z", "postId": null }, { "scheduledFor": "2026-10-12T07:00:00.000Z", "postId": null } ] } ``` ## Analytics The numbers the dashboard shows, with scope `analytics:read`. Every platform fills the same fields (`views`, `reach`, `likes`, `comments`, `shares`, `saves`, `quotes`, followers); a metric a platform does not report is `null`. | Platform | Post metrics | Followers | Notes | | --- | --- | --- | --- | | Facebook Page, Instagram | views, reach, likes, comments, shares, saves | yes | Meta can lag 24-48 hours | | Threads, TikTok | views, likes, comments, shares | yes | | | LinkedIn | views, likes, comments, shares | yes | once its API access is approved | | Bluesky | likes, comments, shares (reposts), quotes | yes | no views | | X | views (impressions), likes, comments, shares (reposts), quotes, saves | yes | opt-in per account with `accounts_set_analytics`, Max and Scale only; posts published through Ampost, up to 29 days old; every read counts against the plan's X units | | YouTube | views, likes, comments | yes (subscribers, unless hidden) | posts published through Ampost | | Pinterest | views (impressions), likes (reactions), comments, saves | yes | posts published through Ampost; Pins older than 90 days report their last 90 days | | Telegram | none | yes (members) | | | Discord | none | none | 422 `ANALYTICS_NOT_AVAILABLE_ON_PLATFORM` | Ampost reads analytics in the background and serves the stored numbers: each account about hourly (daily on the Free plan), a post's metrics hourly in its first 48 hours, every 6 hours up to 14 days and daily up to 90 days. X posts are read daily in their first 3 days and weekly up to 29 days, which costs the plan's X units. Meta itself can lag 24-48 hours. `syncedAt` says when each number was read. When none of the matching accounts has analytics enabled, the call answers 403 `ANALYTICS_NOT_ENABLED` instead of an empty list; `details.accounts[]` gives each account's `reason` (`opt_in_required`, `platform_disabled` (LinkedIn, until Ampost's LinkedIn app has Community Management API access), `page_not_selected`, `account_inactive` or `not_available_on_platform`). Relay it; retrying does not help unless the reason is `opt_in_required` and the user agrees to turn X analytics on. ### Post analytics `GET /api/analytics/posts` · MCP tool `analytics_posts` · scope `analytics:read` · rate limit `apiRead` · success `200` Metrics of your published posts, newest first: one `platformPosts` entry per account the post went to, with views, reach, likes, comments, shares and saves (null when the platform does not report one). Pass `postId` for one post. `analyticsStatus` says why an entry has no metrics. Analytics cover Facebook Pages, Instagram, Threads, TikTok, Bluesky, YouTube and Pinterest accounts (YouTube and Pinterest: only posts published through Ampost) (LinkedIn once its API access is approved), X accounts that opted in with accounts_set_analytics (Max and Scale; only posts published through Ampost), and Telegram followers (no post metrics). Discord has no analytics: asking for it fails with 422 `ANALYTICS_NOT_AVAILABLE_ON_PLATFORM`. When none of the matching accounts has analytics enabled the call fails with 403 `ANALYTICS_NOT_ENABLED` (its `details.accounts` say why per account). Ampost reads analytics in the background and serves the stored numbers: each account about hourly (daily on the Free plan), a post's metrics hourly in its first 48 hours, every 6 hours up to 14 days and daily up to 90 days. X posts are read daily in their first 3 days and weekly up to 29 days, which costs the plan's X units. Meta itself can lag 24-48 hours. `syncedAt` says when each number was read. | Field | In | Type | Required | Description | |---|---|---|---|---| | `postId` | query | string | no | Only this post | | `platform` | query | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | | | `platformSetId` | query | string | no | | | `accountId` | query | string | no | | | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [ { "postId": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "text": "Launch day: Ampost publishes everywhere from one API.", "createdAt": "2026-10-06T09:00:00.000Z", "platformPosts": [ { "platform": "instagram", "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformPostId": "17912345678901234", "publishedUrl": "https://www.instagram.com/p/C9xYz12AbCd/", "publishedAt": "2026-10-06T09:00:04.000Z", "contentType": "image", "analyticsStatus": "available", "metrics": { "views": 1840, "reach": 1210, "likes": 96, "comments": 12, "shares": 7, "saves": 15, "quotes": null }, "syncedAt": "2026-10-08T10:12:00.000Z" }, { "platform": "facebook", "accountId": "8d3b6f1a-2c9e-4a7d-b5f3-1e6a9c2d7b30", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformPostId": "104857600000000_122100000000000", "publishedUrl": "https://www.facebook.com/104857600000000/posts/122100000000000", "publishedAt": "2026-10-06T09:00:04.000Z", "contentType": "image", "analyticsStatus": "available", "metrics": { "views": 920, "reach": 640, "likes": 41, "comments": 3, "shares": 5, "saves": null, "quotes": null }, "syncedAt": "2026-10-08T10:14:00.000Z" } ] } ], "pagination": { "limit": 20, "offset": 0, "total": 1, "hasMore": false } } ``` ### Analytics overview `GET /api/analytics/overview` · MCP tool `analytics_overview` · scope `analytics:read` · rate limit `apiRead` · success `200` Per account and in total, for posts published between `from` and `to` (UTC days, default the last 30): post count, summed views, reach, likes, comments, shares and saves, followers and their change, and sync state. These are the dashboard's numbers. `unavailableAccounts` lists matching accounts without analytics and why. Analytics cover Facebook Pages, Instagram, Threads, TikTok, Bluesky, YouTube and Pinterest accounts (YouTube and Pinterest: only posts published through Ampost) (LinkedIn once its API access is approved), X accounts that opted in with accounts_set_analytics (Max and Scale; only posts published through Ampost), and Telegram followers (no post metrics). Discord has no analytics: asking for it fails with 422 `ANALYTICS_NOT_AVAILABLE_ON_PLATFORM`. When none of the matching accounts has analytics enabled the call fails with 403 `ANALYTICS_NOT_ENABLED` (its `details.accounts` say why per account). Ampost reads analytics in the background and serves the stored numbers: each account about hourly (daily on the Free plan), a post's metrics hourly in its first 48 hours, every 6 hours up to 14 days and daily up to 90 days. X posts are read daily in their first 3 days and weekly up to 29 days, which costs the plan's X units. Meta itself can lag 24-48 hours. `syncedAt` says when each number was read. | Field | In | Type | Required | Description | |---|---|---|---|---| | `from` | query | string | no | First day (UTC), YYYY-MM-DD. Default: 29 days before `to`. At most 90 days with `to`. | | `to` | query | string | no | Last day (UTC), YYYY-MM-DD, included. Default: today. | | `platform` | query | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | | | `platformSetId` | query | string | no | | | `accountId` | query | string | no | | Example response: ```json { "from": "2026-09-09", "to": "2026-10-08", "totals": { "posts": 14, "followers": 5320, "followersChange": 142, "metrics": { "views": 21800, "reach": 15400, "likes": 1130, "comments": 96, "shares": 61, "saves": 140, "quotes": null } }, "accounts": [ { "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "username": "ampost", "posts": 9, "followers": 4100, "followersChange": 120, "metrics": { "views": 16200, "reach": 11300, "likes": 870, "comments": 71, "shares": 40, "saves": 140, "quotes": null }, "syncState": "up_to_date", "syncedAt": "2026-10-08T10:12:00.000Z", "nextSyncAt": "2026-10-08T11:12:00.000Z" }, { "accountId": "8d3b6f1a-2c9e-4a7d-b5f3-1e6a9c2d7b30", "platform": "facebook", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "username": "Ampost", "posts": 5, "followers": 1220, "followersChange": 22, "metrics": { "views": 5600, "reach": 4100, "likes": 260, "comments": 25, "shares": 21, "saves": null, "quotes": null }, "syncState": "up_to_date", "syncedAt": "2026-10-08T10:14:00.000Z", "nextSyncAt": "2026-10-08T11:14:00.000Z" } ], "unavailableAccounts": [ { "accountId": "5e9a1c3d-7b2f-4d8e-a6c1-3f9b2e7d4a60", "platform": "linkedin", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "reason": "platform_disabled" } ] } ``` ### Follower history `GET /api/analytics/followers` · MCP tool `analytics_followers` · scope `analytics:read` · rate limit `apiRead` · success `200` Follower count per account with one daily snapshot per day between `from` and `to` (UTC days, default the last 30). History starts when Ampost first read the account; a day without a snapshot is missing. Analytics cover Facebook Pages, Instagram, Threads, TikTok, Bluesky, YouTube and Pinterest accounts (YouTube and Pinterest: only posts published through Ampost) (LinkedIn once its API access is approved), X accounts that opted in with accounts_set_analytics (Max and Scale; only posts published through Ampost), and Telegram followers (no post metrics). Discord has no analytics: asking for it fails with 422 `ANALYTICS_NOT_AVAILABLE_ON_PLATFORM`. When none of the matching accounts has analytics enabled the call fails with 403 `ANALYTICS_NOT_ENABLED` (its `details.accounts` say why per account). Ampost reads analytics in the background and serves the stored numbers: each account about hourly (daily on the Free plan), a post's metrics hourly in its first 48 hours, every 6 hours up to 14 days and daily up to 90 days. X posts are read daily in their first 3 days and weekly up to 29 days, which costs the plan's X units. Meta itself can lag 24-48 hours. `syncedAt` says when each number was read. | Field | In | Type | Required | Description | |---|---|---|---|---| | `from` | query | string | no | First day (UTC), YYYY-MM-DD. Default: 29 days before `to`. At most 90 days with `to`. | | `to` | query | string | no | Last day (UTC), YYYY-MM-DD, included. Default: today. | | `platform` | query | `instagram` \| `facebook` \| `linkedin` \| `tiktok` \| `threads` \| `twitter` \| `discord` \| `telegram` \| `bluesky` \| `youtube` \| `pinterest` | no | | | `platformSetId` | query | string | no | | | `accountId` | query | string | no | | | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [ { "accountId": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "username": "ampost", "followers": 4100, "syncedAt": "2026-10-08T10:12:00.000Z", "totalLikes": null, "totalPosts": null, "history": [ { "date": "2026-10-06", "followers": 4062, "syncedAt": "2026-10-06T10:05:00.000Z" }, { "date": "2026-10-07", "followers": 4081, "syncedAt": "2026-10-07T10:09:00.000Z" }, { "date": "2026-10-08", "followers": 4100, "syncedAt": "2026-10-08T10:12:00.000Z" } ] } ], "pagination": { "limit": 20, "offset": 0, "total": 1, "hasMore": false } } ``` ## Webhooks Ampost POSTs events to your https URLs as they happen, so you do not have to poll. Webhooks need the `webhooks:write` scope and an unrestricted key (they see every platform set). You can have up to 10; each has its own URL, events and signing secret. The dashboard manages them at `https://ampost.io/app/settings/webhooks`. The full `secret` is only in the `webhooks_create` response and in a `webhooks_update` with `rotateSecret: true`: give it to the user then. Other responses have only `secretPreview`. Revoking the key or app that created a webhook (or last changed its URL or rotated its secret), or an app losing `webhooks:write`, turns the webhook off (`disabledReason: "key_revoked"`). Only the user can turn such a webhook back on, in the dashboard: the API answers `403 WEBHOOK_KEY_REVOKED`. ### Events - `post.scheduled`: A post was scheduled, or its publish time changed. `data.post`. - `post.unscheduled`: A scheduled post went back to a draft (`scheduledFor` set to null). `data.post`. - `post.discarded`: A post that was not published was discarded (posts_delete), scheduled or not. `data.post`. - `post.published`: Every account of a post published. `data.post`. - `post.partial`: A post finished with some accounts published and some failed. `data.post`. - `post.failed`: Every account of a post failed. `data.post`. - `post.platform.published`: One account's post went live. `data.post` and `data.platformPost`. - `post.platform.failed`: One account's post failed. `data.post` and `data.platformPost` (with `error`). - `post.platform.deleted`: One account's post was deleted from its platform (posts_unpublish). `data.post` and `data.platformPost`. - `post.platform.edited`: One account's published text was edited on its platform (posts_edit_published). `data.post` and `data.platformPost` (with the new `text`). - `account.connected`: An account was connected or reconnected. `data.account`. - `account.disconnected`: An account was disconnected: by you, by deleting its set, or by the platform. `data.account`. - `account.needs_reconnect`: An account's access was revoked, expired, or expires within 7 days and Ampost cannot renew it; reconnect it to keep publishing. `data.account`. - `analytics.synced`: Analytics for an account were refreshed. `data` holds the account, status and counts. - `webhook.test`: sent only by `webhooks_test`, whatever the webhook subscribes to. `data.post` and `data.platformPost` have the shapes `posts_get` returns, and `data.account` the shape of `accounts_get`. A post's `metadata` (your own JSON object of at most 4096 bytes, set with `posts_create`) is in every post event, so you can match events to your records. ```json { "id": "4a9e2c7b-3d1f-4b8a-9c6e-1f5d3b7a9e28", "type": "account.disconnected", "createdAt": "2026-10-06T12:20:00.000Z", "data": { "account": { "id": "c41d8e2f-6a3b-4f9c-b1e5-7d2a9c4e6f80", "platform": "instagram", "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platformAccountId": "17841400000000000", "username": "ampost.io", "avatarUrl": null, "status": "revoked", "scopes": [], "tokenExpiresAt": null, "tokenHealth": "inactive", "health": { "canPost": false, "needsReconnect": true, "issues": [ { "code": "ACCOUNT_REVOKED", "severity": "error", "message": "The account is disconnected. Reconnect it with accounts_connect and its accountId." } ] }, "platformMetadata": { "displayName": "Ampost", "accountType": "BUSINESS" }, "createdAt": "2026-10-06T09:00:00.000Z", "updatedAt": "2026-10-06T12:20:00.000Z", "lastUsedAt": "2026-10-06T09:00:04.000Z" } } } ``` ### Delivery and Retries Each event is a `POST` with a JSON body `{ "id", "type", "createdAt", "data" }` and these headers: - `Ampost-Signature`: `sha256=`, the HMAC-SHA256 of `.` keyed with the webhook's `secret`. - `Ampost-Timestamp`: Unix seconds when this attempt was signed. - `Ampost-Event-Id`: the event `id`. Retries and redeliveries keep it, so store it and ignore an id you have already handled. - `Ampost-Event-Type` and `Ampost-Delivery-Id` (one delivery per webhook and event). Answer with any 2xx within 10 seconds; do slow work after answering. Anything else (another status, a redirect, a timeout, a network error) fails the attempt, and Ampost retries after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts in all). Events can arrive out of order: use `createdAt` and the resource's own fields. After 5 deliveries in a row fail all their retries, Ampost turns the webhook off (`enabled: false`, `disabledReason: "failing"`) and shows it on the dashboard; fix the receiver and turn it on with `webhooks_update`. `webhooks_deliveries_list` shows each delivery's status, attempts and last response code and body for 30 days, `webhooks_redeliver` sends one again, and `webhooks_test` sends a signed test event. ### Verify Signatures Compute the HMAC over the raw body exactly as received (before parsing JSON), compare it in constant time, and reject timestamps older than 300 seconds. ```js import crypto from "node:crypto"; // rawBody: the request body as received (a string or Buffer), before JSON.parse. export function verifyAmpostWebhook(rawBody, headers, secret) { const timestamp = headers["ampost-timestamp"]; const signature = headers["ampost-signature"]; if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false; // Reject old requests so a captured one cannot be replayed. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return ( expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) ); } ``` ```python import hashlib import hmac import time # raw_body: the request body bytes as received, before json.loads. def verify_ampost_webhook(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("Ampost-Timestamp", "") signature = headers.get("Ampost-Signature", "") if not timestamp.isdigit() or not signature: return False # Reject old requests so a captured one cannot be replayed. if abs(time.time() - int(timestamp)) > 300: return False message = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` ### List webhooks `GET /api/webhooks/endpoints` · MCP tool `webhooks_list` · scope `webhooks:write` · rate limit `apiRead` · success `200` List your webhooks, oldest first. Secrets show only as `secretPreview`. Webhooks see every platform set, so keys restricted to some sets get 403 PLATFORM_SET_NOT_ALLOWED. | Field | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [ { "id": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "url": "https://example.com/ampost/webhooks", "description": "Production receiver", "events": [ "post.published", "post.partial", "post.failed", "account.needs_reconnect" ], "secretPreview": "whsec_…nM8q", "enabled": true, "disabledReason": null, "disabledAt": null, "consecutiveFailures": 0, "lastDeliveryAt": "2026-10-08T15:00:01.000Z", "lastSuccessAt": "2026-10-08T15:00:01.000Z", "createdAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-01T09:00:00.000Z" } ], "pagination": { "limit": 20, "offset": 0, "total": 1, "hasMore": false } } ``` ### Create a webhook `POST /api/webhooks/endpoints` · MCP tool `webhooks_create` · scope `webhooks:write` · rate limit `createPost` · success `201` Send events to an https URL. Ampost POSTs each event as JSON, signed with the webhook's secret (`Ampost-Signature`), and retries a failed delivery 7 times (1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h). After 5 deliveries in a row fail all retries the webhook is turned off. At most 10 webhooks. The response is the only one with the full `secret`: store it now. Revoking the key or app that created the webhook turns it off. Events: post.scheduled, post.unscheduled, post.discarded, post.published, post.partial, post.failed, post.platform.published, post.platform.failed, post.platform.deleted, post.platform.edited, account.connected, account.disconnected, account.needs_reconnect, analytics.synced. Webhooks see every platform set, so keys restricted to some sets get 403 PLATFORM_SET_NOT_ALLOWED. | Field | In | Type | Required | Description | |---|---|---|---|---| | `url` | body | string | yes | Public https URL that receives the events. Private, loopback, internal and Ampost's own hosts are rejected. | | `events` | body | platform[] | yes | Events to send to this URL | | `description` | body | string \| null | no | | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "url": "https://example.com/ampost/webhooks", "events": [ "post.published", "post.partial", "post.failed", "account.needs_reconnect" ], "description": "Production receiver" } ``` Example response: ```json { "id": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "url": "https://example.com/ampost/webhooks", "description": "Production receiver", "events": [ "post.published", "post.partial", "post.failed", "account.needs_reconnect" ], "secretPreview": "whsec_…nM8q", "enabled": true, "disabledReason": null, "disabledAt": null, "consecutiveFailures": 0, "lastDeliveryAt": "2026-10-08T15:00:01.000Z", "lastSuccessAt": "2026-10-08T15:00:01.000Z", "createdAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-01T09:00:00.000Z", "secret": "whsec_Jx2nQ8vR4tY6uI0oP3aS5dF7gH9jK1lZ2xC4vB6nM8q" } ``` ### Get a webhook `GET /api/webhooks/endpoints/{webhookId}` · MCP tool `webhooks_get` · scope `webhooks:write` · rate limit `apiRead` · success `200` Get one webhook with its status and failure count. The secret shows only as `secretPreview`. Webhooks see every platform set, so keys restricted to some sets get 403 PLATFORM_SET_NOT_ALLOWED. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | Example response: ```json { "id": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "url": "https://example.com/ampost/webhooks", "description": "Production receiver", "events": [ "post.published", "post.partial", "post.failed", "account.needs_reconnect" ], "secretPreview": "whsec_…nM8q", "enabled": true, "disabledReason": null, "disabledAt": null, "consecutiveFailures": 0, "lastDeliveryAt": "2026-10-08T15:00:01.000Z", "lastSuccessAt": "2026-10-08T15:00:01.000Z", "createdAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-01T09:00:00.000Z" } ``` ### Update a webhook `PATCH /api/webhooks/endpoints/{webhookId}` · MCP tool `webhooks_update` · scope `webhooks:write` · rate limit `createPost` · success `200` Change a webhook's URL, events or description, turn it on or off, or rotate its secret (the response then has the new `secret`, the only time it is shown). While a webhook is off no events are queued for it. Turning it back on resets its failure count; events it missed are not sent, so resend the ones you need with webhooks_redeliver. A webhook turned off because its key or app was revoked (`disabledReason: "key_revoked"`) can only be turned on in the dashboard. Changing the URL or rotating the secret ties the webhook to the calling key or app. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | | `url` | body | string | no | | | `events` | body | platform[] | no | | | `description` | body | string \| null | no | | | `enabled` | body | boolean | no | Turn the webhook on or off. Turning it on clears `disabledReason` and the failure count. | | `rotateSecret` | body | boolean | no | True: replace the signing secret. The old one stops working at once. | Example request: ```json { "enabled": true } ``` Example response: ```json { "id": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "url": "https://example.com/ampost/webhooks", "description": "Production receiver", "events": [ "post.published", "post.partial", "post.failed", "account.needs_reconnect" ], "secretPreview": "whsec_…nM8q", "enabled": true, "disabledReason": null, "disabledAt": null, "consecutiveFailures": 0, "lastDeliveryAt": "2026-10-08T15:00:01.000Z", "lastSuccessAt": "2026-10-08T15:00:01.000Z", "createdAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-01T09:00:00.000Z" } ``` ### Delete a webhook `DELETE /api/webhooks/endpoints/{webhookId}` · MCP tool `webhooks_delete` · scope `webhooks:write` · rate limit `createPost` · success `200` Delete a webhook and its delivery log. Pending deliveries are not sent. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | Example response: ```json { "id": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "deleted": true } ``` ### Send a test event `POST /api/webhooks/endpoints/{webhookId}/test` · MCP tool `webhooks_test` · scope `webhooks:write` · rate limit `createPost` · success `200` Send a signed `webhook.test` event to the webhook now, also when it is turned off, and return the delivery with your URL's response. One attempt, no retries. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | Example response: ```json { "id": "9f4b2e6d-8a1c-4d3e-b5f7-3c9a1e7d5b02", "webhookId": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "eventId": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "eventType": "post.published", "status": "succeeded", "attempts": 1, "maxAttempts": 1, "nextAttemptAt": null, "lastAttemptAt": "2026-10-08T15:00:01.000Z", "responseStatus": 200, "responseBody": "ok", "error": null, "durationMs": 84, "redeliveryOf": null, "payload": { "id": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "type": "post.published", "createdAt": "2026-10-08T15:00:00.000Z", "data": { "post": { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "metadata": { "campaignId": "launch-2026" } } } }, "createdAt": "2026-10-08T15:00:00.000Z" } ``` ### List webhook deliveries `GET /api/webhooks/endpoints/{webhookId}/deliveries` · MCP tool `webhooks_deliveries_list` · scope `webhooks:write` · rate limit `apiRead` · success `200` The webhook's delivery log, newest first: each event sent, its status, attempts, and the last response code and body. Kept for 30 days. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | | `limit` | query | integer | no | Page size, 1-100. Default 20. | | `offset` | query | integer | no | Number of items to skip. Default 0. | Example response: ```json { "data": [ { "id": "6b2d8f4a-1c3e-4a5b-9d7f-2e4c6a8b0d13", "webhookId": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "eventId": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "eventType": "post.published", "status": "pending", "attempts": 2, "maxAttempts": 8, "nextAttemptAt": "2026-10-08T15:06:30.000Z", "lastAttemptAt": "2026-10-08T15:01:30.000Z", "responseStatus": 500, "responseBody": "Internal Server Error", "error": "HTTP 500; only 2xx counts as delivered", "durationMs": 132, "redeliveryOf": null, "payload": { "id": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "type": "post.published", "createdAt": "2026-10-08T15:00:00.000Z", "data": { "post": { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "metadata": { "campaignId": "launch-2026" } } } }, "createdAt": "2026-10-08T15:00:00.000Z" }, { "id": "9f4b2e6d-8a1c-4d3e-b5f7-3c9a1e7d5b02", "webhookId": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "eventId": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "eventType": "post.published", "status": "succeeded", "attempts": 1, "maxAttempts": 8, "nextAttemptAt": null, "lastAttemptAt": "2026-10-08T15:00:01.000Z", "responseStatus": 200, "responseBody": "ok", "error": null, "durationMs": 84, "redeliveryOf": null, "payload": { "id": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "type": "post.published", "createdAt": "2026-10-08T15:00:00.000Z", "data": { "post": { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "metadata": { "campaignId": "launch-2026" } } } }, "createdAt": "2026-10-08T15:00:00.000Z" } ], "pagination": { "limit": 20, "offset": 0, "total": 2, "hasMore": false } } ``` ### Redeliver an event `POST /api/webhooks/endpoints/{webhookId}/deliveries/{deliveryId}/redeliver` · MCP tool `webhooks_redeliver` · scope `webhooks:write` · rate limit `createPost` · success `200` Send a past delivery's event again now, with the same event id and body, as a new delivery (one attempt, also when the webhook is turned off). Returns the new delivery with your URL's response. | Field | In | Type | Required | Description | |---|---|---|---|---| | `webhookId` | path | string | yes | Webhook id | | `deliveryId` | path | string | yes | Delivery id | Example response: ```json { "id": "9f4b2e6d-8a1c-4d3e-b5f7-3c9a1e7d5b02", "webhookId": "d3a7c1e9-5b2f-4a8d-9e6c-7f1b3d5a9c21", "eventId": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "eventType": "post.published", "status": "succeeded", "attempts": 1, "maxAttempts": 1, "nextAttemptAt": null, "lastAttemptAt": "2026-10-08T15:00:01.000Z", "responseStatus": 200, "responseBody": "ok", "error": null, "durationMs": 84, "redeliveryOf": null, "payload": { "id": "2c8e4a6f-1b3d-4f5a-9e7c-8d2b4f6a1c35", "type": "post.published", "createdAt": "2026-10-08T15:00:00.000Z", "data": { "post": { "id": "7f1c2a9e-4b1d-4c55-9a0e-2f6d8b3c1a10", "status": "published", "metadata": { "campaignId": "launch-2026" } } } }, "createdAt": "2026-10-08T15:00:00.000Z" } ``` ## Docs Search Answer "how do I…" questions without leaving the client. `docs_search` searches these docs with any valid key, and the public docs MCP server needs no key at all. Both read one index of the sections of this document, each linked to its place on `https://ampost.io/docs`, so they match the docs of the running version. ### Search the docs `GET /api/docs/search` · MCP tool `docs_search` · scope none (any key) · rate limit `apiRead` · success `200` Search the Ampost docs (`/docs`, `/docs/mcp` and `/llms-full.txt`) for a question such as "schedule a post to instagram". Returns the best matching sections, each with its title, a short excerpt and its URL. Any valid key may call it. | Field | In | Type | Required | Description | |---|---|---|---|---| | `query` | query | string | yes | What you want to know, in plain words, e.g. "schedule a post to instagram". | | `limit` | query | integer | no | Number of sections, 1-20. Default 5. | Example response: ```json { "data": [ { "id": "posts/posts_create", "title": "Create, schedule or publish a post (posts_create)", "url": "https://ampost.io/docs#posts_create", "excerpt": "| `useQueue` | body | boolean | no | Schedule the post in the next free slot of its platform set's queue (see queue_get) instead of at `scheduledFor`." }, { "id": "platforms/instagram", "title": "Instagram", "url": "https://ampost.io/docs#platforms", "excerpt": "- To post to Instagram, name `instagram` in `targets[].platforms`; its settings go in `options.instagram`." } ] } ``` ### Public Docs MCP Server Streamable HTTP at `https://api.ampost.io/mcp/docs`, without auth, for clients and agents that have no Ampost key yet. Add it like the main server (for example `claude mcp add --transport http ampost-docs https://api.ampost.io/mcp/docs`), with no `Authorization` header. Tools: - `search`: `query`, optional `limit` (1-20, default 5); the same results as `docs_search`. - `list_pages`: every section with its `id`, `title` and `url`. - `get_page`: `id` from `search` or `list_pages`; the whole section in Markdown, with its subsections. ## Feedback Report a bug or suggest a change to the Ampost team. `feedback_submit` takes any valid key, at most 10 reports an hour. When an Ampost call fails in a way you can't explain or fix, offer the user to report it: send the `request` you made (method and path, or `MCP` and the tool name, and the body) and the `error` you got (status, code, message, `X-Request-Id`). Ampost adds the user's plan and connected accounts with their health, removes fields named like secrets and Ampost keys inside strings, and keeps the report for 12 months. Tell the user the report's `id`. ### Report a bug or suggest a change `POST /api/feedback` · MCP tool `feedback_submit` · scope none (any key) · rate limit `feedback` · success `201` Send the Ampost team a bug report or a suggestion. When an Ampost call fails in a way you can't explain or fix, report it with the `request` you sent (method, path or tool, body) and the `error` you got (status, code, message, `X-Request-Id`). Ampost adds the user's plan and connected accounts with their health. Fields named like secrets and Ampost keys in strings are removed; don't send credentials. Any valid key may call it; at most 10 reports an hour. | Field | In | Type | Required | Description | |---|---|---|---|---| | `kind` | body | `bug` \| `suggestion` | yes | `bug` for something that went wrong, `suggestion` for a change you want | | `message` | body | string | yes | What happened and what you expected, or what you want changed. At most 5000 characters. | | `route` | body | string | no | The dashboard page, or the API path or MCP tool the report is about | | `request` | body | object | no | | | `error` | body | object | no | | | `recentRequests` | body | object[] | no | The dashboard's last API calls; the dashboard fills it in | | `browser` | body | string | no | The browser's user agent; the dashboard fills it in | | `screenshot` | body | string | no | Optional PNG or JPEG, base64 (a `data:` URL is fine). Ampost re-encodes it as JPEG. | | `Idempotency-Key` (MCP `idempotencyKey`) | header | string | no | Idempotency key (1-255 visible ASCII characters, for example a UUID). A retry with the same key and arguments within 24 hours returns the first result instead of running again. Send a new key for each new action. See Idempotency. | Example request: ```json { "kind": "bug", "message": "Scheduling a LinkedIn post for next Monday fails, though the account shows as healthy.", "route": "/api/posts", "request": { "method": "POST", "path": "/api/posts", "requestId": "req_8c1f2a9d4b7e4c0a", "body": { "targets": [ { "platformSetId": "0b6e4d2a-9c3f-4e1b-8a7d-5f2c1e9b3d40", "platforms": [ "linkedin" ] } ], "text": "Launch day", "scheduledFor": "2026-10-26T09:00:00.000Z" } }, "error": { "status": 500, "code": "INTERNAL_ERROR", "message": "Something went wrong", "requestId": "req_8c1f2a9d4b7e4c0a" } } ``` Example response: ```json { "id": "6a0d3f52-1c4b-4e8e-9a51-3f7b2c9d8e10", "kind": "bug", "createdAt": "2026-10-23T09:14:00.000Z", "attached": [ "route", "request", "error", "plan", "accounts" ] } ``` ## MCP Server Streamable HTTP at `https://api.ampost.io/mcp` with `Authorization: Bearer `, or with an OAuth access token: without a credential, `/mcp` answers 401 with `WWW-Authenticate: Bearer resource_metadata="https://api.ampost.io/.well-known/oauth-protected-resource/mcp"`, and clients that support MCP OAuth (claude.ai, ChatGPT, Claude Code, MCP Inspector) discover the authorization server, register (RFC 7591), sign the user in with PKCE and get tokens limited to the scopes and platform sets the user allowed. Access tokens last an hour and work only at `/mcp`, not on REST; refresh tokens rotate. The user sees and disconnects these apps at `https://ampost.io/app/settings/connected-apps`. Configs that name a transport need `"type": "http"`. Install per client (replace `amp_live_YOUR_KEY` with the user's key): Tools (each is the REST operation of the same name above): - `platforms_list`: List platforms and their capabilities (`GET /api/platforms`, scope `posts:read`) - `posts_list`: List posts (`GET /api/posts`, scope `posts:read`) - `posts_create`: Create, schedule or publish a post (`POST /api/posts`, scope `posts:write`) - `posts_import_csv`: Import posts from CSV (`POST /api/posts/bulk`, scope `posts:write`) - `posts_get`: Get a post (`GET /api/posts/{postId}`, scope `posts:read`) - `posts_update`: Update a post (`PATCH /api/posts/{postId}`, scope `posts:write`) - `posts_delete`: Delete a post (`DELETE /api/posts/{postId}`, scope `posts:write`) - `posts_publish`: Publish a post now (`POST /api/posts/{postId}/publish`, scope `posts:write`) - `posts_unpublish`: Unpublish a post (`POST /api/posts/{postId}/unpublish`, scope `posts:write`) - `posts_edit_published`: Edit a published post's text (`POST /api/posts/{postId}/edit-published`, scope `posts:write`) - `posts_retry`: Retry failed accounts (`POST /api/posts/{postId}/retry`, scope `posts:write`) - `media_upload_create`: Start a media upload (`POST /api/media/uploads`, scope `media:write`) - `media_upload_status`: Get a media upload (`GET /api/media/uploads/{uploadId}`, scope `media:write`) - `accounts_list`: List connected accounts (`GET /api/accounts`, scope `accounts:read`) - `accounts_get`: Get a connected account (`GET /api/accounts/{accountId}`, scope `accounts:read`) - `accounts_disconnect`: Disconnect an account (`DELETE /api/accounts/{accountId}`, scope `accounts:write`) - `accounts_set_analytics`: Turn X analytics on or off (`PATCH /api/accounts/{accountId}/analytics`, scope `accounts:write`) - `accounts_list_boards`: List a Pinterest account's boards (`GET /api/accounts/{accountId}/boards`, scope `accounts:read`) - `accounts_set_board`: Pick a Pinterest account's default board (`PATCH /api/accounts/{accountId}/board`, scope `accounts:write`) - `accounts_connect`: Start connecting an account (`POST /api/accounts/connect`, scope `accounts:write`) - `accounts_connect_status`: Get a connection request (`GET /api/accounts/connect/{connectRequestId}`, scope `accounts:read`) - `platform_sets_list`: List platform sets (`GET /api/platform-sets`, scope `accounts:read`) - `platform_sets_create`: Create a platform set (`POST /api/platform-sets`, scope `accounts:write`) - `platform_sets_get`: Get a platform set (`GET /api/platform-sets/{platformSetId}`, scope `accounts:read`) - `platform_sets_update`: Rename a platform set (`PATCH /api/platform-sets/{platformSetId}`, scope `accounts:write`) - `platform_sets_delete`: Delete a platform set (`DELETE /api/platform-sets/{platformSetId}`, scope `accounts:write`) - `queue_get`: Get a posting queue (`GET /api/platform-sets/{platformSetId}/queue`, scope `posts:read`) - `queue_update`: Update a posting queue (`PATCH /api/platform-sets/{platformSetId}/queue`, scope `posts:write`) - `queue_slots`: Preview queue slots (`GET /api/platform-sets/{platformSetId}/queue/slots`, scope `posts:read`) - `analytics_posts`: Post analytics (`GET /api/analytics/posts`, scope `analytics:read`) - `analytics_overview`: Analytics overview (`GET /api/analytics/overview`, scope `analytics:read`) - `analytics_followers`: Follower history (`GET /api/analytics/followers`, scope `analytics:read`) - `webhooks_list`: List webhooks (`GET /api/webhooks/endpoints`, scope `webhooks:write`) - `webhooks_create`: Create a webhook (`POST /api/webhooks/endpoints`, scope `webhooks:write`) - `webhooks_get`: Get a webhook (`GET /api/webhooks/endpoints/{webhookId}`, scope `webhooks:write`) - `webhooks_update`: Update a webhook (`PATCH /api/webhooks/endpoints/{webhookId}`, scope `webhooks:write`) - `webhooks_delete`: Delete a webhook (`DELETE /api/webhooks/endpoints/{webhookId}`, scope `webhooks:write`) - `webhooks_test`: Send a test event (`POST /api/webhooks/endpoints/{webhookId}/test`, scope `webhooks:write`) - `webhooks_deliveries_list`: List webhook deliveries (`GET /api/webhooks/endpoints/{webhookId}/deliveries`, scope `webhooks:write`) - `webhooks_redeliver`: Redeliver an event (`POST /api/webhooks/endpoints/{webhookId}/deliveries/{deliveryId}/redeliver`, scope `webhooks:write`) - `docs_search`: Search the docs (`GET /api/docs/search`, scope none (any key)) - `feedback_submit`: Report a bug or suggest a change (`POST /api/feedback`, scope none (any key)) ### Claude Code For the `claude` CLI, the Code tab of the Claude desktop app and the Claude Code IDE extensions: they are all Claude Code. Run this in a terminal. `--scope user` adds Ampost to every project; leave it out to add it to the current project only. Add with the CLI: ```bash claude mcp add --transport http --scope user ampost https://api.ampost.io/mcp --header "Authorization: Bearer amp_live_YOUR_KEY" ``` Or share it with a project; each person sets `AMPOST_API_KEY` (`.mcp.json`): ```json { "mcpServers": { "ampost": { "type": "http", "url": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer ${AMPOST_API_KEY}" } } } } ``` No `claude` command (the Code tab of the Claude app): add it to the user config instead (`~/.claude.json`): ```json { "mcpServers": { "ampost": { "type": "http", "url": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer amp_live_YOUR_KEY" } } } } ``` - With `.mcp.json`, `AMPOST_API_KEY` must be set where Claude Code starts. macOS and Linux: in the environment that launches it (a shell profile reaches the terminal, not an app started from the Dock or a menu). Windows: `setx AMPOST_API_KEY amp_live_YOUR_KEY`, then restart the app or terminal. Without it the header is empty and Claude Code shows "Dynamic Client Registration rejected". - With `.mcp.json`, Claude Code first lists Ampost as "Pending approval (run `claude` to approve)": start `claude` in the project and approve the server. The `--scope user` command needs no approval. - The Code tab of the Claude desktop app does not put `claude` on PATH (it bundles its own copy). Install the CLI (macOS, Linux: `curl -fsSL https://claude.ai/install.sh | bash`; Windows PowerShell: `irm https://claude.ai/install.ps1 | iex`) and run the command above, or merge the `~/.claude.json` entry into that file: top-level `mcpServers` for every project, or `projects..mcpServers` for one. Do not use the Claude (app) section for the Code tab. - Start a new session after adding the server. - Without a key: leave out `--header`, then run `/mcp` in a session, pick Ampost and sign in with your Ampost account in the browser. - Check: `claude mcp list` or `/mcp` in a session shows Ampost as connected. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### Cursor Click Add to Cursor and confirm in Cursor, or add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project). Add to Cursor link: `cursor://anysphere.cursor-deeplink/mcp/install?name=ampost&config=` with this config. It contains the key: give it only to the user it belongs to. ```json {"url":"https://api.ampost.io/mcp","headers":{"Authorization":"Bearer amp_live_YOUR_KEY"}} ``` Or add it to the config file (`~/.cursor/mcp.json`): ```json { "mcpServers": { "ampost": { "url": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer amp_live_YOUR_KEY" } } } } ``` - To keep the key out of the file, write `"Authorization": "Bearer ${env:AMPOST_API_KEY}"` and set `AMPOST_API_KEY`. - Check: Cursor Settings, MCP lists Ampost with its tools. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### VS Code Needs VS Code 1.102 or later with GitHub Copilot. Click Add to VS Code, or run the command, or add the server to `.vscode/mcp.json`. Add to VS Code link: `vscode:mcp/install?` with this config. It contains the key: give it only to the user it belongs to. ```json {"name":"ampost","type":"http","url":"https://api.ampost.io/mcp","headers":{"Authorization":"Bearer amp_live_YOUR_KEY"}} ``` Add with the CLI (macOS, Linux): ```bash code --add-mcp '{"name":"ampost","type":"http","url":"https://api.ampost.io/mcp","headers":{"Authorization":"Bearer amp_live_YOUR_KEY"}}' ``` Or add it to the workspace; VS Code asks for the key once and stores it securely (`.vscode/mcp.json`): ```json { "inputs": [ { "type": "promptString", "id": "ampost-api-key", "description": "Ampost API key", "password": true } ], "servers": { "ampost": { "type": "http", "url": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer ${input:ampost-api-key}" } } } } ``` - Call tools in agent mode. - Check: The command `MCP: List Servers` shows Ampost as running. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### Codex The Codex CLI, the IDE extension and the Codex app share `~/.codex/config.toml`. Codex reads the key from the `AMPOST_API_KEY` environment variable. Add with the CLI: ```bash export AMPOST_API_KEY="amp_live_YOUR_KEY" codex mcp add ampost --url https://api.ampost.io/mcp --bearer-token-env-var AMPOST_API_KEY ``` Or add it to the config file (`~/.codex/config.toml`): ```toml [mcp_servers.ampost] url = "https://api.ampost.io/mcp" bearer_token_env_var = "AMPOST_API_KEY" ``` - Set `AMPOST_API_KEY` where Codex starts: in your shell profile, or on Windows with `setx AMPOST_API_KEY amp_live_YOUR_KEY`, then restart Codex. - Check: `codex mcp list` shows Ampost. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### Claude (app) For chat in Claude on the web (claude.ai), the desktop app and mobile. Add Ampost as a custom connector: it signs in with your Ampost account, so there is no API key to paste. The Code tab of the app is Claude Code: use the Claude Code section. Remote MCP server URL: ```text https://api.ampost.io/mcp ``` - Open Settings, Connectors and click Add custom connector. Name it Ampost, paste the URL above and click Add. - Click Connect. Ampost's sign-in page opens: sign in, choose what Claude may do (every scope, read only, or custom, and optionally some platform sets) and click Allow. - A connector added on claude.ai also works in the desktop and mobile apps. On Team and Enterprise plans an owner adds it under Organization settings, Connectors first. - In a chat, turn Ampost on in the tools menu. To disconnect, remove the connector in Claude or click Disconnect in Ampost under Settings, Connected apps. - Check: Settings, Connectors shows Ampost as connected, and the tools menu of a new chat lists its tools. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### ChatGPT ChatGPT adds remote MCP servers as connectors in developer mode, which signs in with your Ampost account. Developer mode is offered on some ChatGPT plans only; menu names can differ slightly between versions. MCP server URL: ```text https://api.ampost.io/mcp ``` - Open Settings, Apps & Connectors, Advanced settings and turn on Developer mode. - Back in Apps & Connectors, click Create. Name it Ampost, paste the URL above, choose OAuth as the authentication, confirm that you trust the app and click Create. - ChatGPT opens Ampost's sign-in page: sign in, choose the access and click Allow. - In a chat, pick Developer mode in the + menu and turn Ampost on. ChatGPT asks before tools that change something, such as `posts_create`. - Check: Apps & Connectors lists Ampost as connected, and the chat's developer mode menu lists its tools. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### Devin Desktop (Windsurf) Add the server to `~/.config/devin/mcp_config.json` (Windows: `%APPDATA%\devin\mcp_config.json`), then refresh the MCP servers. Add it to the config file (`mcp_config.json`): ```json { "mcpServers": { "ampost": { "serverUrl": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer amp_live_YOUR_KEY" } } } } ``` - Older Windsurf installs read `~/.codeium/windsurf/mcp_config.json`; the same entry works there. - Check: The MCP servers panel lists Ampost with its tools. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### Other clients Any client that supports Streamable HTTP with custom headers works. Some name the root key `servers` or the URL field `serverUrl`. Generic config (`mcp.json`): ```json { "mcpServers": { "ampost": { "type": "http", "url": "https://api.ampost.io/mcp", "headers": { "Authorization": "Bearer amp_live_YOUR_KEY" } } } } ``` - A client that supports MCP OAuth can leave out `headers`: it then opens Ampost's sign-in page and connects with the access you allow. - A client that only runs local (stdio) servers can use the `mcp-remote` bridge: `npx -y mcp-remote https://api.ampost.io/mcp --header "Authorization:${AMPOST_AUTH}"` with `AMPOST_AUTH` set to `Bearer amp_live_YOUR_KEY` in the server's `env`. - Check: The client's MCP server list shows Ampost with its tools. Ask the assistant to call `platforms_list`. A working setup answers with the platforms Ampost supports (Instagram, Facebook, LinkedIn, TikTok, Threads, X, YouTube, Pinterest, Bluesky, Telegram, Discord) and their rules, such as text limits and media types. The raw result is `{ "data": [{ "platform": "instagram", "name": "Instagram", "text": { "maxLength": 2200 }, ... }], "pagination": { ... } }`. ### If It Fails - `401` with `UNAUTHORIZED`: The key is wrong or revoked. Copy it again without spaces or line breaks, or create a new one in Settings, API keys. - "No API key or access token arrived", or an Ampost sign-in page opens although the config has a key: The key did not arrive, so the client fell back to signing in with OAuth. Signing in works too (the client then shows under Settings, Connected apps). To use the key, check that the `Authorization` header is in the config; if it uses `${AMPOST_API_KEY}`, set the variable where the client starts or paste the key itself. - `403` with `INSUFFICIENT_SCOPE`: The key or connected app lacks the scope the tool needs (in `details.requiredScope`). Create a key with it, or disconnect the app in Settings, Connected apps and connect it again with that access. - `403` with `UPGRADE_REQUIRED`: The plan does not include that feature, for example scheduling on Free. Upgrade on the pricing page. - `claude: command not found` in the Code tab of the Claude app: The app bundles its own Claude Code and does not put `claude` on PATH. Install the CLI or edit `~/.claude.json`, as in the Claude Code section. - Changes not taking effect: Clients load MCP servers at start. Quit and restart the client fully (also from the system tray); in Claude Code and the Claude app, start a new session. ## Rate Limits and Plans The REST API and MCP need the `apiKeys` plan feature (`free`: yes, `pro`: yes, `max`: yes, `scale`: yes; see Plan Requirement). Per-operation limits per API key owner: | Limit | Free | Pro | Max | Scale | Refilled per | |---|---:|---:|---:|---:|---| | `apiRead` | 60 | 600 | 2000 | 4000 | minute | | `createPost` | 30 | 300 | 1000 | 3000 | hour | | `publishPost` | 10 | 100 | 500 | 1500 | hour | | `mediaUpload` | 30 | 200 | 500 | 1500 | hour | | `connectPlatform` | 5 | 20 | 50 | 200 | hour | | `feedback` | 10 | 10 | 10 | 10 | hour | | `importPosts` | 100 | 2000 | 5000 | 10000 | hour | Each limit is a token bucket, not a fixed window: up to N requests at once, refilled continuously at N per window (60 per minute is one more request about every second), so short bursts are fine. API-key responses carry `X-RateLimit-Limit` (N), `X-RateLimit-Remaining` (requests allowed right now) and `X-RateLimit-Reset` (when the bucket is full again, Unix seconds). Over the limit: 429 `RATE_LIMITED`. Over the monthly post quota: 429 `QUOTA_EXCEEDED` with `limit`, `used` and `resetsAt` in `details`. On Free a publish also counts on each social account it goes to, across every Free workspace with that account; over that quota `details` also has `scope: "socialAccount"` and `accountId`, and nothing is published. A publish that fails gives its count back. Every 429 carries `Retry-After` (seconds until a request can pass again; MCP: `details.retryAfterSeconds`): wait that long, then retry. Every API-key call (REST or MCP) that passes the rate limit counts toward the monthly `apiRequestsPerMonth`, shown on the dashboard's Usage & plan page; that limit is not enforced yet. Paid plans have no monthly post quota; the rate limits above still apply. Scheduling needs the Pro plan or higher: 403 `UPGRADE_REQUIRED`. ## Errors ```json { "error": { "code": "VALIDATION_FAILED", "message": "media[0].type: Invalid option: expected one of \"image\"|\"video\"", "details": { "issues": [ { "path": "media[0].type", "message": "Invalid option: expected one of \"image\"|\"video\"" } ] } } } ``` ```json { "error": { "code": "INVALID_PLACEMENT", "message": "\"instagram\" does not support placement \"reel\" with current media (single-image). Available: feed, story", "details": { "platform": "instagram", "placement": "reel", "mediaShape": "single-image", "available": [ "feed", "story" ] } } } ``` ```json { "error": { "code": "QUOTA_EXCEEDED", "message": "Monthly post limit reached (10 posts). It resets at 2026-11-01T00:00:00.000Z.", "details": { "quota": "postsPerMonth", "limit": 10, "used": 10, "resetsAt": "2026-11-01T00:00:00.000Z", "tier": "free" } } } ``` | Code | HTTP | Meaning | |---|---:|---| | `INVALID_REQUEST` | 400 | The body is not JSON, or the multipart form cannot be read. | | `VALIDATION_FAILED` | 400 | A field is missing, has the wrong type or breaks a platform rule, or an id is not a UUID. `details.issues` lists each problem with its path. | | `CONTENT_TOO_LONG` | 400 | The effective text is longer than the platform allows. | | `UNSUPPORTED_MEDIA_TYPE` | 400 | The platform does not accept this media type. | | `UNSUPPORTED_MEDIA_CONFIG` | 400 | The platform does not accept this combination of media. | | `MIXED_MEDIA_NOT_SUPPORTED` | 400 | The platform cannot mix images and videos in one post. | | `INVALID_PLACEMENT` | 400 | The placement does not fit the media. `details.available` lists the allowed values. | | `MEDIA_FETCH_FAILED` | 400 | Ampost could not download a media URL: it failed, timed out, redirected too often or led to a private address. `details.field` names the item. | | `PLATFORM_NOT_CONNECTED` | 400 | The platform set has no active account for a requested platform. Connect one with `accounts_connect`, or target another set. | | `TOO_MANY_ROWS` | 400 | `posts_import_csv`: the file has more rows than the plan imports at once. `details` has `rows`, `maxRows` and `tier`; split the file. | | `UNAUTHORIZED` | 401 | The API key is missing, invalid or revoked (on MCP also an expired or revoked OAuth access token). | | `INSUFFICIENT_SCOPE` | 403 | The API key or connected app lacks the scope this operation needs. `details.requiredScope` names it. | | `PLATFORM_SET_NOT_ALLOWED` | 403 | The API key is restricted to other platform sets. `details.platformSetId` names the refused set (null when creating a set). | | `UPGRADE_REQUIRED` | 403 | The plan does not include this feature (scheduling and queues, X publishing and X analytics). `details` has `feature` or `platform`, and `tier`. | | `ACCOUNT_LIMIT_REACHED` | 403 | The plan's social account limit is reached (`accounts_connect`). `details` has `limit`, `used` and `tier`. Also a platform post's `error.code` when its account is beyond the limit after a downgrade: only the accounts connected first, up to the limit, publish until the user disconnects some or upgrades. | | `ACCOUNT_SUSPENDED` | 403 | Ampost suspended this account, usually after an abuse report: API keys, connected apps and the dashboard stop working, and scheduled posts fail with this code instead of publishing. Write to support@ampost.io. | | `WEBHOOK_KEY_REVOKED` | 403 | `webhooks_update` cannot turn on a webhook with `disabledReason: "key_revoked"`; the user turns it on in the dashboard. | | `ANALYTICS_NOT_ENABLED` | 403 | No matching account has analytics enabled (X needs `accounts_set_analytics`; LinkedIn needs Ampost's Community Management API access). `details.accounts[]` gives each account's `reason`. | | `NOT_FOUND` | 404 | No endpoint at this path. | | `POST_NOT_FOUND` | 404 | No post with this id belongs to you. | | `ACCOUNT_NOT_FOUND` | 404 | No account with this id belongs to you. | | `PLATFORM_SET_NOT_FOUND` | 404 | No platform set with this id belongs to you. | | `CONNECT_REQUEST_NOT_FOUND` | 404 | No connection request with this id belongs to you. | | `MEDIA_UPLOAD_NOT_FOUND` | 404 | No media upload with this id belongs to you. | | `WEBHOOK_NOT_FOUND` | 404 | No webhook with this id belongs to you. | | `DELIVERY_NOT_FOUND` | 404 | No delivery with this id belongs to the webhook. | | `METHOD_NOT_ALLOWED` | 405 | The path exists but not with this method. `details.allowed` and the `Allow` header list the methods. | | `INVALID_STATUS_TRANSITION` | 409 | The post's status does not allow this action, for example `posts_update` on a published post. | | `IDEMPOTENCY_KEY_REUSED` | 409 | The idempotency key was already used for a different request. Send a new key for a new action. | | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | 409 | A request with this idempotency key is still running. Retry with the same key a few seconds later. | | `QUEUE_EMPTY` | 409 | `useQueue`: the platform set's queue has no slots. Add slots with `queue_update`. | | `QUEUE_FULL` | 409 | `useQueue`: every slot of the queue in the next 366 days is taken. | | `QUEUE_SLOT_TAKEN` | 409 | `useQueue`: concurrent requests kept taking the free slots. Retry. | | `ACCOUNT_NEEDS_RECONNECT` | 409 | The account is disconnected, or the platform no longer accepts its access (`accounts_list_boards`, `accounts_set_board`). Reconnect it with `accounts_connect` and its id as `accountId`. | | `PLATFORM_SET_LIMIT_REACHED` | 409 | You already have the maximum number of platform sets. | | `LAST_PLATFORM_SET` | 409 | Your last platform set cannot be deleted. | | `PLATFORM_SET_IN_USE` | 409 | An account was connected to the platform set while it was being deleted. Retry the delete. | | `WEBHOOK_LIMIT_REACHED` | 409 | You already have the maximum number of webhooks. `details.limit` gives it. | | `PLATFORM_ALREADY_CONNECTED` | 409 | `accounts_connect`: the platform set already has an active account for this platform. A set holds one account per platform: connect the second account into another set, or disconnect the first. | | `CONNECT_IN_PROGRESS` | 409 | `accounts_connect`: the user is finishing a connection for this platform and set right now. Poll the open request, then try again. | | `SOCIAL_ACCOUNT_IN_USE` | 409 | Free plan: the social account is connected in another Ampost workspace, or was disconnected from one less than 30 days ago. A paid plan can connect it. | | `PAYLOAD_TOO_LARGE` | 413 | The request body is over 4 MB; send large files with a media upload. A body over 4.5 MB never reaches Ampost: the hosting edge answers a plain-text 413 `FUNCTION_PAYLOAD_TOO_LARGE`. | | `ANALYTICS_NOT_AVAILABLE_ON_PLATFORM` | 422 | The platform has no analytics (Discord). `details.platforms` names it. | | `QUOTA_EXCEEDED` | 429 | The monthly post quota is used up. `details` has `limit`, `used` and `resetsAt`; `Retry-After` says how long to wait. On Free a publish also counts per social account across workspaces; then `details.scope` is `socialAccount` and `details.accountId` names the account. | | `DAILY_PUBLISH_LIMIT_REACHED` | 429 | A platform post's `error.code` when the account's daily publish limit is used up: accounts younger than 7 days publish at most 20 times a day on Free and 100 on a paid plan. `details` has `limit`, `used` and `resetsAt`; Ampost retries the post by itself after the reset. | | `RATE_LIMITED` | 429 | Too many requests. Wait `Retry-After` seconds (`details.retryAfterSeconds`), then retry. | | `INTERNAL_ERROR` | 500 | Something failed on Ampost's side. Retry later, and quote the response's `X-Request-Id` if you contact support. | | `MEDIA_UPLOAD_FAILED` | 502 | Ampost could not store a media file for the post. Retry later. | | `PLATFORM_REQUEST_FAILED` | 502 | The platform refused or failed a request Ampost made for this operation (`accounts_list_boards`, `accounts_set_board`). The message says what the platform answered. Retry later. | | `PLATFORM_NOT_CONFIGURED` | 503 | `accounts_connect`: Ampost cannot connect this platform at the moment. Try again later or contact support. | Platform errors on `platformPosts[].error.code` include `AUTH_EXPIRED`, `AUTH_REVOKED`, `REFRESH_TOKEN_EXPIRED`, `PAGE_IDENTITY_CONFIRMATION_REQUIRED`, `PRIVACY_LEVEL_UNAVAILABLE`, `PUBLISH_FAILED`, `RATE_LIMITED` (the platform's rate limit; Ampost retries it), `PUBLISH_OUTCOME_UNKNOWN` (the platform stopped answering and Ampost cannot check whether the post went out; check the account before `posts_retry`), `MEDIA_FETCH_FAILED` (Ampost could not download the media to upload it), `UNPUBLISH_NOT_SUPPORTED` and `PERMISSION_MISSING` (from `posts_unpublish`), `EDIT_NOT_SUPPORTED` (from `posts_edit_published`), `FIRST_COMMENT_NOT_SUPPORTED` (on `platformPosts[].firstComment.error`) and `PLATFORM_ERROR`. Row errors of `posts_import_csv` (`rows[].errors[].code`) are the codes above, plus `NOT_PROCESSED` for rows the import had no time left for; send those rows again in a new import. ## Dashboard-Only Routes API keys (`/api/keys`), connected apps, billing and the subscription, the dashboard's analytics views and manual sync (`/api/dashboard/analytics/*`), OAuth callbacks and the hosted Facebook Page picker use the dashboard session and reject API keys. Reconnect (`accounts_connect` with `accountId`), disconnect (`accounts_disconnect`) and webhooks are public operations above. The dashboard calls the public endpoints with its session instead of a key. ## Integration Guidance for LLMs When asked to create or schedule a post: 1. Call `platforms_list` once to learn each platform's rules and options. 2. Call `accounts_list` to see which platforms are connected (optionally per `platformSetId`). 3. Call `posts_create` with `targets` naming only connected platforms (one target per platform set), media items that have a `type`, and `options` that fit the platform rules. Leave `scheduledFor` out for a draft. Pass a new `idempotencyKey` (a UUID), and the same one if you retry the call. 4. Publish with `publishNow: true` or `posts_publish` only when the user wants it published now, again with an `idempotencyKey`. 5. Read `platformPosts` for each account's result. An account `pending` with `nextAttemptAt` is waiting for an automatic retry: do not call `posts_retry` for it. Use `posts_retry` for `failed` accounts after fixing the cause. When the user wants to post a file from their device: 1. Call `media_upload_create`, show `uploadPageUrl` and ask the user to upload the files there (if the result has no `uploadPageUrl`, your client already opened the page for the user). If you have the file and can make HTTP requests, PUT it to `uploadUrl` instead. 2. Poll `media_upload_status` until `completed` (or `failed` / `expired`: then offer a new link). 3. Pass its `files` as `media` to `posts_create`. When asked to connect an account: 1. Call `accounts_connect` with the platform key (for example `twitter` for X) and an optional `platformSetId`, or with `accountId` to reconnect an account whose `health.needsReconnect` is true. 2. Show the returned `connectUrl` and tell the user to finish in a browser. If the result has no `connectUrl`, your client already opened the page: tell the user to finish there. 3. Never ask for provider credentials, OAuth codes, state, URL fragments or Page tokens in chat. 4. Poll `accounts_connect_status` until `connected`, `failed` or `expired`, then use `accountId` with `accounts_get` if you need details. When asked to disconnect an account, confirm with the user first, call `accounts_disconnect`, and relay `providerAccessSettings` when `providerRevoked` is false. Instead of polling posts, an integration can subscribe to events with `webhooks_create` (give the user the returned `secret` once) and verify each delivery's signature.