---
name: accessful-api
description: >-
  Integrate a client with the Accessful PDF-to-PDF/UA API. Use when uploading
  PDFs for accessibility remediation, polling job status, downloading the
  resulting accessible PDF/UA, registering and verifying webhooks, authenticating
  with an X-API-Key, or handling the API's limits and errors. Covers every
  endpoint, the job-status lifecycle, HMAC-SHA256 webhook signatures, and the
  RFC 7807 error shape.
---

# Accessful PDF → PDF/UA API

This skill teaches an agent everything needed to connect a client to the
**Accessful API**, which converts PDFs into accessible **PDF/UA** documents over
a simple HTTP API. The whole integration is: **upload → poll → download** (or let
a **webhook** notify you), authenticated with a single API key.

> Scope note: this skill mirrors the public Accessful documentation. It describes
> only the documented public surface — the upload service and the (optional)
> key-management endpoints. Do not assume undocumented endpoints, parameters, or
> behaviour.

## Base URLs

```
Public base URL:        https://api.accessful.de
Upload service base:    https://api.accessful.de/api/v1/upload-service
```

All processing endpoints in this skill are relative to the **upload service base**
unless written out in full.

## Authentication

Every request is authenticated with a single **API key**, sent as the
`X-API-Key` header. There is no OAuth dance and no token exchange for normal use.

- A key looks like `ak_oCe4cm015zL8vCbBne_wUZDcM6G1RWqD7Ekc2944EAA` — the prefix
  `ak_` followed by **43 URL-safe characters**.
- Up to **10 active keys** per account. A key may carry an optional expiry and can
  be revoked at any time.
- The key is created in the Accessful portal and shown **once** at creation (only
  a hash is stored). Copy it immediately.
- **Scope:** an API key grants access to the **upload service only**
  (`/api/v1/upload-service/*` — upload, job status, download, delete). That is the
  entire public processing surface.

Send it on every request:

```bash
curl https://api.accessful.de/api/v1/upload-service/job-status/<caseId> \
  -H "X-API-Key: ak_your_key_here"
```

```js
await fetch(`https://api.accessful.de/api/v1/upload-service/job-status/${caseId}`, {
  headers: { 'X-API-Key': process.env.ACCESSFUL_API_KEY },
});
```

```java
HttpRequest.newBuilder()
    .uri(URI.create("https://api.accessful.de/api/v1/upload-service/job-status/" + caseId))
    .header("X-API-Key", System.getenv("ACCESSFUL_API_KEY"))
    .GET().build();
```

### Verify a key

Smoke-test a key by asking for a random, non-existent case. A working key returns
**404** (the case doesn't exist) — *not* 401.

```bash
curl -i https://api.accessful.de/api/v1/upload-service/job-status/00000000-0000-0000-0000-000000000000 \
  -H "X-API-Key: ak_your_key_here"
```

- **`404 Not Found`** → the key is accepted. ✅
- **`401 Unauthorized`** → the key is missing, malformed, or revoked.

### Auth errors

| Status | Meaning |
| --- | --- |
| `401 Unauthorized` | No key, malformed key, or a revoked/expired key. Rejected at the gateway, before the request reaches the application — no Problem Details body; rely on the status code. |
| `403 Forbidden` | The key is valid but lacks permission for that action. Comes from the application with a [Problem Details](#errors) body. |

### Managing keys programmatically (optional)

Key rotation can be automated via the key-management endpoints under
`/api/v1/auth/api-keys`. These require an **OAuth2 bearer token** from a portal
login (`Authorization: Bearer <jwt>`), **not** an API key:

| Method & path | Purpose |
| --- | --- |
| `POST /api/v1/auth/api-keys` | Create a key — response contains the plaintext **once**. |
| `GET /api/v1/auth/api-keys` | List your keys (values masked). |
| `POST /api/v1/auth/api-keys/{id}/revoke` | Revoke a key. |
| `DELETE /api/v1/auth/api-keys/{id}` | Delete a key. |

## Quickstart — the whole roundtrip

Export your key and the base URL, then run upload → poll → download → (optional)
delete.

```bash
export ACCESSFUL_API_KEY="ak_your_key_here"
export BASE="https://api.accessful.de/api/v1/upload-service"

