REST API
The Feedframer REST API returns an Instagram account's profile and posts as JSON. This page is the reference for every endpoint, parameter and response field.
Base URL
https://feedframer.com
All REST endpoints live under /api/v1.
Authentication
Every request needs your API key in the api_key query parameter. Headers are not read.
?api_key=YOUR_API_KEY
Each connected Instagram account has its own key. You'll find it on the Dashboard, on the account's card, labelled "Feedframer API Token". A key only ever reads its own account. See Authentication for regenerating and revoking keys.
Endpoints
| Method | Path | Returns |
|---|---|---|
GET |
/api/v1/me |
The account's profile plus its posts. Documented on this page. |
GET |
/api/v1/accounts |
The account's profile as a JSON:API resource. See Accounts API. |
GET |
/api/v1/rss |
An RSS 2.0 feed of the latest posts. See RSS Feed. |
GET or POST |
/graphql |
GraphQL. See GraphQL API. |
There is no /api/v1/posts endpoint. Posts come from /api/v1/me. Any other path under /api/ returns a 404 with the code NOT_FOUND.
GET /api/v1/me
Returns the profile of the account that owns the key, with its posts nested in a posts array.
Parameters
All parameters go in the query string. Only api_key is required.
| Parameter | Values | Default | Notes |
|---|---|---|---|
api_key |
Your API key | Required. | |
page[size] |
Integer, 1 to 100 | Your plan's limit: 6 on Free, 100 on Premium | Posts per page. On Free, values from 7 to 100 return 6 posts. Values below 1, above 100, or not a whole number return a 422. |
page[cursor] |
pagination.nextCursor or pagination.prevCursor from a previous response |
Premium only. Free keys can send it, but it is ignored. cursor is accepted as an alias. See Pagination. |
|
filter[type] |
IMAGE, VIDEO, CAROUSEL_ALBUM, REELS |
Case-insensitive. REELS returns the same posts as VIDEO. |
|
filter[publishedAfter] |
ISO 8601 date or date-time | Posts published at or after this time. A plain date means 00:00 UTC on that day. | |
filter[publishedBefore] |
ISO 8601 date or date-time | Posts published at or before this time. A plain date includes the whole of that day, up to 23:59:59 UTC. | |
sort |
-published_at, published_at, -publishedAt, publishedAt, -id, id |
-published_at |
A leading - means descending. See Filtering & Sorting. |
include[] |
children, comments |
Adds carousel items or comments to each post. Must use array syntax. Repeat it for both: include[]=children&include[]=comments. |
|
format |
jpg, webp |
jpg |
Image format of the URLs in sizes. Lowercase only. Premium only, because only premium responses have sizes. Free keys can send it, but it has no effect. |
There is no parameter to choose an account. The key decides which account you get.
Square brackets in query strings trip up some tools. With curl, pass -g (or URL-encode the brackets as %5B and %5D). A + in a time zone offset must be sent as %2B, or it arrives as a space and fails validation.
Free and Premium
| Free | Premium | |
|---|---|---|
| Posts you can read | The 6 most recent | Every post Feedframer has stored for the account |
Default and maximum page[size] |
6 | 100 |
| Further pages | None | Follow pagination.nextCursor |
sizes field and format |
Not included | Included |
On Free, filters and sorting apply within those 6 most recent posts. For example, filter[type]=VIDEO returns only the videos among the latest 6, which may be none. sort=published_at returns the same 6 posts, oldest first. The pagination object on a free key always has nextCursor: null, prevCursor: null and hasMore: false.
The limits are set per plan on the pricing page.
Example request
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[type]=IMAGE'
This returns the image posts, newest first, up to your plan's page size.
Example response
{
"username": "your_username",
"name": "Your Name",
"biography": "Your bio text",
"website": "https://example.com",
"profilePictureUrl": "https://cdn.feedframer.com/your_username/123_profile.jpg",
"followersCount": 1234,
"followsCount": 567,
"mediaCount": 89,
"status": "ready",
"lastFetchAt": "2026-01-15T19:50:34+00:00",
"posts": [
{
"id": "18123456789012345",
"caption": "Sunset over the harbour #photography",
"altText": null,
"mediaType": "IMAGE",
"mediaUrl": "https://cdn.feedframer.com/your_username/18123456789012345_media.jpg",
"thumbnailUrl": null,
"permalink": "https://www.instagram.com/p/ABC123def45/",
"timestamp": "2026-01-15T18:30:00+00:00",
"likeCount": 1542,
"commentsCount": 87,
"sizes": {
"320": "https://cdn.feedframer.com/cdn-cgi/image/width=320,format=jpg/your_username/18123456789012345_media.jpg",
"768": "https://cdn.feedframer.com/cdn-cgi/image/width=768,format=jpg/your_username/18123456789012345_media.jpg",
"960": "https://cdn.feedframer.com/cdn-cgi/image/width=960,format=jpg/your_username/18123456789012345_media.jpg",
"1200": "https://cdn.feedframer.com/cdn-cgi/image/width=1200,format=jpg/your_username/18123456789012345_media.jpg"
}
}
],
"pagination": {
"nextCursor": "eyJwdWJsaXNoZWRfYXQiOiIyMDI2LTAxLTE1IDE4OjMwOjAwIiwiaWQiOjQyLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prevCursor": null,
"hasMore": true,
"perPage": 100
}
}
This is a premium response. A free response has no sizes field on posts.
Response headers
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Last-Fetched-At |
When Feedframer last fetched this account's posts from Instagram, as ISO 8601 (for example 2026-09-30T19:50:34+00:00), or Never if it hasn't yet. Also in the body as lastFetchAt, which browser JavaScript can read. |
X-Feedframer-Account-Status |
State of the Instagram connection: ready, importing, refreshing_token or invalid_token. On every response. See Knowing when a feed has stopped updating. |
X-Feedframer-Feed-Stale |
Only present when the connection is broken. Plain-English explanation of when the feed froze and what fixes it. |
Cache-Control |
max-age=3600, public in production. Responses are cached at the edge for an hour. See Caching & Performance. |
Access-Control-Allow-Origin |
*, so you can call the API from browser JavaScript. |
Account fields
These are at the top level of the response.
| Field | Type | Notes |
|---|---|---|
username |
string | Instagram username, without the @. |
name |
string or null | Display name. |
biography |
string or null | Bio text. May contain line breaks. |
website |
string or null | Website from the Instagram profile. |
profilePictureUrl |
string or null | Profile picture. Usually a copy on cdn.feedframer.com. |
followersCount |
integer or null | Followers. |
followsCount |
integer or null | Accounts this account follows. |
mediaCount |
integer or null | Total posts on Instagram. This counts all posts on Instagram, not just the ones Feedframer returns. |
status |
string | State of the Instagram connection. See Connection status below. |
lastFetchAt |
string or null | When Feedframer last fetched this account's posts from Instagram, as ISO 8601, or null if it never has. Same value as the X-Last-Fetched-At header. |
posts |
array | Posts on this page. See below. Can be empty. |
pagination |
object | See Pagination fields below. |
Profile fields refresh at most once a day.
Connection status
status tells you whether Feedframer is still able to reach Instagram for this
account. A broken connection does not change the HTTP status code. You still
get a 200 with the posts we last stored, so an existing feed keeps rendering
instead of going blank — but nothing new will arrive until the account is
reconnected. Check this field if you want to know.
status |
Meaning | What to do |
|---|---|---|
ready |
Healthy. Posts refresh on your plan's interval. | Nothing. |
importing |
First import is still running. posts may be empty or partial. |
Retry shortly. |
refreshing_token |
A routine token refresh is in flight. Posts are current. | Nothing. |
invalid_token |
Instagram is refusing our access token, so the feed has stopped updating. posts are the last ones we stored and may be months old. |
Reconnect the account at feedframer.com/dashboard. Reconnecting does not change your API key. |
An Instagram access token lasts 60 days, and it can also be cut short by a
password change, by removing Feedframer in Instagram's app settings, or by a
security checkpoint on the account. Feedframer refreshes tokens automatically
and emails you if a connection breaks, but if you are rendering a feed on a
live site it is worth surfacing invalid_token somewhere you will see it:
const feed = await fetch('https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY')
.then((r) => r.json());
if (feed.status === 'invalid_token') {
// Still render feed.posts — they are real, just not fresh.
console.warn(`Feedframer: @${feed.username} needs reconnecting. Last updated ${feed.lastFetchAt}.`);
}
Responses are cached at the edge for an hour, so a status change can take up to
an hour to appear. /api/v1/accounts returns the same status for every account
on your plan.
Post fields
Each item in posts has these fields.
| Field | Type | Notes |
|---|---|---|
id |
string | Instagram's id for the post. |
caption |
string or null | Caption text, including hashtags and mentions. |
altText |
string or null | Almost always null. Instagram rarely provides alt text. |
mediaType |
string | IMAGE, VIDEO or CAROUSEL_ALBUM. Never REELS: Instagram reports reels as VIDEO. |
mediaUrl |
string | The post's media, usually a copy stored on cdn.feedframer.com. For CAROUSEL_ALBUM it is the first item. For VIDEO it is usually an .mp4 file, but can be a .jpg when Instagram withheld the video file. If Feedframer hasn't stored the media, this is Instagram's own URL (scontent.cdninstagram.com), which expires after a while. |
thumbnailUrl |
string or null | A .jpg poster image for VIDEO posts. null for other types. |
permalink |
string | The post on instagram.com. |
timestamp |
string | When the post was published on Instagram, ISO 8601 in UTC, for example 2026-01-15T18:30:00+00:00. |
likeCount |
integer or null | Likes. null when Instagram doesn't provide it. |
commentsCount |
integer or null | Comments. null when Instagram doesn't provide it. |
sizes |
object | Premium only. Resized image URLs keyed "320", "768", "960" and "1200" (pixel widths). For VIDEO posts they are resized copies of the thumbnail. The field is left out, not set to null, on free keys and on any post whose media Feedframer hasn't stored. |
children |
array | Only with include[]=children. See Carousel items below. |
comments |
array | Only with include[]=comments. See Comments below. |
Don't put mediaUrl in an <img> tag for VIDEO posts. It is usually a video file. Use thumbnailUrl instead:
const imageSrc = post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
Pagination fields
| Field | Type | Notes |
|---|---|---|
nextCursor |
string or null | Pass as page[cursor] to get the next page. null on the last page, and always null on Free. |
prevCursor |
string or null | Pass as page[cursor] to go back a page. null on the first page, and always null on Free. |
hasMore |
boolean | true when there is a next page, meaning nextCursor is not null. Always false on Free. |
perPage |
integer | The page size used for this request: your page[size], capped at your plan's limit. |
Cursors are opaque strings. Pass them back unchanged. See Pagination for loops in several languages.
Including extra data
include takes an array, so write it as include[]=. Plain include=children or include=children,comments returns a 422. To get both, repeat the parameter:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&include[]=children&include[]=comments'
Carousel items (children)
A CAROUSEL_ALBUM post's mediaUrl is only its first item. Add include[]=children to get every item:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[type]=CAROUSEL_ALBUM&include[]=children'
Each post then has a children array. Posts that aren't carousels get "children": [].
{
"id": "18123456789012345",
"caption": "Three views of the harbour",
"altText": null,
"mediaType": "CAROUSEL_ALBUM",
"mediaUrl": "https://cdn.feedframer.com/your_username/18123456789012345_media.jpg",
"thumbnailUrl": null,
"permalink": "https://www.instagram.com/p/ABC123def45/",
"timestamp": "2026-01-15T18:30:00+00:00",
"likeCount": 1542,
"commentsCount": 87,
"children": [
{
"id": "18111111111111111",
"caption": null,
"altText": null,
"mediaType": "IMAGE",
"mediaUrl": "https://cdn.feedframer.com/your_username/18111111111111111_media.jpg",
"thumbnailUrl": null,
"permalink": "https://www.instagram.com/p/XYZ789ghi01/",
"timestamp": "2026-01-15T18:30:00+00:00",
"likeCount": null,
"commentsCount": null
},
{
"id": "18222222222222222",
"caption": null,
"altText": null,
"mediaType": "VIDEO",
"mediaUrl": "https://cdn.feedframer.com/your_username/18222222222222222_media.mp4",
"thumbnailUrl": "https://cdn.feedframer.com/your_username/18222222222222222_thumbnail.jpg",
"permalink": "https://www.instagram.com/p/JKL456mno78/",
"timestamp": "2026-01-15T18:30:00+00:00",
"likeCount": null,
"commentsCount": null
}
]
}
This example is from a free key, so there are no sizes.
Children have the same fields as posts, so the same rendering code works for both. The differences:
caption,altText,likeCountandcommentsCountare alwaysnull. Use the parent post's values.- Each child has its own
permalink, which is not the parent's. mediaTypeisIMAGEorVIDEO.VIDEOchildren have athumbnailUrl;IMAGEchildren havenull.mediaUrlandthumbnailUrlalways point to Feedframer's CDN. Children whose media Feedframer hasn't stored are left out of the array.- On premium keys every child has a
sizesfield. It uses the thumbnail forVIDEOchildren, and isnullfor aVIDEOchild with no stored thumbnail. On free keys children have nosizesfield.
Comments
Add include[]=comments to get each post's comments as Instagram returned them at the last fetch:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&include[]=comments'
Each post then has a comments array. It is empty when there are none.
{
"comments": [
{
"id": "17900000000000000",
"text": "Love this view",
"timestamp": "2026-01-15T19:02:11+0000",
"like_count": 3
}
]
}
| Field | Type | Notes |
|---|---|---|
id |
string | Instagram's comment id. |
text |
string | The comment. Instagram occasionally leaves this field out. |
timestamp |
string | ISO 8601, in Instagram's format with no colon in the offset (+0000). |
like_count |
integer | Likes on the comment. Snake case, because Feedframer passes Instagram's data through unchanged. |
Comments don't include the commenter's username.
Responsive images and format (Premium)
Premium responses include a sizes object on each post whose media Feedframer has stored. It holds URLs for the same image resized to 320, 768, 960 and 1200 pixels wide, served through Cloudflare. For VIDEO posts, they are resized copies of the thumbnail.
Add format=webp to get WebP URLs instead of JPEG:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&format=webp'
"sizes": {
"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. In JavaScript, use post.sizes['320']; post.sizes.320 is a syntax error. Because sizes is missing on free keys and on some posts, fall back to thumbnailUrl or mediaUrl:
function imageAttributes(post) {
const fallback = post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
if (!post.sizes) {
return { src: fallback };
}
return {
src: post.sizes['768'],
srcset: ['320', '768', '960', '1200']
.map((width) => `${post.sizes[width]} ${width}w`)
.join(', '),
};
}
Filtering
# Image posts only
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[type]=IMAGE'
# Posts published during March 2026 (both dates included in full)
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&filter[publishedAfter]=2026-03-01&filter[publishedBefore]=2026-03-31'
Filters can be combined. See Filtering & Sorting for every rule.
Sorting
# Oldest first
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&sort=published_at'
| Value | Order |
|---|---|
-published_at or -publishedAt |
Newest first (the default) |
published_at or publishedAt |
Oldest first |
-id |
Most recently stored by Feedframer first |
id |
First stored by Feedframer first |
id sorting uses the order Feedframer stored the posts, not the Instagram post id.
Pagination
Premium keys page through posts with cursors:
# First page, 50 posts (Premium; a free key gets its 6 posts)
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[size]=50'
# Next page: pass pagination.nextCursor from the previous response
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[size]=50&page[cursor]=NEXT_CURSOR'
Keep the same filters and sort on every page. Free keys get one page. See Pagination.
Errors
Every error has the Content-Type application/vnd.api+json and a JSON:API body, whatever Accept header you send. Switch on code, not on the message text.
| HTTP status | code |
When |
|---|---|---|
401 |
MISSING_API_KEY |
No api_key parameter. |
401 |
INVALID_API_KEY |
The key is unknown, was regenerated, or belongs to a disconnected account. |
404 |
NOT_FOUND |
The path doesn't exist, for example /api/v1/posts. |
405 |
METHOD_NOT_ALLOWED |
Anything other than GET on a REST endpoint. |
422 |
VALIDATION_ERROR |
A parameter is invalid. There is one entry per problem. |
500 |
INTERNAL_SERVER_ERROR |
Something went wrong on Feedframer's side. Retry, then contact [email protected]. |
There are no rate limits.
A request with several bad parameters gets one error for each:
curl -g 'https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY&page[size]=500&sort=newest'
{
"errors": [
{
"status": "422",
"code": "VALIDATION_ERROR",
"title": "Invalid Parameter",
"detail": "The page[size] field must not be greater than 100.",
"source": { "parameter": "page[size]" }
},
{
"status": "422",
"code": "VALIDATION_ERROR",
"title": "Invalid Parameter",
"detail": "The sort value must be one of: -published_at, published_at, -publishedAt, publishedAt, -id, id.",
"source": { "parameter": "sort" }
}
]
}
source.parameter names the parameter the way you sent it. These are the validation messages /api/v1/me can return:
source.parameter |
detail |
|---|---|
page[size] |
The page[size] field must be an integer. |
page[size] |
The page[size] field must be at least 1. |
page[size] |
The page[size] field must not be greater than 100. |
filter[type] |
The selected filter[type] is invalid. |
filter[publishedAfter] |
The filter[publishedAfter] field must be a valid date. |
filter[publishedBefore] |
The filter[publishedBefore] field must be a valid date. |
sort |
The sort value must be one of: -published_at, published_at, -publishedAt, publishedAt, -id, id. |
include |
The include field must be an array. |
include[] |
The selected include[] is invalid. |
format |
The selected format is invalid. |
The 401 bodies look like this:
{
"errors": [
{
"status": "401",
"code": "INVALID_API_KEY",
"title": "Authentication Failed",
"detail": "The provided API key is invalid."
}
]
}
See Error Handling for handling them in code.
Code examples
You can also try requests in the API Playground when you're logged in.