Webhooks
Instead of polling, you can have Accessful POST a signed event to your server the moment a job changes state.
How registration works
Section titled “How registration works”There is no dashboard and no registration endpoint. You attach a callback per upload, and you choose the signing secret — Accessful does not issue one.
- Multipart upload — send form fields
webhookUrlandsecret. - Upload by URL — send JSON fields
callbackUrlandhmacSignature.
curl -X POST "https://api.accessful.de/api/v1/upload-service/pdf/upload" \ -H "X-API-Key: $ACCESSFUL_API_KEY" \ -F "files=@document.pdf" \ -F "webhookUrl=https://your-app.example.com/hooks/accessful" \ -F "secret=$YOUR_WEBHOOK_SECRET"Events
Section titled “Events”Event type | Fires when |
|---|---|
case.running | The job started processing. May fire more than once (one per iteration). |
case.completed | The PDF/UA result is ready to download. |
case.failed | Processing failed. |
case.canceled | The case was canceled. |
case.quota_exceeded | Rejected because the contract quota is exhausted. |
The queued and quota_pending states are not delivered as webhooks.
Payload
Section titled “Payload”The request body is JSON. Content-Type: application/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": "document.pdf", "jobStatus": "completed" }}| Field | Type | Notes |
|---|---|---|
id | UUID | Event ID. Use it as the idempotency key (see below). |
type | string | One of the event types above. |
apiVersion | string | Contract version (2026-06-05). Pin it to detect changes. |
occurredAt | ISO-8601 | When the event was created; stable across retries. |
data.caseId | UUID | The case this event is about. |
data.fileName | string | The file name. |
data.jobStatus | string | Raw job status, e.g. completed, failed, canceled, quota_exceeded. |
Null fields are omitted from the JSON.
Delivery headers
Section titled “Delivery headers”| Header | Example | Purpose |
|---|---|---|
X-Accessful-Signature | t=1749126896,v1=9f86d0… | HMAC signature — verify this |
X-Accessful-Webhook-Timestamp | 1749126896 | Unix seconds; same as t= above |
X-Accessful-Event-Id | f1d2c3b4-… | Equals id; idempotency key |
X-Accessful-Event-Type | case.completed | Routing without parsing the body |
X-Accessful-Case-Id | 7c2f1e4a-… | The case ID |
X-Accessful-Delivery-Attempt | 1 | Attempt counter, 1-based |
X-Signature | n4bQgY… | legacy base64 HMAC over body only |
Verify the signature
Section titled “Verify the signature”The X-Accessful-Signature header has the form t=<unix>,v1=<hex>. Recompute it and
compare in constant time:
- Read
tandv1from the header. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")and hex-encode it. The signed string is the timestamp, a literal dot, then the exact raw request body — verify before JSON parsing. - Constant-time compare against
v1. - Optionally reject if
tis older than a few minutes (replay protection).
import crypto from 'node:crypto';
// `rawBody` must be the exact bytes received (e.g. 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 & idempotency
Section titled “Retries & idempotency”- Success = any 2xx response within the timeout (5s to connect, 10s to respond). Answer fast and do your work asynchronously.
- Failure (non-2xx, timeout, or connection error) is retried up to 10 attempts with
exponential backoff and jitter — roughly
10s → 30s → 1.5m → 4.5m → 13.5m → 40m → 2h, then capped at 6h, minus up to 20% jitter. The whole window spans several hours. - After 10 failed attempts the event is given up. Contact support to redeliver.
- Idempotency: the same event may arrive more than once (e.g. a retry after your endpoint
already succeeded). De-duplicate on
X-Accessful-Event-Id— it is stable across every retry.