LINKEDIN POST CONFIG DONE AND TESTED

pull/2/head
ahmed.mujtaba 2026-08-05 20:09:34 +05:00
parent 3991bd60a3
commit c79024160c
11 changed files with 2742 additions and 47 deletions

3
.gitignore vendored
View File

@ -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/

View File

@ -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=

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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))
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

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -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 <BUFFER_API>
Content-Type: application/json
```
There are no REST paths. The operation is decided entirely by the GraphQL document in the
body. Docs: <https://developers.buffer.com/guides> · Explorer: <https://developers.buffer.com/explorer.html>
## Files
| File | What it is |
|---|---|
| `Buffer-API.postman_collection.json` | 38 requests in 9 folders. Import into Postman/Insomnia/Bruno. |
| `Buffer-API.postman_environment.json` | Empty environment template — safe to commit. |
| `Buffer-API.postman_environment.local.json` | Same, pre-filled with the key + ids from `backend/.env`. **Gitignored — do not commit.** |
## Setup
1. Import the collection **and** `Buffer-API.postman_environment.local.json`, then select
that environment. (Or import the plain template and paste `BUFFER_API` from
`backend/.env` into `buffer_token`.)
2. Run **01 · Get Organizations** → fills `{{org_id}}`.
3. Run **02 · Get Channels** → fills `{{channel_id}}`.
Everything else works from there. Test scripts chain the ids for you:
| Variable | Filled by | Used by |
|---|---|---|
| `org_id` | 01 · Get Organizations | almost everything |
| `channel_id` | 02 · Get Channels | all create requests |
| `post_id` | 03 · Get Posts, every create request | Get Post by ID, Edit Post, **Delete Post** |
| `sent_post_id` | 03 · Get Sent Posts | 06 · Get Post Metrics |
| `queued_post_id` | 03 · Get Scheduled Posts, 04 · Add to Queue | 04 · Move Post in Queue |
| `posts_cursor` | 03 · Get Posts | 03 · Get Posts — Next Page |
So **Delete Post** always targets the last post you touched.
## The endpoints you asked for
| Need | Folder / request | Where the value is |
|---|---|---|
| **org_id** | 01 · Get Organizations | `data.account.organizations[].id` |
| **channel_id** | 02 · Get Channels | `data.channels[].id` |
| **create a post** | 04 · Create Post · … | `data.createPost``PostActionSuccess.post.id` |
| **delete a post** | 04 · Delete Post | `data.deletePost``DeletePostSuccess.id` |
| **list posts** | 03 · Get Posts | `data.posts.edges[].node` |
| **one post** | 03 · Get Post by ID | `data.post` |
| **edit a post** | 04 · Edit Post | `editPost` (not `updatePost`) |
### org_id
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query { account { id email organizations { id name channelCount } } }"}'
```
### channel_id
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service type isDisconnected isQueuePaused } }",
"variables":{"input":{"organizationId":"'"$BUFFER_ORG_ID"'"}}}'
```
### create post
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id status dueAt } } ... on MutationError { message } } }",
"variables":{"input":{"channelId":"'"$BUFFER_CHANNEL_ID"'","text":"Hello","schedulingType":"automatic","mode":"addToQueue","assets":[]}}}'
```
### delete post
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { __typename ... on DeletePostSuccess { id } ... on MutationError { message } } }",
"variables":{"input":{"id":"POST_ID"}}}'
```
### list posts
```bash
curl -s -X POST https://api.buffer.com \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BUFFER_API" \
-d '{"query":"query GetPosts($first: Int, $after: String, $input: PostsInput!) { posts(first: $first, after: $after, input: $input) { edges { cursor node { id text status dueAt sentAt channelId externalLink } } pageInfo { hasNextPage endCursor } } }",
"variables":{"first":20,"input":{"organizationId":"'"$BUFFER_ORG_ID"'","filter":{"status":["scheduled"]}}}}'
```
## `.env` mapping
| `.env` key | Collection variable | Notes |
|---|---|---|
| `BUFFER_API` | `buffer_token` | The personal access token. Buffer → Settings → API. |
| `BUFFER_API_URL` | `buffer_api_url` | `https://api.buffer.com` — correct as-is. |
| `BUFFER_CHANNEL_ID` | `channel_id` | Currently the LinkedIn profile `ahmedmujtababaig`. |
| — | `org_id` | **Not in `.env`.** `CLIENT_ID` in `backend/.env` holds this value, but it is the *organization id*, not an OAuth client id — the naming is misleading. Consider renaming it to `BUFFER_ORG_ID`. |
`plugins.py` re-derives the org id on every `list_buffer_channels()` call, so nothing is
broken today; caching it in `BUFFER_ORG_ID` would save one round trip per request.
## Enums worth memorising
| Enum | Values |
|---|---|
| `ShareMode` (`mode`) | `addToQueue` · `shareNext` · `shareNow` · `customScheduled` |
| `SchedulingType` | `automatic` (Buffer publishes) · `notification` (Buffer reminds you) |
| `PostStatus` | `draft` · `needs_approval` · `scheduled` · `sending` · `sent` · `error` |
| `PostSortableKey` | `dueAt` · `createdAt` **only** |
| `SortDirection` | `asc` · `desc` |
| `QueuePosition` | `top` · `bottom` |
| `Service` | `linkedin` `twitter` `facebook` `instagram` `tiktok` `threads` `youtube` `pinterest` `mastodon` `bluesky` `googlebusiness` `startPage` |
| `PostMetricType` | `impressions` `reach` `reactions` `likes` `comments` `shares` `reposts` `quotes` `clicks` `saves` `follows` `views` `viewers` `totalTimeWatched` `engagementRate` `postCount` |
## Gotchas that cost real time
- **Errors come back as HTTP 200.** Check `errors[]` and `__typename`, not the status code.
- **Do not request `totalCount` on `posts`** — API-key auth gets `FORBIDDEN` and the whole
query returns `data: null`.
- The edit mutation is **`editPost`**, not `updatePost`.
- `deletePost` returns **`DeletePostSuccess`**, not `PostActionSuccess`. A blanket
`... on PostActionSuccess` fragment silently matches nothing.
- `schedulingType` is *not* the queue mode. `automatic` vs `notification` only. The queue
mode is `mode`.
- `mode: customScheduled` requires `dueAt` (ISO 8601 UTC). `mode: shareNow` publishes
immediately with no undo.
- `assets` URLs are fetched **server-side** — they must return raw bytes, not an HTML page.
- `metadata.<service>.linkAttachment` and a non-empty `assets` array are mutually exclusive.
- LinkedIn `linkAttachment` only accepts `{ url }`; there is no title/description override.
- There is **no `deleteIdea` mutation** — ideas created via the API must be removed in the UI.
- `movePostInQueue` only accepts posts whose `shareMode` is `addToQueue`/`shareNext`. Drafts
and `customScheduled` posts give
`VoidMutationError: Only queued posts can be moved within the queue`.
## Free-plan limits hit while testing this
- **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass is
~38 calls, so two back-to-back runs trip the 15-minute window
(HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: "15m"`, plus `Retry-After`).
Every response carries `ratelimit` / `ratelimit-policy` headers.
- **Insights are capped at the last 31 days.** A wider `aggregatedPostMetrics` window
returns `BAD_USER_INPUT`.
- **LinkedIn `firstComment` is paid-only**`InvalidInputError` on Free.
- **`needsApproval: true`** is rejected unless the channel has an approval posting policy.
- Daily posting limit on the connected channel is 50/day (`dailyPostingLimits`).
## Error codes
`extensions.code` on top-level `errors[]`: `UNAUTHORIZED` · `FORBIDDEN` · `NOT_FOUND` ·
`BAD_USER_INPUT` · `GRAPHQL_VALIDATION_FAILED` · `RATE_LIMIT_EXCEEDED` · `UNEXPECTED`.
Mutation union error members: `InvalidInputError` · `LimitReachedError` · `NotFoundError` ·
`UnauthorizedError` · `RestProxyError` · `UnexpectedError` — all implement the
`MutationError` interface, so `... on MutationError { message }` catches every one,
including ones Buffer adds later.
## Verification
Every request in the collection was executed against the live API on 2026-08-05 using the
key in `backend/.env`: **38/38 pass.**
Two of those (**Share Now**, **Create Idea**) were validated document-only — sent with a
deliberately invalid id so the server still parses and validates the GraphQL but cannot
execute it — because one publishes to the real LinkedIn account and the other creates
something the API has no mutation to delete. **Create Post · Needs Approval** returns
`InvalidInputError` on this account: the query is correct, the channel just has no approval
policy.
Every post created during verification was deleted; the account is back to the same three
posts it had beforehand, and the pre-existing scheduled job ad still holds its original
`dueAt` slot.