HR-ATS-Portal/docs/integrations/buffer/README.md

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

  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.createPostPostActionSuccess.post.id
delete a post 04 · Delete Post data.deletePostDeletePostSuccess.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 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-onlyInvalidInputError 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.