# Buffer API — working collection Buffer's public API is **GraphQL, one endpoint, POST only**: ``` POST https://api.buffer.com Authorization: Bearer Content-Type: application/json ``` There are no REST paths. The operation is decided entirely by the GraphQL document in the body. Docs: · Explorer: ## Files | File | What it is | |---|---| | `Buffer-API.postman_collection.json` | 38 requests in 9 folders. Import into Postman/Insomnia/Bruno. | | `Buffer-API.postman_environment.json` | Empty environment template — safe to commit. | | `Buffer-API.postman_environment.local.json` | Same, pre-filled with the key + ids from `backend/.env`. **Gitignored — do not commit.** | ## Setup 1. Import the collection **and** `Buffer-API.postman_environment.local.json`, then select that environment. (Or import the plain template and paste `BUFFER_API` from `backend/.env` into `buffer_token`.) 2. Run **01 · Get Organizations** → fills `{{org_id}}`. 3. Run **02 · Get Channels** → fills `{{channel_id}}`. Everything else works from there. Test scripts chain the ids for you: | Variable | Filled by | Used by | |---|---|---| | `org_id` | 01 · Get Organizations | almost everything | | `channel_id` | 02 · Get Channels | all create requests | | `post_id` | 03 · Get Posts, every create request | Get Post by ID, Edit Post, **Delete Post** | | `sent_post_id` | 03 · Get Sent Posts | 06 · Get Post Metrics | | `queued_post_id` | 03 · Get Scheduled Posts, 04 · Add to Queue | 04 · Move Post in Queue | | `posts_cursor` | 03 · Get Posts | 03 · Get Posts — Next Page | So **Delete Post** always targets the last post you touched. ## The endpoints you asked for | Need | Folder / request | Where the value is | |---|---|---| | **org_id** | 01 · Get Organizations | `data.account.organizations[].id` | | **channel_id** | 02 · Get Channels | `data.channels[].id` | | **create a post** | 04 · Create Post · … | `data.createPost` → `PostActionSuccess.post.id` | | **delete a post** | 04 · Delete Post | `data.deletePost` → `DeletePostSuccess.id` | | **list posts** | 03 · Get Posts | `data.posts.edges[].node` | | **one post** | 03 · Get Post by ID | `data.post` | | **edit a post** | 04 · Edit Post | `editPost` (not `updatePost`) | ### org_id ```bash curl -s -X POST https://api.buffer.com \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $BUFFER_API" \ -d '{"query":"query { account { id email organizations { id name channelCount } } }"}' ``` ### channel_id ```bash curl -s -X POST https://api.buffer.com \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $BUFFER_API" \ -d '{"query":"query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service type isDisconnected isQueuePaused } }", "variables":{"input":{"organizationId":"'"$BUFFER_ORG_ID"'"}}}' ``` ### create post ```bash curl -s -X POST https://api.buffer.com \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $BUFFER_API" \ -d '{"query":"mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id status dueAt } } ... on MutationError { message } } }", "variables":{"input":{"channelId":"'"$BUFFER_CHANNEL_ID"'","text":"Hello","schedulingType":"automatic","mode":"addToQueue","assets":[]}}}' ``` ### delete post ```bash curl -s -X POST https://api.buffer.com \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $BUFFER_API" \ -d '{"query":"mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { __typename ... on DeletePostSuccess { id } ... on MutationError { message } } }", "variables":{"input":{"id":"POST_ID"}}}' ``` ### list posts ```bash curl -s -X POST https://api.buffer.com \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $BUFFER_API" \ -d '{"query":"query GetPosts($first: Int, $after: String, $input: PostsInput!) { posts(first: $first, after: $after, input: $input) { edges { cursor node { id text status dueAt sentAt channelId externalLink } } pageInfo { hasNextPage endCursor } } }", "variables":{"first":20,"input":{"organizationId":"'"$BUFFER_ORG_ID"'","filter":{"status":["scheduled"]}}}}' ``` ## `.env` mapping | `.env` key | Collection variable | Notes | |---|---|---| | `BUFFER_API` | `buffer_token` | The personal access token. Buffer → Settings → API. | | `BUFFER_API_URL` | `buffer_api_url` | `https://api.buffer.com` — correct as-is. | | `BUFFER_CHANNEL_ID` | `channel_id` | Currently the LinkedIn profile `ahmedmujtababaig`. | | — | `org_id` | **Not in `.env`.** `CLIENT_ID` in `backend/.env` holds this value, but it is the *organization id*, not an OAuth client id — the naming is misleading. Consider renaming it to `BUFFER_ORG_ID`. | `plugins.py` re-derives the org id on every `list_buffer_channels()` call, so nothing is broken today; caching it in `BUFFER_ORG_ID` would save one round trip per request. ## Enums worth memorising | Enum | Values | |---|---| | `ShareMode` (`mode`) | `addToQueue` · `shareNext` · `shareNow` · `customScheduled` | | `SchedulingType` | `automatic` (Buffer publishes) · `notification` (Buffer reminds you) | | `PostStatus` | `draft` · `needs_approval` · `scheduled` · `sending` · `sent` · `error` | | `PostSortableKey` | `dueAt` · `createdAt` **only** | | `SortDirection` | `asc` · `desc` | | `QueuePosition` | `top` · `bottom` | | `Service` | `linkedin` `twitter` `facebook` `instagram` `tiktok` `threads` `youtube` `pinterest` `mastodon` `bluesky` `googlebusiness` `startPage` | | `PostMetricType` | `impressions` `reach` `reactions` `likes` `comments` `shares` `reposts` `quotes` `clicks` `saves` `follows` `views` `viewers` `totalTimeWatched` `engagementRate` `postCount` | ## Gotchas that cost real time - **Errors come back as HTTP 200.** Check `errors[]` and `__typename`, not the status code. - **Do not request `totalCount` on `posts`** — API-key auth gets `FORBIDDEN` and the whole query returns `data: null`. - The edit mutation is **`editPost`**, not `updatePost`. - `deletePost` returns **`DeletePostSuccess`**, not `PostActionSuccess`. A blanket `... on PostActionSuccess` fragment silently matches nothing. - `schedulingType` is *not* the queue mode. `automatic` vs `notification` only. The queue mode is `mode`. - `mode: customScheduled` requires `dueAt` (ISO 8601 UTC). `mode: shareNow` publishes immediately with no undo. - `assets` URLs are fetched **server-side** — they must return raw bytes, not an HTML page. - `metadata..linkAttachment` and a non-empty `assets` array are mutually exclusive. - LinkedIn `linkAttachment` only accepts `{ url }`; there is no title/description override. - There is **no `deleteIdea` mutation** — ideas created via the API must be removed in the UI. - `movePostInQueue` only accepts posts whose `shareMode` is `addToQueue`/`shareNext`. Drafts and `customScheduled` posts give `VoidMutationError: Only queued posts can be moved within the queue`. ## Free-plan limits hit while testing this - **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass is ~38 calls, so two back-to-back runs trip the 15-minute window (HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: "15m"`, plus `Retry-After`). Every response carries `ratelimit` / `ratelimit-policy` headers. - **Insights are capped at the last 31 days.** A wider `aggregatedPostMetrics` window returns `BAD_USER_INPUT`. - **LinkedIn `firstComment` is paid-only** — `InvalidInputError` on Free. - **`needsApproval: true`** is rejected unless the channel has an approval posting policy. - Daily posting limit on the connected channel is 50/day (`dailyPostingLimits`). ## Error codes `extensions.code` on top-level `errors[]`: `UNAUTHORIZED` · `FORBIDDEN` · `NOT_FOUND` · `BAD_USER_INPUT` · `GRAPHQL_VALIDATION_FAILED` · `RATE_LIMIT_EXCEEDED` · `UNEXPECTED`. Mutation union error members: `InvalidInputError` · `LimitReachedError` · `NotFoundError` · `UnauthorizedError` · `RestProxyError` · `UnexpectedError` — all implement the `MutationError` interface, so `... on MutationError { message }` catches every one, including ones Buffer adds later. ## Verification Every request in the collection was executed against the live API on 2026-08-05 using the key in `backend/.env`: **38/38 pass.** Two of those (**Share Now**, **Create Idea**) were validated document-only — sent with a deliberately invalid id so the server still parses and validates the GraphQL but cannot execute it — because one publishes to the real LinkedIn account and the other creates something the API has no mutation to delete. **Create Post · Needs Approval** returns `InvalidInputError` on this account: the query is correct, the channel just has no approval policy. Every post created during verification was deleted; the account is back to the same three posts it had beforehand, and the pre-existing scheduled job ad still holds its original `dueAt` slot.