# Webhooks

> Receive analysis.completed, analysis.failed and balance.low events. Standard Webhooks signature verification in JavaScript and Python, retries, auto-disable and the test event.

Source: https://fileverity.com/en/docs/webhooks

Webhooks send an HTTPS request to your server when something happens, so you do not have to poll. They follow the [Standard Webhooks](https://www.standardwebhooks.com) specification.

Add endpoints in the Console under **Webhooks**. Choose the URL and the events it receives. The signing secret, which starts with `whsec_`, is shown once when you create the endpoint. You can change the events of an endpoint at any time without changing its secret.

To replace a lost or exposed secret, choose **Roll secret** on the endpoint. The new secret is shown once and the old one stops working immediately, so update your verifier right away.

- The URL must use HTTPS on the default port 443 and point to a public address. URLs with credentials or another port are rejected.
- A workspace can have up to 10 endpoints.
- Test keys never send events.

## Events

| Event | Sent when |
| --- | --- |
| `analysis.completed` | An image or document analysis succeeded. |
| `analysis.failed` | An analysis failed or was canceled. It was not charged. A canceled analysis has `status: canceled` and `error: canceled`. |
| `balance.low` | The balance fell below the alert threshold you set in the Console. |

The supported event types are `analysis.completed`, `analysis.failed`, `balance.low`.

## Payloads

Every event has the same envelope: `id`, `object` (always `event`), `type`, `created` (Unix timestamp) and `data`. The `id` is also sent in the `webhook-id` header.

### analysis.completed and analysis.failed

`data` is the same analysis object that `GET /analyses/{id}` returns, with the same field names and types, except that `result` is always `null`: fetch the result with that endpoint. `status` is `succeeded`, `failed` or `canceled`, and `error` is an object with `code` and `message`, or `null`. Timestamps are Unix seconds.

**analysis.completed**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": "FV-7K2M-9QXD-4T8R",
    "report_url": "https://fileverity.com/verify/FV-7K2M-9QXD-4T8R",
    "summary": {
      "level": "L2",
      "assessment": "authentic_untouched",
      "action": "accept",
      "ai": "very_low",
      "edit": "none"
    },
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931
  }
}
```

**analysis.failed**

```json
{
  "id": "evt_Zc4vB7nP1sK8tL3wR6yQm2Xa",
  "object": "event",
  "type": "analysis.failed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "failed",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": {
      "code": "analysis_failed",
      "message": "The analysis could not be completed. You were not charged; retry it, and contact support with the request ID if it fails again."
    },
    "progress": null,
    "usage": {
      "amount": 0,
      "currency": "usd",
      "funding": null
    },
    "metadata": null,
    "completed_at": 1793700135,
    "expires_at": null
  }
}
```

### balance.low

`balance` and `threshold` are in US cents.

**balance.low**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "balance.low",
  "created": 1793700500,
  "data": {
    "balance": 640,
    "threshold": 1000
  }
}
```

## Verify the signature

Each request carries three headers, as the Standard Webhooks specification defines:

| Header | Value |
| --- | --- |
| `webhook-id` | The event ID. The same across retries; use it to ignore duplicates. |
| `webhook-timestamp` | Unix time in seconds of this delivery attempt. It changes on each retry. |
| `webhook-signature` | `v1,` followed by a base64 HMAC-SHA256. A header can carry several space-separated signatures. |

To verify a request:

1. Read the raw request body as text, before any JSON parsing.
2. Build the signed content: `webhook-id`, a dot, `webhook-timestamp`, a dot and the raw body.
3. Take your signing secret, remove the `whsec_` prefix and base64-decode the rest. That is the HMAC key.
4. Compute HMAC-SHA256 of the signed content and base64-encode it.
5. Compare it, in constant time, with each `v1` signature in the header.
6. Reject timestamps more than five minutes from your clock.

**Verify a webhook: JavaScript**

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("Timestamp outside tolerance");
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(id + "." + timestamp + "." + body).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("Invalid signature");
  return JSON.parse(body);
}

app.post("/webhooks/fileverity", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verify(process.env.FILEVERITY_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
    handle(event);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});
```

**Verify a webhook: Python**

```python
import base64
import binascii
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

def verify(secret, headers, body):
    message_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = (message_id + "." + timestamp + ".").encode() + body
    expected = hmac.new(key, signed, hashlib.sha256).digest()
    for entry in headers["webhook-signature"].split(" "):
        version, _, value = entry.partition(",")
        try:
            given = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if version == "v1" and hmac.compare_digest(given, expected):
            return json.loads(body)
    raise ValueError("Invalid signature")

@app.post("/webhooks/fileverity")
async def receive(request: Request):
    body = await request.body()
    try:
        event = verify(os.environ["FILEVERITY_WEBHOOK_SECRET"], request.headers, body)
    except (KeyError, ValueError):
        raise HTTPException(status_code=400)
    handle(event)
    return {"ok": True}
```

> **Note.** Verify against the exact bytes that were sent. Parsing and re-serializing the JSON changes them and breaks the signature.

## Delivery and retries

- Respond with any `2xx` status within 10 seconds. Redirects are not followed and count as failures.
- Anything else, including a timeout or a connection error, schedules a retry with growing delays: 5, 10, 20, 40, 80, 160, 320 and 640 minutes, then every 12 hours. Retries stop 3 days after the event. Retries run on a schedule, so a retry can come later than its delay, never sooner.
- Delivery is at least once and order is not guaranteed. Use `webhook-id` to ignore duplicates, and fetch the analysis if you need the latest state.
- The Console lists the latest 50 deliveries with their status and errors, keeps them for 30 days and lets you resend one. Open a delivery to see its payload, its response time and the first 2 KB of your endpoint's response, so do not return secrets or personal data in that response. A resend keeps the original event ID.
- Requests carry the user agent `Fileverity-Webhooks/1.0`.

### Automatic disabling

An endpoint that fails continuously for 3 days is disabled. It then receives no new events, and its undelivered events are marked failed. A successful delivery before that clears the failure. Workspace owners and admins are emailed when an endpoint is disabled. Turn the endpoint back on in the Console after you fix it.

## The test event

Use **Send test** on an endpoint in the Console to deliver a sample event right away. It is a signed `analysis.completed` event with `data.test` set to `true` and no report, so you can check your URL and your signature code before real events arrive.

**Test event**

```json
{
  "id": "evt_Qm4vB7nP1sK8tL3wR6yZ2cXa",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700500,
  "data": {
    "id": "an_Pw3mY6zB8Hk2pQ4vN9sL1xR7",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931,
    "test": true
  }
}
```

Related: [Analyses](https://fileverity.com/en/docs/analyses.md), [Idempotency](https://fileverity.com/en/docs/idempotency.md) and [Errors](https://fileverity.com/en/docs/errors.md).