# 1 — Upload a PDF (multipart field "files"); receive a caseId.
curl -X POST "$BASE/pdf/upload" \
  -H "X-API-Key: $ACCESSFUL_API_KEY" \
  -F "files=@document.pdf"
# -> { "successfulUploads": ["<caseId>"], "duplicateFiles": [], "message": "...", "callbackUrl": null }

# 2 — Poll job status until it is "completed".
curl "$BASE/job-status/<caseId>" -H "X-API-Key: $ACCESSFUL_API_KEY"
# -> { "jobStatus": "completed", "stage": "finished", "score": 87 }

# 3 — Download the converted PDF/UA.
curl -L "$BASE/download/<caseId>" \
  -H "X-API-Key: $ACCESSFUL_API_KEY" \
  -o document-pdfua.pdf

# 4 — Delete the case when no longer needed (optional; permanent purge).
curl -X DELETE "$BASE/delete/<caseId>" -H "X-API-Key: $ACCESSFUL_API_KEY"
```

`score` is the accessibility quality of the result (0–100). Instead of step 2 you
can register a [webhook](#webhooks) at upload time and be notified when the job
finishes.

### Full client example (Node.js)

```js
const BASE = 'https://api.accessful.de/api/v1/upload-service';
const headers = { 'X-API-Key': process.env.ACCESSFUL_API_KEY };

// 1. Upload
const form = new FormData();
form.append('files', new Blob([await readFile('document.pdf')], { type: 'application/pdf' }), 'document.pdf');
const up = await fetch(`${BASE}/pdf/upload`, { method: 'POST', headers, body: form });
const { successfulUploads: [caseId] } = await up.json();

// 2. Poll until terminal
const terminal = new Set(['completed', 'failed', 'analyzer_failed', 'canceled', 'quota_exceeded']);
let status;
do {
  await new Promise((r) => setTimeout(r, 2000));
  status = await (await fetch(`${BASE}/job-status/${caseId}`, { headers })).json();
} while (!terminal.has(status.jobStatus));

if (status.jobStatus !== 'completed') throw new Error(`Job ${status.jobStatus}`);

// 3. Download
const pdf = await (await fetch(`${BASE}/download/${caseId}`, { headers })).arrayBuffer();
await writeFile('document-pdfua.pdf', Buffer.from(pdf));
console.log(`Done — score ${status.score}`);
```

## Endpoint reference

All paths below are relative to `https://api.accessful.de/api/v1/upload-service`
and require the `X-API-Key` header.

### Upload a PDF

```http
POST /pdf/upload
Content-Type: multipart/form-data
```

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `files` | file | **yes** | One or more PDFs (`application/pdf`). |
| `webhookUrl` | string | no | Callback URL for [webhook](#webhooks) events. |
| `secret` | string | no | Your HMAC signing secret (required for webhooks). |
| `folder-name` | string | no | Optional folder to group the case. |

**`200 OK`**

```json
{
  "successfulUploads": ["7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40"],
  "duplicateFiles": [{ "fileName": "document.pdf", "fileHash": "ab12cd34…" }],
  "message": "Upload completed successfully. Uploaded 1 files. 1 duplicates found.",
  "callbackUrl": "https://your-app.example.com/hooks/accessful"
}
```

- `successfulUploads` — one **`caseId`** per accepted file. Track and download by this ID.
- `duplicateFiles` — files skipped because the same content was already uploaded under your key.

Errors: `400` (non-PDF file or invalid webhook URL), `413` ([too large](#limits--retention)).
Quota is checked **after** upload — an exhausted quota does not fail this call; the
job ends in the `quota_exceeded` status instead.

### Upload by URL

Hand the API a list of URLs and it fetches the PDFs itself. Downloads run
asynchronously.

```http
POST /pdf/upload-by-url-list
Content-Type: application/json
```

```json
{
  "files": [
    { "url": "https://example.com/report.pdf", "filename": "report.pdf" }
  ],
  "callbackUrl": "https://your-app.example.com/hooks/accessful",
  "hmacSignature": "your-webhook-secret"
}
```

- `filename` must match `^[A-Za-z0-9_-]+\.pdf$`.
- `hmacSignature` (the webhook secret) must match `^[A-Za-z0-9_-]{1,64}$`.

**`202 Accepted`**

```json
{
  "accepted": [
    { "uri": "https://example.com/report.pdf", "jobId": "7c2f1e4a-…", "filename": "report.pdf" }
  ],
  "failures": {},
  "callbackResult": null
}
```

Each `jobId` behaves like a `caseId` — poll and download with it.

### Job status

```http
GET /job-status/{caseId}
```

**`200 OK`**

```json
{ "jobStatus": "completed", "stage": "finished", "score": 87 }
```

`score` is the accessibility quality of the result, **0–100** (final once
`completed`). `404` is returned for an unknown `caseId`.

The response has two independent fields: `jobStatus` is the coarse outcome (use it
to decide when the job is done), while `stage` is the fine-grained pipeline phase
of a running case (use it to show *where* a running case is).

| `jobStatus` | Meaning | Terminal? |
| --- | --- | --- |
| `queued` | Waiting in the queue. | no |
| `running` | Currently processing. | no |
| `completed` | Finished — result ready to download. | **terminal** (success) |
| `failed` | Processing failed. | **terminal** |
| `analyzer_failed` | The accessibility analysis step failed. | **terminal** |
| `canceled` | The job was canceled. | **terminal** |
| `quota_pending` | Awaiting an asynchronous quota check. | no |
| `quota_exceeded` | Rejected — contract quota exhausted. | **terminal** |

**Polling rule:** stop as soon as you see a terminal `jobStatus`. Only `completed`
produces a downloadable result; any other terminal state means no PDF/UA was
produced.

`stage` reports the pipeline phase of a running case. `jobStatus: "completed"`
always coincides with `stage: "finished"` — the point at which `score` is final.

| `stage` | Meaning |
| --- | --- |
| `queued` | Accepted; waiting for the first analysis. |
| `analyzing` | Initial accessibility analysis is running. |
| `resolving` | AI remediation is running. |
| `revalidating` | The remediated file is being re-analyzed; `score` is being recomputed. |
| `finished` | Done — `score` reflects the final remediated file. |
| `failed` | Processing stopped on an error. |

### Download the result

```http
GET /download/{caseId}
```

Returns the converted PDF/UA as binary `application/pdf` with
`Content-Disposition: attachment`. The response also carries a `pdf-version`
header with the iteration number. An optional `?suffix=` query string is appended
to the downloaded file name.

`404` is returned while no result exists yet — poll [job status](#job-status)
until `completed` first.

### Delete a case

```http
DELETE /delete/{caseId}
```

Permanently purges the case, all its iterations, and the stored files.

- Deletion is **irreversible** — no soft-delete, no recovery.
- You can only delete cases that belong to your key.
- Idempotent: deleting an already-removed case still returns `200`.

**Batch delete** — remove several cases in one call:

```http
DELETE /delete
Content-Type: application/json
```

```json
{ "caseIds": ["7c2f1e4a-…", "3a9b8c7d-…"] }
```

The response summarises the outcome per case.

## Webhooks

Instead of polling, Accessful can **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** (`POST /pdf/upload`) — send form fields `webhookUrl` and `secret`.
- **Upload by URL** (`POST /pdf/upload-by-url-list`) — 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"
```

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

`Content-Type: application/json`. Null fields are omitted.

```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**. |
| `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`. |

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

`X-Accessful-Signature` has the form `t=<unix>,v1=<hex>`. Recompute 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));
```

`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 & idempotency

- **Success** = any **2xx** response within the timeout (5 s to connect, 10 s 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.

## Limits & retention

### Upload limits

| Limit | Value |
| --- | --- |
| Max size per file | **200 MB** |
| Max size per request | **815 MB** |
| Max files per request | **1000** |
| Accepted type | **PDF only** (`application/pdf`) |

Exceeding a size limit returns `413`; a non-PDF file is rejected with `400`. There
is **no hard limit on page count** — large documents simply take longer.

### Rate limits & concurrency

- There is **no fixed public request-rate limit** and **no synchronous rejection on
  upload**. Throughput is governed by your **contract upload quota**, checked
  **asynchronously** after the upload is accepted.
- An upload is accepted immediately (`200`, or `202` for upload-by-url) and returns
  a `caseId`. If the quota is exhausted, that job does not process: its status moves
  to `quota_pending` and then terminal `quota_exceeded` (and a `case.quota_exceeded`
  webhook fires if you registered one).
- There is **no `429`** to catch — watch the job status instead.
- **No per-account concurrency cap** — you may submit and process multiple PDFs in
  parallel, bounded only by your quota. Jobs run from a shared processing queue, so
  end-to-end time depends on overall load as well as document size.

### Retention & deletion

- **Retention:** cases and their files are **kept until you delete them**. There is
  no automatic expiry — nothing is purged on a timer.
- **Deletion is permanent.** `DELETE /delete/{caseId}` removes the case, all its
  iterations, and the stored files **irreversibly**. Download and store any result
  you need to keep **before** calling `DELETE`.

## Errors

Application-level errors are returned as **RFC 7807 Problem Details** with
`Content-Type: application/problem+json`:

```json
{
  "type": "about:blank",
  "title": "Case ID not found",
  "status": 404,
  "detail": "No case exists for the given ID.",
  "instance": "/api/v1/upload-service/job-status/7c2f1e4a-…"
}
```

| Field | Description |
| --- | --- |
| `type` | A URI identifying the problem type, or `about:blank`. |
| `title` | Short, human-readable summary. |
| `status` | The HTTP status code, repeated in the body. |
| `detail` | Human-readable explanation for this occurrence. |
| `instance` | The request path that produced the error. |

Some problems add extra fields. A `413` carries the configured limits:

```json
{
  "type": "https://accessful.de/problems/upload/payload-too-large",
  "title": "Payload Too Large",
  "status": 413,
  "detail": "Maximum upload size exceeded.",
  "maxFileSize": "200MB",
  "maxRequestSize": "815MB",
  "maxFileCount": "1000",
  "limitExceeded": "per-file"
}
```

### Common status codes

| Status | When |
| --- | --- |
| `400 Bad Request` | Malformed input — invalid webhook URL, non-PDF file, or a malformed request body. |
| `401 Unauthorized` | Missing, malformed, or revoked API key. Produced at the gateway — no Problem Details body. |
| `403 Forbidden` | Valid key without permission for the action. |
| `404 Not Found` | Unknown or malformed `caseId`, or no result available yet. |
| `409 Conflict` | The request conflicts with current state. |
| `413 Payload Too Large` | File or request exceeds the size limits. |
| `500 Internal Server Error` | Unexpected server error — safe to retry later. |

Notes:

- **401** is produced at the gateway, before the request reaches the application — it does
  **not** carry a Problem Details body, so rely on the status code. **403** and every other
  application error use the Problem Details body above.
- **Quota is not a `429`.** An exhausted contract quota does not return an HTTP
  error: uploads accept with `200`, then the job ends in the `quota_exceeded` status.

## Client integration checklist

1. **Obtain an API key** from the Accessful portal; store it as a secret (e.g.
   `ACCESSFUL_API_KEY`). Treat it like a password — it can upload, download, and
   permanently delete cases. Never ship it in client-side code.
2. **Send `X-API-Key`** on every request to the upload service.
3. **Upload** a PDF via `POST /pdf/upload` (multipart `files`) or
   `POST /pdf/upload-by-url-list`; capture the `caseId` / `jobId`.
4. **Either poll** `GET /job-status/{caseId}` until a terminal state (poll every
   ~2 s; stop on `completed`/`failed`/`analyzer_failed`/`canceled`/`quota_exceeded`),
   **or register a webhook** at upload time (`webhookUrl` + `secret`) and verify the
   `X-Accessful-Signature` on delivery.
5. On `completed`, **download** via `GET /download/{caseId}` and persist the bytes.
6. **Handle errors** as RFC 7807 Problem Details; remember 401 is an empty-body
   gateway error and there is no 429 (quota surfaces as `quota_exceeded`).
7. **Delete** the case with `DELETE /delete/{caseId}` once you have stored the
   result, if you do not need it retained (deletion is permanent).
