Instagram Graph API Integration Guide

Version: 2025 Last Updated: October 3, 2025 API Version: Graph API v23.0+

Table of Contents

  1. Overview
  2. Instagram App Setup
  3. Authentication Flow (OAuth 2.0)
  4. Access Token Management
  5. API Endpoints
  6. Rate Limits & Quotas
  7. Error Handling
  8. Business Account Requirements
  9. Implementation Examples

Overview

IMPORTANT: As of December 4, 2024, the Instagram Basic Display API has been officially deprecated. All new implementations must use the Instagram Graph API for business accounts.

Key Features

  • Access to business/creator account media
  • Long-lived access tokens (60-day validity)
  • Supports all media types: IMAGE, VIDEO, CAROUSEL_ALBUM, REELS
  • Pagination support for large datasets
  • Automatic token refresh capability

Prerequisites

  • Instagram Business or Creator account
  • Facebook Page connected to the Instagram account
  • Facebook account linked to both
  • Meta Developer App with Instagram permissions

Instagram App Setup

Step 1: Create a Facebook App

  1. Visit Meta for Developers
  2. Create a new app (select "Business" type)
  3. Add Instagram Graph API product to your app

Step 2: Configure App Settings

App ID & Secret

After creating your app, you'll receive:

  • App ID (Client ID): INSTAGRAM_CLIENT_ID
  • App Secret (Client Secret): INSTAGRAM_CLIENT_SECRET

Add these to your .env:

INSTAGRAM_CLIENT_ID=your_app_id
INSTAGRAM_CLIENT_SECRET=your_app_secret
INSTAGRAM_REDIRECT_URI=https://yourdomain.com/instagram/callback

OAuth Redirect URIs

Configure valid OAuth redirect URIs in your app settings:

  • Development: http://localhost/instagram/callback or https://yourapp.test/instagram/callback
  • Production: https://yourdomain.com/instagram/callback

Important: The redirect URI must match exactly (including protocol and trailing slashes).

Step 3: Configure Permissions

Required permissions for your app:

  • instagram_business_basic - Read basic business account info and media
  • instagram_business_content_publish - Optional, for publishing features
  • instagram_business_manage_messages - Optional, for message management
  • instagram_business_manage_comments - Optional, for comment management

Step 4: App Review (Production)

For production use, submit your app for review to access:

  • Public data from any Instagram Business account
  • Higher rate limits
  • Extended permissions

Authentication Flow (OAuth 2.0)

Flow Diagram

User clicks "Connect Instagram"
    ↓
Redirect to Instagram OAuth
    ↓
User authorizes app
    ↓
Callback with authorization code
    ↓
Exchange code for short-lived token
    ↓
Exchange short-lived for long-lived token
    ↓
Store token + expiry in database

Step 1: Redirect to Instagram OAuth

Using Laravel Socialite:

use Laravel\Socialite\Facades\Socialite;

Route::get('/instagram/redirect', function () {
    return Socialite::driver('instagram')
        ->setScopes(['instagram_business_basic'])
        ->redirect();
})->name('instagram.redirect');

Manual OAuth URL:

https://www.facebook.com/v23.0/dialog/oauth?
  client_id={app-id}
  &redirect_uri={redirect-uri}
  &scope=instagram_business_basic
  &response_type=code
  &state={csrf-token}

Step 2: Handle OAuth Callback

Receives authorization code from Instagram:

Route::get('/instagram/callback', function () {
    if (!request()->has('code') || request('error')) {
        return redirect()->route('dashboard')
            ->with('error', 'Instagram authorization failed');
    }

    // Get user data and short-lived token via Socialite
    $user = Socialite::driver('instagram')->user();

    // Exchange for long-lived token (next step)
    $longLivedToken = exchangeForLongLivedToken($user->token);

    // Store account details
    Account::create([
        'user_id' => auth()->id(),
        'instagram_user_id' => $user->getId(),
        'username' => $user->getName(),
        'access_token' => encrypt($longLivedToken['access_token']),
        'expires_at' => now()->addSeconds($longLivedToken['expires_in']),
    ]);

    return redirect()->route('dashboard');
});

