Normfluss

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.

the whole loop, in shell
# 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" | jq

Prefer 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.

every request
Authorization: Bearer nf_live_a8f3_xxxxxxxxxxxxxxxxxxxxxxxx

A 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.

ScopeGrants
documents:writeSubmit, revalidate, retry, delete documents; mint review tokens
documents:readList documents, read findings
evidence:readRead evidence records; list grants and share tokens
evidence:writeCreate grants and share links; revoke them
webhooks:manageRegister, 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.

shell
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.

statusMeaningTerminal?
queuedAccepted and waiting for a worker. This is what POST /v1/documents returns.no
processingReceived and parsing.no
remediatingThe remediation engine is fixing structure, tags, reading order, metadata.no
validatingveraPDF is validating against PDF/UA-1 and the parts of WCAG 2.2 AA that apply to PDFs.no
checkingCheck-only run in progress (no remediation).no
awaiting_reviewHuman-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
finalizingReview done; producing the finalized file and issuing evidence.no
completedFinished. Finalized bytes downloadable; evidence issued. Read the record's result: conformant_in_scope or not_conformant.yes
check_completeA check-only job finished (terminal for that path).yes
supersededReplaced by a newer job on the same document.yes
failedEnded 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

POST/v1/documents

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.

FieldInNotes
filemultipartThe PDF. Max size and %PDF magic-byte checks apply.
urlform fieldHTTPS URL to fetch instead of file. One of file/url is required.
remediateform fieldDefault false. false runs a FREE check ending at check_complete (findings only, nothing to download). true runs the paid fix and spends credits.
languageform fieldOptional 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_idform fieldOptional template profile UUID for structured documents.
Idempotency-KeyheaderOptional. Replaying the same key returns the ORIGINAL job, never a duplicate.
request
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
202 Accepted
{
  "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

POST/v1/documents/quote

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.

200 OK
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

GET/v1/jobs/{job_id}

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.

200 OK, mid-flight, human review needed
{
  "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
}
200 OK, completed
{
  "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.

POST/v1/jobs/{job_id}/review-token
shell
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

GET/v1/evidence/{validation_id}

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.

200 OK
{
  "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:

200 OK, a full record
{
  "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):

GET/v1/documents/{document_id}/download

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.

GET/verify/{validation_id}
GET/verify/keys/{signing_key_id}

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:

the signed payload, exactly
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.

offline verification, end to end
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.

POST/v1/webhooks
shell
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

EventFires when
job.awaiting_reviewA job needs human review (Layer 2).
job.completedA job finished successfully.
job.failedA job ended in a failure state (carries error_code).
evidence.issuedA signed evidence record was created (carries validation_id).
evidence.staleA document changed, invalidating earlier evidence.
profile.conflictReserved, Template Profiles (§7.2) are not built yet, so this never fires today. Subscribing is accepted and delivers nothing until they ship.

Payloads

job.completed
{ "event": "job.completed", "job_id": "...", "document_id": "...", "status": "completed" }
job.failed
{ "event": "job.failed", "job_id": "...", "document_id": "...", "status": "failed", "error_code": "encrypted" }
evidence.issued
{ "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.

verify.py, the same logic Normfluss signs with
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 envelope
{
  "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 }
  }
}
HTTPcoderetryableWhen
400invalid_requestnoMalformed request (e.g. neither file nor url).
401unauthorizednoMissing or invalid API key.
402quota_exceedednoOut of credits, overage disabled.
402evaluation_exhaustednoThe one-time free evaluation is spent. Purchase an allotment to continue.
403forbiddennoKey lacks the required scope.
404not_foundnoNo such document/job/evidence (or hidden by tenancy).
409version_conflictnoDocument changed since you opened it.
409already_remediatedonly by choiceThe 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.
415unsupported_medianoNot a PDF.
422unparseablenoThe file couldn't be read.
423encryptednoPassword-protected PDF.
423signednoThe 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.
423usage_rightsonly by choiceThe 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.
422unsupported_languagenoThe 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.
429rate_limitedyesToo many requests, slow down.
500renderer_crashednoOur 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.
500remediation_regressionnoThe 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).
500export_failed / evidence_failedyesTransient finalization failure, retry.
500finalize_timed_outyesFinishing the review ran past 20 minutes and was stopped. The decisions are kept and nothing more is charged; finish the review again.
503validator_unavailableyesValidator 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

MethodPathScopePurpose
POST/v1/documentsdocuments:writeSubmit a PDF (free check; remediate=true to fix)
POST/v1/documents/quotedocuments:writePrice a fix before committing
POST/v1/documents/{id}/remediatedocuments:writeFix a document you already checked
GET/v1/documentsdocuments:readList documents
GET/v1/documents/{id}/findingsdocuments:readList findings
GET/v1/documents/{id}/downloaddocuments:readDownload finalized PDF
POST/v1/documents/{id}/revalidatedocuments:writeRe-check vs current corpus
POST/v1/documents/{id}/retrydocuments:writeRetry a failed job
DELETE/v1/documents/{id}documents:writeDelete a document
GET/v1/jobs/{id}documents:readPoll job state
POST/v1/jobs/{id}/review-tokendocuments:writeMint external review link
GET/v1/evidence/{id}evidence:readRead evidence record
POST/v1/evidence/{id}/share-tokensevidence:writeShare evidence with a customer
GET/verify/{id}publicVerify a record (no key)
GET/verify/keys/{id}publicFetch a signing public key
POST/v1/webhookswebhooks:manageRegister a webhook
GET/v1/webhookswebhooks:manageList webhooks
DELETE/v1/webhooks/{id}webhooks:manageDelete a webhook
POST/v1/api-keyssessionMint a key
POST/v1/api-keys/{id}/rotatesessionRotate a key
DELETE/v1/api-keys/{id}sessionRevoke a key

Current limitations

Stated plainly so you can plan around them rather than discover them on an invoice or a failed job:

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.

Manage your keys in the developer console. This guide mirrors the live API contract; if a response differs from what you see here, the API is authoritative, please report it.

Developer questions

What does the API actually do?
It checks a PDF for accessibility issues and, optionally, remediates it: the same pipeline as the web app, driven from your own system. Every job returns a signed evidence record of what was checked.
Which standards does it validate against?
PDF/UA-1 (ISO 14289-1), validated with veraPDF, the reference open-source validator, plus the parts of WCAG 2.2 AA that apply to PDFs, evaluated through explicit per-criterion mappings. WCAG 2.2 is the current version, not the superseded 2.1; nine WCAG 2.1 criteria — a different nine — stay in our catalogue only so that records signed before the change still point at what they were tested against. It reports technical conformance within that scope; it does not certify legal compliance.
Is there a free trial?
There's no free trial. Checking is always free, and a new organisation gets 200 evaluation credits once.
Will it fix every PDF?
No, and it says so rather than pretend. Before delivering, every fix is compared with the original: text, pages, 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 and apply only the fixes that leave it untouched, and the evidence record says so. If even that would make the document worse, we stop, change nothing and charge nothing, and the job ends with remediation_regression. That is not retryable: the same file stops the same way, so handle it as a document to route to a person, not a transient error.
How do I get notified when a job finishes?
Remediation runs asynchronously. Register a webhook and Normfluss calls it when a job reaches a terminal state, so you never have to poll.
How is access secured?
Each request authenticates with a per-customer API key, scoped to what it may do (for example read-only vs write) and bound to your organization. Keys are shown once at creation and stored only as an argon2id hash.
Where is data processed?
On German/EU infrastructure. EU residency is enforced when the service starts, not by policy.

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.