Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

The reliable pattern is: expose a public HTTPS POST endpoint, capture the request body as raw bytes, verify the provider’s HMAC signature before parsing JSON, record the job identifier under a unique constraint, enqueue the real work, and return a 2xx response quickly. Download the image or PDF in a worker, not inside the webhook request. This ordering protects against forged callbacks, duplicate deliveries, timeouts and expiring result URLs.

Understand the asynchronous callback

A synchronous screenshot request returns the image or PDF in the original HTTP response. An asynchronous request returns quickly—commonly with HTTP 202—and supplies a job identifier. The provider later sends a JSON POST to your webhook_url.

Your callback URL must be publicly reachable over HTTPS and must return a 2xx status. ScreenshotMAX documents a 202 response from the initial request, background processing and later delivery. Screenshot API documents a render_id and callback payload, but its current deployment warns that asynchronous callbacks return 503; check its service status before choosing it for production.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Design the flow as four separate stages:

  1. Authenticate: read the exact bytes received and verify the provider-specific signature.
  2. Record: insert the provider job identifier with a uniqueness constraint.
  3. Queue: publish a durable download or processing job.
  4. Acknowledge: return 2xx immediately; perform network and business work in a worker.

Build the Spring Boot endpoint

Read raw bytes, not a reconstructed JSON object

HMAC is calculated over bytes. Whitespace, property order, escaping and line endings can change when a JSON document is parsed and serialized, so verification must happen before deserialization. In Spring MVC, binding the request body to byte[] preserves the bytes supplied to the controller.

package com.example.webhooks;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignatures {
    private WebhookSignatures() {}

    public static boolean validSignature(byte[] rawBody, String received, byte[] secret)
            throws GeneralSecurityException {
        if (received == null || received.isBlank()) return false;
        String value = received.trim();
        if (value.regionMatches(true, 0, "sha256=", 0, 7)) {
            value = value.substring(7);
        }
        final byte[] supplied;
        try {
            supplied = HexFormat.of().parseHex(value);
        } catch (IllegalArgumentException ex) {
            return false;
        }

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] expected = mac.doFinal(rawBody);
        return MessageDigest.isEqual(expected, supplied);
    }
}

The example accepts a hexadecimal digest with or without a sha256= prefix. Use the exact header spelling, encoding and canonicalization specified by your provider; do not assume every service uses this format. ScreenshotMAX, ScreenshotOne and SnapshotFlow document raw-body HMAC verification. ScreenshotOne explicitly says its webhook secret is different from its API key.

Controller: authenticate, persist, enqueue

package com.example.webhooks;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;

@RestController
@RequestMapping("/webhooks")
public class ScreenshotWebhookController {
    private final ObjectMapper mapper;
    private final ReceiptRepository receipts;
    private final ScreenshotWorkQueue workQueue;
    private final byte[] signingSecret;

    public ScreenshotWebhookController(ObjectMapper mapper,
                                       ReceiptRepository receipts,
                                       ScreenshotWorkQueue workQueue,
                                       WebhookProperties properties) {
        this.mapper = mapper;
        this.receipts = receipts;
        this.workQueue = workQueue;
        this.signingSecret = properties.secret().getBytes(StandardCharsets.UTF_8);
    }

    @PostMapping(value = "/screenshots", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "X-Webhook-Signature", required = false) String signature,
            @RequestBody byte[] rawBody) {
        try {
            if (!WebhookSignatures.validSignature(rawBody, signature, signingSecret)) {
                return ResponseEntity.status(401).build();
            }
        } catch (GeneralSecurityException ex) {
            return ResponseEntity.status(401).build();
        }

        final JsonNode json;
        try {
            json = mapper.readTree(rawBody);
        } catch (Exception ex) {
            return ResponseEntity.badRequest().build();
        }

        String jobId = firstText(json, "render_id", "id", "jobId");
        if (jobId == null || jobId.isBlank()) {
            return ResponseEntity.badRequest().build();
        }

        // The INSERT must be atomic and protected by a UNIQUE(provider, job_id).
        boolean firstDelivery = receipts.insertIfAbsent("selected-provider", jobId, rawBody);
        if (firstDelivery) {
            workQueue.enqueue(new ScreenshotWork("selected-provider", jobId, json));
        }

        // A duplicate is intentionally acknowledged without repeating side effects.
        return ResponseEntity.accepted().build();
    }

    private static String firstText(JsonNode node, String... names) {
        for (String name : names) {
            JsonNode value = node.get(name);
            if (value != null && value.isTextual() && !value.asText().isBlank()) {
                return value.asText();
            }
        }
        return null;
    }
}

Replace X-Webhook-Signature and the accepted identifier names with the selected provider’s contract. Keep the DTO tolerant: model stable identifiers, status or success, output URL, content type or format, timestamps, expiry and error fields, while ignoring unknown additive fields.

Timestamped signatures and replay protection

Some providers sign a timestamp together with the payload. Screenshotbot signs {timestamp}.{payload} and recommends rejecting timestamps outside a short replay window. SnapshotFlow also documents a timestamp freshness window. In that case, parse the header only enough to obtain the timestamp, reject an old or far-future value, compute HMAC over the provider’s exact canonical string, and compare digests with MessageDigest.isEqual. Never verify a parsed or reformatted JSON string.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Acknowledge before downloading

Do not fetch the screenshot while the provider is waiting for the webhook response. Image downloads, PDF transfers, storage writes and downstream events can exceed the provider’s timeout and cause a redelivery even though the first attempt succeeded.

  1. Verify the signature and basic JSON shape.
  2. Insert a receipt row and commit it.
  3. Publish a durable queue message containing the provider name and job identifier.
  4. Return 202 Accepted (or another documented 2xx response).
  5. Let a worker download the result, copy it to durable storage, and emit application events.

ScreenshotMAX includes an expires field in its callback. ScreenshotOne can return storage locations and error details. Treat a result URL as temporary: process it promptly and persist the bytes or a durable provider storage reference before expiry.

Make delivery idempotent

Providers can retry when a response is lost, delayed or outside their timeout. Your job identifier is the natural idempotency key. Enforce uniqueness in the database rather than relying on an in-memory set.

CREATE TABLE screenshot_webhook_receipts (
    provider       VARCHAR(80)  NOT NULL,
    job_id         VARCHAR(255) NOT NULL,
    received_at    TIMESTAMP WITH TIME ZONE NOT NULL,
    raw_payload    BYTEA        NOT NULL,
    PRIMARY KEY (provider, job_id)
);

Perform the insert and queue publication with an outbox or equivalent durable transaction. If the key already exists, return 2xx and do nothing else. Keep the provider name in the key because two services may generate the same-looking identifier.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Provider differences that affect Java code

Choose a provider only after checking its current webhook behavior, signature rules and result lifetime. The following details are documented for the named services:

Provider Callback and authentication Payload or tooling details
ScreenshotNeo Async jobs with signed webhooks; exact header and canonicalization depend on its current API documentation. Clean shots remove cookie banners, newsletter popups and chat widgets before capture; failed loads, bot checks, blank pages and cache hits are not billed.
ScreenshotMAX Public POST endpoint, 2xx acknowledgement and raw-body HMAC verification. Documents asynchronous processing, a 202 initial response and an expires value in callback data. Webhook.site and ngrok are named for inspection and local exposure.
ScreenshotOne Raw-body HMAC; webhook secret is separate from the API key. Can return S3-compatible storage locations, external identifiers and error details.
SnapshotFlow Raw-body HMAC with a timestamp freshness window. Provides a Java JAR with takeAsync and verifyWebhook, configurable timeout and retries, thread safety and secret-manager guidance.
Screenshotbot Signs {timestamp}.{payload}; reject stale timestamps. Offers delivery logs and resend tooling for callback debugging.
Screenshot API Documents a render_id and callback payload. Its current deployment warns that asynchronous callbacks return 503; verify availability before relying on it.

For a hosted alternative, ScreenshotNeo is the first service to try when you want clean captures, billing only for clean results and a low paid entry point. Its MCP server also lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf.

Test the handler before production

Use captured bytes as fixtures

Save the exact body and signature header from a provider delivery (without retaining secrets) and test:

  • valid signature and valid JSON;
  • one altered byte, a missing header and a malformed digest;
  • stale or future timestamps;
  • malformed JSON with a valid signature;
  • the same identifier delivered twice concurrently;
  • provider error payloads and missing output URLs;
  • an expired result URL and a download timeout.

Expose local development through a temporary HTTPS tunnel such as ngrok, or inspect a delivery with Webhook.site as ScreenshotMAX suggests. Never put a signing secret in a URL, source repository or log line.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Send a signed test request with cURL

