Python Examples

Introduction

This guide shows how to use Feedframer from Python with the requests library. The examples run on Python 3.10 or later.

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.

Installation

Install the requests library:

pip install requests

Basic Usage

Simple GET Request

import requests

response = requests.get(
    'https://feedframer.com/api/v1/me',
    params={'api_key': 'YOUR_API_KEY'},
    timeout=10,
)
response.raise_for_status()
data = response.json()

print(f"@{data['username']} has {data['followersCount']} followers")

for post in data['posts']:
    print(post['mediaType'], post['permalink'])

GET /api/v1/me returns the account's profile, a posts array and a pagination object. Without page[size], you get as many posts as your plan allows in one request: 6 on Free, 100 on Premium. See the REST API reference for every field.

With Parameters

import requests

params = {
    'api_key': 'YOUR_API_KEY',
    'filter[type]': 'IMAGE',
    'sort': '-published_at',
}

response = requests.get('https://feedframer.com/api/v1/me', params=params, timeout=10)
response.raise_for_status()

for post in response.json()['posts']:
    print(post['timestamp'], post['caption'] or '(no caption)')
    print(post['mediaUrl'])

requests encodes the square brackets in names like filter[type] for you. filter[type] takes IMAGE, VIDEO, CAROUSEL_ALBUM or REELS; REELS returns the same posts as VIDEO, because Instagram reports reels as videos. caption can be null.

On a Free key, filters and sorting apply within your 6 most recent posts, so a filter can return fewer than 6 posts or none. See Filtering & Sorting.

Complete Client Class

This client wraps the REST endpoints and raises an exception with the API's error code. Save it as feedframer.py; the later examples import it.

# feedframer.py
from typing import Any

import requests


class FeedframerError(Exception):
    def __init__(self, status: int, code: str, detail: str, errors: list[dict[str, Any]] | None = None):
        super().__init__(f'{code}: {detail}')
        self.status = status
        self.code = code
        self.detail = detail
        self.errors = errors or []


