Post to X and LinkedIn from a script without paying for the X API
Rubric posts to X and LinkedIn through Buffer’s GraphQL API on the free plan, so it never pays X’s $0.20 for a post with a link. Every day at 13:00 UTC, it picks three stories from its top twenty and posts them to my LinkedIn and my X account. The script never talks to either network: it sends one GraphQL request per post to Buffer, which holds both accounts and publishes the post at once.
Why not the networks’ own APIs
X’s API is pay-per-use. Its pricing page lists $0.015 to create a post and $0.20 to create one that contains a URL, and every post I send has a link.
LinkedIn’s self-serve API can post to your own profile, with access tokens that last 60 days. Refresh tokens are only for LinkedIn’s approved partners, so someone has to sign in again every two months. Posting as a company page needs a separate API that LinkedIn reviews.
Buffer connects both accounts through its own app, and since May 2026 it has offered a GraphQL API with personal keys on every plan. The free plan covers 3 channels, 10 scheduled posts per channel and one API key, good for 1,000 requests per channel a month. Posting once a day uses about 30.
Find the channel ids
Connect the accounts in Buffer’s app, then create a key in its API settings. Every call is a POST to https://api.buffer.com with the key as a Bearer token. Two queries list what the key can post to:
query { account { organizations { id name } } }
query ($input: ChannelsInput!) {
channels(input: $input) { id name service type displayName }
}
The second takes {"input": {"organizationId": "<organization id>"}} as its variables. X shows up with the service name twitter. Keep the two channel ids next to the key, because the script needs nothing else.
Post
One mutation per post:
mutation ($input: CreatePostInput!) {
createPost(input: $input) {
__typename
... on PostActionSuccess { post { id } }
... on MutationError { message }
}
}
with these variables:
{
"input": {
"channelId": "<channel id>",
"text": "The post",
"schedulingType": "automatic",
"mode": "shareNow"
}
}
mode: "shareNow" skips the queue and publishes right away. For LinkedIn, I add a link card to the input, with the page’s social image as its thumbnail:
"metadata": {
"linkedin": {
"linkAttachment": {
"url": "https://rubric.hrih.si/editions/2026-10-30/",
"title": "…",
"description": "…",
"thumbnail": { "url": "https://rubric.hrih.si/og/2026-10-30.png" }
}
}
}
On X the link goes in a reply, so the input carries a thread. The thread lists every post including the first one, which repeats the text:
"metadata": {
"twitter": {
"thread": [{ "text": "The post" }, { "text": "The reply, with the link" }]
}
}
The same request from a shell:
curl -s https://api.buffer.com \
-H "Authorization: Bearer $BUFFER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"mutation ($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }","variables":{"input":{"channelId":"<channel id>","text":"Hello from a script","schedulingType":"automatic","mode":"shareNow"}}}'
Buffer can refuse a post without the request failing. The answer then has __typename set to MutationError and a message, so check __typename as well as the errors array GraphQL uses for a bad query.
What you give up
Buffer answers with its own post id, not the post’s address on X or LinkedIn, so the script can’t link to what it just posted. You also add a company in the middle that holds your tokens. For me that beats paying per post: the first real posts went out on 28 October 2026, and Buffer took the link card and the thread exactly as shown here.