Developers · API reference
Document accessibility, as an API call
Check and remediate PDF accessibility from your own system: submit a document, poll (or get a webhook), then fetch a signed, independently verifiable evidence record. This page is the exact contract the API implements, with copy-paste examples.
Create an API key and get 200 free credits to evaluate the integration on your own documents: real remediation, real signed evidence, no sales call. One-time evaluation per organisation. Continue on a monthly allotment from €0.30 per credit.
Quickstart
Four calls take a PDF from your system to a signed evidence record. Base URL https://api.normfluss.com; every endpoint below is under /v1 and authenticates with a Bearer key.
Checking is free; fixing is the paid step. POST /v1/documents defaults to remediate=false — a free check that ends at check_complete and produces findings but no downloadable file. Pass remediate=true (below) to fix the document, or check first and call POST /v1/documents/{id}/remediate once you have seen what it costs. Credits are only ever spent on the remediate path.
# 0. Create a key in the developer console, then export it.
# (Windows PowerShell: $env:NF_KEY = "nf_live_...")
export NF_KEY=nf_live_...
# 1. Submit a PDF (multipart). Returns 202 with a job_id, a document_id, and
# status "queued". KEEP BOTH IDS - the job poll returns only the job_id.
# remediate=true asks for the paid fix; omit it for a free check-only run.
SUBMIT=$(curl -s -X POST https://api.normfluss.com/v1/documents \
-H "Authorization: Bearer $NF_KEY" \
-F file=@document.pdf \
-F remediate=true)
JOB=$(echo "$SUBMIT" | jq -r .job_id)
DOC=$(echo "$SUBMIT" | jq -r .document_id)
# 2. Poll until the job leaves a non-terminal status.
curl -s https://api.normfluss.com/v1/jobs/$JOB \
-H "Authorization: Bearer $NF_KEY" | jq
# 3. When status is "completed", download the remediated PDF.
# (A free check ends at "check_complete" and has nothing to download.
# A job at "awaiting_review" is waiting on a human decision - see below.)
curl -s https://api.normfluss.com/v1/documents/$DOC/download \
-H "Authorization: Bearer $NF_KEY" -o remediated.pdf
# 4. ...and fetch the signed evidence record. Once evidence is issued the job
# poll carries validation_id, so no webhook is needed to find it.
VID=$(curl -s https://api.normfluss.com/v1/jobs/$JOB \
-H "Authorization: Bearer $NF_KEY" | jq -r .validation_id)
curl -s https://api.normfluss.com/v1/evidence/$VID \
-H "Authorization: Bearer $NF_KEY" | jqPrefer not to poll? Register a webhook (see below) and Normfluss calls you on job.completed, job.failed, job.awaiting_review, and evidence.issued.
Authentication & keys
Every request carries an API key as a Bearer token. Keys are minted per organisation in the developer console (or via POST /v1/api-keys). The full secret is shown once at creation and stored only as an argon2id hash, if you lose it, rotate or reissue; it cannot be recovered.
Authorization: Bearer nf_live_a8f3_xxxxxxxxxxxxxxxxxxxxxxxxA key looks like nf_live_<id>_<secret>. To try the API without spending, your first key comes with a one-time free evaluation of 200 credits — enough to remediate real documents end to end before you buy.
Scopes
A self-serve key carries the full partner loop. Each endpoint requires one scope; a key missing it gets 403 forbidden.
| Scope | Grants |
|---|---|
| documents:write | Submit, revalidate, retry, delete documents; mint review tokens |
| documents:read | List documents, read findings |
| evidence:read | Read evidence records; list grants and share tokens |
| evidence:write | Create grants and share links; revoke them |
| webhooks:manage | Register, list, and delete webhook endpoints |
Rotating a key
Rotation mints a replacement and keeps the old key alive for a grace window (default 24h, max 7 days) so you can swap secrets without downtime. Pass {"grace_seconds": 0} to kill a compromised key immediately.
curl -X POST https://api.normfluss.com/v1/api-keys/$KEY_ID/rotate \
-H "Authorization: Bearer $NF_KEY" \
-H "Content-Type: application/json" \
-d '{"grace_seconds": 86400}'Job lifecycle & statuses
Submitting a document creates a job. A job runs asynchronously and its status is one of exactly these values. Nothing is labelled conformant before human review completes, that is the point of the awaiting_review state.
| status | Meaning | Terminal? |
|---|---|---|
| queued | Accepted and waiting for a worker. This is what POST /v1/documents returns. | no |
| processing | Received and parsing. | no |
| remediating | The remediation engine is fixing structure, tags, reading order, metadata. | no |
| validating | veraPDF is validating against PDF/UA-1 and the parts of WCAG 2.2 AA that apply to PDFs. | no |
| checking | Check-only run in progress (no remediation). | no |
| awaiting_review | Human-judgement findings are open. Either every machine-checkable rule passed, or some still fail (then error_code is automated_fixes_complete). See the review block. | no |
| finalizing | Review done; producing the finalized file and issuing evidence. | no |
| completed | Finished. Finalized bytes downloadable; evidence issued. Read the record's result: conformant_in_scope or not_conformant. | yes |
| check_complete | A check-only job finished (terminal for that path). | yes |
| superseded | Replaced by a newer job on the same document. | yes |
| failed | Ended in a failure state. See error_code + retryable. | yes |
A failed job carries a machine-readable error_code (see Errors) and a retryable flag. Retryable failures can be re-run with POST /v1/documents/{id}/retry. A retry re-runs the failed job from the file it started from. If only the last step failed after the work was done (finishing a review, or signing the evidence record), the same job is finished instead: your review decisions are kept and nothing is charged again.
Submit a document
Multipart form. Provide either a file (multipart upload) or a url to fetch, never both. A URL is fetched over HTTPS only and re-validated through the same checks an upload faces. Requires scope documents:write.
| Field | In | Notes |
|---|---|---|
| file | multipart | The PDF. Max size and %PDF magic-byte checks apply. |
| url | form field | HTTPS URL to fetch instead of file. One of file/url is required. |
| remediate | form field | Default false. false runs a FREE check ending at check_complete (findings only, nothing to download). true runs the paid fix and spends credits. |
| language | form field | Optional BCP-47 hint, e.g. de-DE, overriding what we detect. Accepted for any language. Draft image descriptions are available in English, Dutch, French and German (see Current limitations). |
| profile_id | form field | Optional template profile UUID for structured documents. |
| Idempotency-Key | header | Optional. Replaying the same key returns the ORIGINAL job, never a duplicate. |
curl -X POST https://api.normfluss.com/v1/documents \
-H "Authorization: Bearer $NF_KEY" \
-H "Idempotency-Key: 3f9c2a10-annual-report" \
-F file=@annual-report.pdf \
-F language=de-DE \
-F remediate=true{
"job_id": "8f2c4e6a-1b3d-4f5e-9a7c-2d8e0f1a3b5c",
"document_id": "d1e2f3a4-5b6c-7d8e-9f0a-1b2c3d4e5f60",
"status": "queued"
}On the remediate path credits are reserved before work starts (1 credit = 1 page). If the balance is short you get 402 quota_exceeded with credits_needed / credits_available in detail, or 402 evaluation_exhausted once the free evaluation is spent. An encrypted or over-limit PDF is refused up front.
Ask the price first
Send the same bytes to get the page count and the exact cost before committing. Nothing is stored and nothing is charged: the PDF is validated, counted, and dropped — no document, no job, no ledger entry. Useful for totalling a backlog without uploading it one refusal at a time. A quote is a snapshot, not a reservation.
curl -X POST https://api.normfluss.com/v1/documents/quote \
-H "Authorization: Bearer $NF_KEY" \
-F file=@annual-report.pdf
{ "pages": 3, "credits_required": 3, "credits_available": 97,
"credits_short": 0, "sufficient": true, "pool": "api",
"enforcement_active": true }pool names which wallet pays: an API key always draws the api wallet, a web session the platform one.
Poll a job
Returns the job's current status, a findings summary, an optional review block, and, on failure, the error code and whether it is retryable. Poll until the status is terminal, or rely on webhooks.
{
"job_id": "8f2c4e6a-1b3d-4f5e-9a7c-2d8e0f1a3b5c",
"status": "awaiting_review",
"findings_summary": { "machine": 2, "human_required": 4, "fix_at_source": 0, "instances": 9 },
"review": {
"review_required": true,
"review_url": "https://www.normfluss.com/review/3b7d9e1f-...",
"review_status": "not_started",
"review_scope": { "criteria": ["pdfua_7.1-3", "wcag_1.1.1"], "count": 4 },
"reviewer": null
},
"error_code": "automated_fixes_complete",
"retryable": false,
"validation_id": null,
"progress": null
}{
"job_id": "8f2c4e6a-1b3d-4f5e-9a7c-2d8e0f1a3b5c",
"status": "completed",
"findings_summary": { "machine": 41, "human_required": 0 },
"review": null,
"error_code": null,
"retryable": null,
"validation_id": "e5f6a7b8-...",
"progress": null
}validation_id is populated once evidence is issued, so a poller can go straight to the record without waiting for the webhook. A job may carry error_code: automated_fixes_complete. That is not a failed job: the automated fixes were applied, but some machine-checkable rules still fail on the file. At awaiting_review there are also human-judgement findings to decide; at completed the file was delivered with a not_conformant record that lists what still fails. No human decision makes a failing machine-checkable rule pass.
Criterion identifiers
Every criterion is named by a stable code, the same in every response and in the signed record: a WCAG success criterion is wcag_ and its number (wcag_1.1.1, text alternatives); a PDF/UA-1 rule is pdfua_, its clause, a hyphen and the test number, as veraPDF numbers them (pdfua_7.1-3: content is neither tagged nor marked as an artifact). Where a record lists the profile's criteria, a WCAG code carries its version (wcag_1.1.1@2.2). In the example above, review_scope.criteria lists each criterion with an item to decide once, and count is the number of items: three images under wcag_1.1.1 and one untagged passage under pdfua_7.1-3. The findings endpoint gives each item's criterion_code with a readable title; dispositions in the record name the same code in criterion. UUIDs identify resources only (jobs, documents, findings, records), never a criterion.
Human review (Layer 2)
Some findings need human judgement (is this alt-text meaningful? does the reading order make sense?). A machine cannot decide these, so a job that reaches awaiting_review pauses until they are dispositioned. There are two ways to handle it.
You review it yourself
When you poll GET /v1/jobs/{id} and the job is awaiting review, the response contains a review.review_url: the editor for this document in the web app. Open it signed in to your Normfluss account; the record names the account that decided. No extra call needed.
Your end-customer reviews
If the reviewer is your customer (not a Normfluss user), mint a review link bound to an attested identity. This is the single injection point for an external reviewer's identity; it is recorded in every disposition and in the evidence.
In the evidence record, each decision made through a link minted with an API key has by.kind: "external" and by.party: "end_customer", with attested_by_platform_org_id (your organisation), reviewer_name and reviewer_email. A link minted by a signed-in user instead records by.party: "named_by_account" with named_by_organization_id and named_by_user_id: the account holder named the reviewer, and nobody verified who they are. reviewers_summary.parties lists customer, end_customer and named_by_account as they occur. A reviewer who decides while signed in is by.kind: "internal". A decision that wrote an image description also carries text: the description written into the file, and proposal: machine when the machine drafted a description for the reviewer to accept or edit, none when the reviewer wrote it with no draft. Both keys are absent on every other decision, and proposal on records issued before 8 October 2026. criteria_not_testable_automatically lists the profile's criteria a machine cannot decide (9 in the current profile, the same on every record), not this document's items; the items this document needed a person for are the dispositions. Records issued before this change, on 7 October 2026, list one entry per item there instead. Records issued earlier keep the values they were signed with. A link must name a reviewer (a name and an email address), or minting returns 400 invalid_request.
The job must already be at awaiting_review. A review token is only meaningful once Layer 1 has passed and human-judgement findings are open, so minting one earlier returns 400 invalid_request. review_scope.criteria carries criterion codes (see Criterion identifiers); the readable titles come from the findings endpoint.
curl -X POST https://api.normfluss.com/v1/jobs/$JOB/review-token \
-H "Authorization: Bearer $NF_KEY" \
-H "Content-Type: application/json" \
-d '{
"reviewer_email": "editor@customer.example",
"reviewer_name": "Jordan Rivera",
"reviewer_org_ref": "customer-acct-4471"
}'
# → 201
# { "review_url": "https://app.normfluss.com/r/cd34_xxxx", "expires_at": "..." }Send your customer to review_url, they need no Normfluss account, and the link is scoped to this one job (default TTL 14 days). It is returned once, here: we keep only its hash, so store it if you need it again. The editor is built to run in an iframe.
Once all required findings are dispositioned, the job advances to finalizing and then completed, and you receive job.completed + evidence.issued.
Evidence & download
Every completed job issues a signed evidence record. It states what was checked, the result, and an Ed25519 signature over the document hash and scope. Requires scope evidence:read. The validation_id arrives in the evidence.issued webhook, or as validation_id on the document listing.
{
"validation_id": "e5f6a7b8-...",
"scope": { "conformance_target": {"standard_id": "pdf_ua", "version": "1"}, "...": "..." },
"result": "conformant_in_scope",
"revalidation_state": "current",
"signature": "base64-ed25519-signature",
"signing_key_id": "nf-prod-2026-09",
"report_url": null,
"conformance_json_url": null,
"stale_warning": null
}A full record, with its scope (the object the signature covers), looks like this:
{
"validation_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"result": "conformant_in_scope",
"revalidation_state": "current",
"signing_key_id": "nf-prod-2026-09",
"signature": "q4Hc…(base64 Ed25519 signature over scope)",
"stale_warning": null,
"scope": {
"result": "conformant_in_scope",
"conformance_target": {
"standard_id": "pdf_ua", "version": "1", "full_ref": "ISO 14289-1:2014"
},
"language": "en-GB",
"document": {
"sha256": "ce8c79ce111e868337ca46e5de9cea7359c1a2165faf9d173ecd8708b02ad86f",
"page_count": 4
},
"validator": { "name": "veraPDF", "version": "verapdf-1.30.2" },
"criteria_tested": [
{ "code": "pdfua_7.1-1", "method": "automated", "result": "pass" },
{ "code": "pdfua_7.1-3", "method": "automated", "result": "pass" },
"… one entry for each of the profile's 110 machine-checkable criteria"
],
"criteria_not_testable_automatically": [
{ "code": "wcag_1.1.1@2.2", "note": "…" },
{ "code": "wcag_1.3.2@2.2", "note": "…" },
"… the profile's 9 criteria a machine cannot decide, the same on every record"
],
"human_review": {
"performed": true,
"completed_at": "2026-09-28T10:14:02Z",
"reviewers_summary": { "parties": ["customer"], "reviewer_count": 1, "mixed": false },
"dispositions": [
{
"criterion": "wcag_1.1.1",
"finding_id": "2a5d359d-48a5-4f2e-9d86-b7ab00d38bd8",
"disposition": "reviewed_accepted",
"by": { "kind": "internal", "role": "reviewer",
"user_id": "…", "organization_id": "…" },
"at": "2026-09-28T10:13:47Z",
"text": "Riverbank Trust logo: a line drawing of a bridge over water.",
"proposal": "machine"
}
]
},
"exceptions": [],
"engines": { "validator": "verapdf-1.30.2", "tagger": "opendataloader-2.5.0", "…": "…" },
"issued_at": "2026-09-28T10:14:05Z",
"validation_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"disclaimer": "Attests automated checks in scope passed and listed human-judgment criteria were reviewed by the recorded reviewers (see human_review). Not a determination of legal compliance."
}
}If the document changed after this evidence was issued, stale_warning is populated, stale evidence is never presented as current. Download the finalized PDF (only available once completed):
Public verification
Anyone, your customer, an auditor, can verify an evidence record without an API key. The public endpoint confirms the record exists, which document hash it attests, and that a known key signed it. It reveals nothing about the document, organisation, or reviewers.
The second endpoint publishes the Ed25519 public key so a third party can verify the signature offline, without trusting us. The signed payload is the canonical JSON (UTF-8, recursively sorted keys, no insignificant whitespace) of an object with exactly two members — the lowercase-hex bound_sha256 and the evidence scope:
canonical_json({"bound_sha256": "<lowercase hex>", "scope": { ... }})bound_sha256 binds the signature to the exact bytes the record attests, and is returned as document_sha256 by GET /verify/{validation_id}. Note this is an object containing both values, not the two values concatenated — canonicalise the whole object or the bytes, and the signature, will differ. Floats are rejected outright, because their representation varies between languages.
import base64, json, urllib.request
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature
V = "<validation_id>"
# The record (needs evidence:read) and the public key + hash (no key at all).
req = urllib.request.Request(f"https://api.normfluss.com/v1/evidence/{V}",
headers={"Authorization": f"Bearer {NF_KEY}"})
ev = json.load(urllib.request.urlopen(req))
pub = json.load(urllib.request.urlopen(f"https://api.normfluss.com/verify/keys/{ev['signing_key_id']}"))
vf = json.load(urllib.request.urlopen(f"https://api.normfluss.com/verify/{V}"))
def canonical(obj) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
payload = canonical({"bound_sha256": vf["document_sha256"].lower(),
"scope": ev["scope"]})
key = load_pem_public_key(pub["public_key_pem"].encode())
try:
key.verify(base64.b64decode(ev["signature"]), payload)
print("VALID")
except InvalidSignature:
print("NOT AUTHENTIC")Webhooks
Register an endpoint and Normfluss calls it when a job reaches a notable state, so you never poll. Delivery is at-least-once with exponential backoff, dead-lettering after 8 attempts, treat events as idempotent and dedupe on the X-Normfluss-Delivery header. Any non-2xx (including a 4xx during your deploy) is retried. A dead endpoint is your outage, never a failed job. Requires scope webhooks:manage.
curl -X POST https://api.normfluss.com/v1/webhooks \
-H "Authorization: Bearer $NF_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example/hooks/normfluss",
"events": ["job.completed", "job.failed", "evidence.issued"]
}'
# → 201 (secret is returned ONCE, store it)
# { "id": "...", "url": "...", "events": [...],
# "secret": "f488a4... (64 hex chars, no prefix)" }Events
| Event | Fires when |
|---|---|
| job.awaiting_review | A job needs human review (Layer 2). |
| job.completed | A job finished successfully. |
| job.failed | A job ended in a failure state (carries error_code). |
| evidence.issued | A signed evidence record was created (carries validation_id). |
| evidence.stale | A document changed, invalidating earlier evidence. |
| profile.conflict | Reserved, Template Profiles (§7.2) are not built yet, so this never fires today. Subscribing is accepted and delivers nothing until they ship. |
Payloads
{ "event": "job.completed", "job_id": "...", "document_id": "...", "status": "completed" }{ "event": "job.failed", "job_id": "...", "document_id": "...", "status": "failed", "error_code": "encrypted" }{ "event": "evidence.issued", "validation_id": "...", "job_id": "...", "document_id": "...", "result": "conformant_in_scope" }Verifying the signature
Each delivery carries an X-Normfluss-Signature header of the form t=<unix>,v1=<hex>, plus X-Normfluss-Event and X-Normfluss-Delivery. The signature is HMAC-SHA256, keyed by your endpoint secret, over the exact string <unix>.<raw-body>. Reject anything whose timestamp is outside your tolerance window (this defeats replay) or whose HMAC does not match. Compare in constant time.
import hashlib, hmac, time
def verify(header: str, raw_body: str, secret: str, tolerance_s: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
unix, presented = int(parts["t"]), parts["v1"]
except (ValueError, KeyError):
return False
if abs(time.time() - unix) > tolerance_s:
return False # stale, reject replays
expected = hmac.new(
secret.encode(), f"{unix}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, presented)Sign over the raw request body exactly as received, do not re-serialize the JSON first, or the bytes (and the HMAC) will differ.
Errors & rate limits
Every error uses one envelope. code is drawn from a closed registry; retryable tells you whether backing off and retrying can succeed.
{
"error": {
"code": "quota_exceeded",
"message": "Your plan's quota is exhausted and overage is not enabled.",
"job_id": null,
"retryable": false,
"detail": { "credits_needed": 12, "credits_available": 3 }
}
}| HTTP | code | retryable | When |
|---|---|---|---|
| 400 | invalid_request | no | Malformed request (e.g. neither file nor url). |
| 401 | unauthorized | no | Missing or invalid API key. |
| 402 | quota_exceeded | no | Out of credits, overage disabled. |
| 402 | evaluation_exhausted | no | The one-time free evaluation is spent. Purchase an allotment to continue. |
| 403 | forbidden | no | Key lacks the required scope. |
| 404 | not_found | no | No such document/job/evidence (or hidden by tenancy). |
| 409 | version_conflict | no | Document changed since you opened it. |
| 409 | already_remediated | only by choice | The document has already been remediated, so nothing was started and nothing was charged: remediating again would re-process our own output, which can damage it. To remediate it again from the file you uploaded, POST /v1/documents/{id}/remediate with from_original=true; that is a new remediation and is charged as one. |
| 415 | unsupported_media | no | Not a PDF. |
| 422 | unparseable | no | The file couldn't be read. |
| 423 | encrypted | no | Password-protected PDF. |
| 423 | signed | no | The PDF is digitally signed (an applied or certification signature). Remediating it would invalidate the signature, so it is refused before anything touches it; nothing is charged. Send an unsigned copy instead. Checking a signed PDF still works: only remediation is refused, and a retry is refused the same way. |
| 423 | usage_rights | only by choice | The PDF's only signature is an Adobe usage-rights one, which enables extended features in Adobe Reader such as saving a filled-in form. Remediating would remove those features, so it is refused by default; nothing is charged. To remediate it anyway, POST /v1/documents/{id}/retry with {"remove_usage_rights": true}: the signature is removed (never left broken) and the evidence record says so. |
| 422 | unsupported_language | no | The document has images needing descriptions in a language we cannot draft them in (we draft in English, Dutch, French and German). Nothing is changed and nothing is charged. Documents without images are processed in any language. |
| 429 | rate_limited | yes | Too many requests, slow down. |
| 500 | renderer_crashed | no | Our PDF renderer could not process this document safely. Nothing was changed and nothing charged; no other job was affected. Deterministic for that file: route it to a person. |
| 500 | remediation_regression | no | The fix would have made the document worse, so nothing was changed and nothing charged. Usually a PDF that already carries structure tags. Deterministic: the same file stops the same way (see Current limitations). |
| 500 | export_failed / evidence_failed | yes | Transient finalization failure, retry. |
| 500 | finalize_timed_out | yes | Finishing the review ran past 20 minutes and was stopped. The decisions are kept and nothing more is charged; finish the review again. |
| 503 | validator_unavailable | yes | Validator temporarily down, retry with backoff. |
Rate limits
Write endpoints (submit, revalidate, retry) are throughput-limited per organisation, every key on your org shares one ceiling. New keys start on the Starter tier (60 requests/min); higher tiers are a billing change. Exceeding it returns 429 rate_limited (retryable). Since a remediation run is ~60s of engine work, design for the async flow rather than tight polling, one poll every few seconds, or webhooks, is plenty.
Endpoint reference
| Method | Path | Scope | Purpose |
|---|---|---|---|
| POST | /v1/documents | documents:write | Submit a PDF (free check; remediate=true to fix) |
| POST | /v1/documents/quote | documents:write | Price a fix before committing |
| POST | /v1/documents/{id}/remediate | documents:write | Fix a document you already checked |
| GET | /v1/documents | documents:read | List documents |
| GET | /v1/documents/{id}/findings | documents:read | List findings |
| GET | /v1/documents/{id}/download | documents:read | Download finalized PDF |
| POST | /v1/documents/{id}/revalidate | documents:write | Re-check vs current corpus |
| POST | /v1/documents/{id}/retry | documents:write | Retry a failed job |
| DELETE | /v1/documents/{id} | documents:write | Delete a document |
| GET | /v1/jobs/{id} | documents:read | Poll job state |
| POST | /v1/jobs/{id}/review-token | documents:write | Mint external review link |
| GET | /v1/evidence/{id} | evidence:read | Read evidence record |
| POST | /v1/evidence/{id}/share-tokens | evidence:write | Share evidence with a customer |
| GET | /verify/{id} | public | Verify a record (no key) |
| GET | /verify/keys/{id} | public | Fetch a signing public key |
| POST | /v1/webhooks | webhooks:manage | Register a webhook |
| GET | /v1/webhooks | webhooks:manage | List webhooks |
| DELETE | /v1/webhooks/{id} | webhooks:manage | Delete a webhook |
| POST | /v1/api-keys | session | Mint a key |
| POST | /v1/api-keys/{id}/rotate | session | Rotate a key |
| DELETE | /v1/api-keys/{id} | session | Revoke a key |
Current limitations
Stated plainly so you can plan around them rather than discover them on an invoice or a failed job:
- We stop rather than make a document worse. Every fix is compared with the original before it is delivered: text, page count, appearance, and the accessibility rules the original already passed. A PDF that already carries structure tags (Word and InDesign exports are the usual source) is also checked for what its own structure holds: image descriptions, table header cells, notes, contents lists and headings. If the new structure would lose any of that, we keep the document's own structure, apply only the fixes that leave it untouched (title, language, link descriptions), and the evidence record says so. If even that would make the document worse, the job ends with
remediation_regression: nothing is changed and nothing is charged. It is deterministic, so do not retry it automatically; route the document to a person. - Scans are read with OCR, within a quality bar. A PDF with no text layer is run through OCR. If the text cannot be recognised reliably enough to check, the job returns
ocr_failedrather than guessing at what it says; a clearer scan or the original digital file will work. - Image descriptions are drafts for a person to confirm. Images that need alt text get a machine-drafted description, returned as a Layer 2 finding. Nothing is written into the document until your reviewer confirms or edits it.
- Draft descriptions are available in English, Dutch, French and German. Descriptions are drafted in English by an image-captioning model. Other languages are machine translations of that English draft. You review and confirm every description before it is written into the document. A document with images in any other language is refused with an
unsupported_languageresult rather than given descriptions in the wrong language, and is not charged. Documents without images are processed in any language. The current list is always atGET /v1/capabilities/alt-text.
This page is the reference. The live OpenAPI schema and the interactive explorer (/openapi.json, /docs) are served only outside production, so the full endpoint surface is not published to unauthenticated visitors. They return 404 on https://api.normfluss.com by design. If you need a machine-readable spec for client generation, ask us and we will send you the versioned file.
Developer questions
What does the API actually do?
Which standards does it validate against?
Is there a free trial?
Will it fix every PDF?
How do I get notified when a job finishes?
How is access secured?
Where is data processed?
Integrate the API
Automate document accessibility from inside your own system, and hand every result a verifiable record of what was done. Your first API key comes with 200 free evaluation credits, so you can prove it on your own documents before you pay a cent.