# Content Items

A content item is one piece of content. It holds either a channel-less draft, or the channel-specific posts for that content.

## Why this exists

Today you create one post per channel, one `createPost` call at a time. Five channels means five calls. Nothing ties those posts together, so nothing tells you that the LinkedIn post and the Instagram post cover the same topic.

A content item is that tie. You create the per-channel posts from one item, and the item keeps the title, the tags and the target date that apply to the content as a whole.

It is also one call. `createContentItem` takes a list of posts and creates all of them together, so you no longer send one request per channel. Validation covers the whole list: if one post is rejected, none are created. So you never have to clean up a partial result.

The posts do not have to match. Each one keeps its own text and its own media, so you can write a long post for LinkedIn and a short one for Mastodon and still group them. What the item records is that the two belong together, not that they say the same thing.

A content item also lets you start before you pick a channel. You can save a draft with no channel attached, decide later where it goes, and keep the writing in the meantime.

## The two states

A content item is always in one of two states, and `body` tells you which:

- **`DraftContent`** - a channel-less draft. No channel is selected, so no network rules apply, including media type restrictions.
- **`PostContent`** - the channel-specific posts. Each post targets one channel and follows that channel's rules.

The move from a draft to posts goes one way. Once you promote a draft, you cannot edit it as a draft again. You edit the individual posts instead, through the post mutations.

Read an item with `contentItem`, and pick the state apart with an inline fragment on each member of the union:

```graphql
query GetContentItem {
  contentItem(input: { id: "some_content_item_id" }) {
    id
    title
    targetDate
    body {
      ... on DraftContent {
        text
        assets { ... on ImageAsset { source thumbnail } }
      }
      ... on PostContent {
        posts {
          id
          text
          channel { id name }
        }
      }
    }
  }
}
```

## Creating an item and its posts

Use `createContentItem` when you already know the channels. It creates the item and every channel-specific post in one call.

The `title`, `tagIds` and `targetDate` fields belong to the content item, not to any single post. Each post carries its own text, its own media and its own scheduled time.

```graphql
mutation CreateContentItem {
  createContentItem(input: {
    organizationId: "your_org_id",
    title: "Spring feature announcement",
    targetDate: "2026-09-15T09:00:00Z",
    posts: [
      {
        channelId: "linkedin_channel_id",
        text: "We shipped the thing. Here is what it does and why we built it.",
        schedulingType: automatic,
        mode: addToQueue
      },
      {
        channelId: "mastodon_channel_id",
        text: "We shipped the thing! Short version: it saves you a step.",
        schedulingType: automatic,
        mode: addToQueue
      }
    ]
  }) {
    ... on CreateContentItemSuccess {
      content {
        id
        title
        targetDate
        body {
          ... on PostContent {
            posts {
              id
              status
              dueAt
              channel {
                id
                name
              }
            }
          }
        }
      }
    }
    ... on CreateContentItemFailure {
      message
      errors {
        ... on CreateContentItemVariantInvalidInputError {
          channelId
          message
        }
        ... on CreateContentItemVariantLimitReachedError {
          channelId
          message
        }
        ... on CreateContentItemVariantNotFoundError {
          channelId
          message
        }
        ... on MutationError {
          message
        }
      }
    }
  }
}
```

Validation is all-or-nothing. If any post in the `posts` list fails validation, no content item and no posts are created.

The `CreateContentItemVariant*` errors each name the channel they apply to. `InvalidInputError`, `NotFoundError` and `LimitReachedError` carry a message only, because they apply to the request as a whole.

## Creating a channel-less draft

A content item does not need a channel. `createContentItemDraft` saves a channel-less draft, so you can capture and work on content before you decide where it goes.

A draft is a state you can stay in, not a step you have to pass through. Nothing forces you to attach channels, and a content item can live as a draft for as long as you need.

```graphql
mutation CreateContentItemDraft {
  createContentItemDraft(input: {
    organizationId: "your_org_id",
    title: "Conference recap",
    targetDate: "2026-09-20T09:00:00Z",
    draft: {
      text: "Three things I learned at the conference this week.",
      aiAssisted: false,
      assets: [
        {
          image: {
            url: "https://example.com/keynote.jpg",
            metadata: { altText: "The keynote stage" }
          }
        }
      ]
    }
  }) {
    ... on CreateContentItemDraftSuccess {
      contentItem {
        id
        title
        targetDate
        body {
          ... on DraftContent {
            id
            text
            aiAssisted
            assets {
              ... on ImageAsset {
                source
                thumbnail
              }
            }
          }
        }
      }
    }
    ... on CreateContentItemDraftFailure {
      message
      errors {
        message
      }
    }
  }
}
```

## Editing a draft

`updateContentItemDraft` replaces the draft content in full. Send the whole draft every time, not just the fields you changed: anything you leave out is dropped.

`tagIds` and `targetDate` behave differently. Omit either one to keep its current value.

