Instagram Graph API Integration Guide
Reading an Instagram account's posts through Meta's official API needs four things: a professional account (Business or Creator), the Instagram API with Instagram Login, the instagram_business_basic permission, and a long-lived access token that expires 60 days after you issue it.
This page is the reference for what that API gives you and what it withholds: the two login configurations and their permission scopes, the OAuth flow, every field GET /{ig-user-id}/media returns and the cases where each one is omitted, Business Use Case rate limits, and the error codes. Every statement about Meta's API links to the Meta documentation it comes from.
If you only need to show a feed of posts, the last section explains how Feedframer does this work for you and gives you a simple REST, GraphQL or RSS feed instead.
Which Instagram API to use
Basic Display API is gone
Meta shut down the Instagram Basic Display API on December 4, 2024. Since then, all requests to it return an error, and Meta recommends migrating to the Instagram API (Instagram Platform changelog). Tutorials and libraries written for Basic Display no longer work.
Two configurations
The Instagram API comes in two configurations (Instagram Platform overview):
| Instagram API with Instagram Login | Instagram API with Facebook Login | |
|---|---|---|
| Users log in with | Their Instagram credentials (Business Login for Instagram) | Their Facebook credentials (Facebook Login for Business) |
| Facebook Page | Not needed | Required, linked to the Instagram account |
| Host | graph.instagram.com |
graph.facebook.com |
| Access token | Instagram User access token | Facebook User or Page access token |
| Permissions | instagram_business_basic, instagram_business_content_publish, instagram_business_manage_comments, instagram_business_manage_messages |
instagram_basic, instagram_content_publish, instagram_manage_comments, instagram_manage_insights, instagram_manage_messages, pages_show_list, pages_read_engagement |
| Hashtag search, product tagging | No | Yes |
To read an account's own posts, Instagram Login is the simpler choice: there is no Facebook Page to link, and the user never touches Facebook. The rest of this guide uses Instagram Login. If you need Facebook Login, follow Meta's Get Started guide for Facebook Login, which finds the Instagram account through GET /me/accounts and GET /{page-id}?fields=instagram_business_account.
Business and Creator accounts only
Both configurations work only with Instagram professional accounts, meaning Business or Creator accounts. They cannot read media owned by personal accounts (Overview: App users, IG Media: Limitations). If someone has a personal account, they need to switch it to a professional account in the Instagram app before they can connect.
With Instagram Login, the account_type field on the user tells you which kind it is: Business or Media_Creator (Get Started: Fields).
Standard and Advanced Access
New apps have Standard Access, which Meta intends for apps used only by people who have a role on the app. It suits development, testing and apps that only read accounts you own or manage. To serve Instagram accounts you don't own or manage, you need Advanced Access, which requires App Review and Business Verification (Overview: Access levels).
Set up a Meta app
- Register as a Meta developer and create an app in the App Dashboard. Choose the Business app type. Meta says that if your app is not a Business type app, you need to create a new one (Get Started).
- Add the Instagram product and open API setup with Instagram login (Overview: Develop with Meta).
- Under 3. Set up Instagram business login → Business login settings, note your Instagram App ID and Instagram App Secret, and add your OAuth redirect URIs (Business Login: Requirements). Use the Instagram App ID, not the Meta App ID shown at the top of the dashboard.
The redirect_uri you send must exactly match one of the URIs in that list. Meta warns that the dashboard may add a trailing slash, so check the saved value.
Keep the App Secret on your server. Meta says requests that include it "must be made in server-side code" and must never appear in client-side code or an app binary.
The OAuth flow
Business Login for Instagram uses four endpoints (Business Login for Instagram):
| Step | Endpoint | Gives you |
|---|---|---|
| 1. Authorize | https://www.instagram.com/oauth/authorize |
An authorization code, valid for 1 hour and usable once |
| 2. Exchange the code | POST https://api.instagram.com/oauth/access_token |
A short-lived token (1 hour), the user's ID and the granted permissions |
| 3. Get a long-lived token | GET https://graph.instagram.com/access_token |
A long-lived token (60 days) |
| 4. Refresh | GET https://graph.instagram.com/refresh_access_token |
A new long-lived token (another 60 days) |
Step 1: Send the user to Instagram
Link or redirect the user to the authorization URL:
https://www.instagram.com/oauth/authorize
?client_id=YOUR_INSTAGRAM_APP_ID
&redirect_uri=https://example.com/instagram/callback
&response_type=code
&scope=instagram_business_basic
&state=RANDOM_CSRF_TOKEN
| Parameter | Required | Value |
|---|---|---|
client_id |
Yes | Your Instagram App ID |
redirect_uri |
Yes | One of your registered OAuth redirect URIs |
response_type |
Yes | code |
scope |
Yes | Comma-separated (or URL-encoded space-separated) permissions. instagram_business_basic is enough to read the profile and media. |
state |
No | A value Meta sends back unchanged. Use it to protect against CSRF. |
enable_fb_login |
No | false hides the "Log in with Facebook" option on the Instagram login page |
force_reauth |
No | true makes the user log in with their Instagram credentials even if they are already logged in |
The old scope names (business_basic and so on) were deprecated on January 27, 2025. Use the instagram_business_* names.
If the user approves, Instagram redirects to your redirect_uri with ?code=...&state=.... Meta's example shows #_ on the end of the redirect URL and notes that it is not part of the code. Browsers don't send the # fragment to your server, so you only need to strip it if you copy a code by hand.
If the user cancels, the redirect carries error=access_denied, error_reason=user_denied and error_description=The+user+denied+your+request instead.
Step 2: Exchange the code for a short-lived token
curl -X POST https://api.instagram.com/oauth/access_token \
-F client_id=YOUR_INSTAGRAM_APP_ID \
-F client_secret=YOUR_INSTAGRAM_APP_SECRET \
-F grant_type=authorization_code \
-F redirect_uri=https://example.com/instagram/callback \
-F code=AUTHORIZATION_CODE
Meta's reference shows the result inside a data array:
{
"data": [
{
"access_token": "IGAAexample...",
"user_id": "17841400000000000",
"permissions": "instagram_business_basic"
}
]
}
Feedframer's production code reads access_token and user_id from the top level of this response, without the wrapper, so the PHP example below accepts both shapes. The redirect_uri must be the same one you used in step 1.
A reused or unknown code returns an error in a flat shape that differs from other Graph API errors:
{
"error_type": "OAuthException",
"code": 400,
"error_message": "Matching code was not found or was already used"
}
Step 3: Exchange it for a long-lived token
Do this straight away, because the short-lived token expires after one hour (Access Token reference):
curl -G https://graph.instagram.com/access_token \
-d grant_type=ig_exchange_token \
-d client_secret=YOUR_INSTAGRAM_APP_SECRET \
-d access_token=SHORT_LIVED_TOKEN
{
"access_token": "IGAAexample...",
"token_type": "bearer",
"expires_in": 5183944
}
expires_in is in seconds, about 60 days. Store the token, the user ID and the expiry time together.
The flow in plain PHP
This example uses only PHP's cURL extension. Start with a request helper and an exception that carries Meta's error code:
<?php
const IG_APP_ID = 'YOUR_INSTAGRAM_APP_ID';
const IG_APP_SECRET = 'YOUR_INSTAGRAM_APP_SECRET';
const IG_REDIRECT_URI = 'https://example.com/instagram/callback';
final class InstagramApiException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $httpStatus,
public readonly ?int $errorCode = null,
public readonly ?int $errorSubcode = null,
) {
parent::__construct($message);
}
public static function fromResponse(int $httpStatus, array $body): self
{
// Graph API errors look like {"error": {"message", "type", "code", "error_subcode", ...}}.
// The code exchange uses a flat {"error_type", "code", "error_message"} instead.
$error = $body['error'] ?? $body;
return new self(
$error['message'] ?? $error['error_message'] ?? "HTTP {$httpStatus}",
$httpStatus,
isset($error['code']) ? (int) $error['code'] : null,
isset($error['error_subcode']) ? (int) $error['error_subcode'] : null,
);
}
/** The token has expired or been revoked. The user must log in again. */
public function needsReconnect(): bool
{
return $this->errorCode === 190;
}
/** A rate limit was reached. Wait and retry later. */
public function isRateLimited(): bool
{
return in_array($this->errorCode, [4, 17, 32, 613, 80002], true);
}
}
function instagram_request(string $method, string $url, array $params = []): array
{
$ch = curl_init();
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
} else {
curl_setopt($ch, CURLOPT_URL, $params ? $url.'?'.http_build_query($params) : $url);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException('Network error: '.curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
// Keep large numeric IDs as strings.
$data = json_decode($body, true, 512, JSON_BIGINT_AS_STRING);
if ($status >= 400 || ! is_array($data) || isset($data['error']) || isset($data['error_type'])) {
throw InstagramApiException::fromResponse($status, is_array($data) ? $data : []);
}
return $data;
}
Then one function per step:
function instagram_authorize_url(string $state): string
{
return 'https://www.instagram.com/oauth/authorize?'.http_build_query([
'client_id' => IG_APP_ID,
'redirect_uri' => IG_REDIRECT_URI,
'response_type' => 'code',
'scope' => 'instagram_business_basic',
'state' => $state,
]);
}
/** @return array{access_token: string, user_id: string} */
function instagram_exchange_code(string $code): array
{
$data = instagram_request('POST', 'https://api.instagram.com/oauth/access_token', [
'client_id' => IG_APP_ID,
'client_secret' => IG_APP_SECRET,
'grant_type' => 'authorization_code',
'redirect_uri' => IG_REDIRECT_URI,
'code' => $code,
]);
// Meta documents a "data" array; accept the unwrapped shape too.
$token = $data['data'][0] ?? $data;
return [
'access_token' => $token['access_token'],
'user_id' => (string) $token['user_id'],
];
}
/** @return array{access_token: string, expires_at: int} */
function instagram_long_lived_token(string $shortLivedToken): array
{
$data = instagram_request('GET', 'https://graph.instagram.com/access_token', [
'grant_type' => 'ig_exchange_token',
'client_secret' => IG_APP_SECRET,
'access_token' => $shortLivedToken,
]);
return [
'access_token' => $data['access_token'],
'expires_at' => time() + (int) $data['expires_in'],
];
}
Your connect and callback pages then look like this:
// connect.php
session_start();
$_SESSION['instagram_state'] = bin2hex(random_bytes(20));
header('Location: '.instagram_authorize_url($_SESSION['instagram_state']));
exit;
// callback.php
session_start();
if (! isset($_GET['state'], $_SESSION['instagram_state'])
|| ! hash_equals($_SESSION['instagram_state'], (string) $_GET['state'])) {
exit('Invalid state. Please try connecting again.');
}
unset($_SESSION['instagram_state']);
if (isset($_GET['error'])) {
exit('Instagram connection cancelled: '.htmlspecialchars($_GET['error_description'] ?? $_GET['error']));
}
try {
$shortLived = instagram_exchange_code((string) ($_GET['code'] ?? ''));
$longLived = instagram_long_lived_token($shortLived['access_token']);
} catch (InstagramApiException $e) {
exit('Could not connect to Instagram: '.htmlspecialchars($e->getMessage()));
}
// Store these three values against the signed-in user. Encrypt the token at rest.
$instagramUserId = $shortLived['user_id'];
$accessToken = $longLived['access_token'];
$tokenExpiresAt = $longLived['expires_at'];
Access tokens
| Credential | Lifetime | Source |
|---|---|---|
| Authorization code | 1 hour, single use | Business Login |
| Short-lived Instagram User token | 1 hour | Overview: Authentication |
| Long-lived Instagram User token | 60 days | Access Token reference |
| Token generated in the App Dashboard | 60 days (long-lived) | Get Started |
If you only read your own account, you can skip the login flow: click Generate token next to the account under Instagram → API setup with Instagram business login in the App Dashboard. You still need to refresh that token.
Refreshing long-lived tokens
A long-lived token can be refreshed for another 60 days when all of these are true (Refresh Access Token reference):
- it is at least 24 hours old
- it has not expired
- the user granted your app
instagram_business_basic
A refreshed token is valid for 60 days from the time of the refresh. A token that isn't refreshed within 60 days expires and can't be refreshed. The user then has to go through the login flow again.
curl -G https://graph.instagram.com/refresh_access_token \
-d grant_type=ig_refresh_token \
-d access_token=LONG_LIVED_TOKEN
{
"access_token": "IGAAexample...",
"token_type": "bearer",
"expires_in": 5183944
}
Save the returned token in place of the old one:
/** @return array{access_token: string, expires_at: int} */
function instagram_refresh_token(string $longLivedToken): array
{
$data = instagram_request('GET', 'https://graph.instagram.com/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $longLivedToken,
]);
return [
'access_token' => $data['access_token'],
'expires_at' => time() + (int) $data['expires_in'],
];
}
Run a daily job that refreshes tokens a week before they expire. That leaves several days of retries if Instagram is briefly unavailable. This version uses PDO; the table and column names are yours to choose:
// refresh-tokens.php, run daily from cron
$db = new PDO('sqlite:/path/to/app.sqlite');
$now = time();
$due = $db->prepare(
'SELECT id, access_token FROM instagram_accounts
WHERE needs_reconnect = 0
AND token_refreshed_at <= :day_ago
AND token_expires_at > :now
AND token_expires_at <= :week_ahead'
);
$due->execute(['day_ago' => $now - 86400, 'now' => $now, 'week_ahead' => $now + 7 * 86400]);
$save = $db->prepare(
'UPDATE instagram_accounts
SET access_token = :token, token_expires_at = :expires_at, token_refreshed_at = :now
WHERE id = :id'
);
$flag = $db->prepare('UPDATE instagram_accounts SET needs_reconnect = 1 WHERE id = :id');
foreach ($due->fetchAll(PDO::FETCH_ASSOC) as $account) {
try {
$token = instagram_refresh_token($account['access_token']);
$save->execute([
'token' => $token['access_token'],
'expires_at' => $token['expires_at'],
'now' => $now,
'id' => $account['id'],
]);
} catch (InstagramApiException $e) {
if ($e->needsReconnect()) {
// Revoked or invalid: only a new login fixes this. Ask the owner to reconnect.
$flag->execute(['id' => $account['id']]);
}
// Anything else: leave it. Tomorrow's run tries again while the token is still valid.
}
}
Reading the profile and media
All requests below go to graph.instagram.com with the long-lived token. Graph API calls can include a version (/v25.0/ is the latest listed in Meta's reference at the time of writing). Without one, Meta uses the version that was current when you created your app, or the oldest available version if that one has been retired (IG Media reference).
Profile
curl -G https://graph.instagram.com/v25.0/me \
-d fields=user_id,username,name,account_type,profile_picture_url,followers_count,follows_count,media_count,biography,website \
-d access_token=LONG_LIVED_TOKEN
/me means the account that owns the token. user_id is the Instagram professional account ID, which also appears as id in webhook notifications. The other fields are listed in Get Started and the IG User reference.
Media list
GET /{ig-user-id}/media returns the account's media (IG User Media). Without fields you only get IDs, so name the fields you want:
curl -G https://graph.instagram.com/v25.0/17841400000000000/media \
--data-urlencode 'fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}' \
-d limit=25 \
-d access_token=LONG_LIVED_TOKEN
{
"data": [
{
"id": "18123456789012345",
"caption": "Sunset over the harbour",
"media_type": "IMAGE",
"media_url": "https://scontent.cdninstagram.com/...",
"permalink": "https://www.instagram.com/p/ABC123def45/",
"timestamp": "2026-01-15T18:30:00+0000",
"like_count": 1542,
"comments_count": 87
},
{
"id": "18123456789012346",
"caption": "Behind the scenes",
"media_type": "VIDEO",
"thumbnail_url": "https://scontent.cdninstagram.com/...",
"permalink": "https://www.instagram.com/reel/XYZ987uvw65/",
"timestamp": "2026-01-14T09:12:00+0000",
"like_count": 311,
"comments_count": 12
}
],
"paging": {
"cursors": { "before": "QVFIUk...", "after": "QVFIUm..." },
"next": "https://graph.instagram.com/v25.0/17841400000000000/media?access_token=...&after=QVFIUm..."
}
}
The second item is a reel without media_url, which is common; see media types below.
Limits and paging:
- The endpoint returns at most the 10,000 most recently created media. Stories are not included; they have their own
/storiesendpoint. - Paging is cursor-based (Graph API: Paginated results).
limitis the maximum number of items per page. Followpaging.nextuntil it is absent. Meta says not to treat a short page as the end of the list. sinceanduntil(Unix timestamps) limit results to a time range.
In PHP, follow paging.next, which already carries the fields, cursor and token:
function instagram_fetch_media(string $igUserId, string $accessToken, int $max = 100): array
{
$url = "https://graph.instagram.com/v25.0/{$igUserId}/media";
$params = [
'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,'
.'like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
'limit' => 25,
'access_token' => $accessToken,
];
$items = [];
while ($url !== null && count($items) < $max) {
$page = instagram_request('GET', $url, $params);
array_push($items, ...($page['data'] ?? []));
$url = $page['paging']['next'] ?? null;
$params = [];
}
return array_slice($items, 0, $max);
}
Media fields
The fields you need for a feed, from the IG Media reference:
| Field | Notes |
|---|---|
id |
Media ID |
caption |
Caption text. Excludes album children. |
alt_text |
Accessibility text for images |
media_type |
IMAGE, VIDEO or CAROUSEL_ALBUM |
media_url |
The media file. Omitted if the media contains copyrighted material or was flagged for a copyright violation, for example audio on reels. |
thumbnail_url |
Thumbnail image. Only available on VIDEO media. |
permalink |
Link to the post on Instagram. Can't be used on photos inside albums. |
timestamp |
ISO 8601 creation time in UTC, e.g. 2026-01-15T18:30:00+0000 |
like_count |
Omitted when the owner has hidden like counts and you read it through another endpoint or field expansion |
comments_count |
Includes replies. Excludes comments on album children. |
children |
Edge: the items in a carousel, each an IG Media |
comments |
Edge: comments on the media |
Media types and reels
media_type is only ever IMAGE, VIDEO or CAROUSEL_ALBUM. There is no REELS media type: reels come back as VIDEO. The field that tells a reel apart, media_product_type (FEED, REELS, STORY or AD), is marked "Available for Instagram API with Facebook Login only" in the IG Media reference. With Instagram Login, treat every VIDEO item the same way.
Two rules for displaying media:
- Fall back to
thumbnail_urlwhenmedia_urlis missing. Instagram omitsmedia_urlfor media with copyrighted content, which includes many reels with licensed music. - Never put a
VIDEOitem'smedia_urlin an<img>tag. It points to a video file. Usethumbnail_urlfor grids and video posters.
For carousels, read the images from children:
/** Returns an image URL that is safe to use in <img>, or null. */
function instagram_image_url(array $media): ?string
{
return match ($media['media_type']) {
'VIDEO' => $media['thumbnail_url'] ?? null,
'CAROUSEL_ALBUM' => isset($media['children']['data'][0])
? instagram_image_url($media['children']['data'][0])
: ($media['media_url'] ?? null),
default => $media['media_url'] ?? $media['thumbnail_url'] ?? null,
};
}
foreach (instagram_fetch_media($instagramUserId, $accessToken, 12) as $media) {
$src = instagram_image_url($media);
if ($src === null) {
continue; // Nothing displayable.
}
printf(
'<a href="%s"><img src="%s" alt="%s" loading="lazy"></a>'."\n",
htmlspecialchars($media['permalink']),
htmlspecialchars($src),
htmlspecialchars($media['alt_text'] ?? mb_substr($media['caption'] ?? '', 0, 100))
);
}
The URLs Instagram returns are CDN URLs. Meta describes them as "privacy-aware": they stop returning the media once the content is deleted or has expired (Overview: Content Delivery Network URLs). Don't store them and expect them to keep working. Fetch the media again when you need fresh URLs.
Rate limits
Instagram Platform endpoints use Business Use Case (BUC) rate limits (Overview: Rate limiting, Graph API rate limiting). The limit applies to each app and app-user pair over a rolling 24-hour window:
Calls within 24 hours = 4800 * Number of Impressions
"Number of Impressions" is the number of times any content from the user's Instagram professional account has entered a person's screen in the last 24 hours. An account with little reach therefore has a lower limit. Business Discovery and Hashtag Search, both available only with Facebook Login, use Platform rate limits instead. Messaging has its own per-second and per-hour limits.
Once enough calls have been made, responses carry an X-Business-Use-Case-Usage header. It holds a JSON object keyed by business object ID, with entries like this:
{
"17841400000000000": [
{
"type": "instagram",
"call_count": 28,
"total_cputime": 12,
"total_time": 15,
"estimated_time_to_regain_access": 0
}
]
}
call_count, total_cputime and total_time are percentages of the limit. estimated_time_to_regain_access is in minutes.
When you exceed the limit, requests fail with an error code in the JSON body. Meta's rate-limiting page documents these codes but not an HTTP status, so check error.code rather than the status:
| Code | Meaning |
|---|---|
80002 |
Instagram Business Use Case rate limit reached |
4 |
The app has reached its rate limit |
17 |
The user has reached their rate limit |
32 |
Pages API rate limit reached |
613 |
A custom rate limit has been reached |
When you hit one, stop calling for that account until estimated_time_to_regain_access has passed, and cache responses so page views don't turn into API calls. Meta also recommends webhooks to reduce the number of calls you make, though as the webhooks section explains, there is no webhook for new posts.
Errors
Graph API errors use this shape (Graph API error handling):
{
"error": {
"message": "Error validating access token: Session has expired.",
"type": "OAuthException",
"code": 190,
"error_subcode": 463,
"fbtrace_id": "AbCdEfGhIjK"
}
}
Instagram errors can also include is_transient, error_user_title and error_user_msg (Instagram error codes). The OAuth code exchange uses the flat error_type / code / error_message shape shown in step 2.
Common codes when reading media:
| Code | Meaning | What to do |
|---|---|---|
190 |
Access token has expired or is invalid | Get a new token. If it can't be refreshed, the user must log in again. |
190 subcode 458 |
App not installed | Re-authenticate the user |
190 subcode 459 or 464 |
User checkpointed or unconfirmed | The user must log in to fix their account |
190 subcode 460 |
Password changed | The user must log in to your app again |
190 subcode 463 or 467 |
Token expired or invalid | Handle as an expired token |
10, 200–299 |
Permission not granted or removed | Ask for the permission again |
1, 2 |
Temporary issue | Wait and retry |
4, 17, 613, 80002 |
Rate limited | See rate limits |
25 subcode 2207050 |
The Instagram account is restricted (inactive, checkpointed or restricted) | The user must sign in to the Instagram app and complete any actions it asks for |
Code 190 is the one you will meet most often, and the subcodes above do not always arrive — the same code can mean a changed password, a revoked app, a security checkpoint, a transient blip or a genuine expiry, each needing a different response. Instagram API error 190: every message and the fix for each matches the exact message string to its cause.
With the PHP helper above, handle them like this:
try {
$posts = instagram_fetch_media($instagramUserId, $accessToken);
} catch (InstagramApiException $e) {
if ($e->needsReconnect()) {
// Mark the account and ask the owner to connect again.
} elseif ($e->isRateLimited()) {
// Keep serving what you have and try again later.
} else {
error_log("Instagram error {$e->errorCode}/{$e->errorSubcode}: {$e->getMessage()}");
}
}
Webhooks
Webhooks notify your server about comments, @mentions and messages on an account. With Instagram Login you can subscribe to fields such as comments, live_comments, messages, message_reactions and other messaging events. Mentions are delivered inside the comments notification (Webhooks).
There is no webhook field for new posts. To keep a feed current you have to poll the /media endpoint.
If you do use webhooks, Meta requires:
- the app set to Live, with Advanced Access and Business Verification
- an HTTPS endpoint with a valid certificate (not self-signed) that answers the
hub.mode/hub.challenge/hub.verify_tokenverification request - a subscription for each account, made with
POST /me/subscribed_apps?subscribed_fields=...using the account's token - the account to be public for comment and mention notifications
Each notification is signed with your App Secret in the X-Hub-Signature-256 header (sha256=...). Verify it before trusting the payload.
Skip the setup: use Feedframer
Everything above is what a feed needs before it shows a single post: a Meta app, a login flow, token storage, a refresh job, polling, reel and copyright handling, and a plan for expiring media URLs. Feedframer does that work and gives you one API key per connected account.
What Feedframer handles
- Login. You click Connect Account on the Dashboard and log in to Instagram. Feedframer uses Instagram API with Instagram Login, so no Facebook Page and no Meta app of your own are needed. It asks for
instagram_business_basicandinstagram_business_manage_comments. The account must be a Business or Creator account; Connecting Instagram covers the details. - Tokens. Feedframer exchanges the code for a 60-day token, checks tokens every hour and refreshes any that expire within seven days. If Instagram rejects a token (code 190), the account shows Invalid Token on the Dashboard, the owner gets an email asking them to reconnect, and the API keeps serving the last stored posts. Reconnecting keeps the same API key.
- Fetching. Posts are fetched automatically, daily on the free plan and hourly on Premium, and each fetch stores up to 100 of the latest posts. When Instagram reports a rate limit, Feedframer waits for the next scheduled fetch rather than treating the token as dead.
- Media. Images, video files, thumbnails and profile pictures are copied to
cdn.feedframer.com, so the URLs don't expire the way Instagram's CDN URLs do. When Instagram leaves outmedia_url, Feedframer uses the thumbnail. Items with neither are skipped. Premium responses also include resized images insizes. - Output. The same posts are available as a REST API, a GraphQL API and an RSS feed, with camelCase JSON and cursor pagination. Responses are cached at the edge for an hour, and the cache for an account is cleared when new or changed posts arrive.
Fetch a feed
curl "https://feedframer.com/api/v1/me?api_key=YOUR_API_KEY"
{
"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",
"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
}
],
"pagination": {
"nextCursor": null,
"prevCursor": null,
"hasMore": false,
"perPage": 6
}
}
A free key returns the 6 most recent posts, with no further pages. A Premium key returns up to 100 per request by default, pages through everything stored with page[cursor], and adds sizes to each post. As with Instagram's own API, mediaType is IMAGE, VIDEO or CAROUSEL_ALBUM, and a VIDEO post's mediaUrl is usually an .mp4. Use thumbnailUrl for video posts in an <img>:
<?php
$response = file_get_contents('https://feedframer.com/api/v1/me?api_key='.urlencode('YOUR_API_KEY'));
$feed = json_decode($response, true);
foreach ($feed['posts'] as $post) {
$src = $post['mediaType'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl'];
if (! $src) {
continue;
}
printf(
'<a href="%s"><img src="%s" alt="%s" loading="lazy"></a>'."\n",
htmlspecialchars($post['permalink']),
htmlspecialchars($src),
htmlspecialchars($post['altText'] ?? mb_substr($post['caption'] ?? '', 0, 100))
);
}
The same posts over GraphQL:
curl -X POST "https://feedframer.com/graphql?api_key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ me { username posts { type caption mediaUrl thumbnailUrl permalink publishedAt } pagination { nextCursor hasMore } } }"}'
Or as RSS, for tools that read feeds: https://feedframer.com/api/v1/rss?api_key=YOUR_API_KEY.
Direct API or Feedframer
| Instagram API directly | Feedframer | |
|---|---|---|
| Meta app, App Review, Business Verification | You set these up (Advanced Access to serve accounts you don't own) | Not needed |
| OAuth flow and token storage | You build them | Click Connect Account |
| 60-day token refresh | You schedule it and handle failures | Automatic, before expiry |
| New posts | You poll /media (no webhook) |
Fetched daily (free) or hourly (Premium) |
| Media URLs | Instagram CDN URLs that stop working | Copies on cdn.feedframer.com |
Reels without media_url |
You handle the fallback | Thumbnail used automatically |
| Output | Graph API JSON | REST, GraphQL and RSS |
| Cost | Your hosting and development time | Free for 1 account and 6 posts; Premium is $6/month ($5/month billed annually) for 5 accounts and up to 100 posts per request. See pricing. |
To get started, see Getting Started, then Authentication and Pagination.