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:
mereturns 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 capsfirstat your plan's limit, and treats 0 or a negative number as "use the default". typeis case-sensitive. REST acceptsfilter[type]=video. GraphQL only accepts the upper-case values.- Errors. Bad arguments give HTTP 200 with an
errorsarray, 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 like2026-01-15T18:30:00+00:00. As input, a plain date (2026-01-15) or a datetime with an offset orZ(2026-01-15T09:00:00Z) is accepted.Mixed: any JSON value, returned as-is. You can't select sub-fields of aMixedfield; 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.
Carousel items
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"
}
}
idis Instagram's ID for the item.likeCountandcommentsCountare alwaysnullon items.sizesis 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.
- Request
mewithout acursor. - If
pagination.hasMoreistrue, request again withcursorset topagination.nextCursor. - Repeat until
hasMoreisfalse.
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
- REST API: the same data over REST
- Filtering & Sorting
- Pagination
- Errors
- Caching & Performance