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
Abschnitt betitelt „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
webhookUrlundsecret. - Upload per URL — sende die JSON-Felder
callbackUrlundhmacSignature.
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"Event type |
Feuert, wenn |
|---|---|
case.queued |
Das PDF angenommen wurde und in die Analyse-Warteschlange kam. Feuert einmal und enthält die Start-Warteposition. |
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. |
Der Zustand quota_pending wird nicht als Webhook ausgeliefert.
Payload
Abschnitt betitelt „Payload“Der Request-Body ist JSON. Content-Type: application/json.
{ "id": "f1d2c3b4-0000-4a1e-8f3c-2d6b5a9e1c40", "type": "case.completed", "apiVersion": "2026-06-26", "occurredAt": "2026-06-26T12: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-26). 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. queued, completed, failed, canceled, quota_exceeded. |
data.queuePosition |
integer | nur case.queued — 1-basierter Start-Rang in der Warteschlange. |
data.queueTotal |
integer | nur case.queued — Gesamtzahl wartender Cases zu dem Zeitpunkt. |
data.estimatedWaitSeconds |
integer | nur case.queued — grobe Wartezeit-Schätzung in Sekunden. |
Null-Felder werden im JSON weggelassen.
Warteposition bei case.queued
Abschnitt betitelt „Warteposition bei case.queued“Das case.queued-Event ist das Push-Gegenstück zur Job-Status-Warteposition:
Es feuert einmal, im Moment der Annahme eines PDFs, und enthält die Startposition, sodass du
„du bist 7. in der Reihe, ~5 Min“ ohne Pollen anzeigen kannst.
{ "id": "a3b1c2d4-0000-4a1e-8f3c-2d6b5a9e1c40", "type": "case.queued", "apiVersion": "2026-06-26", "occurredAt": "2026-06-26T12:30:00Z", "data": { "caseId": "7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40", "fileName": "dokument.pdf", "jobStatus": "queued", "queuePosition": 7, "queueTotal": 23, "estimatedWaitSeconds": 300 }}Das ist ein diskretes Lifecycle-Event, kein Live-Stream — die Position ist ein Snapshot zum
Annahmezeitpunkt und wird nicht erneut gepusht, während die Schlange vorrückt. Für eine
fortlaufend aktualisierte Position polle GET /job-status/{caseId},
dessen Antwort dieselben queue*-Felder trägt, solange der Case wartet. Die queue*-Felder
erscheinen nur bei case.queued; jedes andere Event lässt sie weg.
Zustellungs-Header
Abschnitt betitelt „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
Abschnitt betitelt „Signatur verifizieren“Der X-Accessful-Signature-Header hat die Form t=<unix>,v1=<hex>. Berechne sie neu und
vergleiche in konstanter Zeit:
- Lies
tundv1aus dem Header. - 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. - Vergleiche in konstanter Zeit gegen
v1. - Optional: lehne ab, wenn
tälter als ein paar Minuten ist (Replay-Schutz).
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;}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));Retries & Idempotenz
Abschnitt betitelt „Retries & 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.