class FeedframerClient:
    def __init__(self, api_key: str, base_url: str = 'https://feedframer.com', timeout: float = 10):
        self.api_key = api_key
        self.base_url = base_url
        self.timeout = timeout
        self.session = requests.Session()

    def get_feed(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
        """Profile plus posts. Accepts page[size], page[cursor], filter[...], sort, include[] and format."""
        return self._get('/api/v1/me', params)

    def get_account(self) -> dict[str, Any]:
        """The account this key belongs to, including its status."""
        return self._get('/api/v1/accounts')['data'][0]

    def get_all_posts(self, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
        """Every post the key can see, following cursors until hasMore is false."""
        posts: list[dict[str, Any]] = []
        cursor: str | None = None

        while True:
            page_params = dict(params or {})
            if cursor:
                page_params['page[cursor]'] = cursor

            page = self.get_feed(page_params)
            posts.extend(page['posts'])

            cursor = page['pagination']['nextCursor']
            if not page['pagination']['hasMore'] or not cursor:
                return posts

    def get_image_posts(self) -> list[dict[str, Any]]:
        return self.get_feed({'filter[type]': 'IMAGE'})['posts']

    def get_video_posts(self) -> list[dict[str, Any]]:
        return self.get_feed({'filter[type]': 'VIDEO'})['posts']

    def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        try:
            response = self.session.get(
                f'{self.base_url}{path}',
                params={'api_key': self.api_key, **(params or {})},
                timeout=self.timeout,
            )
        except requests.RequestException as e:
            raise FeedframerError(0, 'CONNECTION_FAILED', str(e)) from e

        if response.status_code != 200:
            try:
                errors = response.json().get('errors', [])
            except ValueError:
                errors = []
            first = errors[0] if errors else {}
            raise FeedframerError(
                response.status_code,
                first.get('code', f'HTTP_{response.status_code}'),
                first.get('detail', 'Unexpected response from Feedframer'),
                errors,
            )

        return response.json()

Usage:

from feedframer import FeedframerClient

client = FeedframerClient('YOUR_API_KEY')

account = client.get_account()
print(f"@{account['attributes']['username']} is {account['attributes']['status']}")

for post in client.get_video_posts():
    print(post['timestamp'], post['permalink'])

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. The client raises FeedframerError; check its code:

from feedframer import FeedframerClient, FeedframerError

client = FeedframerClient('YOUR_API_KEY')

try:
    feed = client.get_feed({'sort': 'newest', 'filter[type]': 'STORY'})
except FeedframerError as e:
    if e.code in ('MISSING_API_KEY', 'INVALID_API_KEY'):
        # The key is wrong, was regenerated, or the account was disconnected.
        print('Check the API key on your Feedframer Dashboard')
    elif e.code == 'VALIDATION_ERROR':
        for error in e.errors:
            print(f"{error.get('source', {}).get('parameter')}: {error['detail']}")
    else:
        # INTERNAL_SERVER_ERROR, or no connection: try again later.
        print(f'Feedframer error: {e}')

    feed = {'posts': []}

This prints one line per bad parameter:

filter[type]: The selected filter[type] is invalid.
sort: The sort value must be one of: -published_at, published_at, -publishedAt, publishedAt, -id, id.

Feedframer has no rate limits, so there are no 429 responses to handle. See Error Handling for every code.

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; on Free the key is missing (not null).
  • For VIDEO posts, mediaUrl is usually an .mp4 file. Use thumbnailUrl for an image.
  • For IMAGE and CAROUSEL_ALBUM posts, use mediaUrl.
from typing import Any


def post_image_url(post: dict[str, Any], width: str = '768') -> str | None:
    sizes = post.get('sizes') or {}
    if width in sizes:
        return sizes[width]

    return post['thumbnailUrl'] if post['mediaType'] == 'VIDEO' else post['mediaUrl']

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

Caching

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 never share an entry. Only successful responses reach the cache, because the client raises on errors first.

File-Based Caching

import hashlib
import json
import time
from pathlib import Path
from typing import Any

from feedframer import FeedframerClient


class CachedFeedframerClient(FeedframerClient):
    def __init__(self, api_key: str, cache_dir: str = '/tmp/feedframer-cache', ttl: int = 3600, **kwargs: Any):
        super().__init__(api_key, **kwargs)
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.ttl = ttl

    def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        cache_file = self.cache_dir / f'{self._cache_key(path, params)}.json'

        if cache_file.exists() and time.time() - cache_file.stat().st_mtime < self.ttl:
            return json.loads(cache_file.read_text())

        data = super()._get(path, params)
        cache_file.write_text(json.dumps(data))
        return data

    def _cache_key(self, path: str, params: dict[str, Any] | None) -> str:
        raw = json.dumps([self.api_key, path, params or {}], sort_keys=True)
        return hashlib.sha256(raw.encode()).hexdigest()

    def clear_cache(self) -> None:
        for cache_file in self.cache_dir.glob('*.json'):
            cache_file.unlink()

Redis Caching

Install the Redis client first with pip install redis.

import hashlib
import json
from typing import Any

import redis

from feedframer import FeedframerClient


class RedisCachedFeedframerClient(FeedframerClient):
    def __init__(self, api_key: str, redis_url: str = 'redis://localhost:6379', ttl: int = 3600, **kwargs: Any):
        super().__init__(api_key, **kwargs)
        self.redis = redis.from_url(redis_url)
        self.ttl = ttl

    def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        raw = json.dumps([self.api_key, path, params or {}], sort_keys=True)
        cache_key = 'feedframer:' + hashlib.sha256(raw.encode()).hexdigest()

        cached = self.redis.get(cache_key)
        if cached is not None:
            return json.loads(cached)

        data = super()._get(path, params)
        self.redis.setex(cache_key, self.ttl, json.dumps(data))
        return data

Both classes override _get(), so every client method, including get_all_posts(), uses the cache.

Pagination

Fetch All Pages

Premium keys page through every stored post with cursors. Pass pagination.nextCursor back as page[cursor] and stop when pagination.hasMore is false:

from typing import Any

import requests


def get_all_posts(api_key: str) -> list[dict[str, Any]]:
    """Fetch every post the key can see, following cursors."""
    all_posts: list[dict[str, Any]] = []
    cursor: str | None = None

    while True:
        params = {'api_key': api_key}
        if cursor:
            params['page[cursor]'] = cursor

        response = requests.get('https://feedframer.com/api/v1/me', params=params, timeout=10)
        response.raise_for_status()
        data = response.json()

        all_posts.extend(data['posts'])

        cursor = data['pagination']['nextCursor']
        if not data['pagination']['hasMore'] or not cursor:
            return all_posts


posts = get_all_posts('YOUR_API_KEY')
print(f'Fetched {len(posts)} posts')

On Premium, each request returns up to 100 posts. On Free, the first response already has hasMore: false and nextCursor: null, so the loop makes one request and returns your 6 most recent posts. FeedframerClient.get_all_posts() does the same thing.

Cursors are opaque strings. Pass them back unchanged. See Pagination.

GraphQL

GraphQL has one query for your feed, me, which returns the profile, posts and pagination. Put api_key in the URL query string.

from typing import Any

import requests

QUERY = """
query LatestPosts($first: Int, $cursor: String) {
  me(first: $first, cursor: $cursor) {
    username
    posts {
      id
      instagramPostId
      type
      caption
      mediaUrl
      thumbnailUrl
      permalink
      publishedAt
    }
    pagination {
      nextCursor
      hasMore
    }
  }
}
"""


def fetch_posts_graphql(api_key: str, first: int = 6, cursor: str | None = None) -> dict[str, Any]:
    response = requests.post(
        'https://feedframer.com/graphql',
        params={'api_key': api_key},
        json={'query': QUERY, 'variables': {'first': first, 'cursor': cursor}},
        timeout=10,
    )

    # A missing or invalid key is a 401 with the REST error body.
    if response.status_code != 200:
        error = response.json()['errors'][0]
        raise RuntimeError(f"{error['code']}: {error['detail']}")

    # Other problems come back with HTTP 200 and an "errors" array.
    body = response.json()
    if body.get('errors'):
        raise RuntimeError(body['errors'][0]['message'])

    return body['data']['me']


me = fetch_posts_graphql('YOUR_API_KEY')
for post in me['posts']:
    print(post['type'], post['publishedAt'], post['permalink'])

Some field names differ from REST: GraphQL uses type and publishedAt where REST uses mediaType and timestamp. The GraphQL id is Feedframer's own id; the Instagram id is instagramPostId. first defaults to your plan's limit and can't go above it. See the GraphQL API reference.

Flask Integration

Install Flask with pip install flask. This app serves an HTML page and a JSON endpoint. It only forwards filter[type] when the visitor asks for a valid type, because an empty or unknown filter[type] returns a 422.

# app.py
import os
from typing import Any

from flask import Flask, jsonify, render_template, request

from feedframer import FeedframerClient, FeedframerError

app = Flask(__name__)
client = FeedframerClient(os.environ['FEEDFRAMER_API_KEY'])

POST_TYPES = {'IMAGE', 'VIDEO', 'CAROUSEL_ALBUM', 'REELS'}


def get_instagram_posts(post_type: str | None = None) -> list[dict[str, Any]]:
    params: dict[str, Any] = {'page[size]': 6}
    if post_type and post_type.upper() in POST_TYPES:
        params['filter[type]'] = post_type.upper()

    try:
        return client.get_feed(params)['posts']
    except FeedframerError as e:
        app.logger.error('Feedframer: %s', e)
        return []


@app.route('/')
def index():
    return render_template('instagram.html', posts=get_instagram_posts())


@app.route('/api/posts')
def api_posts():
    return jsonify({'posts': get_instagram_posts(request.args.get('type'))})


if __name__ == '__main__':
    app.run(debug=True)

Put the template in templates/instagram.html. It uses thumbnailUrl for videos, so no .mp4 ends up in an <img>:

<div class="instagram-grid">
  {% for post in posts %}
    {% set src = (post.sizes or {}).get('768') or (post.thumbnailUrl if post.mediaType == 'VIDEO' else post.mediaUrl) %}
    {% if src %}
      <a href="{{ post.permalink }}" target="_blank" rel="noopener">
        <img src="{{ src }}" alt="{{ post.altText or (post.caption or '')[:100] }}" loading="lazy">
      </a>
    {% endif %}
  {% else %}
    <p>No posts found.</p>
  {% endfor %}
</div>

page[size]=6 gives the same number of posts on both plans. Premium keys can raise it to 100.

Django Integration

Add FEEDFRAMER_API_KEY to your settings (read it from the environment), then:

# views.py
from typing import Any

from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import render

from feedframer import FeedframerClient, FeedframerError

POST_TYPES = {'IMAGE', 'VIDEO', 'CAROUSEL_ALBUM', 'REELS'}


def fetch_posts(params: dict[str, Any]) -> list[dict[str, Any]]:
    client = FeedframerClient(settings.FEEDFRAMER_API_KEY)
    return client.get_feed(params)['posts']


def instagram_feed(request):
    try:
        posts = fetch_posts({'page[size]': 6})
    except FeedframerError:
        posts = []

    for post in posts:
        # Resized copy on Premium; the thumbnail for videos; otherwise the media file.
        post['image'] = post.get('sizes', {}).get('768') or (
            post['thumbnailUrl'] if post['mediaType'] == 'VIDEO' else post['mediaUrl']
        )

    return render(request, 'instagram/feed.html', {'posts': posts})


def api_posts(request):
    params: dict[str, Any] = {'page[size]': 6}

    post_type = request.GET.get('type', '').upper()
    if post_type:
        if post_type not in POST_TYPES:
            return JsonResponse({'error': f'type must be one of {sorted(POST_TYPES)}'}, status=400)
        params['filter[type]'] = post_type

    try:
        return JsonResponse({'posts': fetch_posts(params)})
    except FeedframerError as e:
        return JsonResponse({'error': e.code, 'detail': e.detail}, status=502)

The view works out each post's image URL, so the template stays simple. instagram/feed.html:

<div class="instagram-grid">
  {% for post in posts %}
    {% if post.image %}
      <a href="{{ post.permalink }}" target="_blank" rel="noopener">
        <img src="{{ post.image }}" alt="{{ post.altText|default:post.caption|default_if_none:''|truncatechars:100 }}" loading="lazy">
      </a>
    {% endif %}
  {% empty %}
    <p>No posts found.</p>
  {% endfor %}
</div>

api_posts only sends filter[type] when the visitor asks for a known type, because an empty or unknown filter[type] returns a 422.

Async with aiohttp

Install aiohttp with pip install aiohttp. This fetches several accounts at once, one key per account:

import asyncio
from typing import Any

import aiohttp


async def fetch_feed(session: aiohttp.ClientSession, api_key: str) -> dict[str, Any]:
    async with session.get(
        'https://feedframer.com/api/v1/me',
        params={'api_key': api_key},
        timeout=aiohttp.ClientTimeout(total=10),
    ) as response:
        body = await response.json(content_type=None)

        if response.status != 200:
            error = body['errors'][0]
            raise RuntimeError(f"{error['code']}: {error['detail']}")

        return body


async def main(api_keys: list[str]) -> None:
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(
            *(fetch_feed(session, key) for key in api_keys),
            return_exceptions=True,
        )

    for result in results:
        if isinstance(result, Exception):
            print(f'Failed: {result}')
        else:
            print(f"@{result['username']}: {len(result['posts'])} posts")


asyncio.run(main(['YOUR_API_KEY', 'YOUR_OTHER_API_KEY']))

Error responses are sent as application/vnd.api+json, so content_type=None tells aiohttp to parse them as JSON anyway.

Next Steps