186 lines
9.0 KiB
Markdown
186 lines
9.0 KiB
Markdown
# Buffer API — working collection
|
|
|
|
Buffer's public API is **GraphQL, one endpoint, POST only**:
|
|
|
|
```
|
|
POST https://api.buffer.com
|
|
Authorization: Bearer <BUFFER_API>
|
|
Content-Type: application/json
|
|
```
|
|
|
|
There are no REST paths. The operation is decided entirely by the GraphQL document in the
|
|
body. Docs: <https://developers.buffer.com/guides> · Explorer: <https://developers.buffer.com/explorer.html>
|
|
|
|
## 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.<service>.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.
|