ScreenshotNeo

BlogHow-to

How to Capture a Webpage Screenshot from an API in an Indian Serverless App

Build a URL-to-screenshot API with Playwright and AWS Lambda, choose a safe response design, and account for latency, cost, and India-specific requirements.

By the ScreenshotNeo team4 October 202614 min read

To capture a webpage screenshot from an API in an Indian serverless app, expose an HTTP endpoint that validates a requested URL, launches Chromium with Playwright in serverless compute, navigates to the page, captures the viewport or full page, and returns the image bytes or a reference to a stored image. On AWS, use a Lambda Function URL for a simple endpoint or API Gateway when you need API controls such as custom domains, throttling, authentication integration, or request handling. Select an AWS region based on your latency, residency, and operational requirements; the fact that a client or target site is in India does not by itself mean the browser runs in India.

This guide uses AWS Lambda and Node.js as a concrete implementation. It does not assume AWS is the only suitable platform, and it does not claim that any hosted screenshot API runs its browser in India. Confirm regional availability, data handling, quotas, and current browser packaging compatibility before deploying.

1. Choose the request and response architecture

A screenshot service needs four parts: an HTTP entry point, a browser runtime, navigation and capture logic, and a place to deliver the result. Decide early whether the request should wait for the browser or create a job.

Choice Use it when Trade-off
Lambda Function URL You need a direct HTTP endpoint and can manage the request flow in the function. Simpler surface; Lambda concurrency and function limits still apply.
API Gateway in front of Lambda You need API-level throttling, custom domains, richer request and response handling, or a managed API layer. More configuration and another service whose limits and pricing must be considered.
Synchronous image response Typical page load, browser startup, and output fit your client and gateway timeouts and response-size limits. The caller holds a connection open while Chromium works.
Asynchronous job plus object storage Captures can take a long time, pages vary widely, outputs are large, or callers can poll for completion. Requires job state, expiration and cleanup policies, and a result retrieval mechanism.

AWS describes Function URLs as a direct HTTP option and API Gateway as a more feature-rich API option. Its documentation lists default regional concurrency and request-rate figures, but those are defaults subject to account quotas and change; check the current values for your region before launch. AWS: choosing an HTTP invocation method.

For an India-facing application, the execution region is only one part of the decision. Check whether the chosen region is available for the services you use, where logs and stored images reside, whether browser traffic leaves that region, how your target sites respond to the browser’s network location, and any applicable data-residency requirements. The sources here do not establish a particular provider’s India execution, retention, or partner terms.

2. Define a small, explicit API contract

Keep the first version narrow. Accept a target URL and a capture mode, plus a bounded viewport. Return image bytes with an appropriate content type. Add format, full-page capture, and readiness options only when your clients need them.

POST /screenshot
Content-Type: application/json
Authorization: Bearer YOUR_SERVICE_TOKEN

{
  "url": "https://example.com/",
  "fullPage": true,
  "format": "png",
  "viewport": { "width": 1365, "height": 900 }
}

Respond with the image itself and headers such as Content-Type: image/png and Cache-Control: no-store, or return a JSON job identifier if using asynchronous capture. Do not return a publicly accessible storage URL unless its access and expiration behavior are intentional.

Validate URLs and control outbound access

A user-controlled URL makes the service a potential server-side request forgery (SSRF) proxy. Do not rely on a regular expression alone. Require https: or explicitly allow http: if needed; reject credentials in URLs, localhost, private and link-local IP ranges, cloud metadata destinations, and non-web schemes. Resolve hostnames and block prohibited addresses, and account for DNS rebinding and redirects. Enforce those rules at the network layer too where possible. A browser can make secondary requests through redirects, scripts, images, and iframes, so validating only the first hostname is insufficient. Restrict who can call the endpoint, set per-caller rate limits, and cap the number of concurrent browsers.

The example below includes scheme, credentials, hostname, and literal-IP checks as a starting point. It is not a complete SSRF defense: production deployments should add DNS/IP range checks, redirect and subresource controls, and egress restrictions appropriate to their environment.

3. Implement the screenshot handler

This Node.js Lambda example uses playwright-core with a Chromium binary supplied by @sparticuz/chromium. The binary package is community-maintained, not an AWS compatibility guarantee. Runtime and architecture support can change. Verify the package’s current compatibility, bundle size, and licensing, then validate the deployed artifact on the exact Lambda runtime and architecture you select. For larger browser dependencies, a Lambda container image can make packaging easier to inspect, but you still need a compatible Chromium build and libraries.

Playwright supports viewport and full-page capture as distinct modes, along with screenshot options such as type, quality, and masking. This handler exposes PNG, JPEG, and WebP; full-page mode can produce very tall, large images. Playwright screenshot API.

package.json

{
  "name": "screenshot-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "start": "node index.js" },
  "dependencies": {
    "@sparticuz/chromium": "^latest-compatible-version",
    "playwright-core": "^latest-compatible-version"
  }
}

Replace the illustrative version values with mutually compatible pinned versions before building; do not deploy floating dependencies. Commit the lockfile and rebuild deliberately when upgrading Chromium or Playwright.

index.js

import chromium from '@sparticuz/chromium';
import { chromium as playwright } from 'playwright-core';
import { isIP } from 'node:net';

const MAX_BODY_BYTES = 8 * 1024;
const MAX_VIEWPORT_WIDTH = 1920;
const MAX_VIEWPORT_HEIGHT = 1600;
const ALLOWED_FORMATS = new Set(['png', 'jpeg', 'webp']);

function response(statusCode, body, headers = {}) {
  return {
    statusCode,
    headers: {
      'cache-control': 'no-store',
      'x-content-type-options': 'nosniff',
      ...headers
    },
    body,
    isBase64Encoded: false
  };
}

function parseBody(event) {
  const raw = event.isBase64Encoded
    ? Buffer.from(event.body ?? '', 'base64').toString('utf8')
    : (event.body ?? '');
  if (Buffer.byteLength(raw) > MAX_BODY_BYTES) throw new Error('BODY_TOO_LARGE');
  return JSON.parse(raw);
}

function validateUrl(value) {
  if (typeof value !== 'string' || value.length > 2048) throw new Error('INVALID_URL');
  let url;
  try { url = new URL(value); } catch { throw new Error('INVALID_URL'); }
  if (!['https:', 'http:'].includes(url.protocol)) throw new Error('INVALID_URL');
  if (url.username || url.password) throw new Error('INVALID_URL');
  const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
  if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
    throw new Error('BLOCKED_HOST');
  }
  // Literal IPs need a real public-range check in production.
  if (isIP(host)) throw new Error('BLOCKED_HOST');
  return url.toString();
}

function bearerToken(event) {
  const headers = event.headers ?? {};
  return headers.authorization ?? headers.Authorization ?? '';
}

export const handler = async (event) => {
  if (event.requestContext?.http?.method !== 'POST' && event.httpMethod !== 'POST') {
    return response(405, 'Method not allowed', { allow: 'POST' });
  }
  const expected = process.env.API_BEARER_TOKEN;
  if (!expected || bearerToken(event) !== `Bearer ${expected}`) {
    return response(401, 'Unauthorized', { 'www-authenticate': 'Bearer' });
  }

  let input;
  let target;
  try {
    input = parseBody(event);
    target = validateUrl(input.url);
  } catch (error) {
    const status = error.message === 'BODY_TOO_LARGE' ? 413 : 400;
    return response(status, 'Invalid request');
  }

  const format = input.format ?? 'png';
  const viewport = input.viewport ?? { width: 1365, height: 900 };
  if (!ALLOWED_FORMATS.has(format) ||
      !Number.isInteger(viewport.width) || !Number.isInteger(viewport.height) ||
      viewport.width < 1 || viewport.width > MAX_VIEWPORT_WIDTH ||
      viewport.height < 1 || viewport.height > MAX_VIEWPORT_HEIGHT ||
      typeof (input.fullPage ?? false) !== 'boolean') {
    return response(400, 'Invalid capture options');
  }

  let browser;
  try {
    browser = await playwright.launch({
      args: chromium.args,
      executablePath: await chromium.executablePath(),
      headless: true
    });
    const context = await browser.newContext({
      viewport: { width: viewport.width, height: viewport.height },
      serviceWorkers: 'block'
    });
    const page = await context.newPage();
    page.setDefaultNavigationTimeout(25_000);
    page.setDefaultTimeout(10_000);

    const navigation = await page.goto(target, {
      waitUntil: 'domcontentloaded',
      timeout: 25_000
    });
    if (!navigation || !navigation.ok()) {
      return response(502, 'Target page did not return a successful response');
    }
    // Use a deliberately bounded settling period. Do not wait indefinitely for
    // networkidle: analytics, ads, and live pages may keep requests open.
    await page.waitForTimeout(500);
    const image = await page.screenshot({
      type: format,
      fullPage: input.fullPage ?? false,
      animations: 'disabled',
      timeout: 15_000
    });
    const contentType = format === 'jpeg' ? 'image/jpeg' : `image/${format}`;
    return {
      statusCode: 200,
      headers: {
        'content-type': contentType,
        'cache-control': 'no-store',
        'x-content-type-options': 'nosniff'
      },
      body: image.toString('base64'),
      isBase64Encoded: true
    };
  } catch (error) {
    console.error('capture_failed', { name: error?.name, message: error?.message });
    return response(504, 'Screenshot capture failed or timed out');
  } finally {
    if (browser) await browser.close().catch(() => {});
  }
};

Set API_BEARER_TOKEN using a managed secret or secure deployment configuration, not a value committed to source control. The handler returns a base64-encoded Lambda proxy response for binary image bytes. Confirm binary response handling for the HTTP front door you choose; API Gateway integrations may need binary media configuration depending on the API type and configuration. Keep internal error details in logs, and return generic error text to callers.

Deploy and invoke

  1. Choose a Lambda Node.js runtime and CPU architecture supported by the browser package version you pinned. Confirm current Lambda runtime support and package compatibility before building.
  2. Build and deploy the function with all required browser binaries and shared libraries. Test the deployed package, not only a local laptop install.
  3. Configure memory, ephemeral storage, execution timeout, reserved concurrency, and the API secret. Chromium can use substantial memory and temporary disk space; monitor actual use.
  4. Create a Function URL for a direct endpoint or put API Gateway in front when you need its API controls. Require authorization; an unprotected screenshot endpoint can be abused to fetch arbitrary URLs.
  5. Call the endpoint with the JSON contract, verify the returned content type and bytes, then test error cases and the largest page and viewport you plan to support.
curl --fail-with-body \
  -X POST "$SCREENSHOT_ENDPOINT" \
  -H "Authorization: Bearer $SCREENSHOT_API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://example.com/","fullPage":true,"format":"png","viewport":{"width":1365,"height":900}}' \
  --output page.png

4. Capture readiness, viewport, and page content deliberately

domcontentloaded is a practical starting point, not a guarantee that every image or client-rendered component is ready. Choose a readiness policy that matches the pages you capture:

  • commit: earliest navigation milestone; useful only when the page can be captured before parsing finishes.
  • domcontentloaded: waits for the document to be parsed. The example uses this and a short bounded settling delay.
  • load: waits for the load event, which can be delayed by page resources.
  • networkidle: can be useful for quiet pages, but analytics, streaming, polling, and advertisements can prevent an idle state. Apply a timeout and fallback rather than waiting without a bound.
  • Selector readiness: for a known application, wait for a stable selector such as the main content container, using a bounded timeout.

Full-page capture includes content beyond the viewport. It can create an image with very large dimensions, take longer, trigger lazy-loaded content differently, and exceed response limits. Set a maximum page height or image byte limit in production and use an asynchronous job with object storage for large output. Playwright’s fullPage option is documented in its Page screenshot API.

Useful additions include a selector-based element screenshot, masking private page regions, a controlled device scale factor, and a fixed timezone or locale for visual consistency. Treat these as explicit request options with allowlists and bounds. Do not let callers supply arbitrary browser launch flags or scripts to a shared capture service.

5. Return bytes or store a result

Inline bytes make a synchronous endpoint easy to consume, but base64 encoding increases payload size and the API gateway or client can impose response limits. Use an object store when images are large, need retries, or should be retrieved later. In that design, accept a job, return an identifier, capture asynchronously, write the image with a short-lived or private access policy, and expose a status/result endpoint. Set lifecycle expiration and delete failed or abandoned outputs.

Do not treat a successful HTTP response from the target as proof that the screenshot is useful. A page can return an error page, a bot challenge, an empty shell, or a consent overlay. Record the target status, capture duration, output dimensions, and failure category. Avoid logging full URLs if they can contain sensitive query parameters.

6. Performance, reliability, and cost

Performance

  • Measure cold and warm invocations separately. Browser startup, package initialization, DNS, page navigation, fonts, and page scripts all affect end-to-end latency.
  • Use a bounded viewport and prefer viewport screenshots when full-page output is unnecessary. A tall page costs more time and memory to render and encode.
  • Choose a readiness condition that represents the required content. Waiting for every network request can be slow or never finish.
  • Reuse a browser process only if your runtime lifecycle and concurrency model make it safe. Create a fresh context per request and close pages and contexts; never share cookies or authenticated state across callers unintentionally.
  • Set Lambda memory based on measured browser workload. Memory also affects available CPU in Lambda, so increasing it can change duration as well as cost.

Reliability and scaling

  • Set explicit navigation, selector, screenshot, and function timeouts. Ensure the inner browser deadlines leave time for cleanup and the HTTP response.
  • Bound concurrency to protect both the function and target sites. Expect throttling when configured or account concurrency is reached; return a retryable status where appropriate and use backoff with jitter in clients.
  • For bursty or slow workloads, queue jobs and make workers idempotent. Define retry limits and distinguish transient network errors from invalid URLs and blocked pages.
  • Track success rate by failure type, p50/p95 duration, cold starts, output bytes, memory use, and throttles. Do not log secrets, cookies, authorization headers, or full sensitive URLs.
  • Pin and periodically update the browser package and Playwright together. Validate runtime, architecture, and binary packaging after each upgrade.

Cost

Lambda charges depend on request count and execution duration, with duration affected by configured memory; storage and data transfer can add charges. Browser execution time can dwarf a small handler, so estimate from representative target pages and the intended region rather than a generic screenshot benchmark. Include API Gateway, object storage, logs, NAT or networking choices, and image egress where applicable. AWS’s Lambda pricing page describes current pricing dimensions; recalculate using your architecture and region before launch.

For a rough internal estimate, measure average billed duration and memory after representative captures, multiply GB-seconds by your current regional rate, then add request, gateway, storage, log, and transfer costs. Include retries and failed captures in volume assumptions. No workload estimate is meaningful until you define capture mix, concurrency, output retention, and traffic pattern.

7. India-specific deployment checklist

  • Choose a deployment region using current AWS service availability, latency measurements from your users, and your data residency requirements.
  • Verify where function logs, queued jobs, and stored screenshots are retained and who can access them.
  • Check whether target websites show different content based on browser egress location, locale, cookies, or IP reputation.
  • Test from the actual deployed environment: local success does not prove that DNS, TLS, outbound access, or target-site bot checks behave the same in the selected region.
  • Recheck current Lambda concurrency, API Gateway throttling, response size, timeout, and regional quotas. Document any quota increase needed before production.
  • Apply URL allowlists when the service is for a known set of domains. For a general URL service, use network egress controls and robust DNS/IP filtering.

8. Troubleshooting

Symptom Likely cause Fix
Browser executable missing or launch fails Chromium binary was omitted, incompatible with the runtime or architecture, or missing required libraries. Inspect the deployed artifact and package documentation; pin compatible versions, architecture, and runtime. Test the built deployment package.
Function times out on some sites Navigation or network-idle wait is unbounded in practice, or the target is slow. Use explicit timeouts, a less strict readiness condition, and an async job path for slow captures.
Screenshot is blank or incomplete Capture ran before client rendering, page content is lazy-loaded, or a challenge/interstitial replaced the page. Wait for a meaningful selector or bounded delay, inspect status and page title, and classify challenge pages instead of treating them as normal output.
HTTP 502 or 504 from the endpoint Target response failed, browser navigation timed out, or the function exceeded the front door’s deadline. Compare target navigation and function logs, align gateway/client/function timeouts, and return a clear retryable error only for transient failures.
Image response appears corrupted Binary response was treated as text or base64 handling is inconsistent between Lambda and gateway. Return base64 with the Lambda proxy binary flag and configure the chosen API integration for binary content; verify the content type and decoded bytes.
Full-page image is too large Long document dimensions and image encoding exceed response or memory limits. Cap page height and output bytes, use JPEG/WebP where acceptable, or store output and return an authorized reference.
HTTP 429 or elevated latency under load Reserved or account concurrency, API throttles, or target-site rate limiting. Bound caller rates, queue work, tune concurrency, retry with backoff, and check current service quotas.
Unexpected internal URLs can be captured URL validation checks syntax but not DNS resolution, redirects, private address ranges, or subresource requests. Reject private and special-use destinations after resolution, defend against DNS rebinding, constrain redirects and egress, and prefer domain allowlists.

9. Alternatives when you do not want to maintain Chromium

Self-hosting gives you control over capture behavior and deployment choices, but you own browser packaging, updates, scaling, output delivery, and security hardening. A managed screenshot API can remove that browser operations work. Before choosing one for an India-facing workload, verify its actual capture region, retention, data handling, limits, and pricing; the research available for this article does not establish those details for other providers.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns an image or PDF. Its capture flow accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. AI agents can use its MCP server tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Check the ScreenshotNeo API documentation for 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,
)
r.raise_for_status()
with open("shot.webp", "wb") as image:
    image.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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

These examples use a URL parameter; use the documentation for additional capture options and for handling errors in your application. The call above does not establish where the browser executes or where data is retained; check those details against your requirements. Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

10. FAQ

Does an Indian user require the screenshot function to run in India?

Not necessarily. It depends on latency, target-site behavior, and data residency obligations. Verify the actual execution and storage locations with the platform and deployment configuration.

Should I use a Function URL or API Gateway?

Use a Function URL for a direct, simple endpoint. Prefer API Gateway when you need its API management and request handling features. Recheck current quotas and configure authorization either way.

Can the endpoint return a full-page PNG?

Yes. Playwright supports full-page screenshots. Enforce height, time, and output-size bounds because long pages can create large files and slow requests.

Can I expose this endpoint publicly?

Only with authentication or another abuse-control design, strict URL validation, outbound network protections, rate limits, and capture limits. Otherwise callers can use your browser capacity to probe internal services or generate unexpected costs.

Primary references