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 cycleThe 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).
/api/v1/jobsAPI Key requiredSubmit 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=engineResponse
{
"job_id": "d4e5f6a7-1234-4567-89ab-cdef01234567",
"status": "pending",
"message": "Job submitted successfully"
}Request fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
file | file | Yes | — | The PDF (or convertible input) to process. |
profile_id | string | No | lintpdf-default | Preflight profile ID. Ignored when preflight_source is minimal. |
preflight_source | "engine" | "external" | "minimal" | No | engine | How findings are produced for this job. |
external_format | "pitstop_xml" | "callas_json" | "callas_xml" | "acrobat_xml" | "lintpdf_json" | No | — | Format of the external report. Omit to auto-detect. Mutually exclusive with mapping_id. |
external_report | file | No | — | The preflight report when preflight_source=external. XML or JSON. |
mapping_id | uuid | No | — | Tenant-defined custom import mapping. Mutually exclusive with external_format — sets the format to custom internally. |
jdf_file | file | No | — | Optional JDF/XJDF sidecar. Findings include jdf_context when supplied. |
ai_enabled | boolean | No | null (profile decides) | Per-job override: true forces AI on, false forces AI off, unset defers to the profile. |
ai_categories | string | No | — | Comma-separated AI category IDs to enable. Applied only when ai_enabled=true. |
ai_features | string | No | — | Comma-separated AI inspection slugs. Takes precedence over ai_categories. |
ai_preset | string | No | — | AI preset slug (e.g. brand-compliance, packaging-qc, full-ai-scan). Implicitly enables AI. |
brand | "anonymous" | "lintpdf" | uuid | No | — | Per-request brand override. UUID must be a BrandProfile owned by your tenant. Absent → tenant default. |
unbranded | boolean | No | — | Convenience alias: when true, equivalent to brand=anonymous. |
brand_spec_id | uuid | No | — | Per-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. |
wait | float (query param) | No | — | If 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
200 | OK | No | — | Only when ?wait= was set and the job reached a terminal state inline. Body is the full JobResponse. |
202 | Accepted | No | — | Job queued; poll GET /jobs/{id} for completion. |
401 | Unauthorized | No | — | Missing or invalid bearer token. |
403 | Forbidden | No | — | Valid key, but you lack permission — e.g. cross-tenant mapping_id, or plan doesn't include the requested feature. |
404 | Not Found | No | — | profile_id or mapping_id does not exist in your tenant. |
413 | Payload Too Large | No | — | File exceeds the per-tenant upload limit. |
422 | Unprocessable Entity | No | — | Invalid enum value, malformed UUID, unparseable external_report, ClamAV-detected malware, or mutually exclusive fields set together. |
429 | Too Many Requests | No | — | Rate limit exceeded. Back off using the X-RateLimit-* headers. |
/api/v1/jobs/{job_id}API Key requiredRetrieve 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.
/api/v1/jobs?page=1&page_size=25API Key requiredList 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
}/api/v1/jobs/{job_id}API Key requiredDelete 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 ContentUniversal 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.
/api/v1/jobs/{job_id}/stateAPI Key requiredAggregated 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": "..." } ] }
]
}
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
include | string (CSV) | No | all sections | Optional 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).
/api/v1/endpoints/{slug}/submitAPI Key requiredSubmit 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.pdfResponse
# 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
slug | string | Yes | — | Lowercase kebab-case URL slug, unique per tenant (2-255 chars). |
profile_id | string | Yes | — | Profile this endpoint is bound to. Built-in or tenant-owned. |
description | string | No | — | Free-text label shown in the dashboard (max 1024 chars). |
is_active | boolean | No | true | Disable to 404 the submit URL without deleting the slug. |
response_mode | "async" | "sync" | No | async | Default 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_id | uuid | null | No | — | Optional 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.
/api/v1/tenant/import-mappingsAPI Key requiredCreate 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Human-readable label shown in the editor (1–128 chars). |
description | string | null | No | — | Optional description surfaced in the dashboard picker. |
format | "xml" | "json" | No | "xml" | Parser used to load the uploaded report. |
config | object | Yes | — | Mapping DSL. Holds item_selector, fields, severity_map, default_severity. See the Custom Import Mappings doc for the full grammar. |
sample_payload | string | null | No | — | Verbatim sample report used by the preview endpoint. |
sample_mime | string | null | No | — | MIME type hint for the stored sample (e.g. application/xml, application/json). Max 64 chars. |
is_active | boolean | No | true | Inactive mappings are hidden from the submit form and cannot be used on new jobs. |
/api/v1/tenant/import-mappingsAPI Key requiredList 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"
}
]
}/api/v1/tenant/import-mappings/{mapping_id}API Key requiredRetrieve 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, ... }/api/v1/tenant/import-mappings/{mapping_id}API Key requiredReplace 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, ... }/api/v1/tenant/import-mappings/{mapping_id}API Key requiredSoft-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/api/v1/tenant/import-mappings/{mapping_id}/previewAPI Key requiredDry-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.
/api/v1/tenant/branding-defaultsAPI Key requiredRead 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"
}/api/v1/tenant/branding-defaultsAPI Key requiredSet 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
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mode | "anonymous" | "profile" | "lintpdf" | Yes | — | How brand resolves when a job/report does not override. |
brand_profile_id | uuid | null | No | — | Required 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
/api/v1/tenants/{tenant_id}/brand-profilesAPI Key requiredList 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", ... } ] }/api/v1/tenants/{tenant_id}/brand-profilesAPI Key requiredCreate 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Internal 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_name | string | null | No | — | Display name on reports and the viewer chrome (max 255). |
logo_url | string | null | No | — | Absolute HTTPS URL to the logo (max 2048). |
primary_color | string | null | No | — | Hex color, #RRGGBB (max 7). |
accent_color | string | null | No | — | Hex color, #RRGGBB (max 7). |
footer_text | string | null | No | — | Footer copy baked into reports (max 500). |
hide_footer | boolean | No | false | Suppress the footer entirely. |
/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key requiredRetrieve 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", ... }/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key requiredReplace 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, ... }/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}API Key requiredDelete 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/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}/logoAPI Key requiredUpload 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.pngResponse
{ "id": "...", "logo_url": "https://cdn.lintpdf.com/brand-logos/...", ... }/api/v1/tenants/{tenant_id}/default-brand-profileAPI Key requiredConvenience 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.
/api/v1/tenants/{tenant_id}/custom-domainAPI Key requiredRead 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"
}/api/v1/tenants/{tenant_id}/custom-domainAPI Key requiredRegister 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.
/api/v1/tenants/{tenant_id}/brand-profiles/{profile_id}/custom-domainAPI Key requiredAttach (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
/api/v1/tenants/{tenant_id}/app-custom-domainAPI Key requiredRead 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
}/api/v1/tenants/{tenant_id}/app-custom-domainAPI Key requiredRegister 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.
/api/v1/brand-specsAPI Key requiredList 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
}
]
}/api/v1/brand-specsAPI Key requiredCreate 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | No | — | Display label for the spec, e.g. the end-customer's brand. |
customer_name | string | null | No | — | Free-form customer label; use for reporting / filtering. |
description | string | null | No | — | Internal notes about the spec. |
colors[] | array | No | — | One object per swatch. Required shape: {name, value}. Optional: pantone, notes. value is hex, named CSS color, or explicit rgb()/cmyk(). |
rich_black_spec | object | null | No | — | Optional {c, m, y, k} target rich-black composition. When set, print advisories measure against this instead of the profile default. |
is_default | boolean | No | — | At most one non-archived spec per tenant may carry is_default=true. Setting it on a new spec demotes the previous default atomically. |
is_archived | boolean | No | — | Soft-delete flag. Archived specs don't resolve as tenant-default but still resolve for historical jobs that captured them. |
/api/v1/brand-specs/{id}API Key requiredPatch 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
}/api/v1/brand-specs/{id}API Key requiredArchive (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
}/api/v1/brand-specs/{id}/restoreAPI Key requiredUn-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.
/api/v1/viewer/jobs/{job_id}/pagesAPI Key requiredList 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
}
]
}/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tile?dpi=150API Key requiredRender 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.pngResponse
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
/api/v1/viewer/jobs/{job_id}/separationsAPI Key requiredList 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" }
]
}/api/v1/viewer/jobs/{job_id}/pages/{page_num}/channel/{name}?dpi=150API Key requiredSingle-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.pngResponse
200 OK
Content-Type: image/pngTAC heatmap & densitometer
/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tac-heatmap?dpi=150&tac_limit=300API Key requiredTotal 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.pngResponse
200 OK
Content-Type: image/png/api/v1/viewer/jobs/{job_id}/pages/{page_num}/tac-heatmap/runs?dpi=150&tac_limit=300API Key requiredPer-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 }
]
}/api/v1/viewer/jobs/{job_id}/pages/{page_num}/sample?x=200&y=300&dpi=300API Key requiredSingle-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 }/api/v1/viewer/jobs/{job_id}/pages/{page_num}/densitometer?x=200&y=300&dpi=300&tac_limit=300API Key requiredPer-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
/api/v1/viewer/jobs/{job_id}/tile-warmingAPI Key requiredProgress 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
/api/v1/viewer/jobs/{job_id}/layersAPI Key requiredPDF 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
/api/v1/viewer/jobs/{job_id}/config?brand=anonymousAPI Key requiredEffective 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enable_separations | boolean | No | true | Show the separations / channels panel. |
enable_tac_heatmap | boolean | No | true | Show the TAC heatmap overlay toggle. |
enable_annotations | boolean | No | true | Show the annotation/markup tools. |
enable_measurement | boolean | No | true | Show the ruler / measurement tool. |
enable_comparison | boolean | No | true | Show the file-comparison entry point. |
enable_layers | boolean | No | true | Show the OCG layers panel. |
enable_findings_panel | boolean | No | true | Show the findings sidebar. |
enable_page_thumbnails | boolean | No | true | Show the page thumbnail strip. |
enable_zoom | boolean | No | true | Show zoom controls. |
enable_download | boolean | No | true | Show the download-original button. |
enable_html_report_link | boolean | No | true | Show 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_zoom | integer | No | 100 | Initial zoom percentage. |
default_dpi | integer | No | 150 | Initial tile render DPI. |
default_tac_limit | float | No | 300.0 | Initial TAC threshold for the heatmap. |
viewer_logo_url | string | null | No | — | Optional viewer-chrome logo override. |
viewer_accent_color | string | null | No | — | Optional viewer-chrome accent override. |
toolbar_position | "top" | "bottom" | "left" | "right" | No | "top" | Toolbar edge. |
dark_mode | boolean | No | false | Dark theme. |
brand_name | string | null | No | — | Resolved brand name (null when anonymous). |
brand_logo_url | string | null | No | — | Resolved brand logo URL (null when anonymous). |
brand_primary_color | string | null | No | — | Resolved brand primary color (null when anonymous). |
brand_accent_color | string | null | No | — | Resolved brand accent color (null when anonymous). |
anonymous | boolean | No | — | True when all tenant + LintPDF chrome is stripped. |
tenant_name | string | null | No | — | Tenant display name (null when anonymous). |
support_email | string | null | No | — | Tenant support email (null when anonymous). |
preflight_source | "engine" | "external" | "minimal" | No | — | How findings were produced. Drives the viewer's Load-button affordances. |
capabilities | Record<string, boolean> | No | — | Per-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_enabled | boolean | No | — | Plan 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_enabled | boolean | No | — | Plan 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_formats | string[] | No | — | Plan 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).
/api/v1/viewer/jobs/{job_id}/capabilities/{capability}API Key requiredQueue 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
/api/v1/viewer/jobs/{job_id}/verdictAPI Key requiredReturn 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
}/api/v1/viewer/jobs/{job_id}/verdictAPI Key requiredRecord 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"
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
verdict | "pass" | "fail" | Yes | — | Manual verdict. Anything else returns 422. |
notes | string | null | No | — | Free-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
/api/v1/viewer/compareAPI Key requiredCompare 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 }
]
}/api/v1/viewer/compare/{comparison_id}/pages/{page_num}/diff?dpi=150API Key requiredRGBA 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.pngResponse
200 OK
Content-Type: image/pngAnnotations + 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.
/api/v1/viewer/jobs/{job_id}/annotations?include=commentsAPI Key requiredAnnotation 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." }
]
}
]| Field | Type | Required | Default | Description |
|---|---|---|---|---|
include | string | No | unset | Optional. 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.
/api/v1/viewer/public/{token}/stateUnauthenticated 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.
/api/v1/jobs/{job_id}/reportsAPI Key requiredMint 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
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_days | integer | null | No | tenant / 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_to | string | null | No | — | Single email address to deliver the report URLs to. |
branding | BrandingOverride | null | No | — | Per-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" | null | No | "prepend" (or tenant override) | Where the executive summary page lands in the PDF. |
Generate-reports response fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
reports[].format | string | No | — | The requested format name, echoed back. |
reports[].url | string | null | No | — | Signed token URL for the hosted artifact, e.g. https://reports.lintpdf.com/r/{token}{.ext}. null for inline-only rows. |
reports[].token | string | null | No | — | Opaque 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_at | string | null | No | — | ISO-8601 timestamp when the URL stops resolving. null for inline-only rows and for mints created with expiry_days: null. |
reports[].data | object | string | null | No | — | Inline 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_type | string | null | No | — | MIME 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.
/api/v1/jobs/{job_id}/reportsAPI Key requiredList 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
}
]
}/api/v1/jobs/{job_id}/reports/{token}API Key requiredRevoke 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 ContentPublic (token-gated) surfaces
The following endpoints require no authentication. Access is gated by possession of the token alone — treat them like signed URLs.
/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/r/{token}.pdf?download=1Direct 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=1Response
200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"/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
}/api/v1/reports/tokens/{token}/findingsStructured 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.../findingsResponse
{
"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.
/api/v1/viewer/public/{token}/pagesPublic 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.../pagesResponse
{
"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.
/api/v1/reports:batchMintAPI Key requiredMint 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.
/api/v1/webhooksAPI Key requiredRegister 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 }
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url | string (HTTPS URL) | Yes | — | Public endpoint to POST events to. Private networks rejected at registration. |
events | string[] | No | [job.completed, job.failed] | Subscription list. Empty [] subscribes to every event. See the catalog below. |
max_retries | int (0-10) | No | 3 | Retry budget for 5xx/timeout failures. Null inherits the platform default. Capped at 10 platform-wide. |
retry_base_delay_seconds | int (1-600) | No | 5 | Initial retry delay. Subsequent retries double exponentially, capped by retry_max_delay_seconds. |
retry_max_delay_seconds | int (1-3600) | No | 300 | Exponential-backoff ceiling. Keeps a high max_retries from waiting absurdly long. |
delivery_retention_days | int (1-365) | No | null (forever) | Nightly sweep deletes WebhookDelivery audit rows older than this for this endpoint. Null keeps forever. |
retention_overrides | object | No | {} | 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.
/api/v1/webhooksAPI Key requiredList 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" }
]
}/api/v1/webhooks/{webhook_id}API Key requiredUpdate 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, ... }/api/v1/webhooks/{webhook_id}API Key requiredRemove 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/api/v1/webhooks/{webhook_id}/testAPI Key requiredSend 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
job.completed | event | No | — | Preflight finished. Payload carries job_id, status, profile_id, duration_ms, summary. |
job.failed | event | No | — | Preflight errored out. Payload carries job_id, status, error message. |
test.ping | event | No | — | Sent 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
X-LintPDF-Event | string | No | — | The event type, e.g. job.completed. |
X-LintPDF-Signature | string | No | — | HMAC-SHA256 of the raw body, hex-encoded, prefixed with sha256=. |
Content-Type | string | No | — | Always 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
job.completed | event | No | — | Preflight completes successfully. |
job.failed | event | No | — | Engine exception or import parse failure. |
job.state_changed | event | No | — | Umbrella event. Fires whenever GET /jobs/{id}/state would differ. Payload carries the full /state digest inline plus a 'reason' tag. |
approval.chain.started | event | No | — | Approval chain attached + step 0 kicked off. |
approval.step.started | event | No | — | Step enters active review. |
approval.step.decided | event | No | — | Approver submits a decision + optional notes. |
approval.chain.completed | event | No | — | Final step approved → chain success. |
approval.chain.rejected | event | No | — | Any step rejected → chain terminates. |
approval.chain.cancelled | event | No | — | Chain manually cancelled. |
approval.chain.timeout | event | No | — | Step expired without decision. |
annotation.created | event | No | — | Reviewer drew a rect/circle/arrow/note/freehand. |
annotation.deleted | event | No | — | Annotation removed. |
comment.created | event | No | — | New comment on an annotation thread. |
verdict.changed | event | No | — | Manual verdict pass/fail flipped. Payload: previous, current, verdict_by, notes. |
report.minted | event | No | — | POST /jobs/{id}/reports returned at least one URL. Payload lists every minted format + URL + expires_at. |
report.expired | event | No | — | Report token's expires_at passed and the nightly sweep deleted it. One event per token. |
share_link.visited | event | No | — | First touch per (token, visitor_email) pair. Subsequent visits update last_seen_at silently. |
billing.file_quota.low | event | No | — | Monthly file pool dropped from >10% to ≤10% on deduction. One-shot per crossing. |
billing.file_quota.exhausted | event | No | — | Submit rejected with 402 — pool empty + overage off. |
billing.ai_credits.low | event | No | — | AI credits crossed the 10% watermark (CREDIT_PACKAGE billing mode only). |
billing.ai_credits.exhausted | event | No | — | Credit package drained to zero. |
tenant.plan.changed | event | No | — | Admin 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.
/api/v1/webhooks/deliveriesAPI Key requiredList 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
}/api/v1/webhooks/deliveries/{delivery_id}API Key requiredFetch 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": { "...": "..." } }
}/api/v1/webhooks/deliveries/{delivery_id}/replayAPI Key requiredRe-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.
/api/v1/check-namesFull check-name registry keyed by inspection_id.
Request
curl https://api.lintpdf.com/api/v1/check-namesResponse
{
"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).
/api/v1/jobs/{job_id}/findings/{finding_id}/explainAPI Key requiredGenerate (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.
/api/v1/jobs/{job_id}/epmAPI Key requiredRead 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pass | string | No | — | No EPM-related findings — job runs cleanly on the EPM path. |
pass_with_advisory | string | No | — | Tier-C advisory findings only; verdict is still PASS but operators should review. |
marginal | string | No | — | One Tier-B soft-rejection finding fired; treat as borderline. Two or more → reject. |
reject | string | No | — | Any 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.
/api/v1/jobs/{job_id}/decisionsAPI Key requiredList 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
}/api/v1/jobs/{job_id}/decisionsAPI Key requiredRecord 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,
...
}/api/v1/jobs/{job_id}/findings/{finding_id}/decisionsAPI Key requiredRecord 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", … }/api/v1/jobs/{job_id}/decisions/{decision_id}/revokeAPI Key requiredSoft-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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
decision_type | string | No | — | approve | reject | waive | suppress | annotate | escalate |
decided_at | string | No | — | ISO-8601 UTC timestamp. |
decided_by_user_id | string | No | — | Tenant-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).
/api/v1/workflowsAPI Key requiredList 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"
}
]
}/api/v1/workflowsAPI Key requiredCreate 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" }/api/v1/workflows/{workflow_id}API Key requiredUpdate 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", … }/api/v1/workflows/{workflow_id}API Key requiredDelete a workflow.
Request
DELETE /api/v1/workflows/w2
Authorization: Bearer lpdf_live_...Response
HTTP/1.1 204 No ContentEnum appendix
preflight_source
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
engine | string | No | — | Run LintPDF's 500+ checks and produce geometry + capability data. |
external | string | No | — | Import findings from a third-party preflight (PitStop/callas/Acrobat/native or a custom mapping). |
minimal | string | No | — | No preflight — viewer and share surfaces only. Capabilities can be filled on demand. |
external_format
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pitstop_xml | string | No | — | Enfocus PitStop Server / Pro XML report. |
callas_json | string | No | — | callas pdfToolbox JSON report. |
callas_xml | string | No | — | callas pdfToolbox XML report. |
acrobat_xml | string | No | — | Adobe Acrobat Pro Preflight XML report. |
lintpdf_json | string | No | — | LintPDF native v1 import JSON. See /schemas/import/v1.json. |
custom | string | No | — | Set implicitly when mapping_id is supplied — the tenant's custom mapping parses the report. |
brand
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
anonymous | string | No | — | Strip tenant branding AND LintPDF branding. Sanitizes PDF metadata; uses neutral filename. |
lintpdf | string | No | — | Use LintPDF default branding. |
<uuid> | uuid | No | — | Apply a tenant-owned BrandProfile by ID. 403/404 if the profile belongs to another tenant or doesn't exist. |
branding-defaults mode
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
anonymous | string | No | — | Tenant default is no branding at all (broker → distributor use case). Strips logos, headers, PDF metadata, filename slug, viewer chrome, and share-page chrome. |
profile | string | No | — | Tenant default is a specific BrandProfile. Requires brand_profile_id. |
lintpdf | string | No | — | Tenant default is LintPDF's built-in branding. |
severity
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
error | string | No | — | Blocking issue — contributes to summary.error_count and makes summary.passed=false. |
warning | string | No | — | Non-blocking issue — contributes to summary.warning_count. |
advisory | string | No | — | Informational — does not affect summary.passed. |
verdict
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pass | string | No | — | Reviewer-set or auto-derived approval. |
fail | string | No | — | Reviewer-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)
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
engine | string | No | — | Produced by LintPDF's native analyzer pipeline. |
ai | string | No | — | Produced by an AI inspection. |
external:pitstop | string | No | — | Imported from an Enfocus PitStop XML report. |
external:callas | string | No | — | Imported from a callas pdfToolbox JSON or XML report. |
external:acrobat | string | No | — | Imported from an Adobe Acrobat Preflight XML report. |
external:lintpdf_json | string | No | — | Imported from a LintPDF-native v1 import JSON document. |
external:custom:<mapping-id> | string | No | — | Imported via a tenant-defined custom mapping; the mapping UUID is appended for audit. |
BrandProfile profile_type
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
custom | string | No | — | Use this profile's own brand_name / logo_url / colors / footer_text. |
lintpdf | string | No | — | Use LintPDF default branding. Useful as a 'reset to defaults' sibling profile. |
none | string | No | — | Neutral / blind output — blank brand name, generic greys, no footer. |
Finding object_type
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
image | string | No | — | Raster XObject. |
text | string | No | — | Text run. |
path | string | No | — | Vector path. |
font | string | No | — | Font resource. |
page | string | No | — | Whole-page finding. |
document | string | No | — | Document-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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
pass | string | No | — | No EPM-related findings — job runs cleanly on the EPM path. |
pass_with_advisory | string | No | — | Tier-C advisory findings only; verdict is still PASS but operators should review. |
marginal | string | No | — | One Tier-B soft-rejection finding fired; treat as borderline. |
reject | string | No | — | Any Tier-A finding, or two+ Tier-B findings — job is not an EPM candidate. |
decision_type
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
approve | string | No | — | Operator approves the job / finding. |
reject | string | No | — | Operator rejects the job / finding. |
waive | string | No | — | Operator waives a finding (acknowledges + accepts the risk). |
suppress | string | No | — | Hide the finding from future renders without changing severity. |
annotate | string | No | — | Attach a note/comment without changing approval status. |
escalate | string | No | — | Bump the finding to a higher reviewer in the approval chain. |
decision_source
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
dashboard | string | No | — | Recorded from the LintPDF web dashboard. |
api | string | No | — | Recorded directly via the REST API (curl/SDK/server-to-server). |
plugin | string | No | — | Recorded via a Fairy Ring plugin route. |
sdk | string | No | — | Recorded via the Python SDK. |
share_link | string | No | — | Recorded by an anonymous reviewer through a share-link URL. |
approval_chain | string | No | — | Auto-recorded by the multi-step approval chain workflow. |
desktop | string | No | — | Recorded from the desktop app. |
system | string | No | — | Recorded automatically by an internal engine process (no operator). |
migration | string | No | — | Synthetic decision created during a data migration. |