# Webhooks

Statt zu pollen, kannst du dir von Accessful ein **signiertes Event** an deinen Server
POSTen lassen, sobald ein Job seinen Zustand ändert.

## Wie die Registrierung funktioniert

Es gibt **kein Dashboard und keinen Registrierungs-Endpunkt**. Du hängst einen Callback
**pro Upload** an, und **du wählst das Signing-Secret selbst** — Accessful stellt keins aus.

- **Multipart-Upload** — sende die Felder `webhookUrl` und `secret`.
- **Upload per URL** — sende die JSON-Felder `callbackUrl` und `hmacSignature`.

```bash
curl -X POST "https://api.accessful.de/api/v1/upload-service/pdf/upload" \
  -H "X-API-Key: $ACCESSFUL_API_KEY" \
  -F "files=@dokument.pdf" \
  -F "webhookUrl=https://deine-app.example.com/hooks/accessful" \
  -F "secret=$DEIN_WEBHOOK_SECRET"
```
**Caution:** Eine Callback-URL pro **Case**, fest zum Upload-Zeitpunkt. Fehlt `webhookUrl` **oder**
`secret`, wird **kein Webhook gesendet** — ohne dein Secret lässt sich die Signatur nicht berechnen.

## Events

| Event `type` | Feuert, wenn |
| --- | --- |
| `case.running` | Die Verarbeitung gestartet ist. Kann mehrfach feuern (einmal pro Iteration). |
| `case.completed` | Das PDF/UA-Ergebnis zum Download bereit ist. |
| `case.failed` | Die Verarbeitung fehlgeschlagen ist. |
| `case.canceled` | Der Case abgebrochen wurde. |
| `case.quota_exceeded` | Abgelehnt, weil das Vertragskontingent erschöpft ist. |

Die Zustände `queued` und `quota_pending` werden **nicht** als Webhook ausgeliefert.

## Payload

Der Request-Body ist JSON. `Content-Type: application/json`.

```json
{
  "id": "f1d2c3b4-0000-4a1e-8f3c-2d6b5a9e1c40",
  "type": "case.completed",
  "apiVersion": "2026-06-05",
  "occurredAt": "2026-06-05T12:34:56Z",
  "data": {
    "caseId": "7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40",
    "fileName": "dokument.pdf",
    "jobStatus": "completed"
  }
}
```

| Feld | Typ | Hinweise |
| --- | --- | --- |
| `id` | UUID | Event-ID. Nutze sie als **Idempotenz-Schlüssel** (siehe unten). |
| `type` | string | Einer der obigen Event-Typen. |
| `apiVersion` | string | Vertragsversion (`2026-06-05`). Pinne sie, um Änderungen zu erkennen. |
| `occurredAt` | ISO-8601 | Erstellzeitpunkt des Events; stabil über Retries. |
| `data.caseId` | UUID | Der Case, um den es geht. |
| `data.fileName` | string | Der Dateiname. |
| `data.jobStatus` | string | Roher Job-Status, z. B. `completed`, `failed`, `canceled`, `quota_exceeded`. |

Null-Felder werden im JSON weggelassen.

### Zustellungs-Header

| Header | Beispiel | Zweck |
| --- | --- | --- |
| `X-Accessful-Signature` | `t=1749126896,v1=9f86d0…` | HMAC-Signatur — **diese verifizieren** |
| `X-Accessful-Webhook-Timestamp` | `1749126896` | Unix-Sekunden; identisch mit `t=` oben |
| `X-Accessful-Event-Id` | `f1d2c3b4-…` | Entspricht `id`; Idempotenz-Schlüssel |
| `X-Accessful-Event-Type` | `case.completed` | Routing ohne Body-Parsing |
| `X-Accessful-Case-Id` | `7c2f1e4a-…` | Die Case-ID |
| `X-Accessful-Delivery-Attempt` | `1` | Versuchszähler, 1-basiert |
| `X-Signature` | `n4bQgY…` | legacy base64-HMAC nur über den Body |

## Signatur verifizieren

Der `X-Accessful-Signature`-Header hat die Form `t=<unix>,v1=<hex>`. Berechne sie neu und
vergleiche in konstanter Zeit:

1. Lies `t` und `v1` aus dem Header.
2. Berechne `HMAC-SHA256(secret, "<t>.<roher Body>")` und hex-kodiere es. Die signierte
   Zeichenkette ist der Timestamp, ein wörtlicher Punkt, dann der **exakte rohe Request-Body**
   — verifiziere *vor* dem JSON-Parsen.
3. Vergleiche in konstanter Zeit gegen `v1`.
4. Optional: lehne ab, wenn `t` älter als ein paar Minuten ist (Replay-Schutz).

```js
import crypto from 'node:crypto';

// `rawBody` müssen die exakt empfangenen Bytes sein (z. B. express.raw()).
function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const valid = crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 Min
  return valid && fresh;
}
```
```java
String[] kv = signatureHeader.split(",");           // ["t=...", "v1=..."]
long t = Long.parseLong(kv[0].substring(2));
String v1 = kv[1].substring(3);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(sig);

boolean valid = MessageDigest.isEqual(
    expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
```
**Note:** `X-Signature` ist ein Legacy-Fallback: base64 von `HMAC-SHA256(secret, roher Body)` — nur
der Body, ohne Timestamp. Bevorzuge `X-Accessful-Signature`; nutze den Legacy-Header nur,
wenn du den neuen nicht lesen kannst.

## Retries &amp; Idempotenz

- **Erfolg** = jede **2xx**-Antwort innerhalb des Timeouts (5s zum Verbinden, 10s zum
  Antworten). Antworte schnell und erledige deine Arbeit asynchron.
- **Fehler** (kein 2xx, Timeout oder Verbindungsfehler) wird bis zu **10 Mal** mit
  exponentiellem Backoff und Jitter wiederholt — etwa `10s → 30s → 1,5m → 4,5m → 13,5m → 40m → 2h`,
  dann gedeckelt bei **6h**, abzüglich bis zu 20% Jitter. Das Gesamtfenster erstreckt sich
  über mehrere Stunden.
- Nach 10 fehlgeschlagenen Versuchen wird das Event **aufgegeben**. Für eine erneute
  Zustellung wende dich an den Support.
- **Idempotenz:** Dasselbe Event kann mehr als einmal ankommen (z. B. ein Retry, nachdem
  dein Endpunkt bereits erfolgreich war). Dedupliziere über `X-Accessful-Event-Id` — sie ist
  über jeden Retry stabil.