DEV Community

Dan E
Dan E

Posted on • Originally published at rendex.dev

Async Screenshot API With Webhook Delivery

A synchronous screenshot call holds the connection open until the page finishes rendering. That is fine for a fast landing page. It gets painful when you capture a heavy dashboard, a slow single-page app, or a full scrollable page: the request can run for tens of seconds, your worker sits blocked, and a proxy timeout can drop the response before the image is ready.

The async screenshot API removes that wait. You submit the capture, get a job ID back immediately, and Rendex does the work in the background. When the job settles, it POSTs an HMAC-signed webhook to a URL you control. No open connection, no polling loop, no timeout risk. This guide covers the submit call, the signed payload, how to verify it in Node and Python, and how retries and idempotency work.

Async capture to signed webhook: a submit request with async and webhookUrl returns a job ID, and Rendex POSTs a signed job.completed webhook when the render is ready

When to reach for async

Reach for async and webhooks when a capture is slow or when you are firing a lot of them at once:

  • Full-page captures of long or image-heavy pages.
  • Single-page apps that need a few seconds to hydrate before they look right.
  • Background jobs where nothing is waiting on the response in real time.
  • High volume, so you never want a request holding a socket open.

For a handful of quick captures, the synchronous screenshot endpoint is simpler. For rendering many URLs in one shot, see the batch rendering API, which also delivers a webhook when the whole batch is done.

Step 1: Submit a non-blocking capture

Send the same body you would to a normal capture, plus async: true and a webhookUrl. The response comes back right away with a jobId and an HTTP 202, so your code moves on instead of waiting.

curl -X POST https://api.rendex.dev/v1/screenshot \
  -H "Authorization: Bearer $RENDEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://news.ycombinator.com",
    "fullPage": true,
    "async": true,
    "webhookUrl": "https://api.your-app.com/hooks/rendex"
  }'
Enter fullscreen mode Exit fullscreen mode
{
  "success": true,
  "data": {
    "jobId": "a1f9c2e4-8b3d-4c7a-9f10-2b6e5c1d4a70",
    "status": "queued"
  },
  "meta": { "requestId": "req_...", "timestamp": "2026-07-20T18:24:01.004Z" }
}
Enter fullscreen mode Exit fullscreen mode

Like every Rendex JSON endpoint, the response is wrapped in { success, data, meta }, so the job ID is at data.jobId (the webhook payload below is the one exception: it is POSTed as a raw, unwrapped object). The submit charges one render credit up front. The webhook URL is checked before the job is queued, so a private or unreachable address is rejected at submit time rather than failing silently later. Each plan caps how many jobs can be in flight at once (queued or processing): 3 on Free, 50 on Starter, 200 on Pro. Go over and the submit returns a QUEUE_LIMIT_REACHED response so you can back off.

Step 2: The signed callback

When the capture finishes, Rendex sends a POST to your webhookUrl. A completed job carries the event name, the job ID, and a signed URL to the rendered image. A failed job carries a safe error message instead of a result.

{
  "event": "job.completed",
  "jobId": "a1f9c2e4-8b3d-4c7a-9f10-2b6e5c1d4a70",
  "status": "completed",
  "resultUrl": "https://api.rendex.dev/v1/images/a1f9c2e4...",
  "completedAt": "2026-07-20T18:24:07.512Z"
}
Enter fullscreen mode Exit fullscreen mode

Every delivery carries four headers you will use to verify and de-duplicate it:

  • x-rendex-event: the event name, such as job.completed or job.failed.
  • x-rendex-signature: the HMAC-SHA256 signature of the payload, hex encoded.
  • x-rendex-timestamp: the Unix time in seconds when the signature was made.
  • x-rendex-delivery-id: a stable ID that is the same across every retry of one delivery.

Step 3: Verify the signature

Anyone who learns your webhook URL could POST to it, so treat an unsigned request as untrusted. The signature is an HMAC-SHA256 over the string timestamp + "." + rawBody, keyed with your webhook secret. This is the same scheme Stripe uses, so the pattern will look familiar. Compute the expected signature over the raw request body and compare it in constant time.

import crypto from "node:crypto";

function verifyWebhook(rawBody, signature, timestamp, secret) {
  const message = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(message)
    .digest("hex");
  if (signature.length !== expected.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express: use the RAW body, not the parsed JSON object.
app.post("/hooks/rendex", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ok = verifyWebhook(
    raw,
    req.headers["x-rendex-signature"],
    req.headers["x-rendex-timestamp"],
    process.env.WEBHOOK_SECRET
  );
  if (!ok) return res.status(401).json({ error: "Invalid signature" });

  const payload = JSON.parse(raw);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Same check in Python with Flask:

import hmac, hashlib, os

def verify_webhook(raw_body: str, signature: str, timestamp: str, secret: str) -> bool:
    message = f"{timestamp}.{raw_body}"
    expected = hmac.new(
        secret.encode(), message.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.post("/hooks/rendex")
def rendex_hook():
    raw = request.get_data(as_text=True)
    ok = verify_webhook(
        raw,
        request.headers.get("x-rendex-signature", ""),
        request.headers.get("x-rendex-timestamp", ""),
        os.environ["WEBHOOK_SECRET"],
    )
    if not ok:
        return {"error": "Invalid signature"}, 401
    return {"received": True}, 200
Enter fullscreen mode Exit fullscreen mode

Two things to get right. Verify against the exact bytes you received: if your framework parses JSON and re-serializes it, the whitespace changes and the signature will not match, so read the raw body. And use a constant-time compare (timingSafeEqual or hmac.compare_digest) instead of === so you do not leak timing information. The full reference lives in the webhooks docs.

Step 4: Polling as a fallback

Webhooks are the right default, but you cannot always run a public endpoint, for example during local development or inside a locked-down network. In that case, skip the webhookUrl and poll the job by ID instead.

curl https://api.rendex.dev/v1/jobs/a1f9c2e4-8b3d-4c7a-9f10-2b6e5c1d4a70 \
  -H "Authorization: Bearer $RENDEX_API_KEY"

# While running:  { "success": true, "data": { "status": "processing" } }
# When done:      { "success": true, "data": { "status": "completed", "resultUrl": "https://api.rendex.dev/v1/images/..." } }
Enter fullscreen mode Exit fullscreen mode

Poll on a gentle interval (a few seconds) and stop once status is completed or failed. When you can receive a callback, prefer the webhook: it fires the moment the job settles instead of on your next poll, and it costs you no extra requests. Full field details are in the API reference.

Retries and idempotency

If your endpoint does not answer with a 2xx, Rendex retries the delivery with exponential backoff, up to four attempts total, then gives up. A 4xx response is treated as a permanent rejection and is not retried, so a bug in your handler will not trigger a retry storm. Redirects are not followed: your webhook route should accept the POST directly.

Because a slow acknowledgement can cause a retry after the work is already done, deliveries can arrive more than once. Use the x-rendex-delivery-id header, which stays the same across retries of the same delivery, as an idempotency key: record the IDs you have processed and ignore repeats. Answer with a 2xx as soon as you have stored the event, then do slow work afterward, so a heavy handler never looks like a failure.

Troubleshooting

  • No webhook arrives. The URL must be public and resolve to a routable address. Private and internal hosts are blocked at submit time and return INVALID_WEBHOOK_URL. During local work, use a tunnel that gives you a public URL, or poll instead.
  • Signature never matches. You are almost certainly hashing a re-serialized body. Sign the raw bytes, and confirm the message is timestamp + "." + body in that order.
  • Deliveries stopped after one failure. Your endpoint returned a 4xx, which is treated as permanent. Return a 5xx if you want the retry, or fix the handler and re-submit.
  • QUEUE_LIMIT_REACHED on submit. You have hit your plan's concurrent-job cap. Wait for jobs to finish or move to a higher tier for more in-flight jobs.

Ship it

Async plus webhooks turns screenshots into a background job your app never waits on: submit, get a job ID, and handle a signed callback when the image is ready. Grab an API key and run your first async capture from the screenshot tool, or read the webhooks docs for the full payload and event list. New accounts include a free monthly render allowance, so you can wire up the whole flow before you pay anything. Create an account to get your key.

Top comments (0)