How to Take Screenshots with Flask Using a Screenshot API

2026-05-14 | Tags: [flask, python, api, screenshot, backend, web]

Flask's micro-framework philosophy — minimal core, composable extensions — makes it easy to add a screenshot API endpoint in minutes. Whether you're building a monitoring dashboard, a social preview image generator, or a content archiving tool, this guide covers the full range of Flask integration patterns.

Setup

pip install flask requests

Get a free API key at hermesforge.dev/screenshot. Set it in your environment:

export SCREENSHOT_API_KEY=your_api_key_here

Basic Route

A minimal Flask route that proxies screenshot requests to the API:

# app.py
import os
import requests
from flask import Flask, request, Response, jsonify

app = Flask(__name__)

API_KEY = os.environ['SCREENSHOT_API_KEY']
API_BASE = 'https://hermesforge.dev/api/screenshot'


@app.route('/screenshot')
def screenshot():
    url = request.args.get('url')
    if not url:
        return jsonify(error='url parameter is required'), 400

    params = {
        'url': url,
        'width': request.args.get('width', 1280),
        'height': request.args.get('height', 800),
        'format': request.args.get('format', 'webp'),
    }

    resp = requests.get(API_BASE, params=params, headers={'X-API-Key': API_KEY})

    if not resp.ok:
        return jsonify(error='Screenshot failed', status=resp.status_code), resp.status_code

    return Response(
        resp.content,
        content_type=resp.headers.get('Content-Type', 'image/webp'),
        headers={'Cache-Control': 'public, max-age=300'},
    )


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

Test it:

curl "http://localhost:5000/screenshot?url=https://example.com" --output example.webp

Streaming Response

For large screenshots, stream from the upstream API directly to the client:

import requests
from flask import Flask, request, Response, stream_with_context

@app.route('/screenshot/stream')
def screenshot_stream():
    url = request.args.get('url')
    if not url:
        return {'error': 'url required'}, 400

    params = {
        'url': url,
        'format': request.args.get('format', 'webp'),
        'width': request.args.get('width', 1280),
        'height': request.args.get('height', 800),
    }

    upstream = requests.get(
        API_BASE,
        params=params,
        headers={'X-API-Key': API_KEY},
        stream=True,  # Don't buffer upstream response
    )

    if not upstream.ok:
        return {'error': f'Upstream error: {upstream.status_code}'}, upstream.status_code

    return Response(
        stream_with_context(upstream.iter_content(chunk_size=8192)),
        content_type=upstream.headers.get('Content-Type', 'image/webp'),
    )

stream_with_context is important here — it keeps the Flask application context alive while the generator runs, which is needed for logging and request-scoped resources.

Blueprint Organization

For larger applications, organize with a Blueprint:

# blueprints/screenshot.py
import os
import requests
from flask import Blueprint, request, Response, jsonify, current_app

screenshot_bp = Blueprint('screenshot', __name__, url_prefix='/api')

API_BASE = 'https://hermesforge.dev/api/screenshot'


def _get_api_key():
    return current_app.config.get('SCREENSHOT_API_KEY') or os.environ['SCREENSHOT_API_KEY']


@screenshot_bp.route('/screenshot')
def capture():
    url = request.args.get('url')
    if not url:
        return jsonify(error='url is required'), 400

    try:
        from urllib.parse import urlparse
        parsed = urlparse(url)
        if parsed.scheme not in ('http', 'https'):
            return jsonify(error='Only http/https URLs allowed'), 400
    except Exception:
        return jsonify(error='Invalid URL'), 400

    params = {
        'url': url,
        'width': request.args.get('width', 1280, type=int),
        'height': request.args.get('height', 800, type=int),
        'format': request.args.get('format', 'webp'),
        'full_page': request.args.get('full_page', 'false'),
    }

    if delay := request.args.get('delay', type=int):
        params['delay'] = delay

    resp = requests.get(API_BASE, params=params, headers={'X-API-Key': _get_api_key()}, timeout=30)

    if not resp.ok:
        return jsonify(error='Screenshot failed'), resp.status_code

    return Response(
        resp.content,
        content_type=resp.headers.get('Content-Type', 'image/webp'),
        headers={'Cache-Control': 'public, max-age=300'},
    )
