# Webhooks

Instead of polling, you can have Accessful **POST a signed event** to your server the
moment a job changes state.

## 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 `webhookUrl` and `secret`.
- **Upload by URL** — send JSON fields `callbackUrl` and `hmacSignature`.

```bash
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"
```
**Caution:** One callback URL per **case**, fixed at upload time. If either `webhookUrl` **or**
`secret` is missing, **no webhook is sent** — the signature can't be computed without your secret.

## 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

The request body is 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": "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

| 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

The `X-Accessful-Signature` header has the form `t=<unix>,v1=<hex>`. Recompute it and
compare in constant time:

1. Read `t` and `v1` from the header.
2. 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.
3. Constant-time compare against `v1`.
4. Optionally reject if `t` is older than a few minutes (replay protection).

```js
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;
}
```
```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` is a legacy fallback: base64 of `HMAC-SHA256(secret, raw body)` — body only,
no timestamp. Prefer `X-Accessful-Signature`; only use the legacy header if you can't read the new one.

## Retries &amp; 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.