PHP Examples

Introduction

This guide shows how to use Feedframer from plain PHP, without a framework. The examples need PHP 8.1 or later. Most use the cURL extension.

You need your API key. It's on your Dashboard, on the account's card, labelled "Feedframer API Token". Each connected Instagram account has its own key. Pass it as the api_key query parameter on every request.

The examples use GET /api/v1/me, which returns the account's profile and its posts. See the REST API reference for every field and parameter.

Basic Usage

Simple GET Request

<?php

$apiKey = 'YOUR_API_KEY';
$url = 'https://feedframer.com/api/v1/me?' . http_build_query(['api_key' => $apiKey]);

$data = json_decode(file_get_contents($url), true);

echo "@{$data['username']} has {$data['followersCount']} followers\n";

foreach ($data['posts'] as $post) {
    echo $post['mediaType'] . ': ' . $post['permalink'] . "\n";
}

Without page[size], you get as many posts as your plan allows in one request: 6 on Free, 100 on Premium.

file_get_contents() returns false when the API answers with an error, so it can't tell you what went wrong. Use cURL, as below, when you need the error details.

Using cURL

<?php

function feedframerGet(string $apiKey, array $params = []): array
{
    $query = http_build_query(['api_key' => $apiKey] + $params);

    $ch = curl_init('https://feedframer.com/api/v1/me?' . $query);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    if ($body === false) {
        throw new RuntimeException('Could not reach Feedframer: ' . curl_error($ch));
    }

    $data = json_decode($body, true);

    if ($status !== 200) {
        $error = $data['errors'][0] ?? null;
        $message = $error ? "{$error['code']}: {$error['detail']}" : "HTTP {$status}";

        throw new RuntimeException("Feedframer error {$message}");
    }

    return $data;
}

$data = feedframerGet('YOUR_API_KEY', ['filter[type]' => 'IMAGE']);

foreach ($data['posts'] as $post) {
    echo '<a href="' . htmlspecialchars($post['permalink']) . '">';
    echo '<img src="' . htmlspecialchars($post['mediaUrl']) . '" alt="' . htmlspecialchars($post['altText'] ?? '') . '">';
    echo '</a>';
}

http_build_query() encodes the square brackets in names like filter[type] for you.

filter[type]=IMAGE returns image posts only, so mediaUrl is always a JPEG here. For other post types, see Choosing an image URL.

Complete Client Class

This class wraps the three REST endpoints and turns API errors into exceptions. Save it as FeedframerClient.php; the later examples include it.

<?php
// FeedframerClient.php

class FeedframerException extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $errorCode,
        string $message,
        public readonly array $errors = [],
    ) {
        parent::__construct($message, $status);
    }
}

class FeedframerClient
{
    public function __construct(
        protected string $apiKey,
        protected string $baseUrl = 'https://feedframer.com',
    ) {}

    /** Profile plus posts. Accepts page[size], page[cursor], filter[...], sort, include and format. */
    public function getFeed(array $params = []): array
    {
        return $this->get('/api/v1/me', $params);
    }

    /** The account this key belongs to, including its status. */
    public function getAccount(): array
    {
        return $this->get('/api/v1/accounts')['data'][0];
    }

    /** Every post the key can see, following cursors until hasMore is false. */
    public function getAllPosts(array $params = []): array
    {
        $posts = [];
        $cursor = null;

        do {
            $page = $this->getFeed($cursor ? $params + ['page[cursor]' => $cursor] : $params);
            $posts = array_merge($posts, $page['posts']);
            $cursor = $page['pagination']['nextCursor'];
        } while ($page['pagination']['hasMore'] && $cursor !== null);

        return $posts;
    }

    public function getImagePosts(): array
    {
        return $this->getFeed(['filter[type]' => 'IMAGE'])['posts'];
    }

    public function getVideoPosts(): array
    {
        return $this->getFeed(['filter[type]' => 'VIDEO'])['posts'];
    }

