API Status Get API Key

Efficient API Usage

The Buffer GraphQL API lets developers access data efficiently by requesting exactly the fields they need. While the Buffer team aims to build a fast and efficient API, there are a few optimization techniques you can use to significantly reduce the number of requests you send and increase the speed of your integration.

Request only the data you need

Every field you select adds complexity to your requests. Keeping it small and simple will result in faster and more efficient queries.

Select only what you use. For example, if you are building a queue view, you may not need metrics, notes, assets and author on every post. Fewer fields mean a smaller response and a faster query.

Combine queries into a single request

One of GraphQL's most useful features is that a single query can include multiple top-level fields. Instead of making separate requests for your account details, your channels, and your daily posting limits, you can fetch all three in one round trip:

query Bootstrap {
  account {
    id
    email
    timezone
  }
  channels(input: { organizationId: "your_org_id" }) {
    id
    name
    service
  }
  dailyPostingLimits(input: { channelIds: ["your_channel_id"] }) {
    channelId
    isAtLimit
    scheduled
  }
}
curl -X POST 'https://api.buffer.com' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -d '{"query": "query Bootstrap {\n  account {\n    id\n    email\n    timezone\n  }\n  channels(input: { organizationId: \"your_org_id\" }) {\n    id\n    name\n    service\n  }\n  dailyPostingLimits(input: { channelIds: [\"your_channel_id\"] }) {\n    channelId\n    isAtLimit\n    scheduled\n  }\n}"}'
async function bootstrap() {
  const response = await fetch('https://api.buffer.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_API_KEY',
    },
    body: JSON.stringify({
      query: `
      query Bootstrap {
        account {
          id
          email
          timezone
        }
        channels(input: { organizationId: "your_org_id" }) {
          id
          name
          service
        }
        dailyPostingLimits(input: { channelIds: ["your_channel_id"] }) {
          channelId
          isAtLimit
          scheduled
        }
      }
      `,
    }),
  });

  const data = await response.json();
  console.log(JSON.stringify(data, null, 2));
}

bootstrap();
import requests

query = """
query Bootstrap {
  account {
    id
    email
    timezone
  }
  channels(input: { organizationId: "your_org_id" }) {
    id
    name
    service
  }
  dailyPostingLimits(input: { channelIds: ["your_channel_id"] }) {
    channelId
    isAtLimit
    scheduled
  }
}
"""

response = requests.post(
    "https://api.buffer.com",
    headers={
        "Content-Type": "application/json",
        "Authorization": "Bearer YOUR_API_KEY",
    },
    json={
        "query": query,
    },
)

data = response.json()
print(data)
<?php

$query = '
query Bootstrap {
  account {
    id
    email
    timezone
  }
  channels(input: { organizationId: "your_org_id" }) {
    id
    name
    service
  }
  dailyPostingLimits(input: { channelIds: ["your_channel_id"] }) {
    channelId
    isAtLimit
    scheduled
  }
}
';

$payload = [
    'query' => $query,
];

$ch = curl_init('https://api.buffer.com');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer YOUR_API_KEY',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_RETURNTRANSFER => true,
]);

$response = curl_exec($ch);
curl_close($ch);

$data = json_decode($response, true);
print_r($data);

Each top-level field resolves independently, and the response contains all three results under a single data object. This is a good pattern for app startup or dashboard views, where you'd otherwise fire several requests at once.

Many Buffer API fields also accept lists - for example, dailyPostingLimits takes an array of channelIds - so you can fetch data for many resources in one call rather than looping over them individually.

Use aliases to run the same query more than once

Combining queries works well when each top-level field is different. But if you request the same field twice with different arguments, the server rejects the query - the response is keyed by field name, and there's no way to place two different result sets under one key. Aliases solve this by letting you rename the response key for each request.

For example, say you want your scheduled posts and your failed posts in a single request. Alias the two posts fields as scheduled and failed:

query QueueOverview {
  scheduled: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [scheduled] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
  failed: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [error] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
}

fragment PostFields on Post {
  id
  text
  status
  dueAt
  channelId
}
curl -X POST 'https://api.buffer.com' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -d '{"query": "query QueueOverview {\n  scheduled: posts(\n    first: 100\n    input: { organizationId: \"your_org_id\", filter: { status: [scheduled] } }\n  ) {\n    edges { node { ...PostFields } }\n    pageInfo { hasNextPage endCursor }\n  }\n  failed: posts(\n    first: 100\n    input: { organizationId: \"your_org_id\", filter: { status: [error] } }\n  ) {\n    edges { node { ...PostFields } }\n    pageInfo { hasNextPage endCursor }\n  }\n}\n\nfragment PostFields on Post {\n  id\n  text\n  status\n  dueAt\n  channelId\n}"}'
async function queueOverview() {
  const response = await fetch('https://api.buffer.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_API_KEY',
    },
    body: JSON.stringify({
      query: `
      query QueueOverview {
        scheduled: posts(
          first: 100
          input: { organizationId: "your_org_id", filter: { status: [scheduled] } }
        ) {
          edges { node { ...PostFields } }
          pageInfo { hasNextPage endCursor }
        }
        failed: posts(
          first: 100
          input: { organizationId: "your_org_id", filter: { status: [error] } }
        ) {
          edges { node { ...PostFields } }
          pageInfo { hasNextPage endCursor }
        }
      }
      
      fragment PostFields on Post {
        id
        text
        status
        dueAt
        channelId
      }
      `,
    }),
  });

  const data = await response.json();
  console.log(JSON.stringify(data, null, 2));
}

queueOverview();
import requests

query = """
query QueueOverview {
  scheduled: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [scheduled] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
  failed: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [error] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
}

fragment PostFields on Post {
  id
  text
  status
  dueAt
  channelId
}
"""

response = requests.post(
    "https://api.buffer.com",
    headers={
        "Content-Type": "application/json",
        "Authorization": "Bearer YOUR_API_KEY",
    },
    json={
        "query": query,
    },
)

data = response.json()
print(data)
<?php

$query = '
query QueueOverview {
  scheduled: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [scheduled] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
  failed: posts(
    first: 100
    input: { organizationId: "your_org_id", filter: { status: [error] } }
  ) {
    edges { node { ...PostFields } }
    pageInfo { hasNextPage endCursor }
  }
}

fragment PostFields on Post {
  id
  text
  status
  dueAt
  channelId
}
';

$payload = [
    'query' => $query,
];

$ch = curl_init('https://api.buffer.com');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer YOUR_API_KEY',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_RETURNTRANSFER => true,
]);

$response = curl_exec($ch);
curl_close($ch);

$data = json_decode($response, true);
print_r($data);

The response contains data.scheduled and data.failed, each with its own result set.

Aliases are also the way to look up a specific set of records in one request, since posts filters on status, channel and date but not on ID. Checking 25 posts one request at a time is 25 requests; the same 25 post lookups aliased into one document is one. A query is capped at 30 aliases, so where a filter can describe what you want, prefer the filter.

Use fragments to simplify complex queries

Another technique that can significantly improve your interactions with the Buffer API is using fragments. The alias query above already uses one. Both posts fields need the same five fields on Post, so rather than spelling that selection out twice, it is defined once and referenced with the spread syntax (...PostFields):

fragment PostFields on Post {
  id
  text
  status
  dueAt
  channelId
}

Anywhere those fields are needed, ...PostFields stands in for them.

Fragments can help simplify queries, make them more readable, and reduce the maintenance overhead when adding or renaming a field.

They also keep query documents small, which matters against the 15,000-token document limit.

Filter and sort on the server

Fetching a wide set and narrowing it in your own code costs you every request that returned data you threw away.

Queries like posts support filters on status, channel, post type, tags, and date ranges through dueAt and createdAt comparators. Using them can significantly reduce the amount of data you don't need:

query RecentlySent {
  posts(
    first: 100
    input: {
      organizationId: "your_org_id"
      filter: {
        status: [sent]
        channelIds: ["your_channel_id"]
        dueAt: { start: "2026-01-01T00:00:00Z" }
      }
      sort: { field: dueAt, direction: desc }
    }
  ) {
    edges { node { id text dueAt channelId } }
    pageInfo { hasNextPage endCursor }
  }
}
curl -X POST 'https://api.buffer.com' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -d '{"query": "query RecentlySent {\n  posts(\n    first: 100\n    input: {\n      organizationId: \"your_org_id\"\n      filter: {\n        status: [sent]\n        channelIds: [\"your_channel_id\"]\n        dueAt: { start: \"2026-01-01T00:00:00Z\" }\n      }\n      sort: { field: dueAt, direction: desc }\n    }\n  ) {\n    edges { node { id text dueAt channelId } }\n    pageInfo { hasNextPage endCursor }\n  }\n}"}'
async function recentlySent() {
  const response = await fetch('https://api.buffer.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_API_KEY',
    },
    body: JSON.stringify({
      query: `
      query RecentlySent {
        posts(
          first: 100
          input: {
            organizationId: "your_org_id"
            filter: {
              status: [sent]
              channelIds: ["your_channel_id"]
              dueAt: { start: "2026-01-01T00:00:00Z" }
            }
            sort: { field: dueAt, direction: desc }
          }
        ) {
          edges { node { id text dueAt channelId } }
          pageInfo { hasNextPage endCursor }
        }
      }
      `,
    }),
  });

  const data = await response.json();
  console.log(JSON.stringify(data, null, 2));
}

recentlySent();
import requests

query = """
query RecentlySent {
  posts(
    first: 100
    input: {
      organizationId: "your_org_id"
      filter: {
        status: [sent]
        channelIds: ["your_channel_id"]
        dueAt: { start: "2026-01-01T00:00:00Z" }
      }
      sort: { field: dueAt, direction: desc }
    }
  ) {
    edges { node { id text dueAt channelId } }
    pageInfo { hasNextPage endCursor }
  }
}
"""

response = requests.post(
    "https://api.buffer.com",
    headers={
        "Content-Type": "application/json",
        "Authorization": "Bearer YOUR_API_KEY",
    },
    json={
        "query": query,
    },
)

data = response.json()
print(data)
<?php

$query = '
query RecentlySent {
  posts(
    first: 100
    input: {
      organizationId: "your_org_id"
      filter: {
        status: [sent]
        channelIds: ["your_channel_id"]
        dueAt: { start: "2026-01-01T00:00:00Z" }
      }
      sort: { field: dueAt, direction: desc }
    }
  ) {
    edges { node { id text dueAt channelId } }
    pageInfo { hasNextPage endCursor }
  }
}
';

$payload = [
    'query' => $query,
];

$ch = curl_init('https://api.buffer.com');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer YOUR_API_KEY',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_RETURNTRANSFER => true,
]);

$response = curl_exec($ch);
curl_close($ch);

$data = json_decode($response, true);
print_r($data);

Use flat fields instead of nested objects

Some values can appear in multiple places in the schema. For example, a Post carries channelId and channelService as plain fields, and the same values are also reachable through the channel object. Accessing them requires different resources.

The flat fields are values the post already has - reading them costs nothing extra. Requesting the channel object makes the API load the full channel first, even if all you select inside it is id and service.

# Slower: loads the full channel for every post
posts(first: 100, input: { organizationId: "your_org_id" }) {
  edges { node { id channel { id service } } }
}

# Faster: reads values the post already has
posts(first: 100, input: { organizationId: "your_org_id" }) {
  edges { node { id channelId channelService } }
}

At 100 posts per page, that's 100 channel loads you didn't need. A good rule of thumb - check whether the value you want already exists on the object you have before reaching through to a nested one.

Use pagination with a large enough page

Pagination allows you to loop through pages to access more results. When you need to access a large number of results, aim to use a large enough page window. At the moment, the Buffer API supports returning up to 100 items per request.

One caveat to keep in mind - page size and selection width multiply. Every field you select is resolved once per item in the page, which counts towards the query complexity budget and towards the size of the response.

See Pagination for how cursors work.

Aggregate when possible

Some queries in the Buffer API are designed to aggregate large amounts of data. When you want totals rather than individual rows, use them instead of paging through the underlying records and adding them up yourself.

aggregatedPostMetrics is the clearest example. It rolls up post performance across a date range of up to 365 days and returns the totals in a single request:

query QuarterRollup {
  aggregatedPostMetrics(
    input: {
      organizationId: "your_org_id"
      startDateTime: "2026-04-01T00:00:00Z"
      endDateTime: "2026-06-30T00:00:00Z"
    }
  ) {
    metrics { type name value unit }
    metricsUpdatedAt
  }
}
curl -X POST 'https://api.buffer.com' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -d '{"query": "query QuarterRollup {\n  aggregatedPostMetrics(\n    input: {\n      organizationId: \"your_org_id\"\n      startDateTime: \"2026-04-01T00:00:00Z\"\n      endDateTime: \"2026-06-30T00:00:00Z\"\n    }\n  ) {\n    metrics { type name value unit }\n    metricsUpdatedAt\n  }\n}"}'
async function quarterRollup() {
  const response = await fetch('https://api.buffer.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_API_KEY',
    },
    body: JSON.stringify({
      query: `
      query QuarterRollup {
        aggregatedPostMetrics(
          input: {
            organizationId: "your_org_id"
            startDateTime: "2026-04-01T00:00:00Z"
            endDateTime: "2026-06-30T00:00:00Z"
          }
        ) {
          metrics { type name value unit }
          metricsUpdatedAt
        }
      }
      `,
    }),
  });

  const data = await response.json();
  console.log(JSON.stringify(data, null, 2));
}

quarterRollup();
import requests

query = """
query QuarterRollup {
  aggregatedPostMetrics(
    input: {
      organizationId: "your_org_id"
      startDateTime: "2026-04-01T00:00:00Z"
      endDateTime: "2026-06-30T00:00:00Z"
    }
  ) {
    metrics { type name value unit }
    metricsUpdatedAt
  }
}
"""

response = requests.post(
    "https://api.buffer.com",
    headers={
        "Content-Type": "application/json",
        "Authorization": "Bearer YOUR_API_KEY",
    },
    json={
        "query": query,
    },
)

data = response.json()
print(data)
<?php

$query = '
query QuarterRollup {
  aggregatedPostMetrics(
    input: {
      organizationId: "your_org_id"
      startDateTime: "2026-04-01T00:00:00Z"
      endDateTime: "2026-06-30T00:00:00Z"
    }
  ) {
    metrics { type name value unit }
    metricsUpdatedAt
  }
}
';

$payload = [
    'query' => $query,
];

$ch = curl_init('https://api.buffer.com');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer YOUR_API_KEY',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_RETURNTRANSFER => true,
]);

$response = curl_exec($ch);
curl_close($ch);

$data = json_decode($response, true);
print_r($data);

Every result includes a baseline of postCount, reactions and comments:

{
  "metrics": [
    { "type": "postCount", "name": "Posts", "value": 250, "unit": "count" },
    { "type": "reactions", "name": "Reactions", "value": 6800, "unit": "count" },
    { "type": "comments", "name": "Comments", "value": 750, "unit": "count" }
  ],
  "metricsUpdatedAt": "2026-06-30T03:23:11.120Z"
}

Building those same numbers by hand means paging every sent post in the window, selecting metrics on each one, and summing them yourself - several requests and a far larger response for a result the API can return in one.

Cache what rarely changes

Channels, organization IDs and tags mostly change when someone connects or disconnects an account. In most cases, these details don't change as dynamically as other data points. For that reason they are good candidates for client-side caching. This can drop the request volume by an order of magnitude.

A few techniques to consider:

  • Cache the organization ID and channel list, and refresh them on a schedule. A daily refresh, or a manual "refresh channels" button in your UI, beats a fetch on every operation.
  • Avoid fetching channels just to validate a channel ID. If you already stored the ID, use it. createPost returns a typed NotFoundError with "Channel not found" when an ID is wrong, so you can recover from the failure instead of paying for a lookup before every write.

GraphQL clients like Apollo can do most of this for you. Apollo's InMemoryCache normalizes anything with an id, so a channel fetched once is reused everywhere it appears. Its default fetchPolicy is cache-first, which means repeat queries are answered from memory without touching the API:

import { ApolloClient, InMemoryCache, gql } from '@apollo/client'

const client = new ApolloClient({
  uri: 'https://api.buffer.com',
  headers: { Authorization: `Bearer ${process.env.BUFFER_API_KEY}` },
  cache: new InMemoryCache(),
})

const GET_CHANNELS = gql`
  query GetChannels {
    channels(input: { organizationId: "your_org_id" }) {
      id
      name
      service
    }
  }
`

// First call goes to the API. Later calls are served from the cache.
const { data } = await client.query({ query: GET_CHANNELS })

// Ask for fresh data only when it matters, such as after someone
// connects or disconnects a channel.
await client.query({ query: GET_CHANNELS, fetchPolicy: 'network-only' })

The pattern holds whichever client you use: read from the cache by default, and go to the network deliberately rather than on every operation.

Poll less, and poll narrowly

There are no webhooks, so keeping data in sync means polling. Make each poll cheap and infrequent.

Ask only for what changed. Keep a checkpoint of the last time you synced, and filter on createdAt: { start: $checkpoint } rather than walking the full history each run. Cursors are tied to the result set that produced them, so store the timestamp, not the cursor, between runs.

Prompts for AI agents

Buffer has an MCP server, so the same API is available to Claude, Cursor, ChatGPT and other assistants. The quota does not change when you reach it that way: MCP connections and your personal API keys share one rate-limit bucket, so connecting another assistant does not give you more room.

The server already steers the agent quite a bit on its own. Its recommended workflow starts at get_account for the organization, moves to list_channels for channels, and routes summaries and averages to get_aggregated_post_metrics. Its tool descriptions tell the agent to prefer the domain tools over raw GraphQL, and to take field and enum names from the schema rather than from memory.

The tool descriptions also tell the agent to reuse an account, organization or channel ID it holds rather than looking it up again. Every ID parameter is constrained to a 24-character hex pattern, so an invented ID is rejected before it costs a request. create_post and edit_post return the post they saved, and the post tools report each post's allowedActions, so an agent does not have to read a post back to confirm an edit landed or to find out whether it may delete it. Confirming a write that way is a common source of wasted quota, and the server covers it rather than leaving it to your prompt.

The prompts below let you calibrate your assistant's behavior. Put them in your system prompt, your project instructions, or an AGENTS.md file, so they apply to every session rather than being repeated by hand.

Load the context once

The server tells the agent to reuse an ID it holds, and to call list_channels only when a channel ID is unknown. What it does not settle is how long a held ID stays good, so an agent may decide its copy has gone stale halfway through a long session. This sets that expectation once.

Treat the organization ID and channel list as fixed for this session.
Fetch them once, then reuse them.

Do not fetch them again unless I tell you the channels have changed.

Do not guess an ID

Much of this is enforced for you. Every ID parameter carries a 24-character hex pattern, so a placeholder such as default or me is rejected by the schema rather than spending a request.

What the pattern cannot catch is a real ID of the wrong kind, because a post's ID on a social network has the same shape as its Buffer ID.

A post's ID on a social network is not its Buffer ID, even though the
two look alike.

If you do not have an ID from an earlier response, ask me for it
rather than constructing one.

Ask for one page at a time

When listing posts, filter by status and date range.

Fetch one page and show me the result. Only fetch another page when I
ask for it.

Combine unrelated reads

Nothing in the server suggests this, and it is the technique that saves the most in a single session.

When you are already using execute_query and need several unrelated
things at once, ask for them in a single document.

Use multiple root fields for different queries.

Use aliases when the same field repeats with different arguments.

Spend the quota knowingly

Every successful response carries the requests left in each window and the seconds until it resets. An error does not: a 429 reports Retry-After instead. If you notice your assistant still hitting rate limits, you can strengthen the behavior with a prompt of your own:

Every successful Buffer MCP tool response reports the requests
remaining and when the window resets. An error result does not carry
those numbers.

Read them when they are there. When a window falls below roughly a
tenth of its quota, stop and tell me before making further calls.

Fail once, not repeatedly

Every request counts against your quota whether it succeeds or fails. Only a 429 is refunded, so a retry loop on a broken assumption is pure waste.

Reads and writes need different handling. Repeating a read costs quota and nothing else, but the write tools take no idempotency key, so a create_post that succeeded on a request whose response never arrived will publish a second post if the agent simply tries again. Retry a write only when the error shows the write never happened, such as a validation error naming a bad field. When the outcome is genuinely unknown, a timeout or a dropped connection, the cheap recovery is to look: list_posts filtered to the channel and a narrow createdAt window says whether it landed.

If a call fails, read the error message and fix the specific thing it
names. Retry at most once.

If it fails again, stop and tell me what went wrong instead of trying
variations.

Reads are safe to repeat. Writes are not: create_post has no
idempotency key, so retry a write only when the error shows it never
happened, such as a validation error.

If a write fails without a clear answer, such as a timeout, check with
list_posts whether it landed before trying it again.

If a call returns a 429, stop and report the Retry-After wait time.
Do not retry in a loop.

Unattended code should behave differently here: back off and retry as described in Rate Limits. The instruction above is for an interactive agent, where surfacing the wait to a human beats sleeping through it.

One block to paste

To set this up once rather than prompt by prompt:

When using the Buffer MCP server:

1. Fetch the organization ID and channel list once per session,
   then reuse them. Do not fetch them again unless I say they changed.
2. A post's ID on a social network is not its Buffer ID.
   Ask me for an ID rather than inventing one.
3. When listing posts, filter by status and date range, and fetch
   one page at a time.
4. When you need several unrelated things from execute_query,
   ask for them in one document, using root fields or aliases.
5. Watch the quota each successful response reports, and tell me when
   a window falls below a tenth of its limit.
6. If a call fails, fix what the error names and retry at most once,
   then stop and tell me. Retry a write only when the error shows it
   never happened; if a write times out, check whether it landed
   before trying again. On a 429, report the Retry-After wait.

Next steps