ScreenshotNeo

BlogHow-to

Use a Website Screenshot API with Symfony and Flask

Call a website screenshot API safely from Symfony or Flask. Keep provider keys server-side, validate URLs, handle image responses, and troubleshoot common failures.

By the ScreenshotNeo team4 October 202611 min read

A Symfony or Flask application can request a website screenshot by calling a hosted screenshot API from its server, then returning the resulting image to an authorized caller. The remote service runs the browser; your application validates the request, protects the provider credential, applies limits, handles errors, and controls how the image is delivered.

The examples below use ScreenshotAPI for Symfony because its PHP SDK documentation includes a Symfony controller example. Flask uses a provider-neutral Python HTTP pattern: use the chosen provider’s documented endpoint, authentication, request fields, and response format. The research sources do not verify a ScreenshotAPI-specific Flask example, so do not copy another provider’s contract and assume it applies.

1. Request flow and provider contracts

A screenshot route is a small server-side integration boundary:

  1. Authenticate and authorize the person or service calling your application.
  2. Validate the requested URL and restrict capture options.
  3. Call the screenshot provider from the application server, using a server-side secret.
  4. Handle provider status, timeouts, and response data.
  5. Return image bytes with the correct media type, or return a controlled URL to a stored result.

Provider APIs are not interchangeable. Check the selected provider’s current documentation for its endpoint, authentication method, parameter names, supported formats, response body, quotas, rate limits, retention, and retry guidance. ScreenshotAPI’s [PHP SDK documentation](https://screenshotapi.net/documentation/php-sdk) describes a Symfony controller and image response handling; its [API reference](https://screenshotapi.net/documentation) describes capture options. Verify current package names and method signatures before deploying because SDK details can change.

2. Return a screenshot from a Symfony controller

Install and configure the SDK

Install the PHP SDK using the Composer command in its current official documentation. Put the API key in deployment secret storage and expose it to Symfony as an environment variable such as SCREENSHOTAPI_KEY. Do not commit the secret or put it in frontend JavaScript.

composer require screenshotapi/php-sdk

Package names, supported PHP versions, class names, constructor arguments, and SDK methods are version-sensitive. The following controller follows the documented SDK integration shape; compare its imports and call signature with the installed version’s [official SDK page](https://screenshotapi.net/documentation/php-sdk) before use.

<?php

namespace App\Controller;

use ScreenshotAPI\ScreenshotAPI;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ScreenshotController
{
    #[Route('/api/screenshot', name: 'api_screenshot', methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        $input = json_decode($request->getContent(), true);
        if (!is_array($input)) {
            return new Response('Invalid JSON', 400);
        }

        $url = $input['url'] ?? null;
        if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
            return new Response('A valid URL is required', 400);
        }
        $parts = parse_url($url);
        if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)) {
            return new Response('Only HTTP and HTTPS URLs are allowed', 400);
        }

        $key = $_ENV['SCREENSHOTAPI_KEY'] ?? '';
        if ($key === '') {
            return new Response('Screenshot service is not configured', 503);
        }

        try {
            // Confirm the exact constructor and method signature for your installed SDK version.
            $client = new ScreenshotAPI($key);
            $result = $client->screenshot($url, [
                'output' => 'image',
                'full_page' => true,
                'width' => 1440,
                'height' => 900,
            ]);

            // Adapt these accessors to the SDK's documented result type.
            $bytes = $result->getBody();
            $contentType = $result->getHeaderLine('Content-Type') ?: 'image/png';
            if (!str_starts_with(strtolower($contentType), 'image/')) {
                return new Response('Screenshot provider returned an unexpected content type', 502);
            }

            return new Response($bytes, 200, [
                'Content-Type' => $contentType,
                'Cache-Control' => 'private, no-store',
                'X-Content-Type-Options' => 'nosniff',
            ]);
        } catch (\Throwable $error) {
            // Log a redacted error identifier and provider status; never log the key or full sensitive URL.
            return new Response('Screenshot request failed', 502);
        }
    }
}

The SDK’s documented example should be treated as authoritative for the installed version’s actual result object and method names. The illustrative accessors above make the response-handling boundary explicit, but may need adapting to that result type.

In a real application, use Symfony dependency injection for the SDK client and configuration rather than reading $_ENV in the action. Add your normal authentication/authorization mechanism, request validation, rate limiting, and error logging. Do not return provider exception text or stack traces to the caller.

Options and response choices

ScreenshotAPI documentation lists output type, viewport dimensions, quality, color scheme, and full-page capture. Use only names and values accepted by the installed SDK/API version. Do not expose every provider option directly to untrusted callers.

Decision Implementation guidance
Image format Allow only documented formats; return the provider’s trusted content type or derive it from a validated format.
Viewport Set bounded width and height values. Reject unreasonable dimensions before the provider call.
Full page Enable only when needed; long pages can take more time and produce larger results.
Delivery Return bytes, a short-lived provider URL, or an application-stored file according to the provider contract and privacy needs.
Caching Use cache headers only when freshness and privacy permit. Private captures should not be placed in a shared cache.

3. Take a website screenshot from Flask

Flask can call a hosted screenshot service using Python’s HTTP tooling. The request fields and authentication below are deliberately configuration points: fill them from the selected provider’s own API reference. The research Python example belongs to a different service, so its host, route, bearer header, and field names must not be presented as ScreenshotAPI’s contract.

Install Requests with python -m pip install requests. Set provider details and the secret in the server environment. For example, configure SCREENSHOT_API_URL as the provider’s documented endpoint and SCREENSHOT_API_KEY as its key. The sample assumes the provider documents a JSON POST and returns raw image bytes; change the request and result parsing if its contract returns JSON or a hosted URL.

import os
from urllib.parse import urlsplit

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

app = Flask(__name__)

API_URL = os.environ['SCREENSHOT_API_URL']
API_KEY = os.environ['SCREENSHOT_API_KEY']

ALLOWED_HOSTS = {'example.com', 'www.example.com'}


def valid_target(value):
    if not isinstance(value, str) or len(value) > 2048:
        return False
    try:
        parts = urlsplit(value)
        return (
            parts.scheme in {'http', 'https'}
            and bool(parts.hostname)
            and parts.hostname.lower() in ALLOWED_HOSTS
            and not parts.username
            and not parts.password
        )
    except ValueError:
        return False


@app.post('/api/screenshot')
def screenshot():
    payload = request.get_json(silent=True) or {}
    target_url = payload.get('url')
    if not valid_target(target_url):
        return jsonify(error='URL is invalid or not allowed'), 400

    # Replace this example authentication and JSON schema with the provider's documented contract.
    headers = {'Authorization': f'Bearer {API_KEY}'}
    provider_payload = {'url': target_url, 'format': 'png'}

    try:
        upstream = requests.post(
            API_URL,
            headers=headers,
            json=provider_payload,
            timeout=(5, 60),
            stream=True,
        )
    except requests.Timeout:
        return jsonify(error='Screenshot provider timed out'), 504
    except requests.RequestException:
        return jsonify(error='Could not reach screenshot provider'), 502

    with upstream:
        if upstream.status_code == 401 or upstream.status_code == 403:
            return jsonify(error='Screenshot provider credentials were rejected'), 502
        if upstream.status_code == 429:
            return jsonify(error='Screenshot provider rate limit or quota reached'), 503
        if upstream.status_code >= 400:
            return jsonify(error='Screenshot provider could not capture this URL'), 502

        content_type = upstream.headers.get('Content-Type', '').split(';', 1)[0].lower()
        allowed_types = {'image/png', 'image/jpeg', 'image/webp', 'application/pdf'}
        if content_type not in allowed_types:
            return jsonify(error='Screenshot provider returned an unsupported response'), 502

        # Bound memory use even when the upstream response lacks Content-Length.
        max_bytes = 20 * 1024 * 1024
        chunks = []
        total = 0
        for chunk in upstream.iter_content(chunk_size=64 * 1024):
            if not chunk:
                continue
            total += len(chunk)
            if total > max_bytes:
                return jsonify(error='Screenshot result is too large'), 502
            chunks.append(chunk)

    return Response(
        b''.join(chunks),
        status=200,
        content_type=content_type,
        headers={
            'Cache-Control': 'private, no-store',
            'X-Content-Type-Options': 'nosniff',
        },
    )


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

This is a framework-neutral pattern, not a verified integration for a specific vendor. Replace the placeholder host allowlist, provider endpoint, authentication, payload, and response assumptions with values appropriate to your product. In production, protect this route with your application’s authentication and authorization, rate limits, request size limits, and structured redacted logging.

4. Keep the screenshot API key out of frontend code

The browser should call your application’s endpoint; your server should call the screenshot provider. Store the provider key in environment configuration or deployment secret storage, and never place it in JavaScript bundles, HTML, public repositories, or logs. This is general server-side credential guidance; see [ScreenshotEngine’s credential guidance](https://screenshotengine.com/docs/security) for an example of this cross-provider practice.

Do not create an unrestricted URL proxy. A caller-controlled URL can turn your application into a path to internal services if your system or remote renderer can reach them. Use an explicit hostname allowlist where possible; otherwise resolve and reject loopback, private, link-local, multicast, and cloud metadata destinations, including after redirects and DNS resolution. Restrict schemes to HTTP and HTTPS, block embedded credentials, set maximum URL length, cap dimensions and output bytes, rate-limit callers, and apply both connect and read timeouts. Review the provider’s own restrictions on target-site access because the remote browser has separate network reachability.

5. Reliability, performance, and cost

  • Bound time: Set connect and response timeouts in your application and keep the overall request budget compatible with your web server and upstream proxy limits. A slow target can occupy a worker.
  • Bound output: Limit viewport, page length where supported, and bytes read. Consider storing large results and returning an access-controlled application URL.
  • Handle retries carefully: Retry only transient failures when the provider documents retry behavior. A retry can repeat billable work; honor rate-limit headers and avoid retrying invalid URLs, authorization failures, or quota exhaustion.
  • Use asynchronous jobs for slow work: If captures exceed normal web request budgets, return a job identifier and process in a queue, provided the provider supports asynchronous capture. Define expiry and access controls for stored results.
  • Cache with intent: Cache only when the target, capture options, user authorization, and freshness window are part of the cache key. Do not leak private or authenticated-page captures across users.
  • Track provider usage: Record request outcome, latency, response size, and provider status without logging secrets or unnecessary sensitive URLs. Consult the provider’s quota and pricing documentation; no universal price or quota applies.

A hosted renderer removes browser installation and browser-process management from your application, but each request still depends on your app’s availability, network path, the provider, and the target website. Check the chosen service’s failure semantics, quotas, retention, and network policy before relying on it for a critical workflow.

6. Troubleshooting common failures

Symptom Likely cause Fix
Provider returns 401 or 403 Missing, invalid, or incorrectly transmitted credential; wrong authentication scheme. Check server secret configuration and follow the provider’s exact authentication documentation. Never send the key from the browser.
Provider returns 400 Invalid URL, unsupported format, misspelled option, or request schema mismatch. Validate inputs and compare the request against the provider’s current API reference and SDK version.
Provider returns 429 Rate limit or quota reached. Respect documented retry headers, reduce request volume, queue work, or adjust the plan. Do not retry immediately in a tight loop.
Application returns 502 Provider error, malformed response, unexpected media type, or upstream connectivity issue. Log a redacted correlation ID and provider status; inspect provider diagnostics and verify the response contract.
Application returns 504 Target page or renderer exceeded the configured deadline. Increase the timeout only within server limits, simplify capture options, or move the work to an asynchronous job.
Image is empty or truncated Stream was not fully consumed, a proxy cut off the response, or an error payload was treated as image bytes. Check upstream status before reading, consume the body fully, bound size safely, and verify content type and response length.
Wrong format in browser Application hard-coded a PNG content type while provider returned JPEG, WebP, or PDF. Use a trusted upstream media type or map a validated requested format to the correct type.
Internal URL is rejected or exposed Destination validation is incomplete, redirects are unchecked, or provider networking differs from the app’s. Enforce an allowlist or robust address checks, account for redirects and DNS, and review provider-side network controls.
SDK class or method not found Example and installed PHP package versions differ. Check Composer’s installed version and the matching official SDK documentation; update imports and calls accordingly.

7. Or skip the browser setup

ScreenshotNeo is a hosted website screenshot API and MCP server. Your Symfony or Flask server can make one GET request to its endpoint; the browser renderer runs remotely. See the ScreenshotNeo API documentation for authentication and request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Keep the access key in your Symfony or Flask server configuration and make this request from the server. ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots. Every feature is on every plan.

Sign up free for 1,000 screenshots a month, with no card required.

8. FAQ

How do I take a website screenshot from Flask?

Accept and validate a request in a Flask route, call the chosen provider with Python HTTP tooling from the server, then return the provider’s image bytes or a controlled result URL. Use that provider’s actual API contract.

How do I return a screenshot from a Symfony controller?

Call the provider SDK or HTTP API in the controller/service, validate the upstream result, and return a Symfony response with the correct content type. The ScreenshotAPI PHP SDK documentation includes a Symfony example.

Can the frontend call the screenshot provider directly?

Keep the provider secret out of frontend code. Have the browser call your authenticated application route, which makes the provider request server-side.

Can I accept any URL from an application user?

Only if you implement strong destination controls and understand both your server’s and provider renderer’s network access. An allowlist is safer for narrowly scoped applications.

Should the route return image bytes or a URL?

Return bytes for a simple short-lived response. Use an application-controlled, access-checked URL when results are large, reused, or processed asynchronously; apply retention and privacy rules either way.