9.0 KiB
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
- Import the collection and
Buffer-API.postman_environment.local.json, then select that environment. (Or import the plain template and pasteBUFFER_APIfrombackend/.envintobuffer_token.) - Run 01 · Get Organizations → fills
{{org_id}}. - 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
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
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
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
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
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
totalCountonposts— API-key auth getsFORBIDDENand the whole query returnsdata: null. - The edit mutation is
editPost, notupdatePost. deletePostreturnsDeletePostSuccess, notPostActionSuccess. A blanket... on PostActionSuccessfragment silently matches nothing.schedulingTypeis not the queue mode.automaticvsnotificationonly. The queue mode ismode.mode: customScheduledrequiresdueAt(ISO 8601 UTC).mode: shareNowpublishes immediately with no undo.assetsURLs are fetched server-side — they must return raw bytes, not an HTML page.metadata.<service>.linkAttachmentand a non-emptyassetsarray are mutually exclusive.- LinkedIn
linkAttachmentonly accepts{ url }; there is no title/description override. - There is no
deleteIdeamutation — ideas created via the API must be removed in the UI. movePostInQueueonly accepts posts whoseshareModeisaddToQueue/shareNext. Drafts andcustomScheduledposts giveVoidMutationError: 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", plusRetry-After). Every response carriesratelimit/ratelimit-policyheaders. - Insights are capped at the last 31 days. A wider
aggregatedPostMetricswindow returnsBAD_USER_INPUT. - LinkedIn
firstCommentis paid-only —InvalidInputErroron Free. needsApproval: trueis 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.