LintPDF LintPDF

API Reference

Base URL: https://api.lintpdf.com

All endpoints return JSON (or PNG for render endpoints). Authenticated endpoints require a Bearer token. Every route supports the three submission modes: engine, external, and minimal.

Authentication & rate limits

All authenticated endpoints require a Bearer token issued from the Dashboard. Keys are prefixed lpdf_live_ for production and lpdf_test_ for sandbox.

Authorization: Bearer lpdf_live_...

Rate-limit headers

Every job-submit response (POST /api/v1/jobs and the vanity /endpoints/{slug}/submit) carries the following headers. Use them to back off gracefully before a 429 is returned.

X-RateLimit-Limit: 5000           # Files included in the current billing cycle
X-RateLimit-Used: 412             # Files consumed so far this cycle
X-RateLimit-Remaining: 4588       # Files remaining before overage kicks in

# The next four only appear once you've exceeded the included allowance:
X-RateLimit-Overage: true              # Boolean flag — present = in overage
X-RateLimit-Overage-Count: 12          # Files billed at the overage rate this cycle
X-RateLimit-Overage-Rate-Cents: 10     # Per-file overage cost in cents
X-RateLimit-Overage-Cost-Cents: 120    # Accrued overage cost this cycle

The overage headers are omitted entirely while you are inside your plan's included allowance — treat their absence as “no overage”.

Error envelope

Errors use FastAPI's default JSON shape. Status codes follow HTTP semantics; the detail field carries the human-readable message.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "detail": "preflight_source must be one of: engine, external, minimal"
}

Request-validation errors (malformed body, missing required fields) follow FastAPI's structured form with detail as an array of { loc, msg, type } entries.

Jobs

A Job is one file submitted for processing. Jobs carry a status, findings, reports, and (optionally) a preflight source. LintPDF supports three submission modes via preflight_source: engine (run our analyzers), external (import findings from your own preflight tool), and minimal (viewer-only, no analysis).

POST/api/v1/jobsAPI Key required

Submit a PDF for processing. Accepts multipart/form-data. All fields except file are optional.

Request

curl -X POST https://api.lintpdf.com/api/v1/jobs \
  -H "Authorization: Bearer lpdf_live_..." \
  -F file=@brochure.pdf \
  -F profile_id=lintpdf-default \
  -F preflight_source=engine

Response

{
  "job_id": "d4e5f6a7-1234-4567-89ab-cdef01234567",
  "status": "pending",
  "message": "Job submitted successfully"
}

Request fields

FieldTypeRequiredDefaultDescription
filefileYesThe PDF (or convertible input) to process.
profile_idstringNolintpdf-defaultPreflight profile ID. Ignored when preflight_source is minimal.
preflight_source"engine" | "external" | "minimal"NoengineHow findings are produced for this job.
external_format"pitstop_xml" | "callas_json" | "callas_xml" | "acrobat_xml" | "lintpdf_json"NoFormat of the external report. Omit to auto-detect. Mutually exclusive with mapping_id.
external_reportfileNoThe preflight report when preflight_source=external. XML or JSON.
mapping_iduuidNoTenant-defined custom import mapping. Mutually exclusive with external_format — sets the format to custom internally.
jdf_filefileNoOptional JDF/XJDF sidecar. Findings include jdf_context when supplied.
ai_enabledbooleanNonull (profile decides)Per-job override: true forces AI on, false forces AI off, unset defers to the profile.
ai_categoriesstringNoComma-separated AI category IDs to enable. Applied only when ai_enabled=true.
ai_featuresstringNoComma-separated AI inspection slugs. Takes precedence over ai_categories.
ai_presetstringNoAI preset slug (e.g. brand-compliance, packaging-qc, full-ai-scan). Implicitly enables AI.
brand"anonymous" | "lintpdf" | uuidNoPer-request brand override. UUID must be a BrandProfile owned by your tenant. Absent → tenant default.
unbrandedbooleanNoConvenience alias: when true, equivalent to brand=anonymous.
brand_spec_iduuidNoPer-submission BrandSpec override. Wins over the endpoint default and the tenant-default BrandSpec. Must be a non-archived spec owned by the current tenant; a foreign or archived ID fails fast with 404 before upload is committed.
waitfloat (query param)NoIf set, block the response up to this many seconds for the job to reach a terminal state. On success the handler returns 200 + the full JobResponse; on timeout it falls back to the 202 + job_id response so you can keep polling. Server-side ceiling is LINTPDF_SYNC_MAX_WAIT_S (default 120s).

Status codes

FieldTypeRequiredDefaultDescription
200OKNoOnly when ?wait= was set and the job reached a terminal state inline. Body is the full JobResponse.
202AcceptedNoJob queued; poll GET /jobs/{id} for completion.
401UnauthorizedNoMissing or invalid bearer token.
403ForbiddenNoValid key, but you lack permission — e.g. cross-tenant mapping_id, or plan doesn't include the requested feature.
404Not FoundNoprofile_id or mapping_id does not exist in your tenant.
413Payload Too LargeNoFile exceeds the per-tenant upload limit.
422Unprocessable EntityNoInvalid enum value, malformed UUID, unparseable external_report, ClamAV-detected malware, or mutually exclusive fields set together.
429Too Many RequestsNoRate limit exceeded. Back off using the X-RateLimit-* headers.
GET/api/v1/jobs/{job_id}API Key required

Retrieve a single job, including summary, findings, and report URLs.

Request

curl https://api.lintpdf.com/api/v1/jobs/d4e5f6a7-... \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "status": "complete",
  "profile_id": "lintpdf-default",
  "file_name": "brochure.pdf",
  "file_size": 842139,
  "page_count": 12,
  "created_at": "2026-04-12T10:30:00Z",
  "completed_at": "2026-04-12T10:30:04Z",
  "duration_ms": 3480,
  "summary": {
    "total_findings": 7,
    "error_count": 1,
    "warning_count": 4,
    "advisory_count": 2,
    "passed": false,
    "page_count": 12,
    "file_size_bytes": 842139
  },
  "findings": [
    {
      "inspection_id": "font.not_embedded",
      "severity": "error",
      "message": "Font 'Helvetica' is not embedded",
      "page_num": 1,
      "bbox": [72.0, 720.0, 540.0, 740.0],
      "object_id": "Font12",
      "object_type": "font",
      "category": "fonts",
      "source": "engine",
      "audit": {
        "status": "confirmed",
        "rationale": "Helvetica glyph references with no matching /FontFile entry.",
        "model": "modal:qwen2-vl-7b",
        "at": "2026-04-22T18:05:34Z"
      }
    }
  ],
  "reports": {
    "pdf": "https://reports.lintpdf.com/r/abc123.pdf",
    "html": "https://reports.lintpdf.com/r/abc123"
  }
}

Capability flags (separations, tac, fonts, images, layers) live on the viewer config (GET /viewer/jobs/{id}/config), not on the job payload.

GET/api/v1/jobs?page=1&page_size=25API Key required

List jobs for the current tenant. Returns newest first.

Request

curl "https://api.lintpdf.com/api/v1/jobs?page=1&page_size=25" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "jobs": [ { "job_id": "...", "status": "complete", "file_name": "...", "created_at": "..." } ],
  "total": 418,
  "page": 1,
  "page_size": 25
}
DELETE/api/v1/jobs/{job_id}API Key required

Delete a job and all derived artifacts (reports, tiles, share tokens). Returns 204 No Content on success.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/jobs/d4e5f6a7-... \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 204 No Content

Universal job state

GET /api/v1/jobs/{job_id}/state returns preflight results, every minted report URL, the approval chain (with each approver's notes), the manual verdict, and every viewer annotation with its comment thread embedded — in one call. Previously this required 3+ round trips and an N+1 fan-out for comments. See the dedicated Universal Job State page for the full field table and a runnable example payload.

GET/api/v1/jobs/{job_id}/stateAPI Key required

Aggregated digest: core job + reports + approval chain + verdict + annotations-with-comments. Filter with ?include=reports,approval_chain,verdict,annotations.

Request

curl "https://api.lintpdf.com/api/v1/jobs/d4e5f6a7-.../state" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job": { "job_id": "...", "status": "complete", "summary": { "passed": true, "total_findings": 2 } },
  "reports": [
    { "format": "annotated_pdf", "url": "https://reports.lintpdf.com/r/...", "token": "...",
      "allow_annotations": false, "require_visitor_email": null }
  ],
  "approval_chain": {
    "status": "approved", "current_step": 0,
    "step_history": [
      { "step_name": "Print ops", "approver_email": "ops@example.com",
        "decision": "approved", "notes": "Looks great, ship it." }
    ]
  },
  "verdict": { "verdict": "approved", "auto_passed": true, "notes": "..." },
  "annotations": {
    "total": 1, "by_page": { "1": 1 },
    "items": [
      { "id": "...", "page_num": 1, "kind": "rect", "text": "Fix the bleed",
        "comments": [ { "body": "Will do by EOD.", "author_email": "..." } ] }
    ]
  }
}
FieldTypeRequiredDefaultDescription
includestring (CSV)Noall sectionsOptional comma-separated list. Accepted keys: reports, approval_chain, verdict, annotations. Unknown keys 422. Core job block is always returned.

