GraphQL API Reference

Introduction

The GraphQL API gives you the same data as the REST /api/v1/me endpoint, but you choose which fields come back. It has two queries:

  • me returns the Instagram account your API key belongs to, with its posts and pagination.
  • post(id:) returns one of your posts by its Feedframer ID.

Both follow the same plan rules as REST: free keys see their 6 most recent posts, premium keys can page through every stored post.

Endpoint

https://feedframer.com/graphql?api_key=YOUR_API_KEY

Send queries with POST and a JSON body:

{
  "query": "query { me { username } }",
  "variables": {}
}

GET also works, with query (and optionally variables as a JSON string) in the query string.

Authentication

Pass your API key as the api_key query parameter on every request, for both POST and GET. Headers such as Authorization are not read.

You'll find the key on your Dashboard, on your account's card, labelled "Feedframer API Token". Each connected Instagram account has its own key, and a key only ever reads its own account. See Authentication.

A missing or invalid key is rejected before your query runs. You get HTTP 401 with the same JSON:API error body the REST API uses, not a GraphQL error. See "Errors" below.

GraphQL Explorer

When you're logged in, the GraphQL Explorer lets you write queries against your own account, with autocompletion and schema docs. The schema also supports standard introspection queries.

How GraphQL differs from REST

The GraphQL Post type uses different field names from REST posts. The main differences:

Meaning REST (/api/v1/me) GraphQL (Post)
Instagram's post ID id instagramPostId
Feedframer's post ID (use with post(id:)) not returned id
Post type mediaType type
Publish time timestamp publishedAt
Responsive images when unavailable sizes key left out sizes: null
Page size page[size] first
Cursor page[cursor] cursor
Type filter filter[type] type
Date filters filter[publishedAfter], filter[publishedBefore] publishedAfter, publishedBefore
Sort sort=-published_at orderBy: [{ column: "published_at", order: DESC }]
Carousel items, comments include[]=children, include[]=comments request the children and comments fields

Other differences:

  • Out-of-range page sizes. REST returns a 422 for page[size] above 100 or below 1. GraphQL caps first at your plan's limit, and treats 0 or a negative number as "use the default".
  • type is case-sensitive. REST accepts filter[type]=video. GraphQL only accepts the upper-case values.
  • Errors. Bad arguments give HTTP 200 with an errors array, not a 422.

