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

  1. 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).
  2. Add the Instagram product and open API setup with Instagram login (Overview: Develop with Meta).
  3. 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 /stories endpoint.
  • Paging is cursor-based (Graph API: Paginated results). limit is the maximum number of items per page. Follow paging.next until it is absent. Meta says not to treat a short page as the end of the list.
  • since and until (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_url when media_url is missing. Instagram omits media_url for media with copyrighted content, which includes many reels with licensed music.
  • Never put a VIDEO item's media_url in an <img> tag. It points to a video file. Use thumbnail_url for 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_token verification 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_basic and instagram_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 out media_url, Feedframer uses the thumbnail. Items with neither are skipped. Premium responses also include resized images in sizes.
  • 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.

Meta documentation