> ## Documentation Index
> Fetch the complete documentation index at: https://docs.screenshotly.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive Screenshotly capture results by webhook: events, payloads, headers, retry schedule, and how to verify the X-Screenshotly-Signature HMAC-SHA256 signature.

Add a `webhook` object to any capture request and Screenshotly sends an HTTP
request to your endpoint when the capture finishes. Webhooks are available on
the Basic plan and above, on both [`POST /api/screenshot`](https://docs.screenshotly.app/api/screenshots) and
[`POST /api/jobs`](https://docs.screenshotly.app/api/async-jobs).

```json theme={null}
{
  "url": "https://example.com/pricing",
  "format": "png",
  "webhook": {
    "url": "https://hooks.example.com/screenshotly",
    "method": "POST",
    "secret": "whsec_a_long_random_value",
    "headers": { "X-Source": "pricing-monitor" }
  }
}
```

| Field | Default | Notes |
| - | - | - |
| `webhook.url` | | Public HTTP(S) URL, up to 2,048 characters. Private and internal addresses are rejected with `400`. |
| `webhook.method` | `POST` | `POST` or `PUT` |
| `webhook.secret` | | Up to 4,096 characters. When set, every delivery is signed. |
| `webhook.headers` | | Up to 50 extra headers. Proxy and hop-by-hop headers such as `Host` are rejected. |

> [!WARNING]
>
> Without a `secret`, deliveries are not signed and your endpoint cannot tell a
> real delivery from a forged one. Always set a secret and verify it.

## Events

| Event | When it is sent |
| - | - |
| `screenshot.completed` | An async job succeeded, or a synchronous capture finished. |
| `screenshot.failed` | An async job failed its final attempt. |
| `usage.warning` | The account crossed its usage alert threshold (usage notification webhook only). |
| `usage.limit` | The account reached its capture limit (usage notification webhook only). |

Synchronous requests report failures in the HTTP response, so they never send
`screenshot.failed`.

### Payloads

Every delivery is a JSON body with `event`, `timestamp`, and `data`:

```json screenshot.completed theme={null}
{
  "event": "screenshot.completed",
  "timestamp": "2026-11-13T10:00:04.512Z",
  "data": {
    "url": "https://example.com/pricing",
    "format": "png",
    "size": 482113,
    "storageUrl": "https://screenshotly.app/api/assets/<asset-id>?expires=<unix-seconds>&signature=<signature>",
    "duration": 2950
  }
}
```

```json screenshot.failed theme={null}
{
  "event": "screenshot.failed",
  "timestamp": "2026-11-13T10:01:10.004Z",
  "data": {
    "url": "https://example.com/pricing",
    "format": "png",
    "error": "The capture could not be completed"
  }
}
```

* `size` is in bytes and `duration` in milliseconds.
* `storageUrl` is a signed download link valid for 24 hours. Async jobs always
  include it. Synchronous captures include it only when the request also sets
  `"storage": { "enabled": true }`; otherwise the file is only in the HTTP
  response.
* The link may answer with a redirect to object storage, so download it with a
  client that follows redirects (for example `curl -L`).
* The file itself is deleted at the end of the account's retention period
  (1–30 days), even if the link has not expired.

## Request headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Screenshotly-Webhook/1.0` |
| `X-Screenshotly-Timestamp` | The payload's `timestamp` value |
| `X-Screenshotly-Signature` | `sha256=<hex>`; present only when `secret` is set |
| Your `webhook.headers` | Sent as provided |

## Delivery and retries

* Deliveries are queued durably and sent by a worker, so they arrive shortly
  after the capture finishes, not inside the capture request.
* Any `2xx` response within 10 seconds counts as delivered. Redirects are not
  followed and count as failures, so point `webhook.url` at the final address.
* A failed delivery is retried up to **5 attempts** in total, after 5, 15, 45,
  and 135 seconds.
* For async jobs, `GET /api/jobs/{id}` shows each delivery's `status`,
  `attempts`, and `deliveredAt`.

A delivery can arrive more than once, for example if your endpoint processed the
request but responded too slowly. Make handlers idempotent: key on the job or
on `data.url` plus `timestamp`, and return `2xx` quickly before doing slow work.

## Verify the signature

When `secret` is set, `X-Screenshotly-Signature` is `sha256=` followed by the
hex-encoded HMAC-SHA256 of the **raw request body**, keyed with your secret.
Compute it over the exact bytes you received, before parsing JSON, and compare
in constant time.

```javascript Node.js (Express) theme={null}
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.SCREENSHOTLY_WEBHOOK_SECRET;

function isValidSignature(rawBody, header) {
  if (typeof header !== 'string' || !header.startsWith('sha256=')) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = header.slice('sha256='.length);
  return received.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

// express.raw keeps the exact bytes that were signed.
app.post('/screenshotly', express.raw({ type: 'application/json' }), (req, res) => {
  if (!isValidSignature(req.body, req.get('X-Screenshotly-Signature'))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(204);
  // Process the event after responding, e.g. enqueue it for a worker.
  console.log(event.event, event.data.storageUrl);
});

app.listen(3000);
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import os
from typing import Optional

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SCREENSHOTLY_WEBHOOK_SECRET"].encode()


def is_valid_signature(raw_body: bytes, header: Optional[str]) -> bool:
    if not header or not header.startswith("sha256="):
        return False
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header.removeprefix("sha256="), expected)


@app.post("/screenshotly")
def screenshotly_webhook():
    raw_body = request.get_data()
    if not is_valid_signature(raw_body, request.headers.get("X-Screenshotly-Signature")):
        abort(401)
    event = request.get_json()
    print(event["event"], event["data"].get("storageUrl"))
    return "", 204
```

`X-Screenshotly-Timestamp` is not part of the signed content. It tells you when
the event was created, but it does not protect against replays by itself, which
is another reason to make handlers idempotent.

## Usage webhooks

The account owner can set one **Usage notification webhook** URL in
[Account controls](https://screenshotly.app/dashboard/settings). It receives
`usage.warning` and `usage.limit` events, at most once per type per billing
month, with this payload:

```json theme={null}
{
  "event": "usage.warning",
  "timestamp": "2026-11-13T10:00:00.000Z",
  "data": { "usage": 2000, "limit": 2500 }
}
```

Usage webhooks use the same delivery queue and retry schedule, but they are not
signed because Account controls has no secret field. Treat them as a prompt to
check the dashboard, not as an authenticated instruction.

## Related

* [Async and batch jobs](https://docs.screenshotly.app/api/async-jobs.md)
* [Security](https://docs.screenshotly.app/guides/security.md)
* [Teams, usage, and retention](https://docs.screenshotly.app/guides/teams-and-usage.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.