WordPress Integration

Introduction

This guide shows how to display your Instagram posts on a WordPress site with Feedframer. It covers a shortcode, a widget, a template tag, a REST endpoint, cache clearing and importing posts into WordPress.

What you get back depends on your plan:

  • Free keys return your 6 most recent posts. There are no further pages.
  • Premium keys return up to 100 posts per request and can page through everything stored using cursors. Premium responses also include sizes, a set of resized image URLs.

The code below works with both plans. Unless you set a limit, each request gets your plan's limit.

Choose one setup

There are two ways to add the code. Use one or the other, not both. They define the same function names, and PHP stops with a fatal error if a function is defined twice.

  • Theme functions.php: paste the sections below into your (child) theme's functions.php, in order. Start with the helper functions in Step 2, which everything else uses.
  • Plugin: copy the complete plugin at the end of this page into a single file. It contains every section except the optional post import.

Setup

Step 1: Add your API key to wp-config.php

Copy your key from the account card on your Dashboard (labelled "Feedframer API Token") and add it to wp-config.php, above the "That's all, stop editing!" line:

// wp-config.php

define('FEEDFRAMER_API_KEY', 'YOUR_API_KEY');

This keeps the key out of the database and out of your theme's files. If the key leaks, press Regenerate on the Dashboard and update this line. The old key stops working straight away.

Step 2: Add the helper functions

These functions call Feedframer, cache the result and pick an image URL for each post. Everything later in this guide uses them.

// functions.php

/**
 * The post types accepted by filter[type]. REELS returns the same posts as
 * VIDEO, because Instagram reports reels as VIDEO.
 */
function feedframer_types() {
    return ['IMAGE', 'VIDEO', 'CAROUSEL_ALBUM', 'REELS'];
}

/**
 * Returns the upper-cased type if it is valid, or '' if it is empty or unknown.
 */
function feedframer_normalise_type($type) {
    $type = strtoupper(trim((string) $type));

    return in_array($type, feedframer_types(), true) ? $type : '';
}

/**
 * Builds a transient key. Bumping the version option (see
 * feedframer_clear_cache()) makes every older key unreachable.
 */
function feedframer_cache_key(array $params) {
    $version = (int) get_option('feedframer_cache_version', 1);

    return 'feedframer_' . $version . '_' . md5(http_build_query($params));
}

/**
 * Calls GET /api/v1/me. Returns the decoded response array, or a WP_Error.
 * Successful responses are cached for an hour, failures for five minutes.
 */
function feedframer_get_feed(array $args = []) {
    if (!defined('FEEDFRAMER_API_KEY') || FEEDFRAMER_API_KEY === '') {
        return new WP_Error('feedframer_no_key', 'FEEDFRAMER_API_KEY is not defined in wp-config.php.');
    }

    $params = array_merge(['api_key' => FEEDFRAMER_API_KEY], $args);
    $cache_key = feedframer_cache_key($params);

    $cached = get_transient($cache_key);
    if ($cached !== false) {
        return $cached;
    }

    $url = 'https://feedframer.com/api/v1/me?' . http_build_query($params);
    $response = wp_remote_get($url, ['timeout' => 10]);

    if (is_wp_error($response)) {
        $result = $response;
    } else {
        $status = (int) wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if ($status === 200 && isset($body['posts'])) {
            set_transient($cache_key, $body, HOUR_IN_SECONDS);

            return $body;
        }

        // Feedframer errors look like {"errors":[{"status","code","title","detail"}]}.
        $error = isset($body['errors'][0]) ? $body['errors'][0] : [];
        $result = new WP_Error(
            isset($error['code']) ? $error['code'] : 'feedframer_http_' . $status,
            isset($error['detail']) ? $error['detail'] : 'Feedframer returned HTTP ' . $status,
            ['status' => $status]
        );
    }

    error_log('Feedframer: ' . $result->get_error_code() . ': ' . $result->get_error_message());

    // Cache the failure briefly so a broken key or an outage
    // doesn't trigger a request on every page view.
    set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS);

    return $result;
}

/**
 * Just the posts, or an empty array if the request failed.
 */
function feedframer_get_posts(array $args = []) {
    $feed = feedframer_get_feed($args);

    return is_wp_error($feed) ? [] : $feed['posts'];
}

/**
 * An image URL that is safe to use in <img> for any post type, or null.
 * Never use mediaUrl of a VIDEO post as an image: it is usually an .mp4.
 */
