diff --git a/.gitignore b/.gitignore index d33ff2e..c9fae03 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ env/ !frontend/.env.development !frontend/.env.production +# Postman/Insomnia environments holding real API keys +*.postman_environment.local.json + # Logs & temp *.log tmp/ diff --git a/backend/.env.example b/backend/.env.example index aee1e77..f299328 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,3 +22,7 @@ FRONTEND_URL=http://localhost:5173 CONFIRM_EMAIL_PATH=/auth/confirm-email CONFIRM_TOKEN_TTL_SECONDS=86400 CONFIRM_TOKEN_RESEND_SECONDS=60 + +BUFFER_API= +BUFFER_API_URL=https://api.buffer.com +BUFFER_CHANNEL_ID= diff --git a/backend/job/app.py b/backend/job/app.py index c5f1577..a1df207 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -1,22 +1,46 @@ -import profile -from fastapi import APIRouter,Depends, Query +from fastapi import APIRouter,Depends from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, EmailStr, model_validator -from users.views import User -from users.permissions import CurrentUser, PermissionTag, require_permission -from job_post.views import JobPost - - +from pydantic import BaseModel, model_validator +from users.permissions import PermissionTag, require_permission +from job.job_post.views import JobPost from dotenv import load_dotenv load_dotenv() router = APIRouter() + +class JobPostCreate(BaseModel): + title: str + experience_min: int | None = None + experience_max: int | None = None + requirements: list[str] = [] + optional_skills: list[str] = [] + salary: str = "Anonymous" + location: str | None = None + employment_type: str | None = None + description: str | None = None + channel_id: str | None = None + mode: str = "addToQueue" + due_at: str | None = None + + @model_validator(mode="after") + def validate_mode_and_due_at(self): + allowed = {"addToQueue", "shareNow", "customScheduled"} + if self.mode not in allowed: + raise ValueError(f"mode must be one of {sorted(allowed)}") + if self.mode == "customScheduled" and not self.due_at: + raise ValueError("due_at is required when mode is customScheduled") + return self + + @router.post("/candidate/cv_upload") -async def cv_upload(session: AsyncSession = Depends(get_session),current_user: Depends(require_permission(PermissionTag.CANDIDATES_MANAGE,PermissionTag.CANDIDATES_VIEW,PermissionTag.CANDIDATES_EDIT,PermissionTag.CANDIDATES_DELETE,PermissionTag.CANDIDATES_CREATE))): +async def cv_upload( + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): try: pass except HTTPException: @@ -27,23 +51,30 @@ async def cv_upload(session: AsyncSession = Depends(get_session),current_user: D @router.post("/job/post-job") async def post_job( + payload: JobPostCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_CREATE)), session: AsyncSession = Depends(get_session), - current_user: Depends(require_permission( - PermissionTag.JOB_BOARD_CREATE, - PermissionTag.JOB_BOARD_VIEW, - PermissionTag.JOB_BOARD_APPROVE, - PermissionTag.JOB_BOARD_EDIT, - PermissionTag.JOB_BOARD_DELETE, - PermissionTag.JOB_BOARD_VIEW_ALL, - PermissionTag.JOB_BOARD_APPROVE_ALL, - PermissionTag.JOB_BOARD_EDIT_ALL, - PermissionTag.JOB_BOARD_DELETE_ALL, - PermissionTag.JOB_BOARD_VIEW_ALL, - PermissionTag.JOB_BOARD_APPROVE_ALL, - PermissionTag.JOB_BOARD_EDIT_ALL, - PermissionTag.JOB_BOARD_DELETE_ALL, - ))): +): try: service=JobPost(session=session) + data=await service.post_job(payload.model_dump(),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/job/buffer/channels") +async def buffer_channels( + current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=JobPost(session=session) + data=await service.list_channels() + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 9f1719c..0c00b49 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -1,25 +1,117 @@ -from os import setegid import uuid -from datetime import datetime +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional -from sqlalchemy import func, or_ +from sqlalchemy import DateTime, JSON from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -from sqlalchemy.orm.base import state_str from sqlmodel import Field, Relationship, SQLModel, select +if TYPE_CHECKING: # runtime import would be circular: users.models imports this module + from users.models import Users + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + class JobPosts(SQLModel, table=True): __tablename__ = "job_posts" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - title: str - position:str - team:str - location:str - requirements:str - responsibilities:str - benefits:str - salary_range:str - description: str - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + title: str = Field(index=True) + + user: Optional["Users"] = Relationship( + back_populates="job_posts", + sa_relationship_kwargs={"lazy": "joined"}, + ) + + platform: str = Field(default="linkedin") + is_active: bool = Field(default=True) is_deleted: bool = Field(default=False) + employment_type: str | None = Field(default=None) + location: str | None = Field(default=None) + experience_min: int | None = Field(default=None) + experience_max: int | None = Field(default=None) + requirements: list[str] = Field(default_factory=list, sa_type=JSON) + optional_skills: list[str] = Field(default_factory=list, sa_type=JSON) + salary: str = Field(default="Anonymous") + description: str | None = Field(default=None) + post_text: str + channel_id: str + buffer_post_id: str | None = Field(default=None) + buffer_external_link: str | None = Field(default=None) + buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + status: str = Field(default="draft") + buffer_error: str | None = Field(default=None) + created_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_job_post_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def insert_job_post(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_job_post_by_id(session, row.id) + + @classmethod + async def mark_buffer_result( + cls, + session: AsyncSession, + record_id: str, + *, + buffer_post_id: str, + status: str, + external_link: str | None = None, + sent_at: datetime | None = None, + platform: str | None = None, + ): + """Record what Buffer reported. + `status` is the mapped Buffer PostStatus, not an assumption: a queued post lands + here as "scheduled" and only becomes "published" once Buffer says `sent`. + """ + row = await cls.get_job_post_by_id(session, record_id) + if not row: + return None + row.status = status + row.buffer_post_id = buffer_post_id + row.buffer_external_link = external_link + row.buffer_sent_at = sent_at + if platform: + row.platform = platform + row.buffer_error = None + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_failed(cls, session: AsyncSession, record_id: str, error: str): + row = await cls.get_job_post_by_id(session, record_id) + if not row: + return None + row.status = "failed" + row.buffer_error = error + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + +import users.models as _users_models \ No newline at end of file diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py new file mode 100644 index 0000000..3c53fb4 --- /dev/null +++ b/backend/job/job_post/plugins.py @@ -0,0 +1,250 @@ +"""Buffer GraphQL helpers and LinkedIn job-post copy rendering. + +Pure module: no FastAPI imports and no HTTPException. +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +BUFFER_API = os.getenv("BUFFER_API") +BUFFER_API_URL = os.getenv("BUFFER_API_URL", "https://api.buffer.com") +BUFFER_CHANNEL_ID = os.getenv("BUFFER_CHANNEL_ID") +LINKEDIN_POST_MAX_CHARS = 3000 + +# Buffer's PostStatus -> the job_posts.status lifecycle. Only `sent` means the post is +# actually live on the network: the default `addToQueue` mode comes back as `scheduled`, +# so treating any successful mutation as "published" would record a post that nobody +# outside Buffer can see yet. +BUFFER_STATUS_TO_LOCAL = { + "sent": "published", + "sending": "publishing", + "scheduled": "scheduled", + "draft": "draft", + "needs_approval": "needs_approval", + "error": "failed", +} + + +def local_status(buffer_status) -> str: + """Map a Buffer PostStatus onto our own. Unknown values stay uncommitted.""" + return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled") + + +def parse_buffer_datetime(value): + """Buffer sends ISO 8601 with a trailing `Z`, which fromisoformat wants as +00:00.""" + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + + +class BufferError(RuntimeError): + def __init__(self, message: str, *, code: str | None = None): + super().__init__(message) + self.code = code + + +def render_job_post(payload) -> str: + title = (payload.get("title") or "").strip() or "Open Role" + location = (payload.get("location") or "").strip() + employment_type = (payload.get("employment_type") or "").strip() + experience_min = payload.get("experience_min") + experience_max = payload.get("experience_max") + requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()] + optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()] + salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous" + description = (payload.get("description") or "").strip() + + lines = [f"We're hiring: {title}", ""] + + meta = [] + if location: + meta.append(location) + if employment_type: + meta.append(employment_type) + if meta: + lines.append(" · ".join(meta)) + lines.append("") + + if experience_min is not None and experience_max is not None: + lines.append(f"Experience: {experience_min}–{experience_max} years") + lines.append("") + elif experience_min is not None: + lines.append(f"Experience: {experience_min}+ years") + lines.append("") + elif experience_max is not None: + lines.append(f"Experience: up to {experience_max} years") + lines.append("") + + if requirements: + lines.append("Requirements:") + for item in requirements: + lines.append(f"• {item}") + lines.append("") + + if optional_skills: + lines.append("Nice to have:") + for item in optional_skills: + lines.append(f"• {item}") + lines.append("") + + lines.append(f"Salary: {salary}") + lines.append("") + + if description: + lines.append(description) + lines.append("") + + lines.append("Interested? Apply via our careers page or reply to this post.") + lines.append("") + + tags = [] + for item in requirements: + tag = re.sub(r"[^A-Za-z0-9]+", "", item) + if tag: + tags.append(f"#{tag}") + if tags: + lines.append(" ".join(tags)) + + text = "\n".join(lines).strip() + if len(text) > LINKEDIN_POST_MAX_CHARS: + text = text[: LINKEDIN_POST_MAX_CHARS - 1].rstrip() + "…" + return text + + +def build_create_post_query(text, channel_id, *, mode="addToQueue", due_at=None) -> str: + fields = [ + f"text: {json.dumps(text)}", + f"channelId: {json.dumps(channel_id)}", + "schedulingType: automatic", + f"mode: {mode}", + ] + if mode == "customScheduled" and due_at: + fields.append(f"dueAt: {json.dumps(due_at)}") + input_block = ",\n ".join(fields) + return ( + "mutation CreatePost {\n" + " createPost(input: {\n" + f" {input_block}\n" + " }) {\n" + " ... on PostActionSuccess {\n" + " post { id text status sentAt externalLink channelService }\n" + " }\n" + " ... on MutationError { message }\n" + " }\n" + "}" + ) + + +async def create_buffer_post(text, channel_id, *, mode="addToQueue", due_at=None) -> dict: + if not BUFFER_API: + raise RuntimeError("BUFFER_API is not configured") + query = build_create_post_query(text, channel_id, mode=mode, due_at=due_at) + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + BUFFER_API_URL, + json={"query": query}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if response.status_code != 200: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) + body = response.json() + errors = body.get("errors") + if errors: + first = errors[0] if isinstance(errors, list) and errors else {} + msg = first.get("message") or "Buffer GraphQL error" + code = (first.get("extensions") or {}).get("code") + raise BufferError(msg, code=code) + create_post = (body.get("data") or {}).get("createPost") or {} + if "message" in create_post and "post" not in create_post: + raise BufferError(create_post.get("message") or "Buffer mutation error") + post = create_post.get("post") + if not post or not post.get("id"): + raise BufferError("Buffer did not return a post id") + return post + + +async def list_buffer_channels() -> list[dict]: + if not BUFFER_API: + raise RuntimeError("BUFFER_API is not configured") + async with httpx.AsyncClient(timeout=15.0) as client: + orgs_response = await client.post( + BUFFER_API_URL, + json={"query": "query { account { organizations { id name } } }"}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if orgs_response.status_code != 200: + raise httpx.HTTPStatusError( + orgs_response.text, + request=orgs_response.request, + response=orgs_response, + ) + orgs_body = orgs_response.json() + if orgs_body.get("errors"): + first = orgs_body["errors"][0] + raise BufferError( + first.get("message") or "Buffer GraphQL error", + code=(first.get("extensions") or {}).get("code"), + ) + organizations = ((orgs_body.get("data") or {}).get("account") or {}).get("organizations") or [] + channels: list[dict] = [] + for org in organizations: + org_id = org.get("id") + if not org_id: + continue + channels_query = ( + "query GetChannels {\n" + f' channels(input:{{organizationId:{json.dumps(org_id)}}}) {{\n' + " id name displayName service isQueuePaused\n" + " }\n" + "}" + ) + channels_response = await client.post( + BUFFER_API_URL, + json={"query": channels_query}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if channels_response.status_code != 200: + raise httpx.HTTPStatusError( + channels_response.text, + request=channels_response.request, + response=channels_response, + ) + channels_body = channels_response.json() + if channels_body.get("errors"): + first = channels_body["errors"][0] + raise BufferError( + first.get("message") or "Buffer GraphQL error", + code=(first.get("extensions") or {}).get("code"), + ) + for channel in (channels_body.get("data") or {}).get("channels") or []: + channels.append({ + **channel, + "organization_id": org_id, + "organization_name": org.get("name"), + }) + return channels diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py new file mode 100644 index 0000000..4589f3d --- /dev/null +++ b/backend/job/job_post/serializers.py @@ -0,0 +1,24 @@ +def serialize_job_post(row) -> dict: + return { + "id": str(row.id), + "title": row.title, + "employment_type": row.employment_type, + "location": row.location, + "experience_min": row.experience_min, + "experience_max": row.experience_max, + "requirements": list(row.requirements or []), + "optional_skills": list(row.optional_skills or []), + "salary": row.salary, + "description": row.description, + "post_text": row.post_text, + "channel_id": row.channel_id, + "platform": row.platform, + "buffer_post_id": row.buffer_post_id, + "buffer_external_link": row.buffer_external_link, + "buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None, + "status": row.status, + "buffer_error": row.buffer_error, + "created_by": str(row.created_by) if row.created_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 0aa0cfd..3b9977f 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -1,17 +1,79 @@ import os + +import httpx from dotenv import load_dotenv -load_dotenv() +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from job.job_post.models import JobPosts +from job.job_post.plugins import ( + BufferError, + create_buffer_post, + list_buffer_channels, + local_status, + parse_buffer_datetime, + render_job_post, +) +from job.job_post.serializers import serialize_job_post + +load_dotenv() + class JobPost: def __init__(self,session:AsyncSession): self.session=session self.buffer_api=os.getenv("BUFFER_API") - self.client_id=os.getenv("CLIENT_ID") + self.channel_id=os.getenv("BUFFER_CHANNEL_ID") + + async def post_job(self,payload,current_user): + channel_id=payload.get("channel_id") or self.channel_id + if not channel_id: + raise HTTPException(status_code=500,detail="BUFFER_CHANNEL_ID is not configured") + + text=render_job_post(payload) + row=await JobPosts.insert_job_post(self.session,{ + "title":payload.get("title"), + "employment_type":payload.get("employment_type"), + "location":payload.get("location"), + "experience_min":payload.get("experience_min"), + "experience_max":payload.get("experience_max"), + "requirements":list(payload.get("requirements") or []), + "optional_skills":list(payload.get("optional_skills") or []), + "salary":payload.get("salary") or "Anonymous", + "description":payload.get("description"), + "post_text":text, + "channel_id":channel_id, + "status":"draft", + "created_by":current_user["id"], + }) - async def post_job(self,payload): try: - pass - except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) \ No newline at end of file + post=await create_buffer_post( + text, + channel_id, + mode=payload.get("mode") or "addToQueue", + due_at=payload.get("due_at"), + ) + except (httpx.HTTPError,BufferError,RuntimeError) as e: + await JobPosts.mark_failed(self.session,str(row.id),str(e)) + raise HTTPException(status_code=502,detail="Failed to publish job post to Buffer") from e + + # Buffer accepting the mutation is not the same as the network publishing it: + # the default addToQueue mode returns `scheduled`, so the row only reads + # "published" once Buffer reports `sent`. + saved=await JobPosts.mark_buffer_result( + self.session, + str(row.id), + buffer_post_id=post["id"], + status=local_status(post.get("status")), + external_link=post.get("externalLink"), + sent_at=parse_buffer_datetime(post.get("sentAt")), + platform=post.get("channelService"), + ) + return serialize_job_post(saved) + + async def list_channels(self): + try: + return await list_buffer_channels() + except (httpx.HTTPError,BufferError,RuntimeError) as e: + raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e diff --git a/backend/users/models.py b/backend/users/models.py index 4171253..cacf22d 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select from role.models import Roles - +from job.job_post.models import JobPosts class Users(SQLModel, table=True): __tablename__ = "users" @@ -17,6 +17,13 @@ class Users(SQLModel, table=True): email: str = Field(unique=True) role_id: int | None = Field(nullable=True, foreign_key="roles.id") role: Roles | None = Relationship(back_populates="users") + # selectin, not joined: this is a one-to-many, so a joined load would repeat the + # user row once per post. Without an explicit strategy the default is a lazy load, + # which raises MissingGreenlet the moment anything touches it under asyncio. + job_posts: list[JobPosts] = Relationship( + back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) password: str created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) diff --git a/docs/integrations/buffer/Buffer-API.postman_collection.json b/docs/integrations/buffer/Buffer-API.postman_collection.json new file mode 100644 index 0000000..d0103c8 --- /dev/null +++ b/docs/integrations/buffer/Buffer-API.postman_collection.json @@ -0,0 +1,1976 @@ +{ + "info": { + "_postman_id": "b0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b", + "name": "Buffer API (GraphQL) — HRMS", + "description": "Buffer's public GraphQL API — every operation the HRMS job-posting integration needs, plus the full read surface.\n\n**One endpoint for everything:** `POST https://api.buffer.com`. There are no REST paths; the operation is decided by the GraphQL document in the body.\n\n---\n\n### Setup\n1. Import `Buffer-API.postman_environment.json` and paste your key from `backend/.env` (`BUFFER_API`) into `buffer_token`.\n2. Run **01 · Get Organizations** → fills `{{org_id}}`.\n3. Run **02 · Get Channels** → fills `{{channel_id}}`.\n4. Everything else now works. Create/list requests fill `{{post_id}}` for you, so **Delete Post** always targets the last post you touched.\n\n### The three answers you were after\n| Need | Request | Field |\n|---|---|---|\n| `org_id` | 01 · Get Organizations | `account.organizations[].id` |\n| `channel_id` | 02 · Get Channels | `channels[].id` |\n| create post | 04 · Create Post · … | `createPost` → `PostActionSuccess.post.id` |\n| delete post | 04 · Delete Post | `deletePost` → `DeletePostSuccess.id` |\n| list posts | 03 · Get Posts | `posts.edges[].node` |\n\n### Gotchas that cost real time\n* Errors come back as **HTTP 200**. Check `errors[]` and `__typename`, not the status code.\n* Do **not** request `totalCount` on `posts` — API keys get `FORBIDDEN`.\n* The edit mutation is `editPost`, not `updatePost`.\n* `deletePost` returns `DeletePostSuccess`, *not* `PostActionSuccess`.\n* `schedulingType` is `automatic` | `notification` only — it is **not** the queue mode. The queue mode is `mode` (`addToQueue` | `shareNext` | `shareNow` | `customScheduled`).\n* `mode: customScheduled` requires `dueAt`; `mode: shareNow` publishes instantly.\n* `metadata..linkAttachment` and a non-empty `assets` array are mutually exclusive.\n* Sorting is only by `dueAt` or `createdAt` — there is no `sentAt` sort key.\n\n### Rate limits\nFree plan: **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass over this collection is ~38 calls, so back-to-back runs will trip the 15-minute window (HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: \"15m\"`). Every response carries `ratelimit` / `ratelimit-policy` headers — see **08 · Rate limit headers**.\n\n### Plan-gated operations\nThese are valid GraphQL but rejected on a Free account: LinkedIn `firstComment`, `needsApproval: true` (needs a posting policy), and Insights windows older than 31 days.\n\nDocs: https://developers.buffer.com/guides · Explorer: https://developers.buffer.com/explorer.html", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{buffer_token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "00 · Auth & Account", + "description": "Verify the API key works and inspect the authenticated account. The key is account-scoped: it can reach every organization and channel on the account.", + "item": [ + { + "name": "Ping / Whoami", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('Token is valid', function () {", + " pm.expect(res.data.account.id).to.be.a('string');", + "});", + "console.log('Rate limit:', pm.response.headers.get('ratelimit'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query Whoami {\\n account {\\n id\\n email\\n name\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Cheapest possible call. 200 + an account id means the token is valid.\n401 / `UNAUTHORIZED` in `errors[]` means the token is wrong or revoked." + }, + "response": [] + }, + { + "name": "Get Account (full)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetAccount {\\n account {\\n id\\n email\\n backupEmail\\n name\\n avatar\\n timezone\\n createdAt\\n organizations {\\n id\\n name\\n ownerEmail\\n channelCount\\n }\\n connectedApps {\\n clientId\\n name\\n category\\n scopes\\n createdAt\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Everything readable about the logged-in account in one call.\n\n`connectedApps[].clientId` is the OAuth **client id** — do not confuse it with `organizations[].id`." + }, + "response": [] + } + ] + }, + { + "name": "01 · Organizations → org_id", + "description": "**Run this first.** Almost every other query needs `organizationId`. The test script writes the first org id into the `org_id` collection variable automatically.", + "item": [ + { + "name": "Get Organizations (captures org_id)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const orgs = res.data.account.organizations;", + "pm.test('At least one organization', () => pm.expect(orgs).to.have.length.above(0));", + "pm.collectionVariables.set('org_id', orgs[0].id);", + "console.log('org_id =', orgs[0].id, '|', orgs[0].name);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetOrganizations {\\n account {\\n organizations {\\n id\\n name\\n ownerEmail\\n channelCount\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the org_id endpoint.**\n\n`account.organizations[].id` is the `organizationId` every other call wants.\nThe test script stores `organizations[0].id` in `{{org_id}}`." + }, + "response": [] + }, + { + "name": "Get Organization Limits", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetOrganizationLimits {\\n account {\\n organizations {\\n id\\n name\\n channelCount\\n limits {\\n channels\\n members\\n scheduledPosts\\n ideas\\n tags\\n postTemplates\\n }\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Plan ceilings for the org (each field is the max, an `Int`) — compare `limits.channels` against `channelCount` before connecting another channel." + }, + "response": [] + } + ] + }, + { + "name": "02 · Channels → channel_id", + "description": "**Run `Get Channels` second.** `channel_id` is what `createPost` publishes to. The test script captures the first channel into `{{channel_id}}`.", + "item": [ + { + "name": "Get Channels (captures channel_id)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const chans = res.data.channels;", + "pm.test('At least one channel', () => pm.expect(chans).to.have.length.above(0));", + "pm.collectionVariables.set('channel_id', chans[0].id);", + "console.log('channel_id =', chans[0].id, '|', chans[0].service, '|', chans[0].name);", + "chans.forEach(c => console.log(` ${c.id} ${c.service.padEnd(14)} ${c.name}`));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannels($input: ChannelsInput!) {\\n channels(input: $input) {\\n id\\n name\\n displayName\\n service\\n type\\n serviceId\\n organizationId\\n avatar\\n externalLink\\n timezone\\n isDisconnected\\n isLocked\\n isQueuePaused\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the channel_id endpoint.**\n\nReturns every connected social profile in the organization. `id` → use as `channelId` in `createPost`. `service` is the network (`linkedin`, `twitter`, `instagram`, `facebook`, `tiktok`, `threads`, `youtube`, `pinterest`, `mastodon`, `bluesky`, `googlebusiness`, `startPage`).\n\nStore the id you actually want in `{{channel_id}}` — the script picks the first one." + }, + "response": [] + }, + { + "name": "Get Channels (filtered)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetFilteredChannels($input: ChannelsInput!) {\\n channels(input: $input) {\\n id\\n name\\n service\\n isLocked\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"isLocked\": false,\n \"product\": \"publish\"\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`filter.isLocked` — true/false/omit. `filter.product` — `publish` | `analyze` | `engage` | `comments` | `startPage` | `buffer`." + }, + "response": [] + }, + { + "name": "Get Channel by ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannel($input: ChannelInput!) {\\n channel(input: $input) {\\n id\\n name\\n displayName\\n service\\n type\\n serviceId\\n organizationId\\n timezone\\n isDisconnected\\n isQueuePaused\\n allowedActions\\n scopes\\n postingSchedule {\\n day\\n times\\n paused\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{channel_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Single channel, including its weekly posting schedule (the slots `mode: addToQueue` will fill)." + }, + "response": [] + }, + { + "name": "Get Daily Posting Limits", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetDailyPostingLimits($input: DailyPostingLimitsInput!) {\\n dailyPostingLimits(input: $input) {\\n channelId\\n limit\\n scheduled\\n sent\\n isAtLimit\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Check before bulk-scheduling. `isAtLimit: true` means `createPost` will come back as `LimitReachedError`.\n\nOptional `input.date` (ISO 8601) checks a specific day." + }, + "response": [] + } + ] + }, + { + "name": "03 · Posts — Read", + "description": "Cursor-paginated. `first` = page size (20–50 recommended), `after` = `pageInfo.endCursor` from the previous page. Cursors are opaque — never parse them.\n\n⚠️ Do **not** add `totalCount` to the `posts` query — it returns `FORBIDDEN` on this API key.", + "item": [ + { + "name": "Get Posts (paginated, captures post_id + cursor)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const conn = res.data.posts;", + "if (conn.edges.length) {", + " pm.collectionVariables.set('post_id', conn.edges[0].node.id);", + " console.log('post_id =', conn.edges[0].node.id);", + "}", + "pm.collectionVariables.set('posts_cursor', conn.pageInfo.endCursor || '');", + "console.log('hasNextPage =', conn.pageInfo.hasNextPage);", + "conn.edges.forEach(e => console.log(` ${e.node.id} ${e.node.status.padEnd(14)} ${(e.node.text || '').slice(0, 60).replace(/\\n/g, ' ')}`));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPosts($first: Int, $after: String, $input: PostsInput!) {\\n posts(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n text\\n status\\n shareMode\\n schedulingType\\n dueAt\\n sentAt\\n createdAt\\n updatedAt\\n channelId\\n channelService\\n externalLink\\n isCustomScheduled\\n via\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n startCursor\\n hasPreviousPage\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the list-posts endpoint.**\n\nStores `edges[0].node.id` in `{{post_id}}` and `pageInfo.endCursor` in `{{posts_cursor}}` so *Get Posts — Next Page* and *Delete Post* just work." + }, + "response": [] + }, + { + "name": "Get Posts — Next Page", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.collectionVariables.set('posts_cursor', res.data.posts.pageInfo.endCursor || '');" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostsPage($first: Int, $after: String, $input: PostsInput!) {\\n posts(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n text\\n status\\n dueAt\\n channelId\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"after\": \"{{posts_cursor}}\",\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Run *Get Posts* first to populate `{{posts_cursor}}`. Re-run this request repeatedly — it rolls the cursor forward each time." + }, + "response": [] + }, + { + "name": "Get Scheduled Posts (the queue)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.posts.edges;", + "const queued = edges.filter(e => !e.node.isCustomScheduled);", + "if (queued.length) {", + " pm.collectionVariables.set('queued_post_id', queued[0].node.id);", + " console.log('queued_post_id =', queued[0].node.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetScheduledPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n isCustomScheduled\\n channelId\\n channelService\\n allowedActions\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 50,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"scheduled\"\n ],\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n },\n \"sort\": [\n {\n \"field\": \"dueAt\",\n \"direction\": \"asc\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Everything waiting to go out, soonest first. `allowedActions` tells you whether `deletePost` / `editPost` is permitted on each one.\n\n`sort.field` (`PostSortableKey`) is only `dueAt` or `createdAt`; `direction` is `asc` or `desc`.\n\nCaptures the first queued post into `{{queued_post_id}}` for **Move Post in Queue**." + }, + "response": [] + }, + { + "name": "Get Sent Posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.posts.edges;", + "if (edges.length) {", + " pm.collectionVariables.set('sent_post_id', edges[0].node.id);", + " console.log('sent_post_id =', edges[0].node.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetSentPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n sentAt\\n externalLink\\n channelService\\n metricsUpdatedAt\\n metrics {\\n name\\n type\\n unit\\n value\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"sent\"\n ],\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n },\n \"sort\": [\n {\n \"field\": \"dueAt\",\n \"direction\": \"desc\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Published posts with their live engagement metrics and the permalink (`externalLink`) on the network. `metrics` is null until the post is sent.\n\nCaptures the newest sent post into `{{sent_post_id}}` for the **06 · Analytics** folder." + }, + "response": [] + }, + { + "name": "Get Drafts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetDrafts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n createdAt\\n channelId\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"draft\",\n \"needs_approval\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`PostStatus` values: `draft`, `needs_approval`, `scheduled`, `sending`, `sent`, `error`." + }, + "response": [] + }, + { + "name": "Get Failed Posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetFailedPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n dueAt\\n channelId\\n error {\\n message\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"error\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Posts the network rejected. `error.message` carries the reason (expired token, media rejected, duplicate content …)." + }, + "response": [] + }, + { + "name": "Get Posts by Date Range", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('range_end', new Date().toISOString());", + "pm.collectionVariables.set('range_start', new Date(Date.now() - 30 * 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostsByDate($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n dueAt\\n sentAt\\n createdAt\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 50,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"startDate\": \"{{range_start}}\",\n \"endDate\": \"{{range_end}}\"\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`startDate`/`endDate` match on `createdAt` **or** `dueAt`. The pre-request script sets a rolling 30-day window.\n\nFiner control: `dueAt` / `createdAt` accept a `DateTimeComparator` (`{ start, end }`), and `dueAtPresence` (`present` | `absent`) filters on whether a schedule exists at all. `absent` cannot be combined with a `dueAt` comparator." + }, + "response": [] + }, + { + "name": "Get Post by ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPost($input: PostInput!) {\\n post(input: $input) {\\n id\\n text\\n status\\n shareMode\\n schedulingType\\n dueAt\\n sentAt\\n createdAt\\n updatedAt\\n channelId\\n channelService\\n externalLink\\n isCustomScheduled\\n sharedNow\\n via\\n allowedActions\\n assets {\\n id\\n type\\n mimeType\\n source\\n thumbnail\\n }\\n tags {\\n id\\n name\\n }\\n author {\\n id\\n name\\n }\\n error {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Full single post. `allowedActions` includes `deletePost` / `updatePost` when those mutations will be accepted." + }, + "response": [] + } + ] + }, + { + "name": "04 · Posts — Create / Edit / Delete", + "description": "Every create/edit response is a **union**. Always select `__typename` plus `... on PostActionSuccess` and `... on MutationError` — an HTTP 200 with `__typename: \"InvalidInputError\"` is still a failure.\n\n`ShareMode`: `addToQueue` · `shareNext` · `shareNow` · `customScheduled`.\n`SchedulingType`: `automatic` (Buffer publishes) · `notification` (Buffer reminds you).\n\nEach create request stores the new id in `{{post_id}}`, so **Delete Post** at the bottom of this folder cleans up whatever you just made.", + "item": [ + { + "name": "Create Post · Add to Queue", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('queued_post_id', out.post.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Posted from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Drops the post into the next free slot of the channel's posting schedule. Buffer picks `dueAt` for you.\n\nThis is the mode the HRMS job-post flow uses by default.\n\nAlso stores the new id in `{{queued_post_id}}` so **Move Post in Queue** has a genuinely queued post to act on." + }, + "response": [] + }, + { + "name": "Create Post · Draft (safe to test with)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Draft from the Buffer API collection — not published.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`saveToDraft: true` creates the post with `status: draft`. Nothing is published and daily posting limits are not consumed.\n\n**Use this one when smoke-testing** — then run *Delete Post* to remove it." + }, + "response": [] + }, + { + "name": "Create Post · Custom Scheduled", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('due_at', new Date(Date.now() + 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Scheduled from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"customScheduled\",\n \"dueAt\": \"{{due_at}}\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`mode: customScheduled` **requires** `dueAt` as an ISO 8601 UTC timestamp (`2026-08-06T09:00:00.000Z`). The pre-request script sets `{{due_at}}` to 24 hours from now." + }, + "response": [] + }, + { + "name": "Create Post · Share Next (top of queue)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Jumping the queue, via the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"shareNext\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Takes the *next* available slot, pushing everything else down." + }, + "response": [] + }, + { + "name": "⚠️ Create Post · Share Now (PUBLISHES IMMEDIATELY)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Published immediately from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"shareNow\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This goes live on the real social account the moment you hit Send.** There is no undo — `deletePost` removes it from Buffer but does not always retract it from the network.\n\nUse *Create Post · Draft* for testing instead." + }, + "response": [] + }, + { + "name": "Create Post · Needs Approval", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Submitted for approval from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"needsApproval\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`needsApproval: true` parks the post at `status: needs_approval` instead of scheduling it.\n\n⚠️ Only accepted when the channel's posting policy actually requires approval (Buffer → Settings → posting policy, paid plans). Otherwise you get `InvalidInputError: needsApproval is only valid when your posting policy on this channel requires approval`." + }, + "response": [] + }, + { + "name": "Create Post · With Image", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Image post from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": [\n {\n \"image\": {\n \"url\": \"https://picsum.photos/1200/630.jpg\",\n \"thumbnailUrl\": \"https://picsum.photos/1200/630.jpg\"\n }\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`assets` is an **ordered** list. Each entry is exactly one of `image` / `video` / `document` / `link`.\n\n* `image` → `{ url!, thumbnailUrl, metadata }`\n* `video` → `{ url!, thumbnailUrl, metadata }`\n* `document` → `{ url!, title!, thumbnailUrl! }`\n* `link` → `{ url!, title, description, thumbnailUrl }`\n\nURLs must be publicly reachable **and return the raw bytes** — Buffer fetches them server-side, so a page that redirects to a login or a CDN that blocks server-side fetches fails with `InvalidInputError: Image could not be read from its URL`. See the *Hosting Media* guide for Buffer's own upload endpoint.\n\nSet to `saveToDraft: true` here so you can run it safely." + }, + "response": [] + }, + { + "name": "Create Post · LinkedIn (first comment + link attachment)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"LinkedIn post from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": [],\n \"metadata\": {\n \"linkedin\": {\n \"linkAttachment\": {\n \"url\": \"https://example.com/careers\"\n }\n }\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`metadata` is keyed by network: `linkedin`, `twitter`, `instagram`, `facebook`, `tiktok`, `threads`, `youtube`, `pinterest`, `mastodon`, `bluesky`, `google`.\n\nLinkedIn accepts `firstComment`, `linkAttachment` (`{ url }` only — no title/description override), and `annotations` (@-mentions).\n\n⚠️ `firstComment` is a **paid-plan feature** — on Free it comes back as `InvalidInputError: LinkedIn first comment requires a paid plan`. It is left out of the body below; add it back once the account is upgraded:\n```json\n\"linkedin\": { \"firstComment\": \"Full JD in the comments 👇\" }\n```\n\n⚠️ `metadata..linkAttachment` and a non-empty `assets` array are **mutually exclusive** — sending both is an `InvalidInputError`." + }, + "response": [] + }, + { + "name": "Edit Post", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('editPost succeeded', function () {", + " pm.expect(res.data.editPost.__typename, res.data.editPost.message || '')", + " .to.eql('PostActionSuccess');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation EditPost($input: EditPostInput!) {\\n editPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n dueAt\\n updatedAt\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\",\n \"text\": \"Edited via the Buffer API collection.\",\n \"schedulingType\": \"automatic\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "The mutation is `editPost` (not `updatePost`). `id` and `schedulingType` are required; every other field is optional and **omitting a field preserves its current value**.\n\nChange the schedule by sending `mode: \"customScheduled\"` together with a new `dueAt`." + }, + "response": [] + }, + { + "name": "Move Post in Queue", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('movePostInQueue succeeded', function () {", + " pm.expect(res.data.movePostInQueue.__typename,", + " res.data.movePostInQueue.message || '').to.eql('PostActionSuccess');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation MovePostInQueue($input: MovePostInQueueInput!) {\\n movePostInQueue(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n dueAt\\n shareMode\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{queued_post_id}}\",\n \"position\": \"top\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`position` is `top` or `bottom`.\n\n⚠️ Only works on posts whose `shareMode` is `addToQueue`/`shareNext`. A draft or a `customScheduled` post gives `VoidMutationError: Only queued posts can be moved within the queue` — hence the separate `{{queued_post_id}}` variable, filled by *Get Scheduled Posts* or *Create Post · Add to Queue*." + }, + "response": [] + }, + { + "name": "Delete Post", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.deletePost;", + "pm.test('deletePost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('DeletePostSuccess');", + "});", + "if (out.__typename === 'DeletePostSuccess') {", + " console.log('deleted', out.id);", + " pm.collectionVariables.set('post_id', '');", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation DeletePost($input: DeletePostInput!) {\\n deletePost(input: $input) {\\n __typename\\n ... on DeletePostSuccess {\\n id\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the delete endpoint.**\n\nTakes only the post id. The payload union is `DeletePostSuccess { id }` | `VoidMutationError { message }` — note it is *not* `PostActionSuccess`.\n\nDeleting a `sent` post removes it from Buffer; it does not necessarily retract it from the social network. Check `allowedActions` on the post for `deletePost` first." + }, + "response": [] + } + ] + }, + { + "name": "05 · Ideas", + "description": "Ideas live on the **organization**, not a channel — drafts that are not yet committed to a network.", + "item": [ + { + "name": "Get Ideas", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.ideas.edges;", + "if (edges.length) pm.collectionVariables.set('idea_id', edges[0].node.id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetIdeas($first: Int, $after: String, $input: IdeasInput!) {\\n ideas(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n createdAt\\n content {\\n title\\n text\\n services\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Cursor-paginated like posts. Optional `groupFilter` and `tagsFilter`." + }, + "response": [] + }, + { + "name": "Create Idea", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreateIdea($input: CreateIdeaInput!) {\\n createIdea(input: $input) {\\n __typename\\n ... on IdeaResponse {\\n refreshIdeas\\n idea {\\n id\\n organizationId\\n createdAt\\n content {\\n title\\n text\\n services\\n }\\n }\\n }\\n ... on Idea {\\n id\\n content {\\n title\\n text\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"content\": {\n \"title\": \"Idea from the Buffer API collection\",\n \"text\": \"Draft copy that is not tied to a channel yet.\",\n \"services\": [\n \"linkedin\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`content` accepts `title`, `text`, `services`, `media`, `tags`, `date`, `aiAssisted`.\n\nThe payload union is `IdeaResponse` | `Idea` | `InvalidInputError` | `UnauthorizedError` | `LimitReachedError` | `UnexpectedError` — this API returns `IdeaResponse`.\n\n⚠️ There is no `deleteIdea` mutation, so anything you create here has to be removed from the Buffer UI." + }, + "response": [] + } + ] + }, + { + "name": "06 · Analytics", + "description": "Metrics only exist for `sent` posts. On the Free plan, Insights history is capped at the **last 31 days** — a wider window returns `BAD_USER_INPUT`.", + "item": [ + { + "name": "Get Post Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostMetrics($input: PostInput!) {\\n post(input: $input) {\\n id\\n sentAt\\n externalLink\\n metricsUpdatedAt\\n metrics {\\n name\\n description\\n type\\n unit\\n value\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{sent_post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Run **03 · Get Sent Posts** first — it fills `{{sent_post_id}}`. (Pointing this at `{{post_id}}` right after a delete gives `BAD_USER_INPUT: Invalid PostId format`, because the variable is empty.)\n\n`metrics` is `null` until the post is sent. `type` is one of `impressions`, `reach`, `reactions`, `likes`, `comments`, `shares`, `reposts`, `quotes`, `clicks`, `saves`, `follows`, `views`, `viewers`, `totalTimeWatched`, `engagementRate`, `postCount`. `unit` is `count` or `percentage`." + }, + "response": [] + }, + { + "name": "Get Aggregated Post Metrics (last 30 days)", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('metrics_end', new Date().toISOString());", + "pm.collectionVariables.set('metrics_start', new Date(Date.now() - 30 * 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetAggregatedPostMetrics($input: AggregatedPostMetricsInput!) {\\n aggregatedPostMetrics(input: $input) {\\n metricsUpdatedAt\\n metrics {\\n name\\n type\\n unit\\n value\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"channelIds\": [\n \"{{channel_id}}\"\n ],\n \"startDateTime\": \"{{metrics_start}}\",\n \"endDateTime\": \"{{metrics_end}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Totals across every sent post in the window. The pre-request script sets a 30-day range to stay inside the Free-plan 31-day cap." + }, + "response": [] + } + ] + }, + { + "name": "07 · HRMS job-post flow", + "description": "The exact calls `backend/job/job_post/plugins.py` makes, so you can reproduce a backend failure directly against Buffer.\n\n`.env` mapping: `BUFFER_API` → `{{buffer_token}}`, `BUFFER_API_URL` → `{{buffer_api_url}}`, `BUFFER_CHANNEL_ID` → `{{channel_id}}`.", + "item": [ + { + "name": "1. list_buffer_channels — organizations", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.collectionVariables.set('org_id', res.data.account.organizations[0].id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query { account { organizations { id name } } }\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "First half of `list_buffer_channels()` — mirrors the literal query string in `plugins.py`." + }, + "response": [] + }, + { + "name": "2. list_buffer_channels — channels per org", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannels {\\n channels(input: { organizationId: \\\"{{org_id}}\\\" }) {\\n id\\n name\\n displayName\\n service\\n isQueuePaused\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Second half of `list_buffer_channels()`, exposed by the backend at `GET /job/buffer/channels`. Note this one inlines the org id rather than using GraphQL variables — same as the Python." + }, + "response": [] + }, + { + "name": "3. create_buffer_post — rendered job ad", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"We're hiring: AI Engineer\\n\\nKarachi · Full-time\\n\\nExperience: 2–3 years\\n\\nRequirements:\\n• AWS\\n• FastAPI\\n• LangChain\\n\\nNice to have:\\n• Azure\\n\\nSalary: Anonymous\\n\\nInterested? Apply via our careers page or reply to this post.\\n\\n#AWS #FastAPI #LangChain\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "What `POST /job/post-job` ends up sending, using the output of `render_job_post()`. The backend supports `mode` of `addToQueue`, `shareNow`, or `customScheduled` (which then requires `due_at`).\n\nLinkedIn caps post text at 3000 characters — `render_job_post()` truncates to that.\n\n`saveToDraft: true` is added here so running it does not queue a real job ad; the backend does not send it." + }, + "response": [] + }, + { + "name": "4. clean up — delete the post created above", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation DeletePost($input: DeletePostInput!) {\\n deletePost(input: $input) {\\n __typename\\n ... on DeletePostSuccess {\\n id\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Removes whatever step 3 created." + }, + "response": [] + } + ] + }, + { + "name": "08 · Error shapes (reference)", + "description": "Run these to see each failure mode. Buffer returns **HTTP 200** for almost everything — you must inspect the body.\n\n* Non-recoverable → top-level `errors[]` with `extensions.code`: `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `BAD_USER_INPUT`, `GRAPHQL_VALIDATION_FAILED`, `UNEXPECTED`, `RATE_LIMIT_EXCEEDED`.\n* Recoverable → `data..__typename` is a member of the error union (`InvalidInputError`, `LimitReachedError`, `NotFoundError`, `UnauthorizedError`, `RestProxyError`, `UnexpectedError`).", + "item": [ + { + "name": "FORBIDDEN — totalCount on posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "console.log(JSON.stringify(res.errors, null, 2));", + "pm.test('Returns a GraphQL error (expected)', function () {", + " pm.expect(res.errors).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query {\\n posts(first: 1, input: { organizationId: \\\"{{org_id}}\\\" }) {\\n totalCount\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`totalCount` is in the schema but rejected for API-key auth. This is the most common cause of a `posts` query failing after copy-pasting from the schema reference — leave it out." + }, + "response": [] + }, + { + "name": "NOT_FOUND — bad post id", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "console.log(JSON.stringify(res.errors, null, 2));", + "pm.test('Returns a GraphQL error (expected)', function () {", + " pm.expect(res.errors).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query {\\n post(input: { id: \\\"000000000000000000000000\\\" }) {\\n id\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Expect `errors[0].extensions.code === 'NOT_FOUND'`." + }, + "response": [] + }, + { + "name": "Rate limit headers", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "console.log('ratelimit :', pm.response.headers.get('ratelimit'));", + "console.log('ratelimit-policy:', pm.response.headers.get('ratelimit-policy'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query { account { id } }\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Every response carries three rolling windows. Free plan: 100 / 15 min, 250 / day, 3000 / 30 days. `r` = remaining, `t` = seconds to reset. Exceeding one gives HTTP 429 + `Retry-After`." + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "buffer_api_url", + "value": "https://api.buffer.com", + "type": "string" + }, + { + "key": "buffer_token", + "value": "", + "type": "string" + }, + { + "key": "org_id", + "value": "", + "type": "string" + }, + { + "key": "channel_id", + "value": "", + "type": "string" + }, + { + "key": "post_id", + "value": "", + "type": "string" + }, + { + "key": "sent_post_id", + "value": "", + "type": "string" + }, + { + "key": "queued_post_id", + "value": "", + "type": "string" + }, + { + "key": "idea_id", + "value": "", + "type": "string" + }, + { + "key": "posts_cursor", + "value": "", + "type": "string" + }, + { + "key": "due_at", + "value": "", + "type": "string" + }, + { + "key": "range_start", + "value": "", + "type": "string" + }, + { + "key": "range_end", + "value": "", + "type": "string" + }, + { + "key": "metrics_start", + "value": "", + "type": "string" + }, + { + "key": "metrics_end", + "value": "", + "type": "string" + } + ] +} diff --git a/docs/integrations/buffer/Buffer-API.postman_environment.json b/docs/integrations/buffer/Buffer-API.postman_environment.json new file mode 100644 index 0000000..e851cc8 --- /dev/null +++ b/docs/integrations/buffer/Buffer-API.postman_environment.json @@ -0,0 +1,61 @@ +{ + "id": "e0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b", + "name": "Buffer API (fill in your key)", + "values": [ + { + "key": "buffer_api_url", + "value": "https://api.buffer.com", + "type": "default", + "enabled": true + }, + { + "key": "buffer_token", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "org_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "channel_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "sent_post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "queued_post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "idea_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "posts_cursor", + "value": "", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/docs/integrations/buffer/README.md b/docs/integrations/buffer/README.md new file mode 100644 index 0000000..bfb552e --- /dev/null +++ b/docs/integrations/buffer/README.md @@ -0,0 +1,185 @@ +# 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.