```graphql
mutation UpdateContentItemDraft {
  updateContentItemDraft(input: {
    id: "the_content_item_id",
    draft: {
      text: "Three things I learned at the conference, and one I got wrong.",
      aiAssisted: false
    },
    targetDate: "2026-09-22T09:00:00Z"
  }) {
    ... on UpdateContentItemDraftSuccess {
      contentItem {
        id
        targetDate
        body {
          ... on DraftContent {
            text
          }
        }
      }
    }
    ... on UpdateContentItemDraftFailure {
      message
      errors {
        ... on ContentItemStateError {
          message
        }
        ... on InvalidInputError {
          message
        }
      }
    }
  }
}
```

`ContentItemStateError` means the item is no longer a draft. Someone promoted it to posts, possibly at the same time as your request. Once that happens you edit the individual posts through the post mutations instead.

## Attaching channels to a draft

`promoteContentItemDraftToPosts` attaches channels to an item that is still a channel-less draft, and creates one post per channel.

Promotion goes one way: after this call the content item holds posts, and you can no longer edit it as a draft.

```graphql
mutation PromoteContentItemDraftToPosts {
  promoteContentItemDraftToPosts(input: {
    id: "the_content_item_id",
    posts: [
      {
        channelId: "linkedin_channel_id",
        text: "Three things I learned at the conference this week.",
        schedulingType: automatic,
        mode: addToQueue
      }
    ]
  }) {
    ... on PromoteContentItemDraftToPostsSuccess {
      contentItem {
        id
        body {
          ... on PostContent {
            posts {
              id
              status
              dueAt
            }
          }
        }
      }
    }
    ... on PromoteContentItemDraftToPostsFailure {
      message
      errors {
        ... on ContentItemStateError {
          message
        }
        ... on PostChannelNotFoundError {
          channelId
          message
        }
        ... on PostInvalidInputError {
          channelId
          message
        }
        ... on PostLimitReachedError {
          channelId
          message
        }
        ... on MutationError {
          message
        }
      }
    }
  }
}
```

Validation is all-or-nothing. If any post fails validation, nothing is created and the content item is still a draft.

`PostChannelNotFoundError`, `PostInvalidInputError` and `PostLimitReachedError` each name the channel they apply to, so you can point the reader at the post that failed. `ContentItemStateError` and `InvalidInputError` carry a message only, because they apply to the request as a whole.

## Titles, tags and target dates

Three fields belong to the content item, not to any single post:

- **`title`** - a short description of what the content is about.
- **`tags`** - tags for the item as a whole. They survive the move from a draft to posts.
- **`targetDate`** - the date you plan this content for. This is a planning aid. It does not schedule anything. The posts carry their own scheduled times.

`updateContentItem` changes the title or the target date, and works in either state. Omitted fields keep their current value, and an explicit null clears them.

```graphql
mutation UpdateContentItem {
  updateContentItem(input: {
    id: "the_content_item_id",
    title: "Conference recap, part one"
  }) {
    ... on UpdateContentItemSuccess {
      contentItem {
        id
        title
        targetDate
      }
    }
    ... on UpdateContentItemFailure {
      message
      errors {
        message
      }
    }
  }
}
```

## Listing content items

`contentItems` lists an organization's items with cursor pagination. You can filter by content status, by tag and by target date, and sort by target date or creation date.

This example asks for the channel-less drafts that are planned for a date, soonest first.

```graphql
query ListContentItems {
  contentItems(
    input: {
      organizationId: "your_org_id",
      filter: {
        contentStatus: draftContent,
        targetDate: { presence: present }
      },
      sort: [{ field: targetDate, direction: asc }]
    },
    first: 20
  ) {
    edges {
      cursor
      node {
        id
        title
        targetDate
        createdAt
        tags {
          id
          name
        }
        body {
          ... on DraftContent {
            text
          }
          ... on PostContent {
            posts {
              id
              channel {
                name
              }
            }
          }
        }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
```

Cursors are opaque. Pass the previous page's `endCursor` as `after` to get the next page, and ask for at most 100 items per page.

A cursor is only valid for the request that produced it. Reset `after` to null whenever the filter or the sort changes, because a cursor from a different result set gives undefined results. See [Pagination](https://developers.buffer.com/guides/pagination.md).

## Content items and posts

A content item does not replace a post. Posts stay exactly as they are: they belong to a channel, they carry the scheduled time, and you edit and delete them through the post mutations.

What a content item adds is the layer above: the grouping, and the state before a channel exists.

`deleteContentItem` deletes the item and its posts together, and it is all-or-nothing. Every post must be deletable on its own, so an item cannot be deleted while any of its posts is publishing or already published. An item still holding a channel-less draft has no posts, so nothing blocks it.

## Scopes

The content item operations use the post scopes, because a content item creates and reads posts:

- `posts:read` for `contentItem` and `contentItems`
- `posts:write` for `createContentItem`, `createContentItemDraft`, `updateContentItem`, `updateContentItemDraft`, `promoteContentItemDraftToPosts` and `deleteContentItem`

See [Authentication](https://developers.buffer.com/guides/authentication.md).

## Next steps

- [Posts & Scheduling](https://developers.buffer.com/guides/posts-and-scheduling.md): the posts a content item creates
- [Pagination](https://developers.buffer.com/guides/pagination.md): how cursor pagination works across the API
- [Data Model](https://developers.buffer.com/guides/data-model.md): how organizations, channels and posts relate
