Skip to main content

PDF Export

PDF Export renders the same validated ZUI view shown by an application as a production PDF. The request carries the ViewSpec or selected AppSpec page, authorized datasets, active filters, view state, theme, and paper options. The renderer does not maintain a second report template and does not fetch business data.

browser ──▶ application backend ──authorize──▶ data/config sources

└── ViewSpec + authorized snapshot


POST /v1/render


branded PDF

The ZUI React packages remain usable without PDF Export. Use the ZUI Cloud module to avoid operating browsers, or deploy the same API with the Cloud Private operations runbook.

Render a minimal view

Only an organization API key is accepted by POST /v1/render. Keep it in a trusted backend or secret manager.

curl --fail-with-body https://cloud.example.com/v1/render \
-H "Authorization: Bearer ${ZUI_CLOUD_KEY}" \
-H 'Content-Type: application/json' \
--data '{
"view": {
"version": 2,
"id": "service-check",
"title": "PDF service check",
"root": {
"type": "stack",
"children": ["PDF Export is ready"]
}
},
"page": {"format":"A4", "pageNumbers":true},
"fileName": "service-check"
}' \
--output service-check.pdf

Success returns 200 application/pdf. A valid PDF starts with %PDF-; also open the document in a PDF parser during acceptance testing rather than checking only the HTTP status.

Render from an application backend

The backend must authorize the user and minimize the data before calling PDF Export. This example returns both the bytes and correlation metadata to its caller:

type RenderResult = {
pdf: Buffer
contentDisposition: string
requestId: string
renderMs: number
issueCount: number
}

export async function renderRenewalRisk(
currentUser: {id: string},
input: {region: string},
): Promise<RenderResult> {
await authorize(currentUser, 'reports:renewal-risk')
const rows = await loadAuthorizedRenewals(currentUser, input.region)

const response = await fetch(`${process.env.ZUI_CLOUD_URL}/v1/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZUI_CLOUD_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
view: renewalRiskView,
datasets: {renewals: rows},
datasetSchemas: [renewalSchema],
params: {region: input.region},
filters: [],
page: {
format: 'A4',
margin: {top: '14mm', end: '12mm', bottom: '16mm', start: '12mm'},
media: 'print',
gridRows: 'all',
pageNumbers: true,
},
fileName: `renewal-risk-${input.region}`,
}),
signal: AbortSignal.timeout(65_000),
})

if (!response.ok) {
const failure = await response.json() as {
error: {code: string; message: string; details?: unknown}
}
throw new Error(`${failure.error.code}: ${failure.error.message}`)
}

return {
pdf: Buffer.from(await response.arrayBuffer()),
contentDisposition: response.headers.get('content-disposition') ?? 'attachment; filename="report.pdf"',
requestId: response.headers.get('x-request-id') ?? '',
renderMs: Number(response.headers.get('x-render-ms') ?? 0),
issueCount: Number(response.headers.get('x-render-issues') ?? 0),
}
}

Do not accept an arbitrary view, dataset, file name, or theme from a browser unless the application has explicitly decided those fields are user-editable, validated them, and authorized every referenced resource. A generic unaudited render proxy turns a server-held organization key into an unintended public rendering endpoint.

Request contract

The JSON body is strict: unknown top-level and page/theme fields are rejected. Send exactly one of view or app.

FieldContract
viewA structurally valid ViewSpec version 2. Required unless app is supplied.
appAn AppSpec version 1 with at least one page. Required unless view is supplied.
appPagePage ID or route when app is used. Defaults to the first page. Invalid IDs/routes are rejected.
datasetsJSON-safe values by dataset name. Values override datasets embedded in view.data.
datasetSchemasDataset schema objects. Each entry requires a string name.
paramsRuntime parameter values merged into the view contract.
filtersActive filters. Every item requires string dataset and field; operators and values follow the Apps filter contract.
viewStateSaved component state, such as data-grid sorting and column state.
themeOptional CSS, root class names, and root data attributes.
pagePaper, margin, scale, media, header/footer, grid, and settle behavior. Defaults are applied server-side.
fileNameSuggested name, at most 200 characters. A safe .pdf name is derived from this, then view title, then view ID.

A representative request is:

{
"view": {
"version": 2,
"id": "weekly-sales",
"title": "Weekly sales",
"root": {"type": "stack", "children": []}
},
"datasets": {"sales": []},
"datasetSchemas": [{"name": "sales", "columns": []}],
"params": {"region": "EMEA"},
"filters": [
{"dataset": "sales", "field": "status", "op": "in", "value": ["active"]}
],
"viewState": {
"sales-grid": {"sorting": [{"id": "amount", "desc": true}]}
},
"theme": {
"css": ".theme-ocean { --primary: #0f766e; }",
"className": "theme-ocean",
"attributes": {"data-zui-theme": "ocean"}
},
"page": {
"format": "A4",
"landscape": false,
"margin": "12mm",
"scale": 1,
"media": "print",
"gridRows": "all",
"pageNumbers": true,
"header": "<div style=\"font-size:9px\">Example · confidential</div>",
"settleMs": 250
},
"fileName": "weekly-sales"
}

Render one AppSpec page

An application can send a complete AppSpec and select a page without extracting its view first:

{
"app": {
"version": 1,
"id": "revenue-operations",
"name": "Revenue Operations",
"pages": [
{
"id": "renewals",
"path": "/renewals",
"title": "Renewal risk",
"view": {
"version": 2,
"root": {"type": "stack", "children": []}
}
}
]
},
"appPage": "/renewals",
"datasets": {"renewals": []}
}

If fileName is omitted, an AppSpec request uses the selected page title.

Paper and rendering behavior

Page fieldAllowed values and default
formatA3, A4, A5, Letter, Legal, or Tabloid; default A4.
landscapeBoolean; default false.
marginOne CSS length or {top,end,bottom,start}. Units: px, mm, cm, in; default 12mm. Omitted object sides become zero.
scale0.1–2; default 1.
mediaprint or screen; default print.
header / footerChromium HTML fragments, each at most 20,000 characters.
pageNumbersAdds Page N of M when no explicit footer is supplied; default false.
gridRowsall removes grid pagination and renders every row; paged retains the view's pagination. Default all.
settleMsExtra wait after fonts and the view report resolve; integer 0–10000, default 250.

The renderer calculates the printable area at 96 CSS pixels per inch before laying out the page. Responsive charts therefore measure against the width they will print at. print media applies the ZUI print rules, including a paper-oriented palette and removal of interactive chrome; screen retains the monitor presentation.

Use real margins when enabling header or footer HTML. Chromium renders those fragments inside the margin box; a small top/bottom margin can clip them. The footer field takes precedence over pageNumbers, including when it is an empty string.

Every render uses a fresh incognito browser context. The render page blocks network requests except to its own service origin, applies reduced motion, waits for document.fonts.ready, waits settleMs, and prints backgrounds. Put all data and required theme CSS in the payload; external images, fonts, or data fetches are not a supported dependency.

Unknown view node types and recoverable renderer problems become visible placeholders plus render issues. They do not necessarily fail the PDF. Treat a non-zero X-Render-Issues as a quality signal and inspect the request record before distributing the document.

Response contract and history

Successful POST /v1/render responses include:

HeaderMeaning
Content-Typeapplication/pdf.
Content-DispositionSanitized attachment file name.
Content-LengthPDF byte count.
X-Request-IdOrganization-scoped render request identifier for support and history.
X-Render-MsServer-side render duration in milliseconds.
X-Render-IssuesNumber of semantic/render issues recorded for the document.

Inspect recent requests and quota from a session or API key:

GET /v1/requests?limit=100
Authorization: Bearer zui_pk_...

GET /v1/requests/{requestId}
Authorization: Bearer zui_pk_...

GET /v1/usage
Authorization: Bearer zui_pk_...

/v1/usage returns {usage: {month, used, quota, remaining}}. Request history is organization-scoped and includes status, view metadata, format, bytes, duration, issue details, error, key ID, and creation time. The limit query is clamped to 1–500; there is no cursor or automatic retention API in this release.

Quota counts accepted browser render attempts, including a render that fails after consuming browser capacity. Payload validation failures and requests rejected because the queue is full are not recorded as completed render attempts. Check /v1/usage before offering high-volume batch exports.

Failure and retry policy

All non-PDF responses use the shared JSON error envelope.

Status/codeMeaningRetry decision
400 invalid_bodyInvalid ViewSpec, payload, page option, filter/schema shape, or unknown field.Never retry unchanged. Surface safe field details to engineering.
401 unauthenticated / api_key_invalidMissing, unknown, or revoked key.Fix or rotate the credential.
403 wrong_credentialA session token was sent to the key-only render route.Use an organization API key from the backend.
413 payload_too_largeBody exceeds ZUI_CLOUD_PDF_BODY_LIMIT_MB.Reduce rows/fields, simplify the view, or split the report.
429 quota_exceededThe organization used its calendar-month quota.Stop automatic retries; inspect /v1/usage and quota policy.
500 render_failedChromium or the render page failed. details.requestId identifies the recorded attempt.Retry only a bounded number of times with backoff after ruling out deterministic payload failure.
503 busyActive render slots are full and the bounded wait queue reached its limit.Retry with exponential backoff and full jitter; cap attempts and preserve the user deadline.
503 render_unavailableThe process has no renderer.Operator action; do not create a client retry storm.

POST /v1/render has no idempotency key, but a successful render creates no external side effect beyond usage/history. If a response is lost, a retry may produce a second charged attempt. Communicate that tradeoff in batch systems.

Visual acceptance strategy

PDF correctness is visual as well as syntactic. Maintain representative golden cases for:

  • every supported paper size and orientation used by the product;
  • long data grids, explicit paged grids, and multi-page charts/text;
  • light/dark or branded themes in both print and screen media;
  • headers, footers, page numbers, and non-ASCII text;
  • missing data, empty states, unknown node types, and renderer issues;
  • maximum normal dataset and page-count cases;
  • the Chromium version shipped in production.

Render these cases after ZUI package, theme, font, or Chromium upgrades. Parse the resulting PDF, compare page count and extracted text, and use image-based visual diffs with reviewed tolerances. A 200 response alone does not prove a report is distributable.

Self-host PDF Export

Follow the complete Cloud Private operations runbook. PDF-specific requirements and controls are:

  • Build and deploy the complete dist/web beside the matching Go binary. The internal browser loads /render.html from that directory through the process's loopback origin.
  • Install the Playwright-pinned Chromium and dependencies. Set ZUI_CLOUD_PDF_CHROMIUM_EXECUTABLE when automatic discovery cannot resolve it. Startup fails early if no compatible executable is found.
  • Run the process as a non-root service user with a writable temporary directory and enough shared memory. Do not weaken the host sandbox merely to make Chromium start.
  • Start with ZUI_CLOUD_PDF_CONCURRENCY=2. Each active render gets its own browser context; ZUI_CLOUD_PDF_QUEUE_LIMIT bounds waiting work. Measure memory, CPU, output size, and p95/p99 duration with production-shaped payloads before changing either value.
  • Set the reverse proxy body limit slightly above ZUI_CLOUD_PDF_BODY_LIMIT_MB and its read timeout above ZUI_CLOUD_PDF_RENDER_TIMEOUT_MS plus expected queue wait. Keep every client deadline finite and longer than the proxy only when the user experience permits it.
  • /health proves only that the Go process responds and reports its current pending count. Chromium starts lazily on the first render, so use an authenticated synthetic PDF to prove readiness.
  • Alert on 503 busy, render failure rate, X-Render-Ms percentiles, non-zero issue counts, quota remaining, process restarts, host memory/CPU, and disk growth from request history.
  • The bundled renderer intentionally has no business-data network access. Keep application authorization and data resolution in the calling backend; do not add general egress as a workaround for incomplete payloads.

The present runtime is a single-process SQLite deployment. Adding replicas behind a load balancer is not a supported PDF scaling mechanism because accounts, quotas, and history share that local database. Scale vertically, control incoming concurrency, and use the bounded queue. Review the topology limits before committing an availability target.

Continue with Config Store when a saved head feeds an export, or Scheduled Delivery when artifacts must be generated and delivered later.