    protected function get(string $path, array $params = []): array
    {
        $url = $this->baseUrl . $path . '?' . http_build_query(['api_key' => $this->apiKey] + $params);

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
        ]);

        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        if ($body === false) {
            throw new FeedframerException(0, 'CONNECTION_FAILED', curl_error($ch));
        }

        $data = json_decode($body, true);

        if ($status !== 200) {
            $errors = $data['errors'] ?? [];
            $first = $errors[0] ?? ['code' => 'HTTP_' . $status, 'detail' => "Unexpected HTTP {$status}"];

            throw new FeedframerException($status, $first['code'], $first['detail'], $errors);
        }

        return $data;
    }
}

Usage:

<?php

require_once 'FeedframerClient.php';

$feedframer = new FeedframerClient('YOUR_API_KEY');

$account = $feedframer->getAccount();
echo "@{$account['attributes']['username']} is {$account['attributes']['status']}\n";

$feed = $feedframer->getFeed(['filter[type]' => 'VIDEO']);

foreach ($feed['posts'] as $post) {
    echo "{$post['timestamp']}  {$post['permalink']}\n";
}

filter[type] takes IMAGE, VIDEO, CAROUSEL_ALBUM or REELS. REELS returns the same posts as VIDEO, because Instagram reports reels as videos. On a Free key, filters apply within your 6 most recent posts, so a VIDEO filter can return an empty list.

Error Handling

Every error response has the same shape, whatever Accept header you send:

{
  "errors": [
    {
      "status": "401",
      "code": "INVALID_API_KEY",
      "title": "Authentication Failed",
      "detail": "The provided API key is invalid."
    }
  ]
}

A 422 can contain several entries, one per bad parameter, each with source.parameter. Switch on code:

<?php

require_once 'FeedframerClient.php';

$feedframer = new FeedframerClient('YOUR_API_KEY');

try {
    $feed = $feedframer->getFeed(['sort' => 'newest']);
} catch (FeedframerException $e) {
    switch ($e->errorCode) {
        case 'MISSING_API_KEY':
        case 'INVALID_API_KEY':
            // The key is wrong, was regenerated, or the account was disconnected.
            error_log('Feedframer: check the API key on your Dashboard');
            break;

        case 'VALIDATION_ERROR':
            foreach ($e->errors as $error) {
                error_log("Feedframer: " . ($error['source']['parameter'] ?? '?') . ": {$error['detail']}");
            }
            break;

        default:
            // INTERNAL_SERVER_ERROR, or no connection: try again later.
            error_log("Feedframer: {$e->errorCode}: {$e->getMessage()}");
    }

    $feed = ['posts' => []];
}

sort=newest isn't a valid value, so this logs Feedframer: sort: The sort value must be one of: -published_at, published_at, -publishedAt, publishedAt, -id, id.

There are no rate limits, so you don't need to handle 429 responses. See Error Handling for the full list of codes.

Pagination

Premium keys can page through every stored post with cursors. Pass pagination.nextCursor back as page[cursor] and stop when pagination.hasMore is false. getAllPosts() in the client does this:

<?php

require_once 'FeedframerClient.php';

$feedframer = new FeedframerClient('YOUR_API_KEY');
$posts = $feedframer->getAllPosts();

echo 'Fetched ' . count($posts) . " posts\n";

On Premium, each request returns up to 100 posts, so an account with 250 stored posts takes three requests. On Free, the first response already has hasMore: false, so the loop makes one request and returns your 6 most recent posts.

To show one page at a time instead, keep the cursor in your own URL:

<?php

require_once 'FeedframerClient.php';

$feedframer = new FeedframerClient('YOUR_API_KEY');

$params = ['page[size]' => 6];
if (!empty($_GET['cursor'])) {
    $params['page[cursor]'] = $_GET['cursor'];
}

$feed = $feedframer->getFeed($params);

// ... render $feed['posts'] ...

if ($feed['pagination']['hasMore']) {
    echo '<a href="?cursor=' . urlencode($feed['pagination']['nextCursor']) . '">Older posts</a>';
}

Cursors are opaque strings. Pass them back unchanged.

Caching with File System

Feedframer caches responses at its edge for up to an hour, but a local cache saves the network round trip. Include the API key in the cache key, so two accounts on one site never share an entry.

<?php
// CachedFeedframerClient.php

require_once 'FeedframerClient.php';