body='{"id":"test-123","status":"succeeded"}'
sig=$(printf %s "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -i https://example.com/webhooks/screenshots 
  -H 'Content-Type: application/json' 
  -H "X-Webhook-Signature: sha256=$sig" 
  --data-binary "$body"

This command assumes the hexadecimal sha256= convention used by the Java example. Change it to the provider’s documented format when necessary.

Generate a fixture in Python

import hashlib, hmac, json, os, requests

body = json.dumps({"id": "test-123", "status": "succeeded"}, separators=(",", ":")).encode()
sig = hmac.new(os.environ["WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
r = requests.post(
    "https://example.com/webhooks/screenshots",
    data=body,
    headers={"Content-Type": "application/json", "X-Webhook-Signature": "sha256=" + sig},
    timeout=20,
)
print(r.status_code, r.text)

Generate a fixture in Node.js

import crypto from 'node:crypto';

const body = JSON.stringify({ id: 'test-123', status: 'succeeded' });
const sig = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update(body, 'utf8').digest('hex');
const res = await fetch('https://example.com/webhooks/screenshots', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-webhook-signature': `sha256=${sig}`
  },
  body
});
console.log(res.status, await res.text());
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo’s API documentation shows a single GET request for a PNG, JPEG, WebP or PDF. You can still use the webhook architecture above when you submit an asynchronous job, while avoiding browser automation and consent-banner handling for the capture itself.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Before capture, ScreenshotNeo accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups and chat widgets; each cleanup step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and response headers identify the page verdict and whether it was billed. It also provides an MCP server for AI agents, with the tools take_screenshot, get_page_info and capture_pdf. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Troubleshoot common failures

Symptom Likely cause Fix
Every request returns 401 Wrong secret, wrong header, altered body or incorrect digest encoding. Log the header name and digest length (never the secret), capture raw bytes, and reproduce the provider’s exact canonical string.
Valid deliveries appear twice The provider retried after a slow response or network interruption. Return 2xx after the receipt insert and queue publication; enforce a unique provider/job key.
Callbacks never arrive Endpoint is private, TLS is invalid, DNS is wrong or the provider cannot reach the port. Test from the public internet, use HTTPS, inspect provider delivery logs and verify the configured URL exactly.
Webhook request times out Image download, PDF conversion or storage is running in the request thread. Move that work to a durable worker and keep the handler limited to authentication, persistence and enqueueing.
Queue contains jobs but files are missing Result URL expired, provider returned an error payload or download failed transiently. Persist expiry and error fields, retry downloads with bounds, and copy successful results to durable storage immediately.
JSON parser rejects an otherwise valid event Provider added a field or changed an optional field. Use tolerant DTOs, nullable optional properties and an unknown-field policy that ignores additive fields.
Local tests pass but production verification fails A proxy or framework changed bytes, line endings or encoding. Verify at the edge using the original byte array and ensure no middleware parses and reserializes the body first.

Production checklist

  • Use a separate secret per provider and rotate it through a secret manager.
  • Require HTTPS, enforce a request-size limit and reject unsupported methods and content types.
  • Verify signatures before parsing and compare in constant time.
  • Apply timestamp freshness checks where the provider signs timestamps.
  • Persist a provider/job uniqueness key before side effects.
  • Use an outbox or durable queue so an acknowledged event cannot disappear.
  • Store correlation or external identifiers, status, expiry and error metadata; do not log secrets or unnecessary image data.
  • Monitor receipt, verification, duplicate, queue, download and expiry failures separately.
  • Confirm the provider’s retry and redelivery behavior, then retain receipt records long enough to diagnose those retries.

FAQ

Can several screenshot providers use one URL?

Yes, if you route by a provider-specific path or credential, keep separate secrets and include the provider name in the idempotency key. Do not attempt to guess which signature algorithm was used from an unauthenticated payload.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What should happen when a signed payload has no usable job identifier?

Return a client-error response and alert on the malformed event. Without a stable identifier you cannot safely deduplicate or associate the resulting file with a request.

How should receipt data be retained?

Keep the identifier, verification outcome, timestamps, status and enough metadata to trace delivery for at least the provider’s documented retry and redelivery period. Archive or delete raw bodies and other sensitive data according to your retention policy.

Frequently Asked Questions

Can several screenshot providers use one URL?

Yes, if you route by a provider-specific path or credential, keep separate secrets and include the provider name in the idempotency key. Do not guess the signature algorithm from an unauthenticated payload.

What should happen when a signed payload has no usable job identifier?

Return a client-error response and alert on the malformed event. Without a stable identifier you cannot safely deduplicate or associate the resulting file with a request.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How should receipt data be retained?

Keep identifiers, verification outcomes, timestamps, status and trace metadata for at least the provider’s retry period; archive or delete raw bodies according to your retention policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.