Step 3: Exchange for Long-Lived Token

Endpoint:

GET https://graph.instagram.com/access_token

Parameters:

  • grant_type: ig_exchange_token
  • client_secret: Your app secret
  • access_token: Short-lived token from OAuth

Example Request:

use Illuminate\Support\Facades\Http;

function exchangeForLongLivedToken(string $shortLivedToken): array
{
    $response = Http::get('https://graph.instagram.com/access_token', [
        'grant_type' => 'ig_exchange_token',
        'client_secret' => config('services.instagram.client_secret'),
        'access_token' => $shortLivedToken,
    ]);

    return $response->json();
    // Returns: ['access_token' => '...', 'expires_in' => 5183944]
}

Response:

{
  "access_token": "long_lived_token_here",
  "token_type": "bearer",
  "expires_in": 5183944
}

expires_in is approximately 60 days in seconds


Access Token Management

Token Lifecycle

Stage Duration Action Required
Short-lived token ~1 hour Exchange immediately
Long-lived token 60 days Refresh before expiry
Expired token N/A Re-authenticate user

Refresh Long-Lived Token

Important: Tokens must be:

  • At least 24 hours old
  • Not yet expired
  • Refreshed at least once every 60 days

Endpoint:

GET https://graph.instagram.com/refresh_access_token

Parameters:

  • grant_type: ig_refresh_token
  • access_token: Current long-lived token

Example Implementation:

public function refreshAccessToken(): void
{
    $response = Http::get('https://graph.instagram.com/refresh_access_token', [
        'grant_type' => 'ig_refresh_token',
        'access_token' => decrypt($this->access_token),
    ]);

    $data = $response->json();

    $this->update([
        'access_token' => encrypt($data['access_token']),
        'expires_at' => now()->addSeconds($data['expires_in']),
    ]);
}

Response:

{
  "access_token": "new_long_lived_token",
  "token_type": "bearer",
  "expires_in": 5183944
}

Automated Refresh Strategy

Recommended Approach:

  1. Create a scheduled job to run daily
  2. Find tokens expiring within 7 days
  3. Refresh each token
  4. Update database with new token and expiry
// In routes/console.php
Schedule::command('instagram:refresh-tokens')->daily();

// Command implementation
Account::query()
    ->where('status', 'active')
    ->where('expires_at', '<=', now()->addDays(7))
    ->where('expires_at', '>', now())
    ->each(function (Account $account) {
        try {
            $account->refreshAccessToken();
        } catch (\Exception $e) {
            // Mark as expired, notify user
            $account->update(['status' => 'expired']);
            Mail::to($account->user)->send(new TokenExpiredMail($account));
        }
    });

API Endpoints

1. Get User Media

Fetch all media for an Instagram business account.

Endpoint:

GET https://graph.instagram.com/{instagram-user-id}/media

Parameters:

  • fields: Comma-separated list of fields to return
  • limit: Number of media items per page (default: 25, max: 100)
  • after: Pagination cursor for next page
  • since: Unix timestamp for time-based filtering
  • until: Unix timestamp for time-based filtering

Available Fields:

id, caption, media_type, media_url, thumbnail_url, permalink,
timestamp, username, comments_count, like_count, children

Example Request:

use Illuminate\Support\Facades\Http;

public function fetchMedia(): void
{
    $fields = [
        'id',
        'caption',
        'media_type',
        'media_url',
        'thumbnail_url',
        'permalink',
        'timestamp',
        'comments_count',
        'like_count',
    ];

    $response = Http::get("https://graph.instagram.com/{$this->instagram_user_id}/media", [
        'fields' => implode(',', $fields),
        'limit' => 100,
        'access_token' => decrypt($this->access_token),
    ]);

    $data = $response->json();

    foreach ($data['data'] as $media) {
        $this->media()->updateOrCreate(
            ['instagram_id' => $media['id']],
            [
                'caption' => $media['caption'] ?? '',
                'media_type' => $media['media_type'],
                'media_url' => $media['media_url'],
                'thumbnail_url' => $media['thumbnail_url'] ?? null,
                'permalink' => $media['permalink'],
                'timestamp' => $media['timestamp'],
                'comments_count' => $media['comments_count'] ?? 0,
                'like_count' => $media['like_count'] ?? 0,
            ]
        );
    }

    // Handle pagination if needed
    if (isset($data['paging']['next'])) {
        $this->fetchNextPage($data['paging']['next']);
    }
}