Posts inside children are plain JSON, so they use the REST field names (mediaType, timestamp, and Instagram's id).

Queries

me

me(
  first: Int
  cursor: String
  page: Int            # deprecated, ignored
  type: String
  publishedAfter: DateTimeTz
  publishedBefore: DateTimeTz
  orderBy: [OrderByClause!]
  format: String = "jpg"
): AccountWithPostsResponse
Argument Type Description
first Int Posts per page. Defaults to your plan's limit and is capped at it: 6 on Free, 100 on Premium. Larger values give the limit; 0 or less gives the default.
cursor String A pagination.nextCursor or pagination.prevCursor from a previous response. Premium only: free keys always get their 6 most recent posts.
page Int Deprecated and ignored. Use cursor.
type String IMAGE, VIDEO, CAROUSEL_ALBUM or REELS, in upper case. REELS returns the same posts as VIDEO, because Instagram reports reels as VIDEO. Any other value is a validation error.
publishedAfter DateTimeTz Posts published at or after this time. A plain date (2026-01-01) means 00:00 UTC that day.
publishedBefore DateTimeTz Posts published at or before this time. A plain date includes the whole of that day (to 23:59:59 UTC).
orderBy [OrderByClause!] Sort order. Only the first clause is used. Default: newest first.
format String jpg (default) or webp. Sets the format of the sizes URLs. Premium only; free keys can send it but get no sizes. Any other value is a validation error.

Filters and sorting apply within the posts your key can see. On Free, me(type: "VIDEO") returns only the videos among your 6 most recent posts, which may be none.

post

post(id: ID!): Post

Returns one post by its Feedframer ID: the id field on Post, not instagramPostId. The result is null if the ID doesn't exist, belongs to another account, or (on Free) isn't one of your 6 most recent posts. It is also null if you pass an Instagram post ID.

There is no root query that lists posts. Use me for that.

Types

! means the field is never null.

AccountWithPostsResponse

Returned by me.

Field Type Description
username String! Instagram username
name String Display name
biography String Profile bio
website String Website from the profile
profilePictureUrl String Profile picture URL
followersCount Int Followers
followsCount Int Accounts followed
mediaCount Int Total posts on the Instagram account
posts [Post!]! This page of posts
pagination PaginationInfo! Cursors for the next and previous pages

PaginationInfo

Field Type Description
nextCursor String Pass as cursor to get the next page. null on the last page, and always null on Free.
prevCursor String Pass as cursor to go back a page. null on the first page, and always null on Free.
hasMore Boolean! Whether there is a next page. Always false on Free.
perPage Int! The page size used

Post

Field Type Description
id ID! Feedframer's ID for the post. Use it with post(id:).
instagramPostId String! Instagram's ID for the post (what REST calls id)
type String! IMAGE, VIDEO or CAROUSEL_ALBUM. Reels are VIDEO.
caption String Caption text
altText String Alt text. Almost always null, because Instagram rarely provides it.
mediaUrl String! The media file. For VIDEO posts this is usually an .mp4, so don't put it in an <img>. If Feedframer hasn't stored the file, this is Instagram's own URL, which expires after a while.
thumbnailUrl String A .jpg poster image for VIDEO posts. null for other types.
sizes Mixed Premium only. An object of responsive image URLs keyed "320", "768", "960", "1200". For videos these are sizes of the thumbnail. null on Free, or when the media isn't stored.
permalink String! The post on Instagram
commentsCount Int Number of comments
likeCount Int Number of likes. null if Instagram doesn't share it.
children Mixed For CAROUSEL_ALBUM posts, an array of the carousel's items (see "Carousel items" below). [] for other types.
comments Mixed An array of comments exactly as Instagram returns them, or null if none are stored (see "Comments" below).
publishedAt DateTimeTz! When the post was published on Instagram
lastUpdatedAt DateTimeTz When the post last changed on Instagram
fetchedAt DateTimeTz! When Feedframer last fetched the post
createdAt DateTimeTz! When Feedframer first stored the post
updatedAt DateTimeTz! When Feedframer last updated its copy
instagramAccount InstagramAccount The account the post belongs to

InstagramAccount

Reached through Post.instagramAccount.

Field Type Description
id ID! Feedframer's ID for the account
instagramUserId String! Instagram's ID for the account
username String! Instagram username
name String Display name
biography String Profile bio
website String Website from the profile
profilePictureUrl String Profile picture URL
followersCount Int Followers
followsCount Int Accounts followed
mediaCount Int Total posts on the Instagram account
status String! ready, importing, refreshing_token or invalid_token
lastFetchAt DateTimeTz When posts were last fetched
createdAt DateTimeTz! When the account was connected
updatedAt DateTimeTz! When Feedframer last updated the account
posts [Post!]! The account's most recent posts, newest first, capped at your plan's limit (6 or 100). It takes no arguments; use me to filter and page.

OrderByClause

Field Type Description
column String! published_at (or publishedAt) or id. id is the order Feedframer stored the posts, not Instagram's ID. Any other column is an error.
order SortOrder! ASC or DESC

Scalars

  • DateTimeTz: ISO 8601. Output looks like 2026-01-15T18:30:00+00:00. As input, a plain date (2026-01-15) or a datetime with an offset or Z (2026-01-15T09:00:00Z) is accepted.
  • Mixed: any JSON value, returned as-is. You can't select sub-fields of a Mixed field; you get the whole value.

Plans

Free Premium
Posts per request (first) 6 up to 100 (default 100)
Which posts your 6 most recent every stored post, paged with cursor
pagination always nextCursor: null, prevCursor: null, hasMore: false, perPage: 6 real cursors
sizes and format: "webp" sizes is null, format has no effect yes
post(id:) only your 6 most recent posts any of your posts

There are no rate limits. See Pricing for plan details.

Example queries

Every example works with a free key unless it says otherwise. None of them set first, so free keys get 6 posts and premium keys get up to 100.

Account and latest posts

query LatestPosts {
  me {
    username
    name
    profilePictureUrl
    posts {
      id
      type
      caption
      mediaUrl
      thumbnailUrl
      permalink
      publishedAt
    }
    pagination {
      nextCursor
      hasMore
      perPage
    }
  }
}

Response:

{
  "data": {
    "me": {
      "username": "your_username",
      "name": "Your Name",
      "profilePictureUrl": "https://cdn.feedframer.com/your_username/123_profile.jpg",
      "posts": [
        {
          "id": "4521",
          "type": "IMAGE",
          "caption": "Sunset over the harbour #photography",
          "mediaUrl": "https://cdn.feedframer.com/your_username/18123456789012345_media.jpg",
          "thumbnailUrl": null,
          "permalink": "https://www.instagram.com/p/ABC123def45/",
          "publishedAt": "2026-01-15T18:30:00+00:00"
        },
        {
          "id": "4519",
          "type": "VIDEO",
          "caption": "Behind the scenes",
          "mediaUrl": "https://cdn.feedframer.com/your_username/18123456789012346_media.mp4",
          "thumbnailUrl": "https://cdn.feedframer.com/your_username/18123456789012346_thumbnail.jpg",
          "permalink": "https://www.instagram.com/reel/DEF456ghi78/",
          "publishedAt": "2026-01-12T09:05:00+00:00"
        }
      ],
      "pagination": {
        "nextCursor": "eyJwdWJsaXNoZWRfYXQiOi...",
        "hasMore": true,
        "perPage": 100
      }
    }
  }
}

On a free key, pagination is {"nextCursor": null, "hasMore": false, "perPage": 6}.

To show a grid of images, use thumbnailUrl for VIDEO posts and mediaUrl for everything else.

Filter by type

query Videos {
  me(type: "VIDEO") {
    posts {
      id
      caption
      thumbnailUrl
      mediaUrl
      permalink
    }
  }
}

type: "REELS" returns the same posts.

Filter by date

query January {
  me(publishedAfter: "2026-01-01", publishedBefore: "2026-01-31") {
    posts {
      id
      caption
      publishedAt
    }
  }
}

Both bounds are inclusive, and publishedBefore: "2026-01-31" includes posts from the whole of 31 January (UTC). You can also pass a full datetime, such as "2026-01-15T09:00:00+01:00".

Oldest first

query OldestFirst {
  me(orderBy: [{ column: "published_at", order: ASC }]) {
    posts {
      id
      publishedAt
    }
  }
}

On Free, this returns your 6 most recent posts, oldest first.

For CAROUSEL_ALBUM posts, mediaUrl is the first item only. Request children to get every item:

query Carousels {
  me(type: "CAROUSEL_ALBUM") {
    posts {
      id
      caption
      children
    }
  }
}

children is a JSON array. Each item has the same shape as a REST post, so it uses REST field names:

{
  "id": "18123456789012399",
  "caption": null,
  "altText": null,
  "mediaType": "IMAGE",
  "mediaUrl": "https://cdn.feedframer.com/your_username/18123456789012399_media.jpg",
  "thumbnailUrl": null,
  "permalink": "https://www.instagram.com/p/ABC123def45/",
  "timestamp": "2026-01-15T18:30:00+00:00",
  "likeCount": null,
  "commentsCount": null,
  "sizes": {
    "320": "https://cdn.feedframer.com/cdn-cgi/image/width=320,format=jpg/your_username/18123456789012399_media.jpg",
    "768": "https://cdn.feedframer.com/cdn-cgi/image/width=768,format=jpg/your_username/18123456789012399_media.jpg",
    "960": "https://cdn.feedframer.com/cdn-cgi/image/width=960,format=jpg/your_username/18123456789012399_media.jpg",
    "1200": "https://cdn.feedframer.com/cdn-cgi/image/width=1200,format=jpg/your_username/18123456789012399_media.jpg"
  }
}
  • id is Instagram's ID for the item.
  • likeCount and commentsCount are always null on items.
  • sizes is only present on Premium, and only when the item's media is stored. On Free the key is left out.
  • Items whose media Feedframer couldn't store are left out.

Comments

query Comments {
  me {
    posts {
      id
      commentsCount
      comments
    }
  }
}

comments is null when no comments are stored. Otherwise it's an array of comments exactly as Instagram returns them:

[
  {
    "id": "17900000000000001",
    "text": "Love this view",
    "timestamp": "2026-01-15T19:02:00+0000",
    "like_count": 2
  }
]

Note the snake_case like_count and the +0000 offset. Comments don't include the commenter's username.

Responsive images (Premium)

query ResponsiveImages {
  me(format: "webp") {
    posts {
      id
      type
      caption
      sizes
    }
  }
}

On Premium, sizes looks like this:

{
  "320": "https://cdn.feedframer.com/cdn-cgi/image/width=320,format=webp/your_username/18123456789012345_media.jpg",
  "768": "https://cdn.feedframer.com/cdn-cgi/image/width=768,format=webp/your_username/18123456789012345_media.jpg",
  "960": "https://cdn.feedframer.com/cdn-cgi/image/width=960,format=webp/your_username/18123456789012345_media.jpg",
  "1200": "https://cdn.feedframer.com/cdn-cgi/image/width=1200,format=webp/your_username/18123456789012345_media.jpg"
}

The keys are strings, so in JavaScript write post.sizes['320'], not post.sizes.320. On Free, and for posts whose media isn't stored, sizes is null, so fall back to thumbnailUrl (videos) or mediaUrl (images).

One post

query OnePost {
  post(id: "4521") {
    id
    instagramPostId
    type
    caption
    permalink
    instagramAccount {
      username
      status
    }
  }
}

id is the Feedframer ID from a me response. The result is {"data": {"post": null}} if the post isn't one your key can see.

Variables

Pass values separately from the query with variables:

query Feed($type: String, $cursor: String, $after: DateTimeTz) {
  me(type: $type, cursor: $cursor, publishedAfter: $after) {
    posts {
      id
      type
      caption
      publishedAt
    }
    pagination {
      nextCursor
      hasMore
    }
  }
}
{
  "type": "IMAGE",
  "cursor": null,
  "after": "2026-01-01"
}

In variables, orderBy is an array of objects and order is a string: "orderBy": [{"column": "published_at", "order": "ASC"}].

Pagination

Pagination is cursor-based and works on Premium only.

  1. Request me without a cursor.
  2. If pagination.hasMore is true, request again with cursor set to pagination.nextCursor.
  3. Repeat until hasMore is false.

prevCursor takes you back a page. Cursors are opaque strings: pass them back unchanged, and keep the other arguments (first, type, dates, orderBy) the same between pages. An unreadable cursor is ignored and you get the first page.

On Free, the first response is the only page. hasMore is always false and cursor is ignored, so the loop below stops after one request.

The page argument still exists so old queries don't break, but it is ignored.

Making requests

Each sample fetches the account and its latest posts, handles both kinds of error, and works on either plan.

cURL

curl -X POST 'https://feedframer.com/graphql?api_key=YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"query": "query { me { username posts { id type caption mediaUrl thumbnailUrl publishedAt } pagination { nextCursor hasMore } } }"}'

With GET:

curl -G 'https://feedframer.com/graphql' \
  --data-urlencode 'api_key=YOUR_API_KEY' \
  --data-urlencode 'query=query { me { username posts { id caption } } }'

JavaScript (fetch)

This fetches every post the key can see: one page on Free, all pages on Premium.

const ENDPOINT = 'https://feedframer.com/graphql';
const API_KEY = 'YOUR_API_KEY';

const QUERY = `
  query Feed($cursor: String) {
    me(cursor: $cursor) {
      username
      posts {
        id
        type
        caption
        mediaUrl
        thumbnailUrl
        permalink
        publishedAt
      }
      pagination {
        nextCursor
        hasMore
      }
    }
  }
`;

async function graphql(query, variables = {}) {
  const response = await fetch(`${ENDPOINT}?api_key=${encodeURIComponent(API_KEY)}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });

  const body = await response.json();

  // Missing or invalid key: HTTP 401 with a JSON:API error body.
  if (!response.ok) {
    const error = body.errors?.[0];
    throw new Error(`${response.status} ${error?.code}: ${error?.detail}`);
  }

  // Query problems: HTTP 200 with an errors array.
  if (body.errors) {
    throw new Error(body.errors.map((e) => e.message).join('; '));
  }

  return body.data;
}

async function fetchAllPosts() {
  const posts = [];
  let cursor = null;

  while (true) {
    const { me } = await graphql(QUERY, { cursor });
    posts.push(...me.posts);

    if (!me.pagination.hasMore) break;
    cursor = me.pagination.nextCursor;
  }

  return posts;
}

fetchAllPosts()
  .then((posts) => {
    for (const post of posts) {
      // Videos: use the .jpg thumbnail in an <img>, never the .mp4 mediaUrl.
      const image = post.type === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
      console.log(post.id, post.type, image);
    }
  })
  .catch((error) => console.error(error.message));

GraphQL responses allow cross-origin requests, so this also works in a browser. Anything in browser code is visible to visitors, including your API key. The key can only read your own account's posts, and you can regenerate it from the Dashboard.

PHP

<?php

function feedframerGraphql(string $query, array $variables = []): array
{
    $ch = curl_init('https://feedframer.com/graphql?api_key=' . urlencode('YOUR_API_KEY'));
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode([
            'query' => $query,
            'variables' => (object) $variables,
        ]),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
    ]);

    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new RuntimeException('Request failed: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    $body = json_decode($raw, true);

    // Missing or invalid key: HTTP 401 with a JSON:API error body.
    if ($status !== 200) {
        $error = $body['errors'][0] ?? [];
        throw new RuntimeException("{$status} " . ($error['code'] ?? 'UNKNOWN') . ': ' . ($error['detail'] ?? $raw));
    }

    // Query problems: HTTP 200 with an errors array.
    if (isset($body['errors'])) {
        throw new RuntimeException(implode('; ', array_column($body['errors'], 'message')));
    }

    return $body['data'];
}

$query = <<<'GRAPHQL'
query Feed {
  me {
    username
    posts {
      id
      type
      caption
      mediaUrl
      thumbnailUrl
      publishedAt
    }
    pagination {
      nextCursor
      hasMore
    }
  }
}
GRAPHQL;

try {
    $me = feedframerGraphql($query)['me'];

    foreach ($me['posts'] as $post) {
        $image = $post['type'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl'];
        echo $post['id'] . ' ' . $post['type'] . ' ' . $image . PHP_EOL;
    }
} catch (RuntimeException $e) {
    echo 'Feedframer error: ' . $e->getMessage() . PHP_EOL;
}

Python

import requests

ENDPOINT = "https://feedframer.com/graphql"
API_KEY = "YOUR_API_KEY"

QUERY = """
query Feed($cursor: String) {
  me(cursor: $cursor) {
    username
    posts {
      id
      type
      caption
      mediaUrl
      thumbnailUrl
      publishedAt
    }
    pagination {
      nextCursor
      hasMore
    }
  }
}
"""


def graphql(query, variables=None):
    response = requests.post(
        ENDPOINT,
        params={"api_key": API_KEY},
        json={"query": query, "variables": variables or {}},
        timeout=10,
    )
    body = response.json()

    # Missing or invalid key: HTTP 401 with a JSON:API error body.
    if response.status_code != 200:
        error = body["errors"][0]
        raise RuntimeError(f"{response.status_code} {error['code']}: {error['detail']}")

    # Query problems: HTTP 200 with an errors array.
    if "errors" in body:
        raise RuntimeError("; ".join(e["message"] for e in body["errors"]))

    return body["data"]


def fetch_all_posts():
    posts, cursor = [], None
    while True:
        me = graphql(QUERY, {"cursor": cursor})["me"]
        posts.extend(me["posts"])
        if not me["pagination"]["hasMore"]:
            return posts
        cursor = me["pagination"]["nextCursor"]


for post in fetch_all_posts():
    image = post["thumbnailUrl"] if post["type"] == "VIDEO" else post["mediaUrl"]
    print(post["id"], post["type"], image)

Errors

There are two kinds of error.

Authentication errors: HTTP 401

A missing, unknown or revoked API key (including the key of a disconnected account) is rejected before GraphQL runs. You get HTTP 401, Content-Type: application/vnd.api+json, and the REST error body:

{
  "errors": [
    {
      "status": "401",
      "code": "INVALID_API_KEY",
      "title": "Authentication Failed",
      "detail": "The provided API key is invalid."
    }
  ]
}

With no key at all, code is MISSING_API_KEY. See Errors for both bodies.

Query errors: HTTP 200

Everything else comes back as HTTP 200 with an errors array. Check for errors before you read data.

An invalid type or format argument gives a validation error. The details are in extensions.validation, keyed by argument name, and data.me is null:

{
  "errors": [
    {
      "message": "Validation failed for the field [me].",
      "locations": [{ "line": 1, "column": 3 }],
      "path": ["me"],
      "extensions": {
        "validation": {
          "type": ["The selected type is invalid."],
          "format": ["The selected format is invalid."]
        }
      }
    }
  ],
  "data": { "me": null }
}

Other errors have a message, plus locations and sometimes path. Don't rely on extensions for these. Real messages you might see:

Cause message
Unsupported orderBy column Cannot order by "likes". Use published_at or id.
Unparseable date "31/01/2026" is not a valid date. Use ISO 8601, e.g. 2024-01-31 or 2024-01-31T09:00:00Z.
Unparseable date in a variable Variable "$after" got invalid value "31/01/2026"; "31/01/2026" is not a valid date. Use ISO 8601, e.g. 2024-01-31 or 2024-01-31T09:00:00Z.
Unknown field Cannot query field "posts" on type "Query". Did you mean "post"?
Malformed query Syntax Error: Expected Name, found <EOF>

Errors caught before the query runs (bad dates, unknown fields, syntax errors) have no data key at all.

post(id:) for a post you can't see is not an error. It returns {"data": {"post": null}}.

CORS and caching

  • CORS is enabled: responses carry Access-Control-Allow-Origin: *, so you can call the API from browser code on any domain.
  • GraphQL responses are not cached at the edge. Every request runs against current data. REST responses are cached; see Caching & Performance.

Next steps