function feedframer_image_url(array $post, $width = '768') {
    if (!empty($post['sizes'][$width])) {
        return $post['sizes'][$width]; // premium only; for videos these are thumbnail sizes
    }

    if ($post['mediaType'] === 'VIDEO') {
        return isset($post['thumbnailUrl']) ? $post['thumbnailUrl'] : null;
    }

    return $post['mediaUrl'];
}

What they do:

  • feedframer_get_feed() calls GET /api/v1/me with the key and any extra query parameters you pass, such as ['filter[type]' => 'IMAGE']. It returns the whole response (username, posts, pagination and the profile fields) or a WP_Error.
  • It checks the HTTP status, not just the body. A failed request is logged and cached for five minutes, so a broken key or an outage doesn't cost a request on every page view.
  • feedframer_image_url() returns sizes['768'] on premium keys, the thumbnail for videos, and mediaUrl for everything else.

Step 3: Add the rendering function

The shortcode, widget and template tag all share this function. It escapes every value from the API before output.

// functions.php

/**
 * Returns the HTML for a grid of posts. Every value from the API is escaped.
 */
function feedframer_render_posts(array $posts, $columns = 3) {
    if (empty($posts)) {
        return '<p class="feedframer-empty">' . esc_html('No Instagram posts to show.') . '</p>';
    }

    $columns = max(1, min(6, (int) $columns));

    $html = '<div class="feedframer-grid" style="display:grid;grid-template-columns:repeat('
        . esc_attr($columns) . ',1fr);gap:1rem;">';

    foreach ($posts as $post) {
        $image = feedframer_image_url($post, '768');
        $caption = isset($post['caption']) ? (string) $post['caption'] : '';
        $alt = !empty($post['altText']) ? $post['altText'] : wp_trim_words($caption, 12, '...');

        $html .= '<a class="feedframer-post" href="' . esc_url($post['permalink']) . '" target="_blank" rel="noopener">';

        if ($image) {
            $html .= '<img src="' . esc_url($image) . '" alt="' . esc_attr($alt) . '" loading="lazy"'
                . ' style="width:100%;aspect-ratio:1;object-fit:cover;display:block;border-radius:8px;" />';
        }

        if ($caption !== '') {
            $html .= '<p style="margin-top:0.5rem;font-size:14px;">' . esc_html(wp_trim_words($caption, 20, '...')) . '</p>';
        }

        $html .= '</a>';
    }

    return $html . '</div>';
}

Shortcode

// functions.php

/**
 * [feedframer type="IMAGE" limit="6" columns="3"]
 */
function feedframer_shortcode($atts) {
    $atts = shortcode_atts([
        'type' => '',
        'limit' => '',
        'columns' => 3,
    ], $atts, 'feedframer');

    $args = [];

    $type = feedframer_normalise_type($atts['type']);
    if ($atts['type'] !== '' && $type === '') {
        return '<!-- Feedframer: unknown type "' . esc_html($atts['type']) . '" -->';
    }
    if ($type !== '') {
        $args['filter[type]'] = $type;
    }

    // Leave limit empty to get your plan's limit (6 free, 100 premium).
    if ($atts['limit'] !== '') {
        $args['page[size]'] = max(1, min(100, (int) $atts['limit']));
    }

    return feedframer_render_posts(feedframer_get_posts($args), $atts['columns']);
}
add_shortcode('feedframer', 'feedframer_shortcode');

Use it in any post, page, or Shortcode block:

[feedframer]
[feedframer type="image" columns="4"]
[feedframer type="video" limit="3"]

Attributes:

  • type: IMAGE, VIDEO, CAROUSEL_ALBUM or REELS, in any case. REELS returns the same posts as VIDEO, because Instagram reports reels as videos. An unknown type renders nothing and leaves an HTML comment explaining why.
  • limit: number of posts. Leave it out to get your plan's limit. On the free plan, values above 6 still return 6.
  • columns: grid columns, 1 to 6 (default 3).

On a free key, type filters within your 6 most recent posts, so type="video" shows only the videos among those 6, which may be none.

In the block editor, add a Shortcode block and type the shortcode into it. You don't need a custom block.

Widget

For themes that use classic widgets:

// functions.php

class Feedframer_Widget extends WP_Widget {
    public function __construct() {
        parent::__construct(
            'feedframer_widget',
            'Instagram Feed (Feedframer)',
            ['description' => 'Shows your Instagram posts from Feedframer.']
        );
    }