class CachedFeedframerClient extends FeedframerClient
{
    public function __construct(
        string $apiKey,
        protected string $cacheDir = '/tmp/feedframer-cache',
        protected int $ttl = 3600,
        string $baseUrl = 'https://feedframer.com',
    ) {
        parent::__construct($apiKey, $baseUrl);

        if (!is_dir($this->cacheDir)) {
            mkdir($this->cacheDir, 0755, true);
        }
    }

    protected function get(string $path, array $params = []): array
    {
        ksort($params);
        $cacheFile = $this->cacheDir . '/' . hash('sha256', $this->apiKey . '|' . $path . '|' . serialize($params)) . '.json';

        if (is_file($cacheFile) && time() - filemtime($cacheFile) < $this->ttl) {
            return json_decode(file_get_contents($cacheFile), true);
        }

        $data = parent::get($path, $params);
        file_put_contents($cacheFile, json_encode($data), LOCK_EX);

        return $data;
    }

    public function clearCache(): void
    {
        array_map('unlink', glob($this->cacheDir . '/*.json') ?: []);
    }
}

Errors are thrown before anything is written, so they are never cached. If you regenerate your key, the old key's entries stay on disk until you call clearCache(), but nothing reads them.

Choosing an Image URL

Pick the image for each post like this:

  • sizes holds resized copies at 320, 768, 960 and 1200 pixels wide. Only Premium keys get it, and the key is missing (not null) on Free.
  • For VIDEO posts, mediaUrl is usually an .mp4 file. Use thumbnailUrl in an <img>.
  • For IMAGE and CAROUSEL_ALBUM posts, use mediaUrl.
<?php

function postImageUrl(array $post, string $width = '768'): ?string
{
    if (isset($post['sizes'][$width])) {
        return $post['sizes'][$width];
    }

    return $post['mediaType'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl'];
}

For VIDEO posts, sizes are resized copies of the thumbnail, so the first branch is safe for every type.

HTML Template

A complete page that shows a grid of the latest posts. Put it next to FeedframerClient.php and CachedFeedframerClient.php.

<?php
require_once 'CachedFeedframerClient.php';

function postImageUrl(array $post, string $width = '768'): ?string
{
    if (isset($post['sizes'][$width])) {
        return $post['sizes'][$width];
    }

    return $post['mediaType'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl'];
}

$feedframer = new CachedFeedframerClient('YOUR_API_KEY');

try {
    $posts = $feedframer->getFeed(['page[size]' => 6])['posts'];
} catch (FeedframerException $e) {
    error_log("Feedframer: {$e->errorCode}: {$e->getMessage()}");
    $posts = [];
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Instagram Feed</title>
    <style>
        .instagram-grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 1rem;
            max-width: 1200px;
            margin: 0 auto;
            padding: 2rem;
        }

        .instagram-post img {
            width: 100%;
            aspect-ratio: 1;
            object-fit: cover;
            display: block;
            border-radius: 8px;
        }

        .instagram-caption {
            padding: 0.5rem 0;
            font-size: 14px;
            color: #333;
        }
    </style>
</head>
<body>
    <h1>Latest from Instagram</h1>

    <div class="instagram-grid">
        <?php foreach ($posts as $post): ?>
            <?php $src = postImageUrl($post); ?>
            <?php if ($src === null) continue; ?>
            <div class="instagram-post">
                <a href="<?= htmlspecialchars($post['permalink']) ?>" target="_blank" rel="noopener">
                    <img src="<?= htmlspecialchars($src) ?>"
                         alt="<?= htmlspecialchars($post['altText'] ?? mb_substr($post['caption'] ?? '', 0, 100)) ?>"
                         loading="lazy">
                </a>

                <?php if (!empty($post['caption'])): ?>
                    <div class="instagram-caption">
                        <?= htmlspecialchars(mb_substr($post['caption'], 0, 100)) ?>
                    </div>
                <?php endif; ?>
            </div>
        <?php endforeach; ?>
    </div>

    <?php if (empty($posts)): ?>
        <p>No posts found.</p>
    <?php endif; ?>
</body>
</html>

page[size]=6 shows the same number of posts on both plans. Leave it out on Premium to get up to 100.

Next Steps