# app.py
from flask import Flask
from blueprints.screenshot import screenshot_bp

app = Flask(__name__)
app.config['SCREENSHOT_API_KEY'] = os.environ['SCREENSHOT_API_KEY']
app.register_blueprint(screenshot_bp)

Caching with Flask-Caching

Avoid redundant API calls for identical requests using Flask-Caching:

pip install Flask-Caching
from flask import Flask, request, Response, jsonify
from flask_caching import Cache

app = Flask(__name__)
app.config['CACHE_TYPE'] = 'SimpleCache'      # In-memory; use 'RedisCache' for production
app.config['CACHE_DEFAULT_TIMEOUT'] = 300     # 5 minutes

cache = Cache(app)


def _cache_key():
    """Generate a stable cache key from query parameters."""
    url = request.args.get('url', '')
    width = request.args.get('width', '1280')
    height = request.args.get('height', '800')
    fmt = request.args.get('format', 'webp')
    return f'screenshot:{url}:{width}:{height}:{fmt}'


@app.route('/screenshot')
@cache.cached(timeout=300, key_prefix=_cache_key)
def screenshot():
    url = request.args.get('url')
    if not url:
        return jsonify(error='url required'), 400

    params = {'url': url, 'width': request.args.get('width', 1280),
              'height': request.args.get('height', 800), 'format': request.args.get('format', 'webp')}

    resp = requests.get(API_BASE, params=params, headers={'X-API-Key': API_KEY})

    if not resp.ok:
        return jsonify(error='Screenshot failed'), resp.status_code

    return Response(resp.content, content_type=resp.headers.get('Content-Type', 'image/webp'))

For production, switch to Redis cache:

app.config['CACHE_TYPE'] = 'RedisCache'
app.config['CACHE_REDIS_URL'] = 'redis://localhost:6379/0'

Input Validation with Flask-WTF / Marshmallow

Validate request parameters before forwarding upstream:

# Using marshmallow for schema validation
from marshmallow import Schema, fields, validate, ValidationError

class ScreenshotSchema(Schema):
    url = fields.Url(required=True, schemes={'http', 'https'})
    width = fields.Int(load_default=1280, validate=validate.Range(min=1, max=3840))
    height = fields.Int(load_default=800, validate=validate.Range(min=1, max=2160))
    format = fields.Str(load_default='webp', validate=validate.OneOf(['webp', 'png', 'jpeg']))
    full_page = fields.Bool(load_default=False)
    delay = fields.Int(load_default=None, validate=validate.Range(min=0, max=10000), allow_none=True)


screenshot_schema = ScreenshotSchema()


@app.route('/screenshot')
def screenshot():
    try:
        params = screenshot_schema.load(request.args)
    except ValidationError as err:
        return jsonify(errors=err.messages), 400

    api_params = {k: v for k, v in params.items() if v is not None}
    resp = requests.get(API_BASE, params=api_params, headers={'X-API-Key': API_KEY})

    if not resp.ok:
        return jsonify(error='Screenshot failed'), resp.status_code

    return Response(resp.content, content_type='image/webp')

Async View (Flask 2.0+)

Flask 2.0 supports async views natively with asyncio:

pip install flask[async] aiohttp
import aiohttp
from flask import Flask, request, Response, jsonify

app = Flask(__name__)


@app.route('/screenshot')
async def screenshot():
    url = request.args.get('url')
    if not url:
        return jsonify(error='url required'), 400

    params = {
        'url': url,
        'width': request.args.get('width', 1280),
        'height': request.args.get('height', 800),
        'format': 'webp',
    }

    async with aiohttp.ClientSession() as session:
        async with session.get(
            API_BASE,
            params=params,
            headers={'X-API-Key': os.environ['SCREENSHOT_API_KEY']},
        ) as resp:
            if not resp.ok:
                return jsonify(error=f'Upstream error {resp.status}'), resp.status

            data = await resp.read()
            content_type = resp.headers.get('Content-Type', 'image/webp')

    return Response(data, content_type=content_type)

Note: Flask async views use a thread pool under the hood (via Werkzeug's async handling), not a full async event loop. For true async concurrency, use Quart (async Flask-compatible framework) or FastAPI.

Batch Endpoint

Accept multiple URLs and return all results:

from flask import Flask, request, jsonify
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

@app.route('/screenshots/batch', methods=['POST'])
def batch_screenshot():
    data = request.get_json()
    urls = data.get('urls', [])

    if not urls or not isinstance(urls, list):
        return jsonify(error='urls must be a non-empty array'), 400
    if len(urls) > 10:
        return jsonify(error='Maximum 10 URLs per batch'), 400

    width = data.get('width', 1280)
    height = data.get('height', 800)
    fmt = data.get('format', 'webp')

    def capture_one(url):
        params = {'url': url, 'width': width, 'height': height, 'format': fmt}
        try:
            resp = requests.get(API_BASE, params=params, headers={'X-API-Key': API_KEY}, timeout=30)
            if resp.ok:
                import base64
                return {'url': url, 'status': 'ok',
                        'data': base64.b64encode(resp.content).decode(),
                        'contentType': resp.headers.get('Content-Type', 'image/webp')}
            return {'url': url, 'status': 'error', 'error': f'HTTP {resp.status_code}'}
        except Exception as e:
            return {'url': url, 'status': 'error', 'error': str(e)}

    results = []
    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = {executor.submit(capture_one, url): url for url in urls}
        for future in as_completed(futures):
            results.append(future.result())

    return jsonify(results=results)

Error Handler

Register a global error handler for screenshot-related exceptions:

class ScreenshotError(Exception):
    def __init__(self, message, status_code=500):
        super().__init__(message)
        self.status_code = status_code


@app.errorhandler(ScreenshotError)
def handle_screenshot_error(err):
    return jsonify(error=str(err)), err.status_code


@app.route('/screenshot')
def screenshot():
    url = request.args.get('url')
    if not url:
        raise ScreenshotError('url is required', 400)

    resp = requests.get(API_BASE, params={'url': url, 'format': 'webp'},
                        headers={'X-API-Key': API_KEY})

    if not resp.ok:
        raise ScreenshotError(f'Screenshot API returned {resp.status_code}', resp.status_code)

    return Response(resp.content, content_type='image/webp')

Production Config

A production-ready Flask app structure with environment-based config:

# config.py
import os

class Config:
    SCREENSHOT_API_KEY = os.environ.get('SCREENSHOT_API_KEY', '')
    CACHE_TYPE = 'SimpleCache'
    CACHE_DEFAULT_TIMEOUT = 300
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB max upload


class ProductionConfig(Config):
    DEBUG = False
    CACHE_TYPE = 'RedisCache'
    CACHE_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')


class DevelopmentConfig(Config):
    DEBUG = True


config = {
    'production': ProductionConfig,
    'development': DevelopmentConfig,
    'default': DevelopmentConfig,
}
# app.py
from flask import Flask
from flask_caching import Cache
from config import config

def create_app(env='default'):
    app = Flask(__name__)
    app.config.from_object(config[env])

    cache = Cache(app)

    from blueprints.screenshot import screenshot_bp
    app.register_blueprint(screenshot_bp)

    return app

Deploy with Gunicorn:

pip install gunicorn
gunicorn "app:create_app('production')" --workers 4 --bind 0.0.0.0:8000

Get Your API Key

Free API key at hermesforge.dev/screenshot. Full parameter reference at hermesforge.dev/api/docs.