Response Structure:

{
  "data": [
    {
      "id": "17895695668004550",
      "caption": "Beautiful sunset at the beach!",
      "media_type": "IMAGE",
      "media_url": "https://scontent.cdninstagram.com/...",
      "permalink": "https://www.instagram.com/p/ABC123/",
      "timestamp": "2025-01-15T10:30:00+0000",
      "comments_count": 42,
      "like_count": 384
    },
    {
      "id": "17895695668004551",
      "media_type": "VIDEO",
      "media_url": "https://scontent.cdninstagram.com/...",
      "thumbnail_url": "https://scontent.cdninstagram.com/...",
      "permalink": "https://www.instagram.com/p/XYZ789/",
      "timestamp": "2025-01-14T15:45:00+0000",
      "comments_count": 23,
      "like_count": 156
    }
  ],
  "paging": {
    "cursors": {
      "before": "QVFIUjRSa...",
      "after": "QVFIUjA3b..."
    },
    "next": "https://graph.instagram.com/v23.0/.../media?after=QVFIUjA3b..."
  }
}

Limitations:

  • Returns maximum of 10,000 most recently created media
  • Stories are not included (use separate /stories endpoint)
  • Only returns media created by the specified Instagram user

2. Get User Profile Information

Fetch Instagram business account details.

Endpoint:

GET https://graph.instagram.com/me

Available Fields:

id, username, name, profile_picture_url, followers_count,
follows_count, media_count, biography, website

Example Request:

$response = Http::get('https://graph.instagram.com/me', [
    'fields' => 'id,username,name,profile_picture_url,followers_count,follows_count,media_count,biography',
    'access_token' => decrypt($this->access_token),
]);

Response:

{
  "id": "17841405793187218",
  "username": "mycompany",
  "name": "My Company",
  "profile_picture_url": "https://scontent.cdninstagram.com/...",
  "followers_count": 12453,
  "follows_count": 342,
  "media_count": 187,
  "biography": "Official account for My Company"
}

3. Get Individual Media Details

Endpoint:

GET https://graph.instagram.com/{media-id}

Example Request:

$response = Http::get("https://graph.instagram.com/{$mediaId}", [
    'fields' => 'id,caption,media_type,media_url,timestamp,children{media_url,media_type}',
    'access_token' => $token,
]);

Note: For carousel albums, use children{media_url,media_type} to get all child media.


Rate Limits & Quotas

Platform Rate Limits

Instagram Graph API uses Business Use Cases (BUC) rate limiting:

Formula:

Calls per 24 hours = 4,800 × Number of Impressions

Where impressions = number of times any Instagram content has appeared on a person's screen in the last 24 hours.

Standard Limits

Tier Calls per User per Hour Notes
Per User 200 Standard platform limit
Media Comments 60 writes/hour Special restriction
BUC Daily 4,800 × impressions Resets every 24 hours

Important Notes:

  • Rate limits are per app-user pair
  • Higher engagement accounts = higher rate limits
  • Limits reset every 24 hours
  • Previous short-lived token had 5,000/hour (reduced to 200 in recent years)

Rate Limit Headers

Instagram returns these headers with API responses:

X-App-Usage: {"call_count":45,"total_cputime":25,"total_time":50}
X-Business-Use-Case-Usage: {"123456789":{"call_count":10,"total_cputime":5,"total_time":10}}

Handling Rate Limits

Detection:

  • HTTP Status Code: 429 Too Many Requests
  • Error Code: 4 or 17
  • Error Type: OAuthException or APIThrottleException

Best Practices:

  1. Implement exponential backoff
  2. Monitor usage headers
  3. Cache API responses (1 hour recommended)
  4. Batch requests when possible
  5. Queue jobs with proper delays

Example Implementation:

use Illuminate\Support\Facades\Cache;