Share-link mirror: GET /api/v1/viewer/public/{token}/state returns the same shape minus the reports section (listing sibling share-link tokens from a single token would leak shares that weren't handed to the current visitor).

Custom submission endpoints

Growth-tier customers can mint vanity slugs and give customers a branded submission URL instead of /api/v1/jobs. Each endpoint has a response_mode setting: async (default) returns 202 + job_id so the caller polls GET /api/v1/jobs/{id}; sync blocks the submit request until the job is terminal and returns the full JobResponse inline. A per-request ?wait= query param can override either way (useful for integrations like Zapier / n8n / Make.com that can't orchestrate a polling loop).

POST/api/v1/endpoints/{slug}/submitAPI Key required

Submit a file against a vanity endpoint. The endpoint's bound profile, brand, response_mode, and permissions apply. Pass ?wait=<seconds> to override response_mode for a single call.

Request

# Async (default)
curl -X POST https://api.lintpdf.com/api/v1/endpoints/acme-proofs/submit \
  -H "Authorization: Bearer lpdf_live_..." \
  -F file=@brochure.pdf

# Sync (wait inline for the verdict)
curl -X POST "https://api.lintpdf.com/api/v1/endpoints/acme-proofs/submit?wait=60" \
  -H "Authorization: Bearer lpdf_live_..." \
  -F file=@brochure.pdf

Response

# 202 Accepted (async)
{ "job_id": "d4e5f6a7-...", "status": "pending", "message": "Job submitted successfully" }

# 200 OK (sync, reached terminal within wait budget)
{ "job_id": "d4e5f6a7-...", "status": "complete", "summary": { ... }, "findings": [ ... ], "reports": { ... } }

Endpoint fields

FieldTypeRequiredDefaultDescription
slugstringYesLowercase kebab-case URL slug, unique per tenant (2-255 chars).
profile_idstringYesProfile this endpoint is bound to. Built-in or tenant-owned.
descriptionstringNoFree-text label shown in the dashboard (max 1024 chars).
is_activebooleanNotrueDisable to 404 the submit URL without deleting the slug.
response_mode"async" | "sync"NoasyncDefault response behavior for this endpoint. async = 202 + job_id (caller polls). sync = block for terminal state and return full JobResponse. Server-side ceiling is LINTPDF_SYNC_MAX_WAIT_S (default 120s). Callers can override per-request via ?wait= on the submit route.
default_brand_spec_iduuid | nullNoOptional BrandSpec to apply to every submission through this endpoint. Wins over the tenant-default BrandSpec but not over a submit-time brand_spec_id. Pass the literal string "null" in a PATCH to clear the binding without unsetting the field — omitting the field leaves it unchanged.

External imports & custom mappings

When preflight_source=external, submit the report alongside the PDF. LintPDF parses PitStop XML, callas JSON/XML, Acrobat Preflight XML, and our native v1 JSON schema directly. For any other shape, define a tenant-scoped custom mapping.

POST/api/v1/tenant/import-mappingsAPI Key required

Create a custom import mapping. Requires the branding:manage permission.

Request

curl -X POST https://api.lintpdf.com/api/v1/tenant/import-mappings \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Internal QA XML",
    "description": "Maps our in-house preflight XML to LintPDF findings.",
    "format": "xml",
    "config": {
      "item_selector": "Findings/Finding",
      "fields": {
        "severity": { "selector": "@level" },
        "message":  { "selector": "Description" },
        "page_num": { "selector": "Page", "parse": "int" },
        "bbox":     { "selector": "BBox", "format": "space_xywh" },
        "inspection_id": { "selector": "@rule" }
      },
      "severity_map": { "S": "error", "W": "warning", "N": "advisory" },
      "default_severity": "warning"
    },
    "sample_payload": "<Findings>...</Findings>",
    "sample_mime": "application/xml",
    "is_active": true
  }'

Response

{
  "id": "2f7c1e8a-1b4d-4e1a-9a2b-9c8d7e6f5a4b",
  "name": "Internal QA XML",
  "description": "Maps our in-house preflight XML to LintPDF findings.",
  "format": "xml",
  "config": { "item_selector": "Findings/Finding", "fields": { ... } },
  "sample_payload": "<Findings>...</Findings>",
  "sample_mime": "application/xml",
  "is_active": true,
  "created_at": "2026-04-12T10:30:00Z",
  "updated_at": "2026-04-12T10:30:00Z"
}

Mapping request fields

FieldTypeRequiredDefaultDescription
namestringYesHuman-readable label shown in the editor (1–128 chars).
descriptionstring | nullNoOptional description surfaced in the dashboard picker.
format"xml" | "json"No"xml"Parser used to load the uploaded report.
configobjectYesMapping DSL. Holds item_selector, fields, severity_map, default_severity. See the Custom Import Mappings doc for the full grammar.
sample_payloadstring | nullNoVerbatim sample report used by the preview endpoint.
sample_mimestring | nullNoMIME type hint for the stored sample (e.g. application/xml, application/json). Max 64 chars.
is_activebooleanNotrueInactive mappings are hidden from the submit form and cannot be used on new jobs.
GET/api/v1/tenant/import-mappingsAPI Key required

List all mappings owned by the current tenant. Soft-deleted mappings are flagged with is_active=false but remain visible for audit.

Request

curl "https://api.lintpdf.com/api/v1/tenant/import-mappings" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "mappings": [
    {
      "id": "2f7c1e8a-...",
      "name": "Internal QA XML",
      "description": "Maps our in-house preflight XML to LintPDF findings.",
      "format": "xml",
      "config": { ... },
      "sample_payload": "<Findings>...</Findings>",
      "sample_mime": "application/xml",
      "is_active": true,
      "created_at": "2026-04-12T10:30:00Z",
      "updated_at": "2026-04-12T10:30:00Z"
    }
  ]
}
GET/api/v1/tenant/import-mappings/{mapping_id}API Key required

Retrieve a single mapping.

Request

curl https://api.lintpdf.com/api/v1/tenant/import-mappings/2f7c1e8a-... \
  -H "Authorization: Bearer lpdf_live_..."

Response

{ "id": "2f7c1e8a-...", "name": "Internal QA XML", "format": "xml", "config": {...}, "is_active": true, ... }
PUT/api/v1/tenant/import-mappings/{mapping_id}API Key required

Replace a mapping. Body takes the same shape as create. Deactivating (is_active=false) stops the mapping being used on new submissions but preserves it for history.

Request

curl -X PUT https://api.lintpdf.com/api/v1/tenant/import-mappings/2f7c1e8a-... \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Internal QA XML",
    "format": "xml",
    "config": { ... },
    "is_active": false
  }'

Response

{ "id": "2f7c1e8a-...", "is_active": false, ... }
DELETE/api/v1/tenant/import-mappings/{mapping_id}API Key required

Soft-delete a mapping by flipping is_active to false. Historical jobs retain their finding provenance. Returns 204.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/tenant/import-mappings/2f7c1e8a-... \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 204 No Content
POST/api/v1/tenant/import-mappings/{mapping_id}/previewAPI Key required

Dry-run a mapping against a sample report. Returns the parsed findings without persisting a job. Parser errors surface as ok=false with a human-readable error instead of a 4xx so the editor can render inline feedback.

Request

curl -X POST https://api.lintpdf.com/api/v1/tenant/import-mappings/2f7c1e8a-.../preview \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "config": { "item_selector": "Findings/Finding", "fields": { ... } },
    "sample_payload": "<Findings>...</Findings>"
  }'

Response

{
  "ok": true,
  "findings_count": 12,
  "sample_findings": [
    {
      "severity": "error",
      "message": "Image resolution too low",
      "page_num": 3,
      "inspection_id": "IMG_LOWRES",
      "bbox": [72, 720, 540, 740],
      "object_id": "Im4",
      "object_type": "image",
      "category": "Images"
    }
  ],
  "error": null
}

Both request fields are optional: when omitted, preview uses the mapping's stored config and sample_payload. Supply them to iterate on a config in the editor before saving. Returns at most the first 5 findings in sample_findings.

Branding & custom domains

LintPDF resolves branding in three modes: anonymous (no brand at all), lintpdf (our default branding), or a specific tenant BrandProfile by UUID. The submit form brand field overrides the tenant default on a per-request basis.

GET/api/v1/tenant/branding-defaultsAPI Key required

Read the tenant's default brand resolution.

Request

curl https://api.lintpdf.com/api/v1/tenant/branding-defaults \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "mode": "profile",
  "unbranded_by_default": false,
  "default_brand_profile_id": "2f7c1e8a-1b4d-4e1a-9a2b-9c8d7e6f5a4b"
}
PATCH/api/v1/tenant/branding-defaultsAPI Key required

Set the tenant default. Requires the branding:manage permission. Anonymous mode strips all branding + sanitises PDF metadata by default (broker → distributor use case).

Request

curl -X PATCH https://api.lintpdf.com/api/v1/tenant/branding-defaults \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "mode": "anonymous" }'

Response

{
  "mode": "anonymous",
  "unbranded_by_default": true,
  "default_brand_profile_id": null
}
FieldTypeRequiredDefaultDescription
mode"anonymous" | "profile" | "lintpdf"YesHow brand resolves when a job/report does not override.
brand_profile_iduuid | nullNoRequired when mode=profile. Must reference a BrandProfile in the current tenant. Returns 404 if the profile doesn't exist.

The response also carries unbranded_by_default (mirrors the tenant flag) and default_brand_profile_id (the currently-pinned profile, or null).

Brand profiles

GET/api/v1/tenants/{tenant_id}/brand-profilesAPI Key required

List every BrandProfile owned by the tenant.

Request

curl https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles \
  -H "Authorization: Bearer lpdf_live_..."

Response

{ "profiles": [ { "id": "...", "name": "Acme Print", "profile_type": "custom", ... } ] }
POST/api/v1/tenants/{tenant_id}/brand-profilesAPI Key required

Create a new BrandProfile. Scale/Enterprise entitlement.

Request

curl -X POST https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Print",
    "profile_type": "custom",
    "brand_name": "Acme Print",
    "logo_url": "https://acmeprint.com/logo.svg",
    "primary_color": "#1a365d",
    "accent_color": "#2563eb",
    "footer_text": "Preflight by Acme Print",
    "hide_footer": false
  }'