    public function widget($args, $instance) {
        $title = isset($instance['title']) ? $instance['title'] : '';
        $feed_args = [];

        if (!empty($instance['limit'])) {
            $feed_args['page[size]'] = max(1, min(100, (int) $instance['limit']));
        }

        $type = feedframer_normalise_type(isset($instance['type']) ? $instance['type'] : '');
        if ($type !== '') {
            $feed_args['filter[type]'] = $type;
        }

        echo $args['before_widget'];

        if ($title !== '') {
            echo $args['before_title'] . esc_html($title) . $args['after_title'];
        }

        echo feedframer_render_posts(feedframer_get_posts($feed_args), 2);
        echo $args['after_widget'];
    }

    public function form($instance) {
        $title = isset($instance['title']) ? $instance['title'] : 'Instagram';
        $limit = isset($instance['limit']) ? $instance['limit'] : '';
        $type = isset($instance['type']) ? $instance['type'] : '';
        $labels = [
            '' => 'All types',
            'IMAGE' => 'Images',
            'VIDEO' => 'Videos and reels',
            'CAROUSEL_ALBUM' => 'Carousels',
        ];
        ?>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('title')); ?>">Title:</label>
            <input class="widefat" type="text"
                id="<?php echo esc_attr($this->get_field_id('title')); ?>"
                name="<?php echo esc_attr($this->get_field_name('title')); ?>"
                value="<?php echo esc_attr($title); ?>" />
        </p>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('limit')); ?>">Number of posts (blank for your plan's limit):</label>
            <input class="tiny-text" type="number" min="1" max="100"
                id="<?php echo esc_attr($this->get_field_id('limit')); ?>"
                name="<?php echo esc_attr($this->get_field_name('limit')); ?>"
                value="<?php echo esc_attr($limit); ?>" />
        </p>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('type')); ?>">Post type:</label>
            <select class="widefat"
                id="<?php echo esc_attr($this->get_field_id('type')); ?>"
                name="<?php echo esc_attr($this->get_field_name('type')); ?>">
                <?php foreach ($labels as $value => $label) : ?>
                    <option value="<?php echo esc_attr($value); ?>" <?php selected($type, $value); ?>><?php echo esc_html($label); ?></option>
                <?php endforeach; ?>
            </select>
        </p>
        <?php
    }

    public function update($new_instance, $old_instance) {
        return [
            'title' => sanitize_text_field($new_instance['title']),
            'limit' => $new_instance['limit'] === '' ? '' : max(1, min(100, (int) $new_instance['limit'])),
            'type' => feedframer_normalise_type($new_instance['type']),
        ];
    }
}

function feedframer_register_widget() {
    register_widget('Feedframer_Widget');
}
add_action('widgets_init', 'feedframer_register_widget');

To use it, go to Appearance → Widgets, add "Instagram Feed (Feedframer)" to a sidebar, and set the title, number of posts and post type. Changing the settings doesn't need a cache clear, because the cache key includes the request parameters.

Template tag

To place the feed in a theme template:

// functions.php

/**
 * Template tag: <?php feedframer_display_feed(['filter[type]' => 'IMAGE']); ?>
 */
function feedframer_display_feed(array $args = [], $columns = 3) {
    echo feedframer_render_posts(feedframer_get_posts($args), $columns);
}

Then in any template file, such as footer.php:

<?php feedframer_display_feed(); ?>

<?php feedframer_display_feed(['filter[type]' => 'IMAGE', 'page[size]' => 4], 4); ?>

The first argument takes any REST API query parameter except api_key, which the helper adds. See Filtering & Sorting.

Filtering

feedframer_get_posts() passes its argument straight through as query parameters:

// Images only
$images = feedframer_get_posts(['filter[type]' => 'IMAGE']);

// Videos. 'REELS' returns the same posts.
$videos = feedframer_get_posts(['filter[type]' => 'VIDEO']);

// Posts from the last 30 days (inclusive)
$recent = feedframer_get_posts([
    'filter[publishedAfter]' => gmdate('Y-m-d', strtotime('-30 days')),
]);

// Oldest first
$oldest_first = feedframer_get_posts(['sort' => 'published_at']);

mediaType in each post is only ever IMAGE, VIDEO or CAROUSEL_ALBUM. On a free key, filters and sorting apply within your 6 most recent posts.

Caching