public function fetchMediaWithRateLimit()
{
    $cacheKey = "instagram_media_{$this->id}";

    return Cache::remember($cacheKey, 3600, function () {
        try {
            return $this->fetchMedia();
        } catch (\Exception $e) {
            if ($this->isRateLimitError($e)) {
                // Wait and retry with exponential backoff
                sleep(60);
                return $this->fetchMedia();
            }
            throw $e;
        }
    });
}

private function isRateLimitError(\Exception $e): bool
{
    return str_contains($e->getMessage(), 'rate limit')
        || str_contains($e->getMessage(), '429');
}

Error Handling

Common Error Codes

Code Type Description Resolution
4 OAuthException Rate limit exceeded Wait and retry with backoff
10 OAuthException Permission denied Check app permissions
17 APIThrottleException Too many calls Implement rate limiting
190 OAuthException Invalid/expired token Refresh or re-authenticate
200 PermissionsError Missing permission Request instagram_business_basic
100 InvalidParameterException Invalid parameter Check API request format
110 InvalidUserException Invalid user ID Verify Instagram user ID
2207013 User Error Subcode Username not found Verify account exists

Error Response Structure

{
  "error": {
    "message": "Application request limit reached",
    "type": "OAuthException",
    "code": 4,
    "error_subcode": 2207013,
    "fbtrace_id": "AXYZabc123"
  }
}

Error Handling Strategy

use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

public function handleInstagramApiError(\Exception $e, Account $account): void
{
    $errorData = json_decode($e->getMessage(), true);
    $errorCode = $errorData['error']['code'] ?? null;

    Log::channel('instagram')->error('Instagram API Error', [
        'account_id' => $account->id,
        'error' => $errorData,
    ]);

    match ($errorCode) {
        190 => $this->handleExpiredToken($account),
        4, 17 => $this->handleRateLimit($account),
        200 => $this->handlePermissionError($account),
        default => $this->handleGenericError($account, $errorData),
    };
}

private function handleExpiredToken(Account $account): void
{
    $account->update(['status' => 'expired']);
    Mail::to($account->user)->send(new InstagramTokenExpiredMail($account));
}

private function handleRateLimit(Account $account): void
{
    // Retry job later
    RefreshInstagramToken::dispatch($account)->delay(now()->addHour());
}

Graceful Degradation

public function getMedia()
{
    try {
        return $this->fetchMediaFromInstagram();
    } catch (\Exception $e) {
        // Log error
        Log::error('Instagram API failed', ['error' => $e->getMessage()]);

        // Return cached data if available
        return Cache::get("instagram_media_{$this->id}", collect());
    }
}

Business Account Requirements

Account Type Validation

Required:

  • Instagram Business or Creator account (personal accounts not supported)
  • Facebook Page connected to Instagram account
  • Facebook account linked to both
  • Using instagram_business_basic scope (not instagram_basic)

How to Validate Account Type

When fetching user data, check the account type:

public function isBusinessAccount(): bool
{
    try {
        $response = Http::get('https://graph.instagram.com/me', [
            'fields' => 'id,username,account_type',
            'access_token' => decrypt($this->access_token),
        ]);

        $data = $response->json();

        // account_type should be 'BUSINESS' or 'CREATOR'
        return in_array($data['account_type'] ?? '', ['BUSINESS', 'CREATOR']);
    } catch (\Exception $e) {
        return false;
    }
}

Converting Personal to Business Account

Users must:

  1. Have a Facebook Page
  2. Go to Instagram Settings → Account → Switch to Professional Account
  3. Choose Business or Creator
  4. Link to Facebook Page

In-App Guidance:

if (!$account->isBusinessAccount()) {
    return redirect()->back()->with('error',
        'Please convert your Instagram account to a Business or Creator account. ' .
        'Visit Instagram Settings → Account → Switch to Professional Account.'
    );
}

Supported Media Types

Media Type Overview

Type Description Required Fields Optional Fields
IMAGE Single photo media_url thumbnail_url, caption
VIDEO Single video media_url, thumbnail_url caption
CAROUSEL_ALBUM Multiple photos/videos N/A (use children) caption
REELS Short-form video media_url, thumbnail_url is_shared_to_feed