Response

{
  "id": "9f8e7d6c-5b4a-4321-9876-543210fedcba",
  "name": "Acme Print",
  "profile_type": "custom",
  "brand_name": "Acme Print",
  "logo_url": "https://acmeprint.com/logo.svg",
  "primary_color": "#1a365d",
  "accent_color": "#2563eb",
  "footer_text": "Preflight by Acme Print",
  "hide_footer": false,
  "is_default": false,
  "created_at": "2026-04-12T10:30:00Z",
  "updated_at": "2026-04-12T10:30:00Z"
}

BrandProfile fields

FieldTypeRequiredDefaultDescription
namestringYesInternal label (1–255 chars).
profile_type"custom" | "lintpdf" | "none"No"custom"custom uses this profile's brand fields; lintpdf falls back to LintPDF defaults; none produces a neutral/blind output.
brand_namestring | nullNoDisplay name on reports and the viewer chrome (max 255).
logo_urlstring | nullNoAbsolute HTTPS URL to the logo (max 2048).
primary_colorstring | nullNoHex color, #RRGGBB (max 7).
accent_colorstring | nullNoHex color, #RRGGBB (max 7).
footer_textstring | nullNoFooter copy baked into reports (max 500).
hide_footerbooleanNofalseSuppress the footer entirely.
GET/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key required

Retrieve a single BrandProfile.

Request

curl https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles/PROFILE \
  -H "Authorization: Bearer lpdf_live_..."

Response

{ "id": "...", "name": "Acme Print", ... }
PUT/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key required

Replace a BrandProfile. All update fields optional; omitted fields keep their current value.

Request

curl -X PUT https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles/PROFILE \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "primary_color": "#0f172a", "hide_footer": true }'

Response

{ "id": "...", "primary_color": "#0f172a", "hide_footer": true, ... }
DELETE/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key required

Delete a BrandProfile. Jobs that used this profile historically retain their rendered branding. Returns 204.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles/PROFILE \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 204 No Content
POST/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}/logoAPI Key required

Upload a logo file (PNG, JPEG, or SVG). Stored on the CDN and referenced by logo_url.

Request

curl -X POST https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles/PROFILE/logo \
  -H "Authorization: Bearer lpdf_live_..." \
  -F file=@logo.png

Response

{ "id": "...", "logo_url": "https://cdn.lintpdf.com/brand-logos/...", ... }
PATCH/api/v1/tenants/{tenant_id}/default-brand-profileAPI Key required

Convenience endpoint to pin a tenant default BrandProfile. Pass null to clear.

Request

curl -X PATCH https://api.lintpdf.com/api/v1/tenants/TENANT/default-brand-profile \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "brand_profile_id": "9f8e7d6c-..." }'

Response

{ "id": "9f8e7d6c-...", "is_default": true, ... }

Custom report domain

White-label reports on reports.yourbrand.com. One CNAME record per hostname pointing at edge.lintpdf.com— LintPDF's Caddy edge on Fly.io issues a Let's Encrypt certificate on first request and path-routes to the right backend. No ACME TXT records, no manual cert installation. Scale/Enterprise only — the engine returns 403 on lower tiers.

GET/api/v1/tenants/{tenant_id}/custom-domainAPI Key required

Read the current state of the tenant's report-domain claim.

Request

curl https://api.lintpdf.com/api/v1/tenants/TENANT/custom-domain \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "tenant_id": "...",
  "domain": "reports.acmeprint.com",
  "verified": false,
  "requested_at": "2026-04-12T10:30:00Z",
  "plan_allows_whitelabel": true,
  "dns_target": "edge.lintpdf.com"
}
PATCH/api/v1/tenants/{tenant_id}/custom-domainAPI Key required

Register or clear the tenant's report domain. Setting a new domain resets verified to false. 409 on duplicate claim, 422 on blocklisted hostnames.

Request

curl -X PATCH https://api.lintpdf.com/api/v1/tenants/TENANT/custom-domain \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "domain": "reports.acmeprint.com" }'

Response

{
  "tenant_id": "...",
  "domain": "reports.acmeprint.com",
  "verified": false,
  "requested_at": "2026-04-12T10:30:00Z",
  "plan_allows_whitelabel": true,
  "dns_target": "edge.lintpdf.com"
}

To clear the domain, PATCH with { "domain": null }.

Per-BrandProfile domain

Agencies serving multiple end-clients can point each BrandProfile at its own subdomain.

PATCH/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}/custom-domainAPI Key required

Attach (or clear) a custom domain for a single BrandProfile.

Request

curl -X PATCH \
  https://api.lintpdf.com/api/v1/tenants/TENANT/brand-profiles/PROFILE/custom-domain \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "domain": "proofs.end-brand.com" }'

Response

{ "id": "...", "custom_domain": "proofs.end-brand.com", "custom_domain_verified": false, ... }

App / viewer domain

GET/api/v1/tenants/{tenant_id}/app-custom-domainAPI Key required

Read the current dashboard/viewer domain claim.

Request

curl https://api.lintpdf.com/api/v1/tenants/TENANT/app-custom-domain \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "tenant_id": "...",
  "domain": "app.acmeprint.com",
  "verified": false,
  "requested_at": "2026-04-12T10:30:00Z",
  "plan_allows_whitelabel": true
}
PATCH/api/v1/tenants/{tenant_id}/app-custom-domainAPI Key required

Register or clear the dashboard/viewer app domain. Separate from the reports domain.

Request

curl -X PATCH https://api.lintpdf.com/api/v1/tenants/TENANT/app-custom-domain \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "domain": "app.acmeprint.com" }'

Response

{
  "tenant_id": "...",
  "domain": "app.acmeprint.com",
  "verified": false,
  "requested_at": "2026-04-12T10:30:00Z",
  "plan_allows_whitelabel": true
}

BrandSpecs — per-customer color specifications

A BrandSpec is a named color specification a tenant maintains per end-customer — swatches, optional rich-black, and flags. The resolver walks job.brand_spec_id → endpoint.default_brand_spec_id → tenant-default spec and applies the first hit. When nothing resolves, strict color advisories stay suppressed.

GET/api/v1/brand-specsAPI Key required

List BrandSpecs for the current tenant. Pass ?include_archived=true to include soft-deleted rows.

Request

curl https://api.lintpdf.com/api/v1/brand-specs \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "brand_specs": [
    {
      "id": "...",
      "name": "Coca-Cola",
      "customer_name": "Coca-Cola Co.",
      "colors": [{"name": "Coke Red", "value": "#F40009", "pantone": "PMS 185 C"}],
      "rich_black_spec": {"c": 60, "m": 50, "y": 50, "k": 100},
      "is_default": true,
      "is_archived": false
    }
  ]
}
POST/api/v1/brand-specsAPI Key required

Create a new BrandSpec. Setting is_default=true atomically demotes any existing default spec for this tenant.

Request

curl -X POST https://api.lintpdf.com/api/v1/brand-specs \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Coca-Cola",
    "customer_name": "Coca-Cola Co.",
    "colors": [{"name": "Coke Red", "value": "#F40009"}],
    "is_default": true
  }'

Response

{
  "id": "brand-spec-uuid",
  "name": "Coca-Cola",
  "customer_name": "Coca-Cola Co.",
  "colors": [{"name": "Coke Red", "value": "#F40009"}],
  "rich_black_spec": null,
  "is_default": true,
  "is_archived": false
}

BrandSpec fields

FieldTypeRequiredDefaultDescription
namestringNoDisplay label for the spec, e.g. the end-customer's brand.
customer_namestring | nullNoFree-form customer label; use for reporting / filtering.
descriptionstring | nullNoInternal notes about the spec.
colors[]arrayNoOne object per swatch. Required shape: {name, value}. Optional: pantone, notes. value is hex, named CSS color, or explicit rgb()/cmyk().
rich_black_specobject | nullNoOptional {c, m, y, k} target rich-black composition. When set, print advisories measure against this instead of the profile default.
is_defaultbooleanNoAt most one non-archived spec per tenant may carry is_default=true. Setting it on a new spec demotes the previous default atomically.
is_archivedbooleanNoSoft-delete flag. Archived specs don't resolve as tenant-default but still resolve for historical jobs that captured them.
PATCH/api/v1/brand-specs/{id}API Key required

Patch any subset of fields. Setting is_default=true demotes the previous default.

Request

curl -X PATCH https://api.lintpdf.com/api/v1/brand-specs/SPEC_ID \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "colors": [{"name": "New", "value": "#123456"}] }'

Response

{
  "id": "brand-spec-uuid",
  "name": "Coca-Cola",
  "customer_name": "Coca-Cola Co.",
  "colors": [{"name": "New", "value": "#123456"}],
  "rich_black_spec": null,
  "is_default": true,
  "is_archived": false
}
DELETE/api/v1/brand-specs/{id}API Key required

Archive (soft-delete) a BrandSpec. Clears is_default. Historical jobs still resolve to the archived spec.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/brand-specs/SPEC_ID \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "id": "brand-spec-uuid",
  "is_archived": true,
  "is_default": false
}
POST/api/v1/brand-specs/{id}/restoreAPI Key required

Un-archive a BrandSpec. is_default stays false — mark it default again explicitly if needed.

Request

curl -X POST https://api.lintpdf.com/api/v1/brand-specs/SPEC_ID/restore \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "id": "brand-spec-uuid",
  "is_archived": false,
  "is_default": false
}

Viewer

The Viewer surface lets you embed or build a PDF viewer against any complete job — engine, external, or minimal. Tiles, separations, TAC heatmaps, densitometer samples, and layer metadata are served as JSON/PNG from a dedicated route group.

GET/api/v1/viewer/jobs/{job_id}/pagesAPI Key required

List every page in the job with its geometry and rotation.

Request

curl https://api.lintpdf.com/api/v1/viewer/jobs/d4e5f6a7-.../pages \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "page_count": 1,
  "pages": [
    {
      "page_num": 1,
      "width_pts": 595.28,
      "height_pts": 841.89,
      "media_box": { "x0": 0, "y0": 0, "x1": 595.28, "y1": 841.89 },
      "crop_box":  { "x0": 0, "y0": 0, "x1": 595.28, "y1": 841.89 },
      "trim_box":  { "x0": 14.17, "y0": 14.17, "x1": 581.10, "y1": 827.72 },
      "bleed_box": null,
      "rotation": 0
    }
  ]
}
GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tile?dpi=150API Key required

Render a single page as a PNG tile. dpi range 36–600. Optional ocg_on / ocg_off query params render the page with specific OCG (layer) indices toggled — see the Layers section below.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/d4e5f6a7-.../pages/1/tile?dpi=200&ocg_on=0,3&ocg_off=2" \
  -H "Authorization: Bearer lpdf_live_..." \
  --output page-1.png

Response

200 OK
Content-Type: image/png
# 422 if ocg_on/ocg_off conflict, indices are out of range, or the PDF has no OCGs.

Separations & channels

GET/api/v1/viewer/jobs/{job_id}/separationsAPI Key required

List every ink plate (process + spot) present in the job.

Request

curl https://api.lintpdf.com/api/v1/viewer/jobs/d4e5f6a7-.../separations \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "channels": [
    { "name": "Cyan", "type": "process" },
    { "name": "Magenta", "type": "process" },
    { "name": "Yellow", "type": "process" },
    { "name": "Black", "type": "process" },
    { "name": "PANTONE 185 C", "type": "spot" }
  ]
}
GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/channel/{name}?dpi=150API Key required

Single-channel greyscale render. name is URL-encoded (use + for spaces). Returns PNG.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../pages/1/channel/PANTONE+185+C?dpi=150" \
  -H "Authorization: Bearer lpdf_live_..." \
  --output pantone-185c-page1.png

Response

200 OK
Content-Type: image/png

TAC heatmap & densitometer

GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tac-heatmap?dpi=150&tac_limit=300API Key required

Total Area Coverage heatmap. Pixels above the limit (100–500%) are tinted red.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../pages/1/tac-heatmap?tac_limit=300" \
  -H "Authorization: Bearer lpdf_live_..." \
  --output tac-page1.png

Response

200 OK
Content-Type: image/png
GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tac-heatmap/runs?dpi=150&tac_limit=300API Key required

Per-text-run mean TAC metadata for interactive tooltips. Coordinates are in PDF points with origin top-left (matching poppler pdftotext -bbox). Powers the hover readout on the TAC heatmap overlay.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../pages/1/tac-heatmap/runs?tac_limit=300" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "page_num": 1,
  "dpi": 150,
  "tac_limit": 300.0,
  "runs": [
    { "x0": 102.3, "y0": 88.1, "x1": 440.7, "y1": 112.5, "mean_tac": 342.8, "limit": 300.0, "exceeds": true }
  ]
}
GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/sample?x=200&y=300&dpi=300API Key required

Single-pixel color-picker sample. Returns RGB + hex for the point (origin lower-left, PDF points). Not a densitometer — see /densitometer below for per-channel readings.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../pages/1/sample?x=200&y=300" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{ "x": 200, "y": 300, "rgb": [12, 99, 180], "hex": "#0c63b4", "tac": null }
GET/api/v1/viewer/jobs/{job_id}/pages/{page_num}/densitometer?x=200&y=300&dpi=300&tac_limit=300API Key required

Per-channel CMYK + spot densitometer reading at the requested point. Runs Ghostscript tiffsep on the page (cached in S3 after the first call) and samples a 3x3 patch on each channel.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../pages/1/densitometer?x=200&y=300" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "x": 200, "y": 300, "dpi": 300,
  "channels": [
    { "name": "Cyan", "percent": 62.3 },
    { "name": "Magenta", "percent": 18.1 },
    { "name": "Yellow", "percent": 4.7 },
    { "name": "Black", "percent": 91.5 }
  ],
  "tac": 176.6,
  "tac_limit": 300,
  "limit_exceeded": false
}

Tile pre-warming progress

GET/api/v1/viewer/jobs/{job_id}/tile-warmingAPI Key required

Progress of the background Celery task (lintpdf.viewer.warm_tiles) that pre-renders every page tile into S3 when a job completes. Poll every ~1.5s to show a readiness badge; once status=complete the frontend kicks off a browser-side prefetch pass so page clicks paint from the browser HTTP cache (<20 ms).

Request

curl https://api.lintpdf.com/api/v1/viewer/jobs/.../tile-warming \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "status": "in_progress",
  "rendered": 7,
  "total": 20,
  "dpi": 150,
  "percent": 35,
  "started_at": "2026-04-14T10:22:13Z",
  "completed_at": null,
  "error": null
}

Layers

GET/api/v1/viewer/jobs/{job_id}/layersAPI Key required

PDF Optional Content Groups (OCG). Not fillable after ingest — if layers were absent at parse time, they stay absent.

Request

curl https://api.lintpdf.com/api/v1/viewer/jobs/.../layers \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "layers": [
    { "name": "Varnish", "ocg_index": 0, "default_on": true },
    { "name": "Dieline", "ocg_index": 1, "default_on": false }
  ]
}

Viewer config

GET/api/v1/viewer/jobs/{job_id}/config?brand=anonymousAPI Key required

Effective viewer configuration. The brand query param follows the same enum as the submit field.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/.../config?brand=lintpdf" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "enable_separations": true,
  "enable_tac_heatmap": true,
  "enable_annotations": true,
  "enable_measurement": true,
  "enable_comparison": true,
  "enable_layers": true,
  "enable_findings_panel": true,
  "enable_page_thumbnails": true,
  "enable_zoom": true,
  "enable_download": true,
  "enable_html_report_link": true,
  "verdict_mode": "auto",
  "default_zoom": 100,
  "default_dpi": 150,
  "default_tac_limit": 300.0,
  "viewer_logo_url": null,
  "viewer_accent_color": null,
  "toolbar_position": "top",
  "dark_mode": false,
  "brand_name": "LintPDF",
  "brand_logo_url": "https://lintpdf.com/logo.svg",
  "brand_primary_color": "#1a3a7a",
  "brand_accent_color": "#2563eb",
  "anonymous": false,
  "tenant_name": "Acme Print",
  "support_email": "support@acmeprint.com",
  "preflight_source": "engine",
  "capabilities": {
    "separations": true, "tac": true, "tac_runs": true, "tiles_warmed": true,
    "fonts": true, "images": true, "layers": false,
    "text_regions": false,
    "ai_explain": true, "epm_verdict": true
  },
  "capability_fillin_enabled": true,
  "annotations_enabled": true,
  "allowed_report_formats": ["json", "html", "pdf", "xml"]
}

Viewer config fields

FieldTypeRequiredDefaultDescription
enable_separationsbooleanNotrueShow the separations / channels panel.
enable_tac_heatmapbooleanNotrueShow the TAC heatmap overlay toggle.
enable_annotationsbooleanNotrueShow the annotation/markup tools.
enable_measurementbooleanNotrueShow the ruler / measurement tool.
enable_comparisonbooleanNotrueShow the file-comparison entry point.
enable_layersbooleanNotrueShow the OCG layers panel.
enable_findings_panelbooleanNotrueShow the findings sidebar.
enable_page_thumbnailsbooleanNotrueShow the page thumbnail strip.
enable_zoombooleanNotrueShow zoom controls.
enable_downloadbooleanNotrueShow the download-original button.
enable_html_report_linkbooleanNotrueShow the link to the HTML report.
verdict_mode"auto" | "manual" | "off"No"auto"Verdict workflow: auto uses summary.passed, manual requires a reviewer action, off hides the panel.
default_zoomintegerNo100Initial zoom percentage.
default_dpiintegerNo150Initial tile render DPI.
default_tac_limitfloatNo300.0Initial TAC threshold for the heatmap.
viewer_logo_urlstring | nullNoOptional viewer-chrome logo override.
viewer_accent_colorstring | nullNoOptional viewer-chrome accent override.
toolbar_position"top" | "bottom" | "left" | "right"No"top"Toolbar edge.
dark_modebooleanNofalseDark theme.
brand_namestring | nullNoResolved brand name (null when anonymous).
brand_logo_urlstring | nullNoResolved brand logo URL (null when anonymous).
brand_primary_colorstring | nullNoResolved brand primary color (null when anonymous).
brand_accent_colorstring | nullNoResolved brand accent color (null when anonymous).
anonymousbooleanNoTrue when all tenant + LintPDF chrome is stripped.
tenant_namestring | nullNoTenant display name (null when anonymous).
support_emailstring | nullNoTenant support email (null when anonymous).
preflight_source"engine" | "external" | "minimal"NoHow findings were produced. Drives the viewer's Load-button affordances.
capabilitiesRecord<string, boolean>NoPer-capability availability map. Fillable keys: separations, tac, fonts, images, text_regions (PR 2 OCR/ML — shared OCR pass that highlights outlined captions and fold-zone text; on-demand via POST /api/v1/viewer/jobs/{id}/capabilities/text_regions, persists to Job.detected_text_regions). Non-fillable: layers (extracted at ingest), tac_runs (derived on demand, tracks the tac flag), tiles_warmed (flipped by the background warm_tiles task; see /tile-warming endpoint), ai_explain (on-call via POST /api/v1/jobs/{id}/findings/{fid}/explain — cost-cap gates with HTTP 402), epm_verdict (computed at ingest from fired LPDF_EPM_* findings; mirrored on JobResponse.epm_verdict and via GET /api/v1/jobs/{id}/epm).
capability_fillin_enabledbooleanNoPlan gate. When false the fill-in endpoint returns 403 plan_upgrade_required — render an UpgradePrompt instead of Load buttons. Viewer tier: false. Starter+: true.
annotations_enabledbooleanNoPlan gate. When false the annotation toolbar must be hidden; annotation write endpoints return 403; share-link minting forces allow_annotations=false. Viewer tier: false. Starter+: true.
allowed_report_formatsstring[]NoPlan gate. Formats the tenant may request on POST /api/v1/jobs/{id}/reports. Empty list means report downloads are disabled (Viewer tier) — the share link is the only output. Starter+ includes json/html/pdf/xml; Scale+ adds annotated_pdf.

