How to Secure a Screenshot API Callback Handler
Secure screenshot API callbacks by verifying signatures, blocking replays, validating events, limiting abuse, and preventing SSRF.
Short answer: Treat every screenshot API callback as an untrusted request. Read the raw body, verify the provider’s documented signature and freshness rules, deduplicate delivery identifiers before causing side effects, validate the event schema, restrict methods and request sizes, rate-limit the endpoint, and isolate any outbound URL fetching so a signed callback cannot become an SSRF primitive.
A valid signature proves that the provider created the signed message. It does not make every value in that message safe, prevent replay, or authorize your server to fetch an arbitrary URL.
1. Define the provider’s signing contract before writing code
Do not guess header names, algorithms, payload fields, timestamp tolerances, retry intervals, or key URLs. Obtain the current callback documentation for the screenshot API and record:
- Signature scheme: shared-secret HMAC or asymmetric HTTP message signatures.
- Exact signed bytes: raw body only, or a construction containing a timestamp, delivery ID, method, path, or other components.
- Signature and timestamp headers, event or delivery identifier, and permitted clock skew.
- Secret provisioning, public-key retrieval, rotation, revocation, and key identifiers.
- Allowed HTTP methods, content type, maximum body size, timeout, and retry behavior.
- Whether the provider signs each delivery attempt or signs an event identifier that remains stable across retries.
Standard Webhooks describes HMAC with a pre-shared secret as a common model and asymmetric signatures as an alternative. RFC 9421 defines a standardized model for HTTP message signatures. Follow the provider’s contract when it differs.
2. Verify the raw request before parsing JSON
Read the body bytes exactly as received. Framework JSON parsing can change whitespace, character encoding, or key ordering and make a correct signature fail. Verify the signature with a constant-time comparison, then parse the body.
For a shared-secret HMAC contract, the shape is:
expected = HMAC(secret, documented_signed_bytes, documented_hash)
constant_time_compare(expected, signature_from_header)
Never log secrets or full signed payloads by default. Use TLS, because signatures provide authenticity and integrity but not confidentiality.
Python example (Flask, generic HMAC contract)
Replace the placeholder header names and signed-byte construction with the provider’s documented values. This example assumes the provider signs timestamp + "." + raw_body with HMAC-SHA256; that assumption is only for demonstrating code structure.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024
SECRET = os.environ["CALLBACK_SECRET"].encode()
FRESHNESS_SECONDS = 300 # Set from the provider's retry and clock-skew guidance.
def verify_callback(raw_body: bytes, timestamp: str, supplied: str) -> bool:
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > FRESHNESS_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, supplied)
@app.post("/callbacks/screenshot")
def callback():
raw_body = request.get_data(cache=True)
timestamp = request.headers.get("X-Provider-Timestamp")
signature = request.headers.get("X-Provider-Signature")
delivery_id = request.headers.get("X-Provider-Delivery-Id")
if not timestamp or not signature or not delivery_id:
return jsonify(error="unauthorized"), 401
if not verify_callback(raw_body, timestamp, signature):
return jsonify(error="unauthorized"), 401
try:
event = request.get_json()
except Exception:
return jsonify(error="invalid request"), 400
if not isinstance(event, dict) or event.get("type") not in {"screenshot.completed", "screenshot.failed"}:
return jsonify(error="invalid request"), 400
# Atomically insert delivery_id with a unique constraint.
# If it already exists, acknowledge the retry without repeating work.
if already_processed(delivery_id):
return "", 204
mark_processed(delivery_id)
enqueue_idempotent_job(event, delivery_id)
return "", 204
def already_processed(delivery_id):
# Implement with a durable database unique index, not process memory.
return False
def mark_processed(delivery_id):
pass
def enqueue_idempotent_job(event, delivery_id):
pass
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080)
Node.js example (Express, generic HMAC contract)
import express from "express";
import crypto from "node:crypto";
const app = express();
const secret = Buffer.from(process.env.CALLBACK_SECRET, "utf8");
const freshnessSeconds = 300;
app.post("/callbacks/screenshot", express.raw({ type: "*/*", limit: "256kb" }), (req, res) => {
const rawBody = req.body;
const timestamp = req.get("X-Provider-Timestamp");
const supplied = req.get("X-Provider-Signature");
const deliveryId = req.get("X-Provider-Delivery-Id");
const ts = Number(timestamp);
if (!timestamp || !supplied || !deliveryId || !Number.isInteger(ts) ||
Math.abs(Date.now() / 1000 - ts) > freshnessSeconds) {
return res.status(401).json({ error: "unauthorized" });
}
// Adapt this construction and digest to the provider's documentation.
const signed = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(supplied, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "unauthorized" });
}
let event;
try { event = JSON.parse(rawBody.toString("utf8")); }
catch { return res.status(400).json({ error: "invalid request" }); }
if (!event || typeof event !== "object" ||
!["screenshot.completed", "screenshot.failed"].includes(event.type)) {
return res.status(400).json({ error: "invalid request" });
}
// Use a database unique constraint for this atomic operation.
if (alreadyProcessed(deliveryId)) return res.sendStatus(204);
markProcessed(deliveryId);
enqueueIdempotentJob(event, deliveryId);
return res.sendStatus(204);
});
function alreadyProcessed(id) { return false; }
function markProcessed(id) {}
function enqueueIdempotentJob(event, id) {}
app.listen(8080, "127.0.0.1");
3. Prevent replay and duplicate side effects
Signature verification alone does not stop an attacker from sending a captured, valid message again. Check the signed timestamp or expiration and reject messages outside the provider’s documented freshness window. Persist a stable event or delivery identifier in durable storage.
- Verify timestamp and signature.
- Attempt an atomic insert of the event or delivery ID into a table with a unique constraint.
- If the insert conflicts, return the provider’s successful acknowledgement without repeating work.
- Make downstream jobs idempotent too. A queue can deliver the same job more than once after a worker crash.
Standard Webhooks distinguishes a delivery attempt timestamp from the original event time and recommends a stable event identifier for idempotency across retries. RFC 9421 discusses nonce and timestamp or expiry approaches. Choose the replay window from the provider’s retry schedule and your clock-skew policy; do not copy a universal constant.
4. Validate the event after authentication
Authentication comes before parsing and business actions, but it is not schema validation. Validate:
- Expected event type and version.
- Required identifiers and their formats.
- Allowed status values and bounded numeric fields.
- URL fields, if any, with a strict parser and policy.
- Maximum nesting, string lengths, and array sizes.
- References to a job that belongs to your account or tenant.
Ignore unknown fields unless the provider’s versioning contract requires rejection. Return generic errors so attackers cannot use the endpoint as a schema oracle.
5. Restrict the HTTP boundary
| Control | Implementation |
|---|---|
| Methods | Allow only the provider’s required method, usually POST; return 405 for others as recommended by OWASP REST guidance. |
| Content type | Require the documented media type and reject unexpected bodies. |
| Body size | Set a limit based on the provider’s actual maximum payload, with room for documented expansion. |
| Rate limit | Use a per-route and global limit sized for provider retries; do not rely on the source IP as authentication. |
| Timeout | Verify, deduplicate, enqueue, and acknowledge quickly. Put slow image processing behind a worker. |
| Errors | Use generic 4xx responses and avoid stack traces, signature details, or internal URLs. |
| Observability | Record delivery ID, verification result, event type, latency, and outcome without secrets or sensitive payloads. |
Confirm payload limits, retry behavior, and acknowledgement requirements with the named provider. OWASP’s webhook guidance is a draft and advisory.
6. Treat callback URLs and event URLs as SSRF input
There are two separate trust decisions: whether the callback came from the provider, and whether a URL inside the event is safe for your server to fetch. A correctly signed event does not make an arbitrary URL trustworthy.
SSRF can occur when webhook registration tests a user-supplied callback URL, when a worker downloads a screenshot result, or when event data contains a page URL that your service fetches. OWASP specifically calls out custom webhooks and callback URLs.
Preferred policy: allowlist destinations
For known integrations, allowlist exact origins or tenant-owned domains. Store a destination ID at registration time and ignore replacement URLs in callback bodies.
If public destinations are required
- Parse with a maintained URL library; do not use string prefix checks.
- Allow only HTTPS (or the explicitly required scheme) and approved ports.
- Resolve every A and AAAA answer and block loopback, private, link-local, multicast, carrier-grade NAT, and cloud metadata ranges.
- Disable redirects, or validate every redirect target again.
- Run the fetcher in an isolated network identity with egress controls and short timeouts.
- Consider DNS rebinding and pin or revalidate the address at connection time.
- Never return raw internal responses to the caller.
OWASP API7:2023 describes a webhook setup flow where a test request to a user-provided URL can target cloud metadata. Secure registration and processing paths independently.
7. A robust processing architecture
- Edge: terminate TLS, allowlist the method, enforce body and timeout limits, and apply rate controls.
- Verifier: read raw bytes, verify the documented signature and freshness, and record the key ID used.
- Deduplicator: atomically reserve the event or delivery ID.
- Validator: parse and validate the schema and tenant ownership.
- Queue: enqueue a small internal job and acknowledge according to the provider contract.
- Worker: perform idempotent state changes and any isolated outbound fetches.
- Audit: retain verification failures, duplicate counts, processing latency, and dead-letter jobs.
Keep secrets in a secret manager, rotate them according to the provider’s process, and support a short overlap period when two keys are valid during rotation.
8. Test the controls
Use a provider-supported test delivery or a local fixture. Do not infer production signing behavior from a hand-built request.
curl -i -X POST http://localhost:8080/callbacks/screenshot \
-H 'Content-Type: application/json' \
-H 'X-Provider-Timestamp: REPLACE_WITH_SIGNED_TIMESTAMP' \
-H 'X-Provider-Delivery-Id: test-delivery-1' \
-H 'X-Provider-Signature: REPLACE_WITH_PROVIDER_SIGNATURE' \
--data-binary @event.json
Exercise valid deliveries, altered one-byte bodies, missing headers, stale timestamps, duplicate IDs, unknown event types, oversized bodies, unsupported methods, malformed JSON, redirecting URLs, private IPs, DNS changes, and worker retries.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every signature fails | Framework parsed or reserialized JSON; wrong encoding or signed-byte construction. | Capture raw bytes before parsing and follow the provider’s exact canonicalization rules. |
| Only some deliveries fail | Clock skew, key rotation, or multiple signature formats. | Synchronize time, honor the documented tolerance, identify keys, and support the provider’s rotation overlap. |
| Repeated business actions | Deduplication is in process memory or occurs after side effects. | Use a durable unique constraint and make queue jobs idempotent. |
| Provider retries continuously | Wrong status code, response timeout, or acknowledgement after slow work. | Check the delivery contract, acknowledge only at the required point, and move expensive work to a queue. |
| Valid event rejected as invalid | Schema validation is too strict or assumes one event version. | Validate documented required fields and versions; tolerate unknown additive fields. |
| Internal service reached through a URL | SSRF check covers only host strings or follows redirects. | Parse URLs, resolve all addresses, block internal ranges, isolate the fetcher, and disable redirects. |
| Logs expose secrets | Request middleware logs headers or bodies. | Redact signatures, authorization values, cookies, and sensitive event fields. |
10. Performance, reliability, and cost
- Performance: HMAC verification and schema checks are cheap; database uniqueness and queue operations add predictable latency. Keep callback handling bounded and avoid browser work in the request thread.
- Reliability: Durable deduplication, idempotent workers, dead-letter handling, and provider-aware acknowledgements prevent transient failures from becoming duplicate captures or state changes.
- Capacity: Size rate limits and workers for bursts caused by provider retries, then monitor queue depth and callback latency.
- Cost: Duplicate deliveries should not trigger duplicate paid work. Reserve the delivery ID before starting billable or expensive processing and cache results where the provider permits it.
11. Or skip the browser setup
If your callback exists only because you need screenshots, ScreenshotNeo provides the capture layer and supports asynchronous jobs with signed webhooks. Check the current ScreenshotNeo documentation for its signing headers, key handling, freshness rules, retry behavior, and callback configuration before implementing verification.
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}`);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and responses identify the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with 1,000 screenshots per month without a card.
FAQ
Should I trust a callback because it came from a known IP?
No. IP allowlists can supplement authentication but do not replace signature verification, freshness checks, and deduplication.
Can I verify after parsing JSON?
Usually no. Verify the exact raw representation required by the provider first, then parse it.
What replay window should I use?
Use the provider’s documented retry and clock-skew requirements, then choose the smallest operationally safe window. Persist delivery IDs as a second control.
Does a signed event make its URL safe to fetch?
No. Apply an independent destination policy and SSRF defenses to every URL your server may request.
When should callback work be queued?
Queue work when processing can exceed the provider’s acknowledgement timeout, involve browser or network operations, or need independent retries. Keep the acknowledgement behavior aligned with the provider’s contract.


