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:
sizesholds resized copies at 320, 768, 960 and 1200 pixels wide. Only Premium keys get it; on Free the key is missing (notnull).- For
VIDEOposts,mediaUrlis usually an.mp4file. UsethumbnailUrlfor an image. - For
IMAGEandCAROUSEL_ALBUMposts, usemediaUrl.
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.