API Status Get API Key

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:

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.

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.

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.

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.

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.

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.

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.

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.

Next steps