How to Test a Screenshot API Callback Handler
Test screenshot callbacks in three layers: handler logic, signature verification, and real provider delivery with retries and duplicates.
To test a screenshot API callback handler reliably, use three layers: unit tests for parsing and business logic, signature tests for authenticity, and delivery tests that send a real sandbox event to a reachable endpoint. Then verify the HTTP response, delivery identifier, state change, duplicate handling, ordering behavior, timeout behavior, and provider retries. The exact payload fields, signature algorithm, headers, timeout, and retry schedule come from your screenshot provider’s current contract.
1. Define the callback contract before writing tests
Collect these details from the provider documentation:
- Callback URL and HTTP method.
- Success and failure event types.
- Event identifier, screenshot identifier, status, and result URL fields.
- Signature header names, algorithm, secret format, and timestamp rules.
- Whether verification requires the unmodified raw request body.
- Required response status and maximum response time.
- Retry conditions, retry delays, and duplicate-delivery behavior.
- Whether events can arrive out of order.
Do not copy assumptions from another provider. For example, GitHub requires a 2xx response within 10 seconds and documents out-of-order delivery, while ScreenshotRun documents retries for 4xx/5xx responses and a 10-second connection timeout. Those values apply to those services, not to every screenshot API.
2. Build a handler with explicit stages
A maintainable callback route separates transport, authentication, validation, idempotency, and business work:
- Read and preserve the raw body.
- Verify the provider signature.
- Parse JSON only after verification when the provider requires raw bytes.
- Validate required fields and event type.
- Reject or ignore an already-processed event safely.
- Persist the event and update the screenshot record.
- Queue expensive follow-up work.
- Return the provider’s required success status quickly.
Keep the synchronous path short. Image downloads, database reports, email, and other slow work should run after the event is durably recorded.
3. Unit-test parsing and business logic
Unit tests should not need a network connection or a provider account. Pass representative payloads directly to the functions that validate events and apply state changes.
function parseCompletionEvent(event) {
if (!event || typeof event !== 'object') throw new Error('invalid event');
if (event.type !== 'screenshot.completed') throw new Error('unexpected event type');
if (typeof event.id !== 'string' || event.id.length === 0) throw new Error('missing event id');
if (typeof event.screenshot_id !== 'string' || event.screenshot_id.length === 0) {
throw new Error('missing screenshot id');
}
if (typeof event.image_url !== 'string' || !event.image_url.startsWith('https://')) {
throw new Error('invalid image url');
}
return {
eventId: event.id,
screenshotId: event.screenshot_id,
imageUrl: event.image_url
};
}
async function applyCompletion(event, store) {
const parsed = parseCompletionEvent(event);
if (await store.hasProcessedEvent(parsed.eventId)) return { duplicate: true };
await store.markProcessedEvent(parsed.eventId);
await store.markScreenshotComplete(parsed.screenshotId, parsed.imageUrl);
return { duplicate: false };
}
module.exports = { parseCompletionEvent, applyCompletion };
Cover at least these cases:
- A valid completion updates the expected screenshot record.
- An unknown event type is rejected or ignored according to your contract.
- Missing identifiers fail safely.
- An invalid result URL cannot create trusted state.
- The same event ID twice changes state only once.
- A completion for an unknown screenshot is recorded for investigation and does not update a different record.
4. Test signature verification separately
Signature tests prove that your route rejects tampering and malformed authentication. Include a valid signature, a changed body, a wrong secret, a missing header, and a malformed header. If the provider signs the raw body, parsing and re-serializing JSON before verification can change whitespace or key order and invalidate the signature.
const crypto = require('node:crypto');
function sign(rawBody, secret) {
return crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
}
function verify(rawBody, received, secret) {
if (typeof received !== 'string' || !/^[a-f0-9]{64}$/.test(received)) return false;
const expected = sign(rawBody, secret);
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
const body = JSON.stringify({ id: 'evt_123', type: 'screenshot.completed' });
const secret = 'test-secret';
const valid = sign(body, secret);
console.assert(verify(body, valid, secret));
console.assert(!verify(body + ' ', valid, secret));
console.assert(!verify(body, sign(body, 'wrong-secret'), secret));
console.assert(!verify(body, undefined, secret));
Replace this illustrative verifier with the provider’s documented SDK or algorithm. Stripe’s Node SDK, for example, requires the raw body for constructEvent() and exposes generateTestHeaderString for mocked signed events. That mechanism is Stripe-specific.
5. Test a complete Node.js callback route
The following Express example shows the important ordering. The raw body is captured before JSON parsing; substitute your provider’s verifier and payload fields.
const express = require('express');
const crypto = require('node:crypto');
const app = express();
const secret = process.env.CALLBACK_SECRET;
const processed = new Set();
app.post('/callbacks/screenshots', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body;
const received = req.get('x-provider-signature');
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
if (typeof received !== 'string' || received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(raw.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!event.id || !event.type) return res.sendStatus(422);
if (processed.has(event.id)) return res.sendStatus(200);
// Persist the event and state change in a database transaction in production.
processed.add(event.id);
if (event.type === 'screenshot.completed') {
// await screenshots.markComplete(event.screenshot_id, event.image_url);
}
return res.sendStatus(200);
});
app.listen(3000, () => console.log('listening on 3000'));
For production, replace the in-memory set with a durable unique constraint on the provider event ID. Return a non-2xx response only when you want the provider to treat delivery as failed.
6. Exercise real delivery locally
A provider cannot normally reach localhost or 127.0.0.1. GitHub explicitly disallows those destinations, and its documentation recommends a forwarding service. Stripe documents sandbox actions and CLI-triggered events for testing destinations. Use your provider’s sandbox, CLI, or forwarding tool to send an event to a public HTTPS URL that forwards to your local port.
- Start the handler and log the listening port.
- Start a tunnel or provider CLI forwarder.
- Configure the public callback URL in the provider sandbox.
- Trigger a screenshot completion using a test URL.
- Record the delivery ID, event ID, status code, elapsed time, and response body.
- Confirm the database state and queued follow-up work.
Use a forwarding tool only for development. Keep production secrets out of tunnel configuration and logs.
7. Use cURL to test the local route
Once your handler is reachable, send a fixture with the same content type and signature format your provider uses. The signature below is only an example HMAC scheme.
BODY='{"id":"evt_test_123","type":"screenshot.completed","screenshot_id":"shot_123","image_url":"https://example.com/shot.webp"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CALLBACK_SECRET" -hex | sed 's/^.* //')
curl -i -X POST http://127.0.0.1:3000/callbacks/screenshots \
-H 'content-type: application/json' \
-H "x-provider-signature: $SIGNATURE" \
--data "$BODY"
For a real provider, use its exact header encoding, timestamp format, and signing string.
8. Test with Python
This Flask example preserves the raw bytes for verification and demonstrates safe duplicate handling.
import hashlib
import hmac
import json
import os
from flask import Flask, request, abort
app = Flask(__name__)
secret = os.environ['CALLBACK_SECRET'].encode()
processed = set()
def valid_signature(raw, received):
if not received:
return False
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
@app.post('/callbacks/screenshots')
def callback():
raw = request.get_data(cache=False)
if not valid_signature(raw, request.headers.get('X-Provider-Signature')):
abort(401)
try:
event = json.loads(raw)
except ValueError:
abort(400)
if not event.get('id') or not event.get('type'):
abort(422)
if event['id'] in processed:
return '', 200
processed.add(event['id'])
# Persist the event and update the screenshot in one transaction.
return '', 200
if __name__ == '__main__':
app.run(port=3000)
9. Test failures, retries, duplicates, and ordering
| Test | Expected assertion |
|---|---|
| Valid completion | Screenshot record is updated and follow-up work is queued or completed. |
| Changed body or invalid signature | Request is rejected; no trusted state changes. |
| Missing or malformed fields | Safe 4xx response and diagnostic logging without marking success. |
| Provider delivery | Correct route receives the event and returns the documented success status. |
| Non-2xx response | Provider records failure and retries only as its contract specifies. |
| Timeout | Slow work is moved off the request path; retry behavior is understood. |
| Duplicate event | Idempotency prevents a second state transition or duplicate job. |
| Out-of-order events | Older events cannot overwrite newer state when timestamps or versions are available. |
Force each failure deliberately in a sandbox. Do not infer a universal retry schedule from one provider. ScreenshotRun documents retries for 4xx/5xx responses and a 10-second connection timeout; GitHub documents a 10-second response limit and possible out-of-order delivery.
10. Logging and observability checklist
- Log provider delivery ID and event ID.
- Log verification result, event type, and processing outcome.
- Log elapsed time and response status.
- Redact secrets, authorization headers, cookies, and sensitive URLs.
- Keep enough metadata to correlate retries without storing full payloads indefinitely.
- Alert on repeated signature failures, rising 4xx/5xx responses, and a growing unprocessed-event queue.
11. Performance, reliability, and cost notes
Return an acknowledgement after durable persistence, then process downloads and transformations asynchronously. Use a unique event-ID index, transaction boundaries, bounded queues, and backoff for downstream failures. Keep a replayable event record so a code fix can be tested against historical fixtures.
Provider test environments may have different limits from production. Stripe warns that its testing environment has a stricter test rate limiter and should not be used for load testing. Use synthetic fixtures and an approved performance environment for throughput tests.
A callback test itself does not determine screenshot cost. Check your screenshot provider’s billing rules for failed loads, retries, and repeated captures. ScreenshotNeo bills only clean shots: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and each response includes X-Page-Verdict and X-Billed headers.
12. Troubleshooting
Signature verification fails for a valid-looking request
Cause: the framework parsed and re-serialized JSON, a newline changed, or the wrong timestamp/header format was used. Fix: capture the exact raw bytes and follow the provider’s signing example.
The provider cannot connect locally
Cause: the callback URL points to localhost or a private network. Fix: use the provider CLI or an HTTPS forwarding service and confirm the forwarded path.
The provider retries successful work
Cause: the handler timed out, returned a non-2xx status, or crashed after applying the state change. Fix: persist an idempotency record before acknowledgement and return the documented success status promptly.
Events arrive in the wrong order
Cause: asynchronous delivery does not guarantee ordering. Fix: compare event timestamps or versions, reject stale transitions, and make state updates monotonic.
Malformed payloads create records
Cause: validation happens after business logic. Fix: validate event type, IDs, status, and result fields before any state change.
Tests pass but production callbacks fail
Cause: sandbox secrets, endpoint paths, proxy behavior, or content-type handling differ. Fix: run one production-like signed event in a staging environment and compare headers and raw bytes.
13. A practical test matrix
- Unit: valid, incomplete, unknown, and duplicate payload fixtures.
- Signature: valid signature, altered body, wrong secret, missing header, malformed header, expired timestamp if applicable.
- Delivery: sandbox event through a public forwarder to the local route.
- Failure: forced 400, 401, 500, slow response, and process restart.
- State: duplicate and out-of-order events against a real database transaction.
- Operations: verify logs, alerts, replay tooling, and secret rotation procedure.
Or skip the browser setup
If your callback test starts with generating screenshots, ScreenshotNeo provides a single HTTP request and an MCP server for AI agents. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed. The response reports the page verdict and billing result in headers. Claude, Cursor, and other MCP clients can use take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for the current request options and callback details.
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 includes full-page and element capture, custom waits, headers and cookies, blocking controls, caching, signed links, asynchronous jobs with signed webhooks, bulk capture, PDF output, and more. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Should I test callbacks with mocks or real screenshots?
Both. Mocks cover logic quickly; a sandbox or CLI delivery test covers network reachability, headers, signatures, and provider behavior.
What status should a callback handler return?
Return the success status documented by your provider after durable acceptance. Do not assume every service uses the same status or timeout.
How do I prevent duplicate screenshot processing?
Store the provider event ID with a unique constraint and make the state transition transactional.
Can I verify a signature after parsing JSON?
Only if the provider signs a canonical representation and documents that method. Preserve raw bytes whenever the contract requires them.
How do I test retries without causing a production storm?
Use a sandbox or provider CLI, deliberately return failures for a test event, and follow the documented retry limits and schedule.


