updated 29 October 2026

The Buffer API, explained

Yes, Buffer has a public API again. It is a GraphQL API at api.buffer.com, and Buffer's help center, read 29 October 2026, says it is included on every Buffer plan, including Free. A personal API key acts on your own Buffer account; apps that act for other Buffer users register an OAuth client and use the authorization-code flow with PKCE. The old REST API at api.bufferapp.com is the legacy version, and Buffer publishes a guide for moving off it.

Facts on this page come from Buffer's developer documentation and help center, read on 29 October 2026. Buffer adds to the API often, so check its changelog before you build on a detail.

Is the Buffer API still available?

Yes. Many articles still say Buffer shut its API down. Those describe the legacy REST API at api.bufferapp.com/1/. The current API is a separate GraphQL API at https://api.buffer.com, and Buffer's REST migration guide maps the old endpoints to it. Buffer describes it as covering the core functionality of the legacy API.

Buffer handles publishing to each social network. Your code hands a post to Buffer, and Buffer sends it to the connected channel. The help center lists Instagram, Threads, LinkedIn, X, Facebook, TikTok, Google Business Profile, Mastodon, YouTube, Pinterest, and Bluesky as channels the API can create posts for.

What the API can and can't do

From Buffer's help center:

  • Can: create, edit, schedule, and delete posts, including images and video from a public link, first comments, and threads on X, Bluesky, Threads, and Mastodon; create and list ideas; read organizations, channels, and scheduled and sent posts; manage tags (an early preview).
  • Can't yet: full analytics (only an experimental set of post metrics, readable with a personal key, not by OAuth apps), reading or replying to comments, Start Page posts, custom video thumbnails, and direct file uploads.

If a channel requires approval, posts created through the API are saved as drafts awaiting approval, the same as in the Buffer dashboard.

API key or OAuth app

Buffer offers two ways in (authentication):

  • Personal API key. Create one under Settings → API and send it as Authorization: Bearer <key>. It acts for your account only and can reach every organization and channel on it; there is no per-organization scoping. The help center says only an organization owner can create keys. Use a key for your own scripts, automations, and agents.
  • OAuth app client. For an app that other Buffer users connect, register an app client on the same settings page. Buffer requires the authorization-code flow with PKCE for every client. Confidential clients get a client secret; public clients (mobile, desktop, single-page apps) use PKCE alone. Ask for offline_access to get a refresh token. The building apps guide covers redirect URIs, token storage, and going to production.

Keep keys and tokens on your server. Buffer's own advice is to never commit a key or put it in client-side code.

Scheduling a post from TypeScript

Every call is a POST of a GraphQL document to https://api.buffer.com. To post, you need an organization ID, then a channel ID from that organization, then a createPost mutation. A key can reach several organizations, so the example checks each one for a channel. This example schedules a Bluesky post an hour from now with plain fetch:

const apiKey = process.env["BUFFER_API_KEY"];
if (!apiKey) throw new Error("Set BUFFER_API_KEY");

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null;

// Every Buffer API call is a POST of a GraphQL document to one endpoint.
// The response is checked before use, so it starts as unknown.
async function bufferGraphql(query: string, variables: object = {}): Promise<Record<string, unknown>> {
  const response = await fetch("https://api.buffer.com", {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (response.status === 429) {
    // Retry-After is in seconds. A 30-day window can be weeks away, so surface it.
    throw new Error(`Rate limited; retry after ${response.headers.get("retry-after")} s`);
  }
  const body: unknown = await response.json();
  if (!isRecord(body)) throw new Error("Unexpected response");
  if (Array.isArray(body["errors"]) && body["errors"].length > 0) {
    throw new Error(JSON.stringify(body["errors"]));
  }
  if (!isRecord(body["data"])) throw new Error("No data");
  return body["data"];
}

const { account } = await bufferGraphql(`{ account { organizations { id name } } }`);
const organizations = isRecord(account) ? account["organizations"] : undefined;

// A key can reach several organizations; use the first Bluesky channel in any of them.
let channel: Record<string, unknown> | undefined;
for (const organization of Array.isArray(organizations) ? organizations : []) {
  if (!isRecord(organization)) continue;
  const { channels } = await bufferGraphql(
    `query Channels($organizationId: OrganizationId!) {
      channels(input: { organizationId: $organizationId }) { id service }
    }`,
    { organizationId: organization["id"] },
  );
  const found = (Array.isArray(channels) ? channels : []).find(
    (item) => isRecord(item) && item["service"] === "bluesky",
  );
  if (isRecord(found)) {
    channel = found;
    break;
  }
}
if (!channel) throw new Error("Connect a Bluesky channel in Buffer first");

const { createPost } = await bufferGraphql(
  `mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      ... on PostActionSuccess { post { id status dueAt } }
      ... on MutationError { message }
    }
  }`,
  {
    input: {
      channelId: channel["id"],
      text: "Release notes for v2 are up.",
      schedulingType: "automatic",
      mode: "customScheduled",
      dueAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // UTC, an hour from now
    },
  },
);
if (!isRecord(createPost)) throw new Error("Unexpected createPost result");
// Validation problems arrive as a MutationError on HTTP 200, not in `errors`.
if (!isRecord(createPost["post"])) throw new Error(String(createPost["message"]));
const post = createPost["post"];
// A channel that requires approval returns a draft (needs_approval), not a scheduled post.
console.log(post["status"], post["id"], post["dueAt"]);

We type-checked this against Buffer's GraphQL reference types and ran it on 29 October 2026 with a stub fetch standing in for api.buffer.com. With the Bluesky channel in the second of two organizations, it made four requests and printed scheduled with the post's ID and time. A stubbed needs_approval draft printed that status instead, and a stubbed MutationError and 429 each stopped the script with the message shown. It runs on Node.js 22.12 or later and Bun.

Two details catch people out:

  • Two kinds of error. Problems with your input, such as text over a channel's limit, come back as a MutationError inside data with HTTP 200. Authentication failures, missing records, and rate limits come back in errors. On post mutations such as createPost, select both PostActionSuccess and MutationError; other mutations name their own success and error types in the reference.
  • Media is a URL, not an upload. There is no upload endpoint. Host the file yourself at a public, direct HTTPS URL and pass it in assets. Buffer fetches it when the post publishes, which can be days later, so signed or expiring URLs fail after createPost succeeded (hosting media).

Scheduling modes

createPost needs a mode and a schedulingType (posts and scheduling):

ValueWhat it does
mode: addToQueueNext free slot in the channel's posting schedule
mode: shareNextFront of the queue
mode: shareNowPublish immediately
mode: customScheduledAt dueAt, an ISO 8601 UTC time
schedulingType: automaticBuffer's workers publish the post
schedulingType: notificationBuffer reminds someone to publish it by hand

Set saveToDraft: true to create a draft instead. A post's status moves through values such as scheduled, sending, sent, and error; Buffer's API docs describe no webhooks for post status, so poll it.

Rate limits

Limits depend on your Buffer plan and apply over 15 minutes, 24 hours, and 30 days. All of your API keys and MCP connections share one set of limits, so another key doesn't add requests (rate limits):

PlanKeys and request limits
Free1 key, 1 app client; 150 per 15 min, 600 per 24 h; 1,000 per channel per 30 days, up to 3,000
Essentials3 keys, 3 app clients; 450 per 15 min, 1,800 per 24 h; 3,000 per channel per 30 days, up to 30,000
Team5 keys, 5 app clients; 1,500 per 15 min, 6,000 per 24 h; 10,000 per channel per 30 days, up to 100,000

An OAuth app client has its own limits, separate from your plan's, which grow with the number of Buffer users who authorize it (up to 1,000 counted):

WindowBase, plus per authorized user
15 minutes100, plus 10
24 hours500, plus 25
30 days15,000, plus 100

Every response carries RateLimit and RateLimit-Policy headers, one per window, with the requests remaining (r) and seconds to reset (t). Going over returns HTTP 429 with a Retry-After header in seconds and an error naming the window. Buffer says a 429 doesn't use quota. Queries also have their own limits: a complexity cost of 175,000 points, depth 25, 30 aliases, 50 directives, and 15,000 tokens.

These request limits are separate from each channel's daily posting limits, which you read with the dailyPostingLimits query.

Moving off the legacy REST API

Buffer's migration guide lists the changes: one endpoint instead of one URL per resource, always POST, cursor pagination (first and after) instead of page and count, typed errors in the response body instead of HTTP status codes, and an API key or OAuth with PKCE instead of the old OAuth client flow. REST profiles are now channels, and updates are posts.

Buffer API alternatives

Buffer's API fits best when you or your users already run social accounts in Buffer: Buffer holds the channel connections and the posting schedule, and your code feeds it posts. If that doesn't match what you're building, the options fall into three groups.

  • Post directly to each network. Your own platform apps and tokens, no intermediary, and each platform's own review and limits. Our guides cover the X API, Bluesky API, and Threads API.
  • A unified posting API. A provider holds the platform apps and account connections, and you call one API. Their own sites describe Ayrshare, Post for Me, Upload-Post, Outstand, and bundle.social as unified social media APIs, and Zernio (getlate.dev now redirects there) as marketing infrastructure behind one API key. Each has its own pricing and platform list; compare them on their sites.
  • Another scheduler with an API. Postiz (open source, which you can self-host) and PostFast are schedulers, like Buffer, that also expose an API.

Using Buffer with Social SDK

Social SDK is an open-source TypeScript library that puts direct platform adapters and managed backends behind one posts.publish call with a typed outcome per destination. Buffer is one of those backends, alongside Zernio, Post for Me, PostFast, Postiz, and Upload-Post, so you can start on Buffer and switch later without rewriting your posting code. The Buffer backend page covers API keys, the OAuth PKCE helpers, scheduling, media URLs, and how Buffer statuses map to outcomes.

The Buffer adapter is on the main branch and not yet in the 0.5.0 npm release, which includes the Zernio, Post for Me, and PostFast backends and the direct adapters. You can write and test your posting code today against the mock backend, which needs no keys:

npm install @opencoredev/social-sdk
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { mockBackend } from "@opencoredev/social-sdk/testing";

// The mock backend needs no keys and makes no network calls. In production,
// pass a real backend here instead; the publish call below stays the same.
const social = createSocial({ backend: mockBackend({ scenario: "mixed-success-failure" }) });

const account = (platform: "x" | "bluesky", accountId: string) =>
  connectedAccountRef({ backend: "default", platform, accountId });

const result = await social.posts.publish({
  targets: [{ account: account("x", "mock-account-1") }, { account: account("bluesky", "mock-account-2") }],
  content: { text: "Release notes for v2 are up." },
});

// One outcome per destination: a failure on one network doesn't hide a success on another.
for (const outcome of result.outcomes) {
  console.log(outcome.account.platform, outcome.state);
}

We type-checked this against @opencoredev/social-sdk 0.5.0 from npm and ran it on 29 October 2026. It printed x published and bluesky failed: the mixed-success-failure scenario fails every second target, so you can see how your code handles a partial result. The mock quickstart has the other scenarios, and the managed backends overview compares what each provider's adapter supports.

Common questions

Does Buffer have an API?
Yes, a GraphQL API at https://api.buffer.com. Create a key under Settings → API, or register an OAuth app client to act for other users.
Is the Buffer API free?
Buffer's help center says API access is included on every plan, including Free, which allows 1 API key and 1,000 requests per connected channel every 30 days, up to 3,000. Paid plans allow more keys and requests.
Can I upload images or video through the Buffer API?
Not as a file upload. Pass a public, stable HTTPS URL in assets, and keep it reachable until the post publishes.
Can I get analytics from the Buffer API?
Only an experimental set of post metrics, readable with a personal API key. Buffer says not to rely on it for reporting, and OAuth apps can't read it.
Does my old api.bufferapp.com code still work?
That is the legacy REST API. Buffer's migration guide maps each endpoint to the GraphQL API; plan to move to it.

Sources

Read on 29 October 2026: