Instagram API / Code samples / PHP

Instagram API in PHP — curl Examples, Plus the Laravel Http Facade

Plain curl so the samples run on any PHP 8.1 install with no Composer step, and a note on the Laravel Http facade because that is where most of this code actually ends up.

First call

Calling the Instagram API from PHP.

Copy this, set your key, and you have a working integration. Everything below is the same request with more of the edge cases handled.

Quickstart

<?php

$base = 'https://api.socialscrape.dev/v1';
$key = getenv('INSCRAPE_KEY');

if ($key === false || $key === '') {
    fwrite(STDERR, "INSCRAPE_KEY is not set\n");
    exit(1);
}

$url = $base . '/instagram/profile?' . http_build_query(['handle' => 'natgeo']);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['x-api-key: ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($raw === false) {
    exit('transport failure before a response, nothing charged');
}

$body = json_decode($raw, true);

if ($status !== 200) {
    exit($status . ' ' . $body['error']['code'] . ': ' . $body['error']['message']);
}

echo $body['data']['handle'], ' ', $body['data']['follower_count'], PHP_EOL;
echo 'charged ', $body['credits_charged'],
     ' remaining ', $body['credits_remaining'],
     ' requested ', $body['requested_at'],
     ' ms ', $body['processing_time_ms'], PHP_EOL;

Pagination

PHP cursor pagination for Instagram API lists.

Every list endpoint returns a cursor. Stop when it is absent, not after a fixed number of pages — the page size is not a contract.

PHP — cursor loop

<?php

function inscrape_get(string $path, array $params): array
{
    $url = 'https://api.socialscrape.dev/v1' . $path . '?' . http_build_query($params);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('INSCRAPE_KEY')],
        CURLOPT_TIMEOUT => 30,
    ]);

    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($raw === false) {
        throw new RuntimeException('transport failure, no credits charged');
    }

    $body = json_decode($raw, true);

    if ($status !== 200) {
        throw new RuntimeException($status . ' ' . $body['error']['code']);
    }

    return $body;
}

$posts = [];
$spent = 0;
$cursor = null;
$page = 0;

while ($page < 10) {
    $params = ['handle' => 'natgeo', 'limit' => 50];

    if ($cursor !== null) {
        $params['cursor'] = $cursor;
    }

    $body = inscrape_get('/instagram/posts', $params);

    $spent += $body['credits_charged'];
    $posts = array_merge($posts, $body['data']['posts']);
    $page++;

    $cursor = $body['data']['next_cursor'] ?? null;

    if ($cursor === null) {
        break;
    }
}

echo count($posts), ' posts across ', $spent, ' credits', PHP_EOL;

Errors

PHP error handling for the Instagram API.

Only HTTP 200 is charged, so a retry after a 500 costs you nothing except time. A retry after a 400 costs you nothing and fixes nothing.

PHP — error handling

<?php

final class InScrapeException extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly string $code, string $message)
    {
        parent::__construct($status . ' ' . $code . ': ' . $message);
    }
}

function inscrape_call(string $path, array $params, int $attempts = 3): array
{
    for ($attempt = 0; $attempt < $attempts; $attempt++) {
        $url = 'https://api.socialscrape.dev/v1' . $path . '?' . http_build_query($params);

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['x-api-key: ' . getenv('INSCRAPE_KEY')],
            CURLOPT_TIMEOUT => 30,
        ]);

        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($raw !== false) {
            $body = json_decode($raw, true);

            if ($status === 200) {
                return $body;
            }

            // 4xx will fail identically on every retry. Only 5xx is worth another attempt,
            // and only a 200 is ever charged.
            if ($status < 500) {
                throw new InScrapeException($status, $body['error']['code'], $body['error']['message']);
            }
        }

        if ($attempt < $attempts - 1) {
            sleep(2 ** $attempt);
        }
    }

    throw new InScrapeException(500, 'SCRAPE_FAILED', 'gave up after ' . $attempts . ' attempts');
}

try {
    $body = inscrape_call('/instagram/profile', ['handle' => 'natgeo']);
    if (($body['data']['profile'] ?? null) === null) {
        echo $body['data']['profile'], ' credits charged: ', $body['credits_charged'], PHP_EOL;
    } else {
        echo $body['data']['profile']['follower_count'], ' credits left: ', $body['credits_remaining'], PHP_EOL;
    }
} catch (InScrapeException $e) {
    echo match ($e->code) {
        'ENDPOINT_NOT_FOUND' => 'API route not found',
        'INSUFFICIENT_CREDITS' => 'out of credits, top up before retrying',
        'INVALID_API_KEY' => 'check INSCRAPE_KEY',
        default => $e->getMessage(),
    }, PHP_EOL;
}

Gotchas

Things that only bite in PHP.

Laravel: use the Http facade and do not enable throwing

Http::withHeaders(['x-api-key' => config('services.inscrape.key')])->timeout(30)->get('https://api.socialscrape.dev/v1/instagram/profile', ['handle' => 'natgeo'])->json() gives you the same array. Skip ->throw(), because the error body carries the code and the confirmation that credits_charged is 0, and an exception discards both.

curl_exec returning false is not an HTTP error

false means no response arrived at all: DNS, TLS or the timeout. That case is never charged. Check it before you touch json_decode, or you will get a null-index error that hides the real cause.

http_build_query encodes the cursor for you

Cursors are opaque strings that contain characters needing escaping. Building the query by hand with string concatenation is the reliable way to get a 400 on page two.

FAQ

Instagram API in PHP FAQ.

Guzzle or curl?

Either. Guzzle throws on 4xx by default, so pass ['http_errors' => false] and read the envelope yourself; otherwise you lose the error code and the credit confirmation. The plain curl above avoids that trap and needs no Composer package.

Where do I put the key in a Laravel app?

config/services.php reading from the .env file, then config('services.inscrape.key'). Never in a Blade template or a compiled asset — the key is a bearer credential with a credit balance attached.

Can I cache responses in Laravel to save credits?

Yes. Cache::remember with a TTL matched to the endpoint avoids repeat API requests and their credit charges. InScrape charges successful calls even when it serves a locally cached result.

Does it need the Instagram Basic Display API or a Meta app?

No. There is no Meta app, no app review and no access token to refresh. One x-api-key header, and only public data comes back.

What happens if I request a private account?

HTTP 200 with available profile details, charged at the endpoint rate. This confirms that the account is private; private content is not returned.

Other languages

Instagram API examples beyond PHP.

Every endpoint these samples call is documented at /docs/instagram.