Three caches sit between Instagram and your visitors:

  1. Feedframer fetches your posts from Instagram once a day on the free plan and once an hour on premium. To fetch sooner, press Refresh Posts on the Dashboard.
  2. Feedframer's edge cache keeps each API response for up to 1 hour. It's cleared automatically when a fetch finds new or changed posts. You can clear it yourself with Purge Cache on the Dashboard.
  3. Your WordPress transients keep each response for 1 hour (5 minutes for failures), using the code above.

Caching for less than an hour in WordPress gains little, because the edge cache already holds responses for that long. On the free plan you can raise HOUR_IN_SECONDS to DAY_IN_SECONDS in feedframer_get_feed().

Clearing the cache

The helper puts a version number in every transient key. Clearing the cache bumps that number, so WordPress stops finding the old entries and fetches fresh ones. This works whether transients live in the database or in a persistent object cache, and it doesn't touch any other plugin's cached data. WordPress deletes the old, expired transients itself.

This adds Tools → Feedframer with a button to clear the cache. Only users who can manage_options see it, and the form is protected by a nonce:

// functions.php

/**
 * Makes every cached Feedframer response unreachable by bumping the
 * version in the transient keys. WordPress deletes the old, expired
 * transients itself.
 */
function feedframer_clear_cache() {
    $version = (int) get_option('feedframer_cache_version', 1);
    update_option('feedframer_cache_version', $version + 1, false);
}

function feedframer_admin_menu() {
    add_management_page(
        'Feedframer',
        'Feedframer',
        'manage_options',
        'feedframer',
        'feedframer_admin_page'
    );
}
add_action('admin_menu', 'feedframer_admin_menu');

function feedframer_admin_page() {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html('You are not allowed to do this.'));
    }

    if (isset($_POST['feedframer_clear_cache'])) {
        check_admin_referer('feedframer_clear_cache');
        feedframer_clear_cache();
        echo '<div class="notice notice-success"><p>' . esc_html('Feedframer cache cleared.') . '</p></div>';
    }
    ?>
    <div class="wrap">
        <h1>Feedframer</h1>
        <form method="post">
            <?php wp_nonce_field('feedframer_clear_cache'); ?>
            <p>Clear the cached Instagram posts so the next page view asks Feedframer again.</p>
            <?php submit_button('Clear cache', 'primary', 'feedframer_clear_cache'); ?>
        </form>
    </div>
    <?php
}

You can also call feedframer_clear_cache() from your own code, for example after changing the key.

Clearing the WordPress cache only removes your copy. If Feedframer's edge cache still holds the old response, you'll get that until it expires or you press Purge Cache on the Dashboard.

REST endpoint

To load posts from JavaScript on your site without exposing your key, add a WordPress REST route that calls Feedframer on the server:

// functions.php

function feedframer_register_rest_route() {
    register_rest_route('feedframer/v1', '/posts', [
        'methods' => 'GET',
        'callback' => 'feedframer_rest_get_posts',
        'permission_callback' => '__return_true',
        'args' => [
            'type' => [
                'type' => 'string',
                'validate_callback' => function ($value) {
                    return feedframer_normalise_type($value) !== '';
                },
                'sanitize_callback' => 'feedframer_normalise_type',
            ],
            'cursor' => [
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ],
        ],
    ]);
}
add_action('rest_api_init', 'feedframer_register_rest_route');

function feedframer_rest_get_posts(WP_REST_Request $request) {
    $args = [];

    if ($request['type']) {
        $args['filter[type]'] = $request['type'];
    }

    // Pass pagination.nextCursor back as ?cursor= to get the next page (premium only).
    if ($request['cursor']) {
        $args['page[cursor]'] = $request['cursor'];
    }

    $feed = feedframer_get_feed($args);

    if (is_wp_error($feed)) {
        return new WP_Error('feedframer_unavailable', 'Could not load Instagram posts.', ['status' => 502]);
    }

    return rest_ensure_response([
        'posts' => $feed['posts'],
        'pagination' => $feed['pagination'],
    ]);
}

Requests:

GET /wp-json/feedframer/v1/posts
GET /wp-json/feedframer/v1/posts?type=image
GET /wp-json/feedframer/v1/posts?cursor=eyJwdWJsaXNoZWRfYXQiOi...

The response is {"posts": [...], "pagination": {...}}, in the same shape as Feedframer's. An unknown type gets WordPress's standard 400 rest_invalid_param error. If Feedframer can't be reached, or the key is wrong, the route returns 502.

Here's the browser side, loading every page with the cursor:

async function loadAllPosts() {
  const posts = [];
  let cursor = null;

  do {
    const url = new URL('/wp-json/feedframer/v1/posts', window.location.origin);
    if (cursor) url.searchParams.set('cursor', cursor);

    const res = await fetch(url);
    if (!res.ok) throw new Error(`Feedframer proxy returned HTTP ${res.status}`);

    const data = await res.json();
    posts.push(...data.posts);
    cursor = data.pagination.hasMore ? data.pagination.nextCursor : null;
  } while (cursor);

  return posts;
}

On a free key the loop stops after the first page of 6.

Importing posts into WordPress

This optional section copies each Instagram post into an instagram_post post type, with the image as the featured image. It works with either setup: add it to functions.php, or to the end of the plugin file.

// functions.php, or the end of the plugin file

function feedframer_register_post_type() {
    register_post_type('instagram_post', [
        'label' => 'Instagram posts',
        'public' => true,
        'has_archive' => true,
        'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
        'show_in_rest' => true,
    ]);
}
add_action('init', 'feedframer_register_post_type');

// Hourly suits premium. Free plans refresh daily, so 'daily' is enough there.
function feedframer_schedule_import() {
    if (!wp_next_scheduled('feedframer_import_posts')) {
        wp_schedule_event(time(), 'hourly', 'feedframer_import_posts');
    }
}
add_action('init', 'feedframer_schedule_import');

function feedframer_import_posts() {
    $cursor = null;

    do {
        $args = $cursor ? ['page[cursor]' => $cursor] : [];
        $feed = feedframer_get_feed($args);

        if (is_wp_error($feed)) {
            return; // already logged; try again next run
        }

        foreach ($feed['posts'] as $post) {
            feedframer_import_post($post);
        }

        // Free keys always get hasMore = false, so this stops after one page.
        $cursor = $feed['pagination']['hasMore'] ? $feed['pagination']['nextCursor'] : null;
    } while ($cursor);
}
add_action('feedframer_import_posts', 'feedframer_import_posts');

function feedframer_import_post(array $post) {
    $existing = get_posts([
        'post_type' => 'instagram_post',
        'post_status' => 'any',
        'meta_key' => 'feedframer_instagram_id',
        'meta_value' => $post['id'],
        'posts_per_page' => 1,
        'fields' => 'ids',
    ]);

    if (!empty($existing)) {
        return;
    }

    $caption = isset($post['caption']) ? (string) $post['caption'] : '';

    $post_id = wp_insert_post([
        'post_type' => 'instagram_post',
        'post_status' => 'publish',
        'post_title' => $caption !== '' ? wp_trim_words($caption, 10, '...') : 'Instagram post ' . $post['id'],
        'post_content' => $caption,
        'post_date_gmt' => gmdate('Y-m-d H:i:s', strtotime($post['timestamp'])),
        'meta_input' => [
            'feedframer_instagram_id' => $post['id'],
            'feedframer_media_type' => $post['mediaType'],
            'feedframer_permalink' => $post['permalink'],
        ],
    ]);

    if (is_wp_error($post_id) || !$post_id) {
        return;
    }

    // For videos this is the thumbnail, not the .mp4.
    $image_url = feedframer_image_url($post, '1200');

    if ($image_url) {
        require_once ABSPATH . 'wp-admin/includes/media.php';
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';

        $attachment_id = media_sideload_image($image_url, $post_id, null, 'id');

        if (!is_wp_error($attachment_id)) {
            set_post_thumbnail($post_id, $attachment_id);
        }
    }
}

