# Webhooks

> Empfangen Sie die Ereignisse analysis.completed, analysis.failed und balance.low. Signaturprüfung nach Standard Webhooks in JavaScript und Python, Wiederholungen, automatisches Deaktivieren und das Testereignis.

Quelle: https://fileverity.com/de/docs/webhooks

Webhooks senden eine HTTPS-Anfrage an Ihren Server, sobald etwas passiert, sodass Sie nicht abfragen müssen. Sie folgen der Spezifikation [Standard Webhooks](https://www.standardwebhooks.com).

Fügen Sie Endpunkte in der Konsole unter **Webhooks** hinzu. Wählen Sie die URL und die Ereignisse, die an sie gesendet werden. Das Signing-Secret, das mit `whsec_` beginnt, wird beim Erstellen des Endpunkts einmalig angezeigt. Die Ereignisse eines Endpunkts können Sie jederzeit ändern, ohne sein Secret zu ändern.

Um ein verlorenes oder bekannt gewordenes Secret zu ersetzen, wählen Sie beim Endpunkt **Secret erneuern**. Das neue Secret wird einmalig angezeigt, und das alte funktioniert sofort nicht mehr. Aktualisieren Sie deshalb umgehend Ihre Signaturprüfung.

- Die URL muss HTTPS am Standardport 443 verwenden und auf eine öffentliche Adresse zeigen. URLs mit Zugangsdaten oder einem anderen Port werden abgelehnt.
- Ein Workspace kann bis zu 10 Endpunkte haben.
- Testschlüssel senden nie Ereignisse.

## Ereignisse

| Ereignis | Gesendet, wenn |
| --- | --- |
| `analysis.completed` | Eine Bild- oder Dokumentanalyse war erfolgreich. |
| `analysis.failed` | Eine Analyse ist fehlgeschlagen oder wurde abgebrochen. Sie wurde nicht berechnet. Eine abgebrochene Analyse hat `status: canceled` und `error: canceled`. |
| `balance.low` | Das Guthaben ist unter die Warnschwelle gefallen, die Sie in der Konsole festgelegt haben. |

Die unterstützten Ereignistypen sind `analysis.completed`, `analysis.failed`, `balance.low`.

## Nutzdaten

Jedes Ereignis hat denselben Aufbau: `id`, `object` (immer `event`), `type`, `created` (Unix-Zeitstempel) und `data`. Die `id` wird auch im Header `webhook-id` gesendet.

### analysis.completed und analysis.failed

`data` ist dasselbe Analyseobjekt, das `GET /analyses/{id}` zurückgibt, mit denselben Feldnamen und Typen, nur dass `result` immer `null` ist: Rufen Sie das Ergebnis über diesen Endpunkt ab. `status` ist `succeeded`, `failed` oder `canceled`, und `error` ist ein Objekt mit `code` und `message` oder `null`. Zeitstempel sind in Unix-Sekunden angegeben.

**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` und `threshold` sind in US-Cent angegeben.

**balance.low**

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

## Signatur prüfen

Jede Anfrage enthält drei Header, wie die Spezifikation Standard Webhooks sie definiert:

| Header | Wert |
| --- | --- |
| `webhook-id` | Die Ereignis-ID. Bei Wiederholungen bleibt sie gleich; nutzen Sie sie, um Duplikate zu ignorieren. |
| `webhook-timestamp` | Unix-Zeit in Sekunden dieses Zustellversuchs. Sie ändert sich bei jeder Wiederholung. |
| `webhook-signature` | `v1,` gefolgt von einem Base64-kodierten HMAC-SHA256. Ein Header kann mehrere durch Leerzeichen getrennte Signaturen enthalten. |

So prüfen Sie eine Anfrage:

1. Lesen Sie den Anfragetext unverändert als Text, noch bevor Sie ihn als JSON parsen.
2. Bilden Sie den signierten Inhalt: `webhook-id`, einen Punkt, `webhook-timestamp`, einen Punkt und den unveränderten Anfragetext.
3. Nehmen Sie Ihr Signing-Secret, entfernen Sie das Präfix `whsec_` und dekodieren Sie den Rest aus Base64. Das ist der HMAC-Schlüssel.
4. Berechnen Sie den HMAC-SHA256 des signierten Inhalts und kodieren Sie ihn in Base64.
5. Vergleichen Sie ihn in konstanter Zeit mit jeder `v1`-Signatur im Header.
6. Lehnen Sie Zeitstempel ab, die mehr als fünf Minuten von Ihrer Systemzeit abweichen.

**Webhook prüfen: 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);
  }
});
```

**Webhook prüfen: 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}
```

> **Hinweis.** Prüfen Sie gegen genau die Bytes, die gesendet wurden. Wenn Sie das JSON parsen und erneut serialisieren, ändern sich diese Bytes, und die Signatur stimmt nicht mehr.

## Zustellung und Wiederholungen

- Antworten Sie innerhalb von 10 Sekunden mit einem beliebigen `2xx`-Status. Weiterleitungen werden nicht verfolgt und zählen als Fehler.
- Jede andere Antwort, auch ein Timeout oder ein Verbindungsfehler, löst eine Wiederholung mit wachsenden Abständen aus: 5, 10, 20, 40, 80, 160, 320 und 640 Minuten, danach alle 12 Stunden. Die Wiederholungen enden 3 Tage nach dem Ereignis. Sie laufen nach einem Zeitplan, daher kann eine Wiederholung später als ihr Abstand erfolgen, aber nie früher.
- Die Zustellung erfolgt mindestens einmal, und die Reihenfolge ist nicht garantiert. Nutzen Sie `webhook-id`, um Duplikate zu ignorieren, und rufen Sie die Analyse ab, wenn Sie den aktuellen Stand benötigen.
- Die Konsole listet die letzten 50 Zustellungen mit Status und Fehlern auf, bewahrt sie 30 Tage lang auf und lässt Sie einzelne erneut senden. Wenn Sie eine Zustellung öffnen, sehen Sie ihre Nutzdaten, ihre Antwortzeit und die ersten 2 KB der Antwort Ihres Endpunkts. Geben Sie in dieser Antwort daher keine Secrets oder personenbezogenen Daten zurück. Beim erneuten Senden bleibt die ursprüngliche Ereignis-ID erhalten.
- Anfragen enthalten den User-Agent `Fileverity-Webhooks/1.0`.

### Automatisches Deaktivieren

Ein Endpunkt, bei dem die Zustellung 3 Tage lang durchgehend fehlschlägt, wird deaktiviert. Er erhält dann keine neuen Ereignisse, und seine nicht zugestellten Ereignisse werden als fehlgeschlagen markiert. Eine erfolgreiche Zustellung vor Ablauf dieser Frist setzt den Fehlerstatus zurück. Inhaber und Admins des Workspace werden per E-Mail informiert, wenn ein Endpunkt deaktiviert wird. Aktivieren Sie den Endpunkt nach der Behebung des Fehlers in der Konsole wieder.

## Das Testereignis

Wählen Sie in der Konsole bei einem Endpunkt **Test senden**, um sofort ein Beispielereignis zuzustellen. Es ist ein signiertes Ereignis `analysis.completed` mit `data.test` auf `true` und ohne Bericht, damit Sie Ihre URL und Ihren Signaturcode prüfen können, bevor echte Ereignisse eintreffen.

**Testereignis**

```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
  }
}
```

Siehe auch: [Analysen](https://fileverity.com/de/docs/analyses.md), [Idempotenz](https://fileverity.com/de/docs/idempotency.md) und [Fehler](https://fileverity.com/de/docs/errors.md).