Field Requirements by Type

IMAGE:

{
  "id": "123",
  "media_type": "IMAGE",
  "media_url": "https://...",
  "caption": "Photo caption",
  "permalink": "https://instagram.com/p/...",
  "timestamp": "2025-01-15T10:30:00+0000"
}

VIDEO:

{
  "id": "456",
  "media_type": "VIDEO",
  "media_url": "https://video.cdninstagram.com/...",
  "thumbnail_url": "https://scontent.cdninstagram.com/...",
  "caption": "Video caption",
  "permalink": "https://instagram.com/p/...",
  "timestamp": "2025-01-14T15:45:00+0000"
}

CAROUSEL_ALBUM:

{
  "id": "789",
  "media_type": "CAROUSEL_ALBUM",
  "caption": "Carousel caption",
  "children": {
    "data": [
      {
        "id": "789_1",
        "media_type": "IMAGE",
        "media_url": "https://..."
      },
      {
        "id": "789_2",
        "media_type": "VIDEO",
        "media_url": "https://...",
        "thumbnail_url": "https://..."
      }
    ]
  },
  "permalink": "https://instagram.com/p/...",
  "timestamp": "2025-01-13T12:00:00+0000"
}

Fetching Carousel Children

Use the children field with nested field selection:

$response = Http::get("https://graph.instagram.com/{$mediaId}", [
    'fields' => 'id,media_type,caption,children{id,media_type,media_url,thumbnail_url}',
    'access_token' => $token,
]);

Implementation Examples

Complete OAuth Flow Implementation

config/services.php:

'instagram' => [
    'client_id' => env('INSTAGRAM_CLIENT_ID'),
    'client_secret' => env('INSTAGRAM_CLIENT_SECRET'),
    'redirect' => env('INSTAGRAM_REDIRECT_URI'),
],

Provider Configuration (for Socialite):

// In EventServiceProvider or AppServiceProvider
use SocialiteProviders\Instagram\InstagramExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;

protected $listen = [
    SocialiteWasCalled::class => [
        InstagramExtendSocialite::class,
    ],
];

Complete Controller:

namespace App\Http\Controllers;

use App\Models\Account;
use App\Jobs\FetchInstagramMedia;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;

class InstagramController extends Controller
{
    public function redirect()
    {
        return Socialite::driver('instagram')
            ->setScopes(['instagram_business_basic'])
            ->redirect();
    }

    public function callback()
    {
        if (!request()->has('code') || request('error')) {
            return redirect()->route('dashboard')
                ->with('error', 'Instagram authorization failed');
        }

        $instagramUser = Socialite::driver('instagram')->user();

        // Exchange for long-lived token
        $longLivedResponse = Http::get('https://graph.instagram.com/access_token', [
            'grant_type' => 'ig_exchange_token',
            'client_secret' => config('services.instagram.client_secret'),
            'access_token' => $instagramUser->token,
        ]);

        $longLivedData = $longLivedResponse->json();

        // Create or update account
        $account = Account::updateOrCreate(
            [
                'user_id' => auth()->id(),
                'instagram_user_id' => $instagramUser->getId(),
            ],
            [
                'username' => $instagramUser->getName(),
                'access_token' => encrypt($longLivedData['access_token']),
                'expires_at' => now()->addSeconds($longLivedData['expires_in']),
                'status' => 'active',
            ]
        );

        // Dispatch job to fetch media
        FetchInstagramMedia::dispatch($account);

        return redirect()->route('dashboard')
            ->with('success', 'Instagram account connected successfully!');
    }
}

Token Refresh Command

namespace App\Console\Commands;

use App\Models\Account;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use App\Mail\InstagramTokenExpiredMail;

class RefreshInstagramTokens extends Command
{
    protected $signature = 'instagram:refresh-tokens';
    protected $description = 'Refresh Instagram access tokens expiring soon';

    public function handle()
    {
        $accounts = Account::query()
            ->where('status', 'active')
            ->where('expires_at', '<=', now()->addDays(7))
            ->where('expires_at', '>', now())
            ->get();

        $this->info("Found {$accounts->count()} accounts to refresh");

        foreach ($accounts as $account) {
            try {
                $response = Http::get('https://graph.instagram.com/refresh_access_token', [
                    'grant_type' => 'ig_refresh_token',
                    'access_token' => decrypt($account->access_token),
                ]);

                $data = $response->json();

                $account->update([
                    'access_token' => encrypt($data['access_token']),
                    'expires_at' => now()->addSeconds($data['expires_in']),
                ]);

                $this->info("Refreshed token for account: {$account->username}");
            } catch (\Exception $e) {
                $this->error("Failed to refresh {$account->username}: {$e->getMessage()}");

                $account->update(['status' => 'expired']);
                Mail::to($account->user)->send(new InstagramTokenExpiredMail($account));
            }
        }

        $this->info('Token refresh completed');
    }
}

Fetch Media Job

namespace App\Jobs;

use App\Models\Account;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class FetchInstagramMedia implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $backoff = [60, 300, 900]; // 1min, 5min, 15min

    public function __construct(public Account $account)
    {
    }

    public function handle(): void
    {
        $fields = [
            'id',
            'caption',
            'media_type',
            'media_url',
            'thumbnail_url',
            'permalink',
            'timestamp',
            'comments_count',
            'like_count',
        ];

        try {
            $response = Http::get(
                "https://graph.instagram.com/{$this->account->instagram_user_id}/media",
                [
                    'fields' => implode(',', $fields),
                    'limit' => 100,
                    'access_token' => decrypt($this->account->access_token),
                ]
            );

            if (!$response->successful()) {
                throw new \Exception($response->body());
            }

            $data = $response->json();

            foreach ($data['data'] as $media) {
                $this->account->media()->updateOrCreate(
                    ['instagram_id' => $media['id']],
                    [
                        'caption' => $media['caption'] ?? '',
                        'media_type' => $media['media_type'],
                        'media_url' => $media['media_url'],
                        'thumbnail_url' => $media['thumbnail_url'] ?? null,
                        'permalink' => $media['permalink'],
                        'timestamp' => $media['timestamp'],
                        'comments_count' => $media['comments_count'] ?? 0,
                        'like_count' => $media['like_count'] ?? 0,
                    ]
                );
            }

            $this->account->update(['last_fetch_at' => now()]);

            // Handle pagination if needed
            if (isset($data['paging']['next'])) {
                $this->fetchNextPage($data['paging']['next']);
            }
        } catch (\Exception $e) {
            Log::channel('instagram')->error('Failed to fetch media', [
                'account_id' => $this->account->id,
                'error' => $e->getMessage(),
            ]);

            throw $e;
        }
    }

    private function fetchNextPage(string $nextUrl): void
    {
        // Implement pagination logic if needed
        // For MVP, limiting to first 100 might be sufficient
    }
}

Additional Resources

Official Documentation

Helpful Tools

Testing Considerations

  • Use sandbox/test Instagram accounts for development
  • Mock Instagram API responses in tests
  • Test token expiry scenarios
  • Test rate limit handling
  • Test all media types (IMAGE, VIDEO, CAROUSEL_ALBUM)

Summary

Key Takeaways

  1. Instagram Basic Display API is deprecated - Use Instagram Graph API
  2. Business accounts only - Personal accounts not supported
  3. Tokens expire in 60 days - Must refresh at least every 60 days
  4. Rate limits are dynamic - Based on Business Use Cases (4,800 × impressions per day)
  5. Pagination required - Maximum 10,000 media items returned
  6. Error handling critical - Implement retry logic and user notifications

Implementation Checklist

  • Create Facebook App with Instagram Graph API product
  • Configure OAuth redirect URIs
  • Implement OAuth flow with Socialite
  • Exchange short-lived for long-lived tokens
  • Store encrypted tokens in database
  • Implement token refresh command (scheduled daily)
  • Create job for fetching media
  • Implement rate limit handling with backoff
  • Add error logging and user notifications
  • Validate business account type
  • Handle all media types (IMAGE, VIDEO, CAROUSEL_ALBUM)
  • Test token expiry and refresh flows

Document Version: 1.0 Last Reviewed: October 3, 2025 Next Review: January 3, 2026