Notes:

  • Posts are matched on feedframer_instagram_id (Instagram's post id), so each is imported once.
  • For videos, the featured image is thumbnailUrl. mediaUrl for a video is usually an .mp4, which media_sideload_image() can't import.
  • WP-Cron only runs when someone visits the site. Posts deleted on Instagram are not removed from WordPress.
  • If you remove the code, also run wp_clear_scheduled_hook('feedframer_import_posts'); once. The plugin below does this when it's deactivated.

Error handling

When a request fails, feedframer_get_feed() returns a WP_Error whose code is Feedframer's error code:

$feed = feedframer_get_feed();

if (is_wp_error($feed)) {
    // e.g. INVALID_API_KEY: The provided API key is invalid.
    error_log($feed->get_error_code() . ': ' . $feed->get_error_message());
} else {
    echo esc_html($feed['username']);
}
HTTP Code What to do
401 INVALID_API_KEY The key was regenerated, or the account was disconnected. Copy the current key from the Dashboard into wp-config.php, then clear the cache.
401 MISSING_API_KEY No key was sent. With this helper you'll see feedframer_no_key instead, because it checks the constant first.
422 VALIDATION_ERROR A parameter is wrong, such as page[size] above 100 or an unknown filter[type]. The message names it.
500 INTERNAL_SERVER_ERROR Retry later. If it persists, contact [email protected].

Network failures (timeouts, DNS) come back as WordPress's own http_request_failed error. Feedframer has no rate limits, so you won't get a 429. See Errors for details.

Complete plugin

This file contains everything above except the post import. Save it as wp-content/plugins/feedframer/feedframer.php, then activate Feedframer Instagram Feed under Plugins. Don't also paste the sections into functions.php.

<?php
/**
 * Plugin Name: Feedframer Instagram Feed
 * Description: Shows your Instagram posts from Feedframer with a shortcode, a widget, a template tag and a REST endpoint.
 * Version: 1.0.0
 * Requires PHP: 7.4
 */

if (!defined('ABSPATH')) {
    exit;
}

function feedframer_missing_key_notice() {
    if (defined('FEEDFRAMER_API_KEY') || !current_user_can('manage_options')) {
        return;
    }

    echo '<div class="notice notice-error"><p>'
        . esc_html('Feedframer: add define(\'FEEDFRAMER_API_KEY\', \'...\'); to wp-config.php.')
        . '</p></div>';
}
add_action('admin_notices', 'feedframer_missing_key_notice');

function feedframer_deactivate() {
    wp_clear_scheduled_hook('feedframer_import_posts');
}
register_deactivation_hook(__FILE__, 'feedframer_deactivate');

/**
 * The post types accepted by filter[type]. REELS returns the same posts as
 * VIDEO, because Instagram reports reels as VIDEO.
 */
function feedframer_types() {
    return ['IMAGE', 'VIDEO', 'CAROUSEL_ALBUM', 'REELS'];
}

/**
 * Returns the upper-cased type if it is valid, or '' if it is empty or unknown.
 */
function feedframer_normalise_type($type) {
    $type = strtoupper(trim((string) $type));

    return in_array($type, feedframer_types(), true) ? $type : '';
}

/**
 * Builds a transient key. Bumping the version option (see
 * feedframer_clear_cache()) makes every older key unreachable.
 */
function feedframer_cache_key(array $params) {
    $version = (int) get_option('feedframer_cache_version', 1);

    return 'feedframer_' . $version . '_' . md5(http_build_query($params));
}

/**
 * Calls GET /api/v1/me. Returns the decoded response array, or a WP_Error.
 * Successful responses are cached for an hour, failures for five minutes.
 */
function feedframer_get_feed(array $args = []) {
    if (!defined('FEEDFRAMER_API_KEY') || FEEDFRAMER_API_KEY === '') {
        return new WP_Error('feedframer_no_key', 'FEEDFRAMER_API_KEY is not defined in wp-config.php.');
    }

    $params = array_merge(['api_key' => FEEDFRAMER_API_KEY], $args);
    $cache_key = feedframer_cache_key($params);

    $cached = get_transient($cache_key);
    if ($cached !== false) {
        return $cached;
    }

    $url = 'https://feedframer.com/api/v1/me?' . http_build_query($params);
    $response = wp_remote_get($url, ['timeout' => 10]);

    if (is_wp_error($response)) {
        $result = $response;
    } else {
        $status = (int) wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if ($status === 200 && isset($body['posts'])) {
            set_transient($cache_key, $body, HOUR_IN_SECONDS);

            return $body;
        }

        // Feedframer errors look like {"errors":[{"status","code","title","detail"}]}.
        $error = isset($body['errors'][0]) ? $body['errors'][0] : [];
        $result = new WP_Error(
            isset($error['code']) ? $error['code'] : 'feedframer_http_' . $status,
            isset($error['detail']) ? $error['detail'] : 'Feedframer returned HTTP ' . $status,
            ['status' => $status]
        );
    }

    error_log('Feedframer: ' . $result->get_error_code() . ': ' . $result->get_error_message());

    // Cache the failure briefly so a broken key or an outage
    // doesn't trigger a request on every page view.
    set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS);

    return $result;
}

/**
 * Just the posts, or an empty array if the request failed.
 */
function feedframer_get_posts(array $args = []) {
    $feed = feedframer_get_feed($args);

    return is_wp_error($feed) ? [] : $feed['posts'];
}

/**
 * An image URL that is safe to use in <img> for any post type, or null.
 * Never use mediaUrl of a VIDEO post as an image: it is usually an .mp4.
 */
function feedframer_image_url(array $post, $width = '768') {
    if (!empty($post['sizes'][$width])) {
        return $post['sizes'][$width]; // premium only; for videos these are thumbnail sizes
    }

    if ($post['mediaType'] === 'VIDEO') {
        return isset($post['thumbnailUrl']) ? $post['thumbnailUrl'] : null;
    }

    return $post['mediaUrl'];
}

/**
 * Returns the HTML for a grid of posts. Every value from the API is escaped.
 */
function feedframer_render_posts(array $posts, $columns = 3) {
    if (empty($posts)) {
        return '<p class="feedframer-empty">' . esc_html('No Instagram posts to show.') . '</p>';
    }

    $columns = max(1, min(6, (int) $columns));

    $html = '<div class="feedframer-grid" style="display:grid;grid-template-columns:repeat('
        . esc_attr($columns) . ',1fr);gap:1rem;">';

    foreach ($posts as $post) {
        $image = feedframer_image_url($post, '768');
        $caption = isset($post['caption']) ? (string) $post['caption'] : '';
        $alt = !empty($post['altText']) ? $post['altText'] : wp_trim_words($caption, 12, '...');

        $html .= '<a class="feedframer-post" href="' . esc_url($post['permalink']) . '" target="_blank" rel="noopener">';

        if ($image) {
            $html .= '<img src="' . esc_url($image) . '" alt="' . esc_attr($alt) . '" loading="lazy"'
                . ' style="width:100%;aspect-ratio:1;object-fit:cover;display:block;border-radius:8px;" />';
        }

        if ($caption !== '') {
            $html .= '<p style="margin-top:0.5rem;font-size:14px;">' . esc_html(wp_trim_words($caption, 20, '...')) . '</p>';
        }

        $html .= '</a>';
    }

    return $html . '</div>';
}

/**
 * [feedframer type="IMAGE" limit="6" columns="3"]
 */
function feedframer_shortcode($atts) {
    $atts = shortcode_atts([
        'type' => '',
        'limit' => '',
        'columns' => 3,
    ], $atts, 'feedframer');

    $args = [];

    $type = feedframer_normalise_type($atts['type']);
    if ($atts['type'] !== '' && $type === '') {
        return '<!-- Feedframer: unknown type "' . esc_html($atts['type']) . '" -->';
    }
    if ($type !== '') {
        $args['filter[type]'] = $type;
    }

    // Leave limit empty to get your plan's limit (6 free, 100 premium).
    if ($atts['limit'] !== '') {
        $args['page[size]'] = max(1, min(100, (int) $atts['limit']));
    }

    return feedframer_render_posts(feedframer_get_posts($args), $atts['columns']);
}
add_shortcode('feedframer', 'feedframer_shortcode');

class Feedframer_Widget extends WP_Widget {
    public function __construct() {
        parent::__construct(
            'feedframer_widget',
            'Instagram Feed (Feedframer)',
            ['description' => 'Shows your Instagram posts from Feedframer.']
        );
    }

    public function widget($args, $instance) {
        $title = isset($instance['title']) ? $instance['title'] : '';
        $feed_args = [];

        if (!empty($instance['limit'])) {
            $feed_args['page[size]'] = max(1, min(100, (int) $instance['limit']));
        }

        $type = feedframer_normalise_type(isset($instance['type']) ? $instance['type'] : '');
        if ($type !== '') {
            $feed_args['filter[type]'] = $type;
        }

        echo $args['before_widget'];

        if ($title !== '') {
            echo $args['before_title'] . esc_html($title) . $args['after_title'];
        }

        echo feedframer_render_posts(feedframer_get_posts($feed_args), 2);
        echo $args['after_widget'];
    }

    public function form($instance) {
        $title = isset($instance['title']) ? $instance['title'] : 'Instagram';
        $limit = isset($instance['limit']) ? $instance['limit'] : '';
        $type = isset($instance['type']) ? $instance['type'] : '';
        $labels = [
            '' => 'All types',
            'IMAGE' => 'Images',
            'VIDEO' => 'Videos and reels',
            'CAROUSEL_ALBUM' => 'Carousels',
        ];
        ?>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('title')); ?>">Title:</label>
            <input class="widefat" type="text"
                id="<?php echo esc_attr($this->get_field_id('title')); ?>"
                name="<?php echo esc_attr($this->get_field_name('title')); ?>"
                value="<?php echo esc_attr($title); ?>" />
        </p>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('limit')); ?>">Number of posts (blank for your plan's limit):</label>
            <input class="tiny-text" type="number" min="1" max="100"
                id="<?php echo esc_attr($this->get_field_id('limit')); ?>"
                name="<?php echo esc_attr($this->get_field_name('limit')); ?>"
                value="<?php echo esc_attr($limit); ?>" />
        </p>
        <p>
            <label for="<?php echo esc_attr($this->get_field_id('type')); ?>">Post type:</label>
            <select class="widefat"
                id="<?php echo esc_attr($this->get_field_id('type')); ?>"
                name="<?php echo esc_attr($this->get_field_name('type')); ?>">
                <?php foreach ($labels as $value => $label) : ?>
                    <option value="<?php echo esc_attr($value); ?>" <?php selected($type, $value); ?>><?php echo esc_html($label); ?></option>
                <?php endforeach; ?>
            </select>
        </p>
        <?php
    }

    public function update($new_instance, $old_instance) {
        return [
            'title' => sanitize_text_field($new_instance['title']),
            'limit' => $new_instance['limit'] === '' ? '' : max(1, min(100, (int) $new_instance['limit'])),
            'type' => feedframer_normalise_type($new_instance['type']),
        ];
    }
}

function feedframer_register_widget() {
    register_widget('Feedframer_Widget');
}
add_action('widgets_init', 'feedframer_register_widget');

/**
 * Template tag: <?php feedframer_display_feed(['filter[type]' => 'IMAGE']); ?>
 */
function feedframer_display_feed(array $args = [], $columns = 3) {
    echo feedframer_render_posts(feedframer_get_posts($args), $columns);
}

/**
 * Makes every cached Feedframer response unreachable by bumping the
 * version in the transient keys. WordPress deletes the old, expired
 * transients itself.
 */
function feedframer_clear_cache() {
    $version = (int) get_option('feedframer_cache_version', 1);
    update_option('feedframer_cache_version', $version + 1, false);
}

function feedframer_admin_menu() {
    add_management_page(
        'Feedframer',
        'Feedframer',
        'manage_options',
        'feedframer',
        'feedframer_admin_page'
    );
}
add_action('admin_menu', 'feedframer_admin_menu');

function feedframer_admin_page() {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html('You are not allowed to do this.'));
    }

    if (isset($_POST['feedframer_clear_cache'])) {
        check_admin_referer('feedframer_clear_cache');
        feedframer_clear_cache();
        echo '<div class="notice notice-success"><p>' . esc_html('Feedframer cache cleared.') . '</p></div>';
    }
    ?>
    <div class="wrap">
        <h1>Feedframer</h1>
        <form method="post">
            <?php wp_nonce_field('feedframer_clear_cache'); ?>
            <p>Clear the cached Instagram posts so the next page view asks Feedframer again.</p>
            <?php submit_button('Clear cache', 'primary', 'feedframer_clear_cache'); ?>
        </form>
    </div>
    <?php
}

function feedframer_register_rest_route() {
    register_rest_route('feedframer/v1', '/posts', [
        'methods' => 'GET',
        'callback' => 'feedframer_rest_get_posts',
        'permission_callback' => '__return_true',
        'args' => [
            'type' => [
                'type' => 'string',
                'validate_callback' => function ($value) {
                    return feedframer_normalise_type($value) !== '';
                },
                'sanitize_callback' => 'feedframer_normalise_type',
            ],
            'cursor' => [
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ],
        ],
    ]);
}
add_action('rest_api_init', 'feedframer_register_rest_route');

function feedframer_rest_get_posts(WP_REST_Request $request) {
    $args = [];

    if ($request['type']) {
        $args['filter[type]'] = $request['type'];
    }

    // Pass pagination.nextCursor back as ?cursor= to get the next page (premium only).
    if ($request['cursor']) {
        $args['page[cursor]'] = $request['cursor'];
    }

    $feed = feedframer_get_feed($args);

    if (is_wp_error($feed)) {
        return new WP_Error('feedframer_unavailable', 'Could not load Instagram posts.', ['status' => 502]);
    }

    return rest_ensure_response([
        'posts' => $feed['posts'],
        'pagination' => $feed['pagination'],
    ]);
}

Next steps