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

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.