On-demand capability fill-in

Missing analyzer output can be filled lazily. Fillable capabilities: separations, tac, fonts, images. layers is read-only (detected at parse time).

POST/api/v1/viewer/jobs/{job_id}/capabilities/{capability}API Key required

Queue an analyzer run for the named capability. Returns 202; poll config to see it flip true.

Request

curl -X POST https://api.lintpdf.com/api/v1/viewer/jobs/.../capabilities/separations \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "job_id": "d4e5f6a7-...",
  "capability": "separations",
  "status": "queued",
  "task_id": "tsk_01HXY..."
}

When the capability is already populated the response comes back with status: "already_filled" and no task_id. Non-fillable capabilities return 422.

# Python polling pattern
import time, requests
r = requests.post(f"{BASE}/viewer/jobs/{jid}/capabilities/tac", headers=H)
while True:
    cfg = requests.get(f"{BASE}/viewer/jobs/{jid}/config", headers=H).json()
    if cfg["capabilities"].get("tac"):
        break
    time.sleep(1.5)

Verdict

GET/api/v1/viewer/jobs/{job_id}/verdictAPI Key required

Return the current verdict state for a job.

Request

curl https://api.lintpdf.com/api/v1/viewer/jobs/.../verdict \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "verdict": "pass",
  "auto_passed": true,
  "verdict_by": "reviewer@acmeprint.com",
  "verdict_at": "2026-04-12T15:22:11Z",
  "notes": null
}
POST/api/v1/viewer/jobs/{job_id}/verdictAPI Key required

Record a manual pass/fail verdict. Fail requires notes.

Request

curl -X POST https://api.lintpdf.com/api/v1/viewer/jobs/.../verdict \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "verdict": "fail", "notes": "Low-res image on page 4" }'

Response

{
  "verdict": "fail",
  "auto_passed": false,
  "verdict_by": "reviewer@acmeprint.com",
  "verdict_at": "2026-04-12T15:22:11Z",
  "notes": "Low-res image on page 4"
}
FieldTypeRequiredDefaultDescription
verdict"pass" | "fail"YesManual verdict. Anything else returns 422.
notesstring | nullNoFree-form reviewer notes. Required when verdict=fail.

auto_passed reflects the engine's summary verdict; verdict reflects a reviewer's manual call. A job can be auto-passed and manually failed at the same time — clients should render both.

File comparison

POST/api/v1/viewer/compareAPI Key required

Compare two complete jobs owned by the same tenant. Returns per-page similarity scores.

Request

curl -X POST https://api.lintpdf.com/api/v1/viewer/compare \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "job_a": "d4e5f6a7-...", "job_b": "a1b2c3d4-...", "dpi": 150 }'

Response

{
  "comparison_id": "cmp_8e7d6c5b4a3f",
  "page_count_a": 12,
  "page_count_b": 12,
  "pages": [
    { "page_num": 1, "ssim_score": 0.987, "diff_pixel_count": 1842, "total_pixels": 1404000 }
  ]
}
GET/api/v1/viewer/compare/{comparison_id}/pages/{page_num}/diff?dpi=150API Key required

RGBA diff heatmap PNG. Green=ink in job_a only, red=ink in job_b only, amber=color delta beyond tolerance.

Request

curl "https://api.lintpdf.com/api/v1/viewer/compare/cmp_.../pages/1/diff?dpi=150" \
  -H "Authorization: Bearer lpdf_live_..." \
  --output diff-page1.png

Response

200 OK
Content-Type: image/png

Annotations + comments

GET /api/v1/viewer/jobs/{job_id}/annotations returns the flat annotation list by default (back-compat). Pass ?include=comments to embed each annotation's full comment thread inline in one round trip — no N+1 fan-out of per-annotation comment fetches. The aggregated GET /api/v1/jobs/{id}/state endpoint uses the same embedding.

GET/api/v1/viewer/jobs/{job_id}/annotations?include=commentsAPI Key required

Annotation list with each comment thread embedded inline under items[].comments.

Request

curl "https://api.lintpdf.com/api/v1/viewer/jobs/d4e5f6a7-.../annotations?include=comments" \
  -H "Authorization: Bearer lpdf_live_..."

Response

[
  {
    "id": "...", "page_num": 1, "kind": "rect",
    "geometry": { "x": 10, "y": 10, "w": 100, "h": 50 },
    "color": "#dc2626", "text": "Fix the bleed",
    "author_email": "reviewer@example.com",
    "comments": [
      { "id": "...", "annotation_id": "...",
        "author_email": "reviewer@example.com", "body": "Will do by EOD." }
    ]
  }
]
FieldTypeRequiredDefaultDescription
includestringNounsetOptional. Set to "comments" to embed each annotation's comment thread inline. Any other value returns 422. Omit the param for the legacy shape (flat AnnotationResponse list without a comments key).

The same ?include=comments param works on the share-link mirror GET /api/v1/viewer/public/{token}/annotations?include=comments — unauthenticated read, subject to the token's expiry.

Public share-link state digest

Mirror of the authenticated universal-state endpoint for share links. Returns the same stitched digest (summary + approval chain + verdict + annotations + comments) minus the reports section — those are scoped to the issuing tenant and shouldn't leak sibling tokens. See Universal Job State for the full field reference.

GET/api/v1/viewer/public/{token}/state

Unauthenticated state digest for a share link. Same payload shape as /api/v1/jobs/{id}/state minus reports[]. Accepts ?include=approval_chain,verdict,annotations.

Request

curl "https://api.lintpdf.com/api/v1/viewer/public/CahsfLjcly.../state?include=verdict,annotations"

Response

{
  "job": { "job_id": "...", "status": "complete" },
  "summary": { "total_findings": 275, "passed": true },
  "approval_chain": { "status": "approved", "step_history": [ ... ] },
  "verdict": { "verdict": "pass", "auto_passed": true, "notes": "..." },
  "annotations": { "total": 1, "by_page": {"1": 1}, "items": [ ... ] }
}

Reports & share links

Reports are immutable artifacts minted from a complete job. Each report carries its own token for unauthenticated access and freezes its branding at mint time — flipping the tenant default later does not retroactively rebrand existing share links.

POST/api/v1/jobs/{job_id}/reportsAPI Key required

Mint one or more reports for a completed job. Returns 201 on success, 403 if any requested format or the branding override exceeds the tenant's plan. Supports an optional Idempotency-Key header; repeated requests with the same key converge on the same token and reuse the stored artifact instead of regenerating it.

Request

curl -X POST https://api.lintpdf.com/api/v1/jobs/d4e5f6a7-.../reports \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-42-2026-04-17" \
  -d '{
    "formats": [
      { "format": "json", "return": "inline" },
      { "format": "annotated_pdf", "return": "url" },
      "html"
    ],
    "expiry_days": 14,
    "email_to": "printer@acme.com",
    "branding": {
      "name": "Acme Print",
      "logo_url": "https://acmeprint.com/logo.svg",
      "primary_color": "#1a365d",
      "accent_color": "#2563eb",
      "hide_footer": false
    },
    "detail_level": "comprehensive",
    "summary_page": "prepend"
  }'

Response

{
  "reports": [
    { "format": "json", "url": null, "token": null, "expires_at": null,
      "data": { "summary": { "error_count": 2 }, "findings": [ ... ] },
      "content_type": "application/json" },
    { "format": "annotated_pdf", "token": "rpt_01HXY...",
      "expires_at": "2026-04-26T10:30:00Z",
      "url": "https://reports.lintpdf.com/r/rpt_01HXY....pdf",
      "data": null, "content_type": null },
    { "format": "html", "token": "rpt_01HXZ...",
      "expires_at": "2026-04-26T10:30:00Z",
      "url": "https://reports.lintpdf.com/r/rpt_01HXZ...",
      "data": null, "content_type": null }
  ]
}

Generate-reports request fields

FieldTypeRequiredDefaultDescription
formats(FormatName | { format: FormatName; return?: "url" | "inline" | "both" })[]No["html","pdf"]Output formats to mint. Each entry is either a bare format string (back-compat; equivalent to return="url") or an object selecting the return mode. Inline returns embed the payload in the response body and are supported only for json and xml; requesting inline on a binary format (pdf, annotated_pdf, annotated_pdf_markup, html) returns 422. Formats not in your plan return 403.
expiry_daysinteger | nullNotenant / plan default (typically 7)Token lifetime in days. Null defers to the tenant setting or plan limit. Ignored for inline-only formats (no token minted).
email_tostring | nullNoSingle email address to deliver the report URLs to.
brandingBrandingOverride | nullNoPer-call branding override. Object with name/logo_url/primary_color/accent_color/hide_footer fields (each optional). Requires the white-label entitlement.
detail_level"executive" | "standard" | "comprehensive"No"standard"Narrative density of the generated PDF/HTML report.
summary_page"prepend" | "only" | "off" | nullNo"prepend" (or tenant override)Where the executive summary page lands in the PDF.

Generate-reports response fields

FieldTypeRequiredDefaultDescription
reports[].formatstringNoThe requested format name, echoed back.
reports[].urlstring | nullNoSigned token URL for the hosted artifact, e.g. https://reports.lintpdf.com/r/{token}{.ext}. null for inline-only rows.
reports[].tokenstring | nullNoOpaque share token. 43 characters and deterministic when the caller sent an Idempotency-Key header, otherwise a random 32-byte urlsafe string. null for inline-only rows.
reports[].expires_atstring | nullNoISO-8601 timestamp when the URL stops resolving. null for inline-only rows and for mints created with expiry_days: null.
reports[].dataobject | string | nullNoInline payload. Parsed object for format="json", raw string for format="xml". Present only when return is "inline" or "both" — omitted (null) for URL-only rows.
reports[].content_typestring | nullNoMIME type for the inline payload (application/json or application/xml). null when data is null.

Idempotency-Key (optional)

Send the Idempotency-Key request header (max 255 characters) to make mints safe to retry. The engine derives each token as sha256(tenant_id + idempotency_key + format) and reuses the stored artifact instead of regenerating it when the same key recurs. Keys are scoped per tenant — a shared key will never collide with another tenant's reports.

For a job-submit-time brand override using the three-way enum ("anonymous" | "lintpdf" | uuid) use the brand field on POST /api/v1/jobs. The reports endpoint takes the richer BrandingOverride object form so per-report tweaks (logo URL, hide footer, etc.) survive.

GET/api/v1/jobs/{job_id}/reportsAPI Key required

List existing report tokens for a job.

Request

curl https://api.lintpdf.com/api/v1/jobs/.../reports \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "reports": [
    {
      "token": "rpt_01HXY...",
      "format": "pdf",
      "expires_at": "2026-04-26T10:30:00Z",
      "created_at": "2026-04-12T10:30:00Z",
      "accessed_count": 3
    }
  ]
}
DELETE/api/v1/jobs/{job_id}/reports/{token}API Key required

Revoke a specific report token and delete the stored file. Public URLs immediately return 410 Gone / 404. Returns 204 on success.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/jobs/.../reports/rpt_01HXY... \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 204 No Content

Public (token-gated) surfaces

The following endpoints require no authentication. Access is gated by possession of the token alone — treat them like signed URLs.

GET/r/{token}

HTML landing page for a report. Anonymous reports omit all LintPDF branding.

Request

curl https://reports.lintpdf.com/r/rpt_01HXY...

Response

200 OK
Content-Type: text/html
GET/r/{token}.pdf?download=1

Direct download of the PDF report. download=1 sets Content-Disposition: attachment. Filename is "report.pdf" for normal reports and a neutral "preflight-<short-id>.pdf" for anonymous reports.

Request

curl -o report.pdf https://reports.lintpdf.com/r/rpt_01HXY....pdf?download=1

Response

200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
GET/api/v1/reports/tokens/{token}

Token metadata used by the plugin proxy to validate public viewer access. Returns 404 on unknown tokens and 410 Gone on expired tokens.

Request

curl https://api.lintpdf.com/api/v1/reports/tokens/rpt_01HXY...

Response

{
  "job_id": "d4e5f6a7-...",
  "tenant_id": "...",
  "file_name": "brochure.pdf",
  "email_required": true
}
GET/api/v1/reports/tokens/{token}/findings

Structured findings payload for a share link. Mirrors the findings array on GET /jobs/{id} but authenticated by token.

Request

curl https://api.lintpdf.com/api/v1/reports/tokens/rpt_01HXY.../findings

Response

{
  "findings": [
    {
      "inspection_id": "font.not_embedded",
      "severity": "error",
      "message": "Font 'Helvetica' is not embedded",
      "page_num": 1,
      "details": null,
      "source": "engine",
      "category": "fonts",
      "bbox": [72.0, 720.0, 540.0, 740.0],
      "object_id": "Font12",
      "object_type": "font"
    }
  ]
}

Public viewer surfaces

Every authenticated viewer route has an unauthenticated parallel rooted at /api/v1/viewer/public/{token}/*. The token carries the frozen brand, so GET .../config emits the same branding block regardless of later tenant setting changes.

GET/api/v1/viewer/public/{token}/pages

Public page list for a token-scoped viewer session. Same shape as the authenticated endpoint.

Request

curl https://api.lintpdf.com/api/v1/viewer/public/rpt_01HXY.../pages

Response

{
  "job_id": "d4e5f6a7-...",
  "page_count": 1,
  "pages": [ { "page_num": 1, "width_pts": 595.28, "height_pts": 841.89, ... } ]
}

Parallel public routes exist for: tile, info, separations, channel, tac-heatmap, sample, layers, config, and verdict (read-only GET).

Bulk report-mint

When a client has completed many independent jobs and needs share links for all of them, the single-endpoint approach is one HTTP round trip per job. At bulk scale (hundreds of jobs) that N-request storm is a common source of dropped mints.POST /api/v1/reports:batchMintcollapses the fan-out into a single request. Minted tokens are byte-identical to the single-endpoint output; the only behavioral difference is that advanced per-call knobs (universal overrides envelope, inline returns, idempotency-key) live only on the single endpoint. Hard-capped at 500 job_ids per call.

POST/api/v1/reports:batchMintAPI Key required

Mint reports for up to 500 completed jobs in one round trip. Returns 200 with a per-job result array; a single failure does not drop the rest of the batch.

Request

curl -X POST https://api.lintpdf.com/api/v1/reports:batchMint \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "job_ids": ["d4e5f6a7-...", "a1b2c3d4-...", "e5f6a7b8-..."],
    "formats": ["html", "pdf", "json"],
    "expiry_days": 7,
    "allow_annotations": false,
    "require_visitor_email": false
  }'

Response

{
  "results": [
    { "job_id": "d4e5f6a7-...", "status": "ok",
      "reports": [
        { "format": "html", "token": "rpt_01HXY...",
          "url": "https://reports.lintpdf.com/r/rpt_01HXY...",
          "viewer_url": "https://app.lintpdf.com/view/rpt_01HXY...",
          "expires_at": "2026-04-28T10:30:00Z" },
        { "format": "pdf", "token": "rpt_01HXZ...", ... },
        { "format": "json", "token": "rpt_01HXA...", ... }
      ]
    },
    { "job_id": "a1b2c3d4-...", "status": "failed",
      "error": "404: Job not found" }
  ],
  "summary": { "ok": 1, "failed": 1 }
}

Webhooks & check-name registry

Webhooks deliver real-time job events to your HTTPS endpoint. Every delivery is signed with X-LintPDF-Signature using HMAC-SHA256, hex-encoded, and prefixed with sha256=. Private-IP destinations are blocked at registration. Webhooks require the Growth plan or above.

POST/api/v1/webhooksAPI Key required

Register a webhook. url must be HTTPS and must not resolve to a private network. The signing secret is generated server-side.

Request

curl -X POST https://api.lintpdf.com/api/v1/webhooks \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/lintpdf",
    "events": ["job.state_changed", "approval.chain.completed"],
    "max_retries": 5,
    "retry_base_delay_seconds": 2,
    "retry_max_delay_seconds": 60,
    "delivery_retention_days": 30,
    "retention_overrides": { "billing.*": 365 }
  }'

Response

{
  "id": "0e7f6c5b-4a3f-4210-9876-543210fedcba",
  "url": "https://your-app.com/webhooks/lintpdf",
  "events": ["job.state_changed", "approval.chain.completed"],
  "is_active": true,
  "created_at": "2026-04-12T10:30:00Z",
  "max_retries": 5,
  "retry_base_delay_seconds": 2,
  "retry_max_delay_seconds": 60,
  "delivery_retention_days": 30,
  "retention_overrides": { "billing.*": 365 }
}
FieldTypeRequiredDefaultDescription
urlstring (HTTPS URL)YesPublic endpoint to POST events to. Private networks rejected at registration.
eventsstring[]No[job.completed, job.failed]Subscription list. Empty [] subscribes to every event. See the catalog below.
max_retriesint (0-10)No3Retry budget for 5xx/timeout failures. Null inherits the platform default. Capped at 10 platform-wide.
retry_base_delay_secondsint (1-600)No5Initial retry delay. Subsequent retries double exponentially, capped by retry_max_delay_seconds.
retry_max_delay_secondsint (1-3600)No300Exponential-backoff ceiling. Keeps a high max_retries from waiting absurdly long.
delivery_retention_daysint (1-365)Nonull (forever)Nightly sweep deletes WebhookDelivery audit rows older than this for this endpoint. Null keeps forever.
retention_overridesobjectNo{}Per-event retention overrides, e.g. {"billing.*": 365, "annotation.*": 7}. Keys are fnmatch globs matched against event names; longest-match wins.

The signing secret is generated server-side at registration and is not returned in the response body. Retrieve it from the dashboard (Webhooks → Endpoint → Reveal secret). Store it alongside the endpoint URL and use it in the signature-validation recipes below.

GET/api/v1/webhooksAPI Key required

List every webhook endpoint registered for the current tenant.

Request

curl https://api.lintpdf.com/api/v1/webhooks \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "webhooks": [
    { "id": "0e7f6c5b-...", "url": "...", "events": [...], "is_active": true,
      "created_at": "2026-04-12T10:30:00Z" }
  ]
}
PATCH/api/v1/webhooks/{webhook_id}API Key required

Update a webhook's URL, events, or active status. All body fields are optional.

Request

curl -X PATCH https://api.lintpdf.com/api/v1/webhooks/0e7f6c5b-... \
  -H "Authorization: Bearer lpdf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'

Response

{ "id": "0e7f6c5b-...", "is_active": false, ... }
DELETE/api/v1/webhooks/{webhook_id}API Key required

Remove a webhook endpoint. Returns 204.

Request

curl -X DELETE https://api.lintpdf.com/api/v1/webhooks/0e7f6c5b-... \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 204 No Content
POST/api/v1/webhooks/{webhook_id}/testAPI Key required

Send a synthetic test.ping event to the registered URL. Helpful when debugging signature validation.

Request

curl -X POST https://api.lintpdf.com/api/v1/webhooks/0e7f6c5b-.../test \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "success": true,
  "status_code": 200,
  "error": "",
  "event": "test.ping"
}

Events

FieldTypeRequiredDefaultDescription
job.completedeventNoPreflight finished. Payload carries job_id, status, profile_id, duration_ms, summary.
job.failedeventNoPreflight errored out. Payload carries job_id, status, error message.
test.pingeventNoSent only by the /test endpoint. Never fires in production flows.

Event payload — job.completed

{
  "event": "job.completed",
  "job_id": "d4e5f6a7-1234-4567-89ab-cdef01234567",
  "status": "complete",
  "profile_id": "lintpdf-default",
  "duration_ms": 3480,
  "summary": {
    "total_findings": 7,
    "error_count": 1,
    "warning_count": 4,
    "advisory_count": 2,
    "passed": false,
    "page_count": 12,
    "file_size_bytes": 842139
  }
}

Event payload — job.failed

{
  "event": "job.failed",
  "job_id": "d4e5f6a7-...",
  "status": "failed",
  "error": "Unsupported PDF version: 2.1"
}

Request headers

FieldTypeRequiredDefaultDescription
X-LintPDF-EventstringNoThe event type, e.g. job.completed.
X-LintPDF-SignaturestringNoHMAC-SHA256 of the raw body, hex-encoded, prefixed with sha256=.
Content-TypestringNoAlways application/json.

Signature validation — Python

import hmac, hashlib

def valid(body: bytes, header: str, secret: str) -> bool:
    mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={mac}", header)

Signature validation — Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

export function valid(body: Buffer, header: string, secret: string) {
  const mac = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
  const a = Buffer.from(mac);
  const b = Buffer.from(header);
  return a.length === b.length && timingSafeEqual(a, b);
}

Event catalog

The full list of events your endpoint can subscribe to. See the dedicated Webhooks page for delivery semantics, replay, and sample payloads.

FieldTypeRequiredDefaultDescription
job.completedeventNoPreflight completes successfully.
job.failedeventNoEngine exception or import parse failure.
job.state_changedeventNoUmbrella event. Fires whenever GET /jobs/{id}/state would differ. Payload carries the full /state digest inline plus a 'reason' tag.
approval.chain.startedeventNoApproval chain attached + step 0 kicked off.
approval.step.startedeventNoStep enters active review.
approval.step.decidedeventNoApprover submits a decision + optional notes.
approval.chain.completedeventNoFinal step approved → chain success.
approval.chain.rejectedeventNoAny step rejected → chain terminates.
approval.chain.cancelledeventNoChain manually cancelled.
approval.chain.timeouteventNoStep expired without decision.
annotation.createdeventNoReviewer drew a rect/circle/arrow/note/freehand.
annotation.deletedeventNoAnnotation removed.
comment.createdeventNoNew comment on an annotation thread.
verdict.changedeventNoManual verdict pass/fail flipped. Payload: previous, current, verdict_by, notes.
report.mintedeventNoPOST /jobs/{id}/reports returned at least one URL. Payload lists every minted format + URL + expires_at.
report.expiredeventNoReport token's expires_at passed and the nightly sweep deleted it. One event per token.
share_link.visitedeventNoFirst touch per (token, visitor_email) pair. Subsequent visits update last_seen_at silently.
billing.file_quota.loweventNoMonthly file pool dropped from >10% to ≤10% on deduction. One-shot per crossing.
billing.file_quota.exhaustedeventNoSubmit rejected with 402 — pool empty + overage off.
billing.ai_credits.loweventNoAI credits crossed the 10% watermark (CREDIT_PACKAGE billing mode only).
billing.ai_credits.exhaustedeventNoCredit package drained to zero.
tenant.plan.changedeventNoAdmin PATCH /admin/tenants/{id}/plan set a new plan value. Payload: previous_plan, new_plan.

Delivery audit + replay

Every dispatched event lands in the webhook_deliveries table with the exact JSON body LintPDF signed. A failed endpoint no longer means a lost event — operators can inspect and replay any past delivery.

GET/api/v1/webhooks/deliveriesAPI Key required

List delivery attempts newest-first. Filter with ?webhook_id=, ?event=, ?success=false. Paginates via ?page= + ?page_size= (default 50, max 200).

Request

curl "https://api.lintpdf.com/api/v1/webhooks/deliveries?success=false&page_size=25" \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "deliveries": [
    {
      "id": "...",
      "webhook_id": "...",
      "event": "job.state_changed",
      "url": "https://your-app.com/webhooks/lintpdf",
      "attempt_count": 1,
      "final_status_code": 503,
      "success": false,
      "last_error": "HTTP 503",
      "created_at": "2026-04-18T02:11:00Z",
      "delivered_at": "2026-04-18T02:11:02Z"
    }
  ],
  "total": 1, "page": 1, "page_size": 25
}
GET/api/v1/webhooks/deliveries/{delivery_id}API Key required

Fetch one delivery row + the exact signed payload we POSTed.

Request

curl "https://api.lintpdf.com/api/v1/webhooks/deliveries/..." \
  -H "Authorization: Bearer lpdf_live_..."

Response

{
  "id": "...", "event": "job.state_changed", "success": false,
  "payload": { "reason": "verdict.changed", "job": { "...": "..." } }
}
POST/api/v1/webhooks/deliveries/{delivery_id}/replayAPI Key required

Re-fire a past delivery against the original endpoint. Creates a NEW WebhookDelivery row; the original is preserved for audit history. Returns 409 if the endpoint is inactive or deleted.

Request

curl -X POST https://api.lintpdf.com/api/v1/webhooks/deliveries/.../replay \
  -H "Authorization: Bearer lpdf_live_..."

Response

HTTP/1.1 201 Created
{
  "id": "<new-delivery-id>",
  "event": "job.state_changed",
  "attempt_count": 0,
  "success": false,
  "payload": { "...": "same as original" }
}

Check-name registry

Unauthenticated, cache-aggressive endpoint mapping every engine inspection_id to a human-readable name and description. Safe to cache for 24h in your client.

GET/api/v1/check-names

Full check-name registry keyed by inspection_id.

Request

curl https://api.lintpdf.com/api/v1/check-names

Response

{
  "font.not_embedded": {
    "name": "Font Not Embedded",
    "description": "Font is referenced but not embedded in the PDF"
  },
  "image.low_resolution": {
    "name": "Low-Resolution Image",
    "description": "Raster image falls below the profile's effective DPI threshold"
  }
}

AI-Explain

Generate a human-readable explanation for a finding via Claude Haiku 4.5. Explanations are cached on the finding row so repeated calls hit the cache for free. Cost-cap exceeded returns HTTP 402 — preflight + reports keep working, only the LLM features pause until the next monthly reset (or a higher cap).

POST/api/v1/jobs/{job_id}/findings/{finding_id}/explainAPI Key required

Generate (or fetch cached) AI explanation for a finding.

Request

POST /api/v1/jobs/abc/findings/xyz/explain
Authorization: Bearer lpdf_live_...

Response

{
  "finding_id": "xyz",
  "explanation": "This font is not embedded; the press will substitute Helvetica.",
  "model": "claude-haiku-4-5",
  "cached": false,
  "cost_cents": 0.04
}

Cost-cap behavior (Q-C5)

When the tenant's monthly LLM cost cap is exhausted, this endpoint returns 402 with a structured detail. The dashboard and SDK surface that as a “raise the cap” CTA.

HTTP/1.1 402 Payment Required
Content-Type: application/json

{
  "detail": "Cost cap exceeded — raise the cap in Account → Billing.",
  "used_cents": 9987,
  "monthly_cap_cents": 10000
}

EPM candidacy verdict

EPM (Extended Print Mode) is HP's CMY-only press path that skips the K plate for throughput. The engine scores every completed job against the 16 LPDF_EPM_*_REJECT codes plus the legacy LPDF_EPM_001..018 set and surfaces a tier verdict with rejection drivers, advisories, and an IndiChrome-substrate upsell hint.

GET/api/v1/jobs/{job_id}/epmAPI Key required

Read the EPM candidacy verdict for a job's fired findings.

Request

GET /api/v1/jobs/abc/epm
Authorization: Bearer lpdf_live_...

Response

{
  "job_id": "abc",
  "tier": "marginal",
  "rejection_drivers": ["LPDF_EPM_BLEED_REJECT"],
  "advisories": ["LPDF_EPM_TRAPPING_REJECT"],
  "recommends_indichrome": false,
  "legacy_codes_fired": [],
  "epm_findings_count": 2
}

Tiers

FieldTypeRequiredDefaultDescription
passstringNoNo EPM-related findings — job runs cleanly on the EPM path.
pass_with_advisorystringNoTier-C advisory findings only; verdict is still PASS but operators should review.
marginalstringNoOne Tier-B soft-rejection finding fired; treat as borderline. Two or more → reject.
rejectstringNoAny Tier-A finding, or two+ Tier-B findings — job is not an EPM candidate.

Inline on JobResponse

The verdict is also surfaced inline on the single-job GET /api/v1/jobs/{id} response under epm_verdict. List endpoints (GET /api/v1/jobs) leave it null — scoring per row is skipped to keep list latency cheap.

GET /api/v1/jobs/abc

{
  "job_id": "abc",
  "epm_verdict": { "tier": "marginal", … },
  "decisions_count": 1,
  …
}

Decisions audit (V-05)

Operator decisions on jobs and findings are stored in a tenant-scoped append-only audit table. Decisions never delete — revoke stamps revoked_at / revoked_by_user_id / revoked_reason so audit replays stay correct after operators change their minds.

GET/api/v1/jobs/{job_id}/decisionsAPI Key required

List decisions on a job (newest first; active by default).

Request

GET /api/v1/jobs/abc/decisions?include_revoked=false&limit=200
Authorization: Bearer lpdf_live_...

Response

{
  "decisions": [
    {
      "id": "d1",
      "job_id": "abc",
      "finding_id": null,
      "decision_type": "approve",
      "decided_by_user_id": "u1",
      "source": "dashboard",
      "decided_at": "2026-04-27T12:00:00Z",
      "is_active": true
    }
  ],
  "count": 1
}
POST/api/v1/jobs/{job_id}/decisionsAPI Key required

Record a job-level decision (no finding scope).

Request

POST /api/v1/jobs/abc/decisions
Content-Type: application/json
Authorization: Bearer lpdf_live_...

{
  "decision_type": "approve",
  "decided_by_user_id": "u1",
  "source": "api",
  "notes": "Approved after operator review."
}

Response

{
  "id": "d1",
  "decision_type": "approve",
  "decided_by_user_id": "u1",
  "source": "api",
  "is_active": true,
  ...
}
POST/api/v1/jobs/{job_id}/findings/{finding_id}/decisionsAPI Key required

Record a finding-level decision.

Request

POST /api/v1/jobs/abc/findings/xyz/decisions
Content-Type: application/json
Authorization: Bearer lpdf_live_...

{
  "decision_type": "waive",
  "decided_by_user_id": "u1",
  "source": "api",
  "notes": "Customer accepts the risk."
}

Response

{ "id": "d2", "finding_id": "xyz", "decision_type": "waive", … }
POST/api/v1/jobs/{job_id}/decisions/{decision_id}/revokeAPI Key required

Soft-revoke a decision (Q-2). Idempotent — re-revoking is a no-op.

Request

POST /api/v1/jobs/abc/decisions/d1/revoke
Content-Type: application/json
Authorization: Bearer lpdf_live_...

{ "revoked_by_user_id": "u2", "revoked_reason": "Mistake" }

Response

{
  "id": "d1",
  "is_active": false,
  "revoked_at": "2026-04-27T12:30:00Z",
  "revoked_by_user_id": "u2",
  "revoked_reason": "Mistake"
}

Effective decision on findings

The latest non-revoked decision per finding is also surfaced on each FindingResponse as effective_decision — a minimal projection so dashboards can render the verdict chip without a second fetch.

FieldTypeRequiredDefaultDescription
decision_typestringNoapprove | reject | waive | suppress | annotate | escalate
decided_atstringNoISO-8601 UTC timestamp.
decided_by_user_idstringNoTenant-scoped operator id (max 128 chars).

Workflows

A Workflow pins a profile + brand spec + per-call ToggleOverride defaults under a single name so jobs can be submitted with a single named handle. Workflows replace the legacy /api/v1/endpoints custom-endpoint surface (Phase 0.7 unified-config substrate).

GET/api/v1/workflowsAPI Key required

List workflows for the current tenant.

Request

GET /api/v1/workflows
Authorization: Bearer lpdf_live_...

Response

{
  "workflows": [
    {
      "id": "w1",
      "name": "Coated stock",
      "profile_id": "lintpdf-default",
      "brand_spec_id": null,
      "created_at": "2026-04-20T12:00:00Z"
    }
  ]
}
POST/api/v1/workflowsAPI Key required

Create a workflow.

Request

POST /api/v1/workflows
Content-Type: application/json
Authorization: Bearer lpdf_live_...

{
  "name": "Uncoated stock",
  "profile_id": "lintpdf-default",
  "brand_spec_id": null
}

Response

{ "id": "w2", "name": "Uncoated stock", "profile_id": "lintpdf-default" }
PATCH/api/v1/workflows/{workflow_id}API Key required

Update a workflow's editable fields.

Request

PATCH /api/v1/workflows/w2
Content-Type: application/json
Authorization: Bearer lpdf_live_...

{ "name": "Renamed" }

Response

{ "id": "w2", "name": "Renamed", … }
DELETE/api/v1/workflows/{workflow_id}API Key required

Delete a workflow.

Request

DELETE /api/v1/workflows/w2
Authorization: Bearer lpdf_live_...

Response

HTTP/1.1 204 No Content

Enum appendix

preflight_source

FieldTypeRequiredDefaultDescription
enginestringNoRun LintPDF's 500+ checks and produce geometry + capability data.
externalstringNoImport findings from a third-party preflight (PitStop/callas/Acrobat/native or a custom mapping).
minimalstringNoNo preflight — viewer and share surfaces only. Capabilities can be filled on demand.

external_format

FieldTypeRequiredDefaultDescription
pitstop_xmlstringNoEnfocus PitStop Server / Pro XML report.
callas_jsonstringNocallas pdfToolbox JSON report.
callas_xmlstringNocallas pdfToolbox XML report.
acrobat_xmlstringNoAdobe Acrobat Pro Preflight XML report.
lintpdf_jsonstringNoLintPDF native v1 import JSON. See /schemas/import/v1.json.
customstringNoSet implicitly when mapping_id is supplied — the tenant's custom mapping parses the report.

brand

FieldTypeRequiredDefaultDescription
anonymousstringNoStrip tenant branding AND LintPDF branding. Sanitizes PDF metadata; uses neutral filename.
lintpdfstringNoUse LintPDF default branding.
<uuid>uuidNoApply a tenant-owned BrandProfile by ID. 403/404 if the profile belongs to another tenant or doesn't exist.

branding-defaults mode

FieldTypeRequiredDefaultDescription
anonymousstringNoTenant default is no branding at all (broker → distributor use case). Strips logos, headers, PDF metadata, filename slug, viewer chrome, and share-page chrome.
profilestringNoTenant default is a specific BrandProfile. Requires brand_profile_id.
lintpdfstringNoTenant default is LintPDF's built-in branding.

severity

FieldTypeRequiredDefaultDescription
errorstringNoBlocking issue — contributes to summary.error_count and makes summary.passed=false.
warningstringNoNon-blocking issue — contributes to summary.warning_count.
advisorystringNoInformational — does not affect summary.passed.

verdict

FieldTypeRequiredDefaultDescription
passstringNoReviewer-set or auto-derived approval.
failstringNoReviewer-set rejection. Requires notes when set manually.

The viewer config also carries verdict_mode (auto | manual | off) which governs whether a pass/fail comes from the engine's summary or a manual reviewer action.

source (finding provenance)

FieldTypeRequiredDefaultDescription
enginestringNoProduced by LintPDF's native analyzer pipeline.
aistringNoProduced by an AI inspection.
external:pitstopstringNoImported from an Enfocus PitStop XML report.
external:callasstringNoImported from a callas pdfToolbox JSON or XML report.
external:acrobatstringNoImported from an Adobe Acrobat Preflight XML report.
external:lintpdf_jsonstringNoImported from a LintPDF-native v1 import JSON document.
external:custom:<mapping-id>stringNoImported via a tenant-defined custom mapping; the mapping UUID is appended for audit.

BrandProfile profile_type

FieldTypeRequiredDefaultDescription
customstringNoUse this profile's own brand_name / logo_url / colors / footer_text.
lintpdfstringNoUse LintPDF default branding. Useful as a 'reset to defaults' sibling profile.
nonestringNoNeutral / blind output — blank brand name, generic greys, no footer.

Finding object_type

FieldTypeRequiredDefaultDescription
imagestringNoRaster XObject.
textstringNoText run.
pathstringNoVector path.
fontstringNoFont resource.
pagestringNoWhole-page finding.
documentstringNoDocument-level finding (no page reference).

Preflight profiles (as opposed to brand profiles) don't use a string enum — the API exposes a boolean is_builtin on ProfileSummaryResponse to distinguish LintPDF-shipped profiles from tenant-owned customs.

EpmTier

FieldTypeRequiredDefaultDescription
passstringNoNo EPM-related findings — job runs cleanly on the EPM path.
pass_with_advisorystringNoTier-C advisory findings only; verdict is still PASS but operators should review.
marginalstringNoOne Tier-B soft-rejection finding fired; treat as borderline.
rejectstringNoAny Tier-A finding, or two+ Tier-B findings — job is not an EPM candidate.

decision_type

FieldTypeRequiredDefaultDescription
approvestringNoOperator approves the job / finding.
rejectstringNoOperator rejects the job / finding.
waivestringNoOperator waives a finding (acknowledges + accepts the risk).
suppressstringNoHide the finding from future renders without changing severity.
annotatestringNoAttach a note/comment without changing approval status.
escalatestringNoBump the finding to a higher reviewer in the approval chain.

decision_source

FieldTypeRequiredDefaultDescription
dashboardstringNoRecorded from the LintPDF web dashboard.
apistringNoRecorded directly via the REST API (curl/SDK/server-to-server).
pluginstringNoRecorded via a Fairy Ring plugin route.
sdkstringNoRecorded via the Python SDK.
share_linkstringNoRecorded by an anonymous reviewer through a share-link URL.
approval_chainstringNoAuto-recorded by the multi-step approval chain workflow.
desktopstringNoRecorded from the desktop app.
systemstringNoRecorded automatically by an internal engine process (no operator).
migrationstringNoSynthetic decision created during a data migration.