Pulp Engine Document Rendering
Get started

Pulp Engine API Usage Guide

Base URL: http://localhost:3000 (configurable via HOST and PORT env vars)

For a full request collection, open api-test.http in VS Code with the REST Client extension.


Authentication

All requests (except GET /health, GET /health/ready, GET /metrics, and — in public mode — GET /assets/:filename) must include:

X-Api-Key: <your-key>

GET /health and GET /health/ready are always exempt — they are intended for load balancer probes and health checks. GET /metrics is exempt by default — when METRICS_TOKEN is not set, Prometheus scrapers can reach it without an API key; restrict access at the network layer. When METRICS_TOKEN is set, the endpoint requires Authorization: Bearer <METRICS_TOKEN>. GET /assets/:filename is public by default (ASSET_ACCESS_MODE=public) so the PDF renderer can fetch embedded images at render time. When ASSET_ACCESS_MODE=private, this route requires admin or editor scope — the PDF renderer inlines assets server-side and the visual editor fetches with X-Editor-Token.

If no credentials are configured on the server (local dev / CI), requests without the header are accepted and no authentication is performed.

Scoped credentials (v0.14.0)

Four named keys provide distinct privilege levels. Use the most restrictive key for each integration.

Environment variableScopeRoute access
API_KEY_ADMINadminFull access — templates, assets, render, preview, validate
API_KEY_RENDERrenderPOST /render, POST /render/html, POST /render/csv, POST /render/xlsx, POST /render/docx, POST /render/pptx, POST /render/batch, POST /render/batch/docx, POST /render/batch/pptx, POST /render/pdf/*
API_KEY_PREVIEWpreviewPOST /render/preview/* only
API_KEY_EDITOReditorTemplate management, asset management, POST /render/preview/*, POST /render/validate — not production render

Authorization matrix:

Route groupPathsRequired scope
RenderPOST /render, POST /render/html, POST /render/csv, POST /render/xlsx, POST /render/docx, POST /render/pptx, POST /render/batch, POST /render/batch/docx, POST /render/batch/pptx, POST /render/pdf/*render or admin
Publish-readiness validationPOST /render/validateeditor or admin
PreviewPOST /render/preview/html, POST /render/preview/pdfpreview, editor, or admin
Template management (read/write)GET, POST, PUT on /templates/*editor or admin
Template deleteDELETE /templates/:keyadmin only
Template version restorePOST /templates/:key/versions/:version/restoreadmin only
Asset managementPOST /assets/upload, GET /assets, DELETE /assets/:ideditor or admin
Asset binary (private mode)GET /assets/:filenameeditor or admin (only when ASSET_ACCESS_MODE=private)
All other authenticated routesAny route not matched aboveadmin

Error responses:

ResponseMeaning
401 UnauthorizedHeader missing, or key not recognised
403 ForbiddenKey recognised, but its scope does not cover this route

Migration from API_KEY

The previous single API_KEY is still accepted as a migration aid and is treated as admin scope. The server logs a deprecation warning at startup when it is used.

Migration steps:

  1. Generate a new strong secret and set API_KEY_ADMIN=<secret> in your environment.
  2. For any integration that only renders documents, create a separate secret and set API_KEY_RENDER=<secret>.
  3. Update callers to use the appropriate key:
    • Integrations that only render documents → use API_KEY_RENDER
    • Visual editor → set API_KEY_EDITOR; operators enter this value in the editor’s login form (no VITE_API_KEY required)
    • Everything else → use API_KEY_ADMIN
  4. Remove API_KEY from your environment.

API_KEY and the new scoped keys cannot coexist — the server rejects the combination at startup to prevent ambiguity.

Editor auth (v0.15.0 — session token login gate)

The visual editor uses a short-lived session token obtained via an interactive login form rather than a static credential baked into the browser bundle.

How it works:

  1. On first load the editor calls GET /auth/status to determine whether auth is required and whether a login form is available.
  2. If auth is required the editor shows a “Sign in” form. The operator enters the value of API_KEY_EDITOR (or API_KEY_ADMIN).
  3. The editor calls POST /auth/editor-token with that key. On success the server returns an HMAC-signed session token (default 8 hours; configurable via EDITOR_TOKEN_TTL_MINUTES).
  4. The token is stored in sessionStorage (cleared on tab close) and sent as X-Editor-Token on every subsequent API call.
  5. If a call returns 401 (token expired or rotated key), the editor automatically returns to the login form.

No VITE_API_KEY or frontend build-time environment variable is needed. The API_KEY_EDITOR value is entered interactively at runtime and never embedded in the JS bundle.

The editor scope cannot call any of the production render routes (POST /render, /render/html, /render/csv, /render/xlsx, /render/docx, /render/pptx, /render/batch, /render/batch/docx, /render/batch/pptx, /render/pdf/*), nor can it delete templates, restore versions, promote labels, or call admin routes — those operations require admin scope.

Named-user mode: Users configured with "role": "admin" receive admin scope from their X-Editor-Token. A named-user admin token therefore has the same route access as an admin-scoped X-Api-Key — it can delete templates and restore versions in addition to all editor operations. Users configured with "role": "editor" receive only editor scope and are blocked from admin-only routes with 403 Forbidden.

HTTPS requirement: POST /auth/editor-token transmits the API_KEY_EDITOR value over the network. In production, the API must be served behind HTTPS (TLS termination via reverse proxy). On plain HTTP a network observer can capture the key at login time.

Dev mode: when no credentials are configured (local dev), GET /auth/status returns { authRequired: false } and the editor skips the login gate entirely.

OIDC / SSO (opt-in)

When the server is configured with OIDC_DISCOVERY_URL plus OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_COOKIE_SECRET, the editor login gate can exchange an identity provider’s authorization code for an editor session token via the PKCE auth-code flow. The presence of OIDC_DISCOVERY_URL is itself what enables OIDC — there is no separate enable flag. First-time logins auto-provision the user from sub/email/name claims, with subsequent silent refresh. The embedded editor supports the same flow via a code-exchange endpoint. See oidc-guide.md for configuration and provider-specific notes (Okta, Azure AD, Keycloak, Auth0).

Multi-tenant mode (opt-in)

When MULTI_TENANT_ENABLED=true (Postgres or SQL Server), all authenticated routes resolve a tenant from either the API key (API_KEYS_JSON entries are tenant-scoped), the signed tenant claim inside X-Editor-Token, or — for super-admin routes — an explicit X-PulpEngine-Tenant-Id header. Cross-tenant access is rejected. The /admin/tenants CRUD endpoints are reserved for super-admin keys. See tenant-isolation-guarantees.md.

Auth endpoints (public — no X-Api-Key required)

GET /auth/status

Returns whether authentication is required and whether the editor login is available.

200 OK
Content-Type: application/json

{
  "authRequired": true,
  "editorLoginAvailable": true,
  "identityMode": "shared-key"
}
FieldTypeDescription
authRequiredbooleanfalse only when neither API credentials nor EDITOR_USERS_JSON are configured (dev mode with no auth)
editorLoginAvailablebooleantrue when API_KEY_EDITOR, API_KEY_ADMIN, or EDITOR_USERS_JSON is configured
identityMode"shared-key" | "named-users""named-users" when EDITOR_USERS_JSON is configured (per-user keys, server-derived actor). "shared-key" otherwise (legacy shared-key mode, optional operator-supplied actor label).

If authRequired is true but editorLoginAvailable is false, the server has render/preview-only keys and the editor login form cannot succeed. Set API_KEY_EDITOR, API_KEY_ADMIN, or EDITOR_USERS_JSON.

POST /auth/editor-token

Accepts an editor-or-admin-scoped API key and returns a short-lived session token (default 8 hours; see EDITOR_TOKEN_TTL_MINUTES).

Request:

Shared-key mode (no EDITOR_USERS_JSON):

POST /auth/editor-token
Content-Type: application/json

{
  "key": "<API_KEY_EDITOR or API_KEY_ADMIN value>",
  "actor": "alice@example.com"
}

actor is optional. Omit it or send a non-empty string (max 200 chars, no control characters).

Named-user mode (EDITOR_USERS_JSON configured):

POST /auth/editor-token
Content-Type: application/json

{
  "key": "<personal user key from EDITOR_USERS_JSON>"
}

In named-user mode, only personal user keys (from EDITOR_USERS_JSON) are accepted — shared API keys (API_KEY_EDITOR, API_KEY_ADMIN) are rejected. The actor field in the request body is silently ignored; actor identity is always server-derived from the user registry.

Success — 200 OK:

{
  "token": "<opaque session token>",
  "expiresAt": "2026-03-22T18:00:00.000Z",
  "actor": "alice",
  "displayName": "Alice Smith"
}
FieldTypeDescription
tokenstringHMAC-signed session token for X-Editor-Token header
expiresAtstringISO 8601 expiry timestamp
actorstring | nullIn named-user mode: server-derived user id. In shared-key mode: operator-supplied label, or null if not provided.
displayNamestring | nullHuman-readable name. Only set in named-user mode. null in shared-key mode.

Error responses:

StatusCondition
400 Bad Requestkey field missing, auth is disabled (dev mode), actor exceeds 200 chars, or actor contains control characters
401 UnauthorizedKey not recognised, has insufficient scope (render/preview only), or (named-user mode) key is not in user registry
503 Service UnavailableNo editor/admin key is configured on the server

The X-Editor-Token header (returned on success) is accepted by all authenticated routes instead of X-Api-Key. Scope is editor by default; in named-user mode, users with "role": "admin" get admin scope. Rate limit: 10 requests per minute per IP.

Token format variants:

FormatPartsHMAC payloadUsed when
Legacy (pre-v0.19.0){expiry}.{sig}editor:{expiry}Old tokens — verifier accepts, cannot mint
v0.19.0{iat}.{expiry}.{sig}editor:{iat}:{expiry}No actor supplied
v0.20.0{iat}.{expiry}.{actor_b64url}.{sig}editor:{iat}:{expiry}:{actor_raw}Actor supplied at login

The base64url-encoded actor segment uses URL-safe base64 (no padding). An empty decoded actor in a 4-part token is rejected — “no actor” is strictly the 3-part format.

Cluster-upgrade note: A v0.20.0 actor-bearing (4-part) token presented to a v0.19.0 verifier is correctly rejected. In multi-instance deployments, upgrade all instances to v0.20.0 before distributing actor-bearing tokens. Using the 3-part format (omitting actor) during a mixed-version rollout is safe.

Token lifetime: Controlled by EDITOR_TOKEN_TTL_MINUTES (default 480 = 8 hours, range 5–1440). Shorter values reduce the exposure window if a token is compromised.

Attributed operator sessions (v0.20.0):

Operators may supply an optional actor label at login (display name, email, or any identifier). The label is cryptographically bound into the session token via HMAC and propagated as request.actor through the API request lifecycle. All template and asset write operations (POST /templates, PUT /templates/:key, DELETE /templates/:key, POST /templates/:key/versions/:version/restore, POST /assets/upload, DELETE /assets/:id) emit a structured audit log event containing the actor field.

Named-user identity model (v0.23.0):

Set EDITOR_USERS_JSON to a JSON array of user objects to enable per-user named credentials:

[
  { "id": "alice", "displayName": "Alice Smith", "key": "alice-unique-secret", "role": "editor" },
  { "id": "bob",   "displayName": "Bob Jones",   "key": "bob-unique-secret",   "role": "admin"  },
  { "id": "carol", "displayName": "Carol Wu",    "key": "carol-secret",        "role": "editor",
    "tokenIssuedAfter": "2026-03-25T12:00:00Z" }
]
FieldTypeRequiredDescription
idstringyesURL-safe unique identifier. Used as the actor value in tokens and audit records.
displayNamestringyesHuman-readable name shown in the editor UI.
keystringyesPersonal login credential. Must be unique; must not duplicate any API_KEY_* value.
role"editor" | "admin"yesMaps directly to credential scope. admin users can restore versions and delete templates.
tokenIssuedAfterstring (ISO 8601)noTokens with iat before this timestamp are rejected for this user only. Use for targeted session invalidation.

When EDITOR_USERS_JSON is configured:

  • POST /auth/editor-token accepts only personal user keys — shared API keys are rejected (HTTP 401).
  • The actor field in the request body is silently ignored; actor is always server-derived from the registry.
  • The actor value in issued tokens is the user’s id, which is cryptographically bound by HMAC.
  • Scope is derived at request-verification time from the live registry (role), not embedded in the token.
  • X-Api-Key machine access (API keys for CI/CD, rendering) continues to work unchanged.

Startup validation: Duplicate id values, duplicate key values, and user keys that collide with API_KEY_* values all cause the server to refuse to start.

Persistent audit attribution (v0.23.0): Template version history (GET /templates/:key/versions) and asset list (GET /assets) responses include createdBy: string | null. In named-user mode this is the user’s id. In shared-key mode it is the operator-supplied actor label (or null). Existing records created before v0.23.0 have createdBy: null.

Security posture: In named-user mode, actor identity is server-derived and cryptographically bound — it cannot be spoofed by submitting a different actor value. Scope (role) is derived at request time from the live registry; role changes take effect on the next restart. In shared-key mode, the actor label remains an operator-supplied claim — suitable for audit traceability but not authorization.

actor: null in an audit log means the write was performed via direct X-Api-Key auth, or no actor label was supplied at login (shared-key mode).

Hardened production enforcement (v0.54.0+): When HARDEN_PRODUCTION=true and editor login is capable (any of API_KEY_EDITOR, API_KEY_ADMIN, or API_KEY set), the server requires either EDITOR_USERS_JSON or ALLOW_SHARED_KEY_EDITOR=true. Set ALLOW_SHARED_KEY_EDITOR=true to explicitly accept shared-key identity without configuring a user registry.

Invalidation options:

  • Remove user from registry + restart: Blocks new logins and immediately invalidates all active sessions for that user.
  • Per-user tokenIssuedAfter + restart: Invalidates existing sessions for a single user (sessions with iat < tokenIssuedAfter). New logins are not affected.
  • Global EDITOR_TOKEN_ISSUED_AFTER (v0.19.0+): Set to a UTC ISO-8601 datetime and restart. All tokens across all users with an iat before that value are rejected. Useful for a clean migration cutover (e.g. switching from shared-key to named-user mode).
  • Key rotation: Changing API_KEY_EDITOR immediately invalidates all outstanding tokens. Users will see 401 and must log in again with the new key.
  • Near-zero-downtime key rotation: Set API_KEY_EDITOR_PREVIOUS=<old key> and API_KEY_EDITOR=<new key> and restart. Outstanding editor session tokens signed with the old key continue to verify through the rollover window (up to EDITOR_TOKEN_TTL_MINUTES); new tokens are minted with the new key. The previous key is verify-only — it cannot mint tokens and cannot be used as X-Api-Key. Direct callers still using the old key via X-Api-Key must switch to the new key. Remove API_KEY_EDITOR_PREVIOUS after EDITOR_TOKEN_TTL_MINUTES elapses and restart again. API_KEY_ADMIN_PREVIOUS provides the same rollover support for admin-signed editor tokens. See the operator runbook for single-instance and multi-instance rolling procedures.

OpenAPI / Swagger UI

The API exposes a machine-readable OpenAPI 3.0 spec and an interactive Swagger UI for exploring and testing all endpoints.

URLDescription
GET /docsInteractive Swagger UI (Redoc-style)
GET /docs/jsonOpenAPI 3.0 spec — JSON format
GET /docs/yamlOpenAPI 3.0 spec — YAML format

All three endpoints are public by default — no X-Api-Key is required. Disable them in production with DOCS_ENABLED=false (all /docs* routes return 404), or restrict access at your reverse proxy or network boundary.

Authentication in the UI: Protected routes require X-Api-Key or X-Editor-Token. Use the “Authorize” button in the Swagger UI to enter your key before trying requests. Two security schemes are configured: ApiKeyAuth (X-Api-Key header) and EditorTokenAuth (X-Editor-Token header).

Scope notes visible in the spec:

  • POST /render and POST /render/html show only ApiKeyAuth — editor session tokens cannot call production render.
  • DELETE /templates/{key} and POST /templates/{key}/versions/{version}/restore show only ApiKeyAuth — these are admin-only operations.
  • All other protected routes accept both auth methods.

Schema coverage: Request and response schemas are documented for all routes. The TemplateDefinition body fields (key, version, name, document, etc.) are shown in the spec as the expected shape; full structural validation of the document node tree is enforced at runtime by the Zod schema in validation/template-definition.schema.ts. The schema accepts 20 content node types: container, columns, heading, text, image, table, chart, pivotTable, barcode, spacer, divider, pageBreak, conditional, repeater, richText, signatureField, templateRef (with optional fieldMappings), toc, positioned, and custom.

Preview routes: The preview routes (POST /render/preview/html, POST /render/preview/pdf) appear in the spec only when PREVIEW_ROUTES_ENABLED=true (always in dev; opt-in in production).


curl

# Render with render-scoped key
curl -H "X-Api-Key: $API_KEY_RENDER" http://localhost:3000/render ...

# Manage templates with admin-scoped key
curl -H "X-Api-Key: $API_KEY_ADMIN" http://localhost:3000/templates

C#

client.DefaultRequestHeaders.Add("X-Api-Key", "<your-key>");

JavaScript (fetch)

headers: {
  'Content-Type': 'application/json',
  'X-Api-Key': '<your-key>',
}

PHP

curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-Api-Key: <your-key>',
]);

VB.NET

client.DefaultRequestHeaders.Add("X-Api-Key", "<your-key>")

Security configuration (v0.25.0)

Five optional env vars tighten the default-open surfaces. All have backward-compatible defaults.

VariableDefaultEffect
CORS_ALLOWED_ORIGINSunset (allow all)Comma-separated list of trusted origins for cross-origin browser requests. Origins must include the scheme (e.g. https://editor.example.com). Use * for explicit open access.
DOCS_ENABLEDtrueSet false to disable Swagger UI — all /docs* return 404.
METRICS_TOKENunset (open)When set, GET /metrics requires Authorization: Bearer <token>.
TRUST_PROXYfalseSet true when behind a reverse proxy that sets X-Forwarded-Proto. Required for REQUIRE_HTTPS to work.
REQUIRE_HTTPSfalseWhen true, POST /auth/editor-token rejects non-HTTPS requests with 400 Bad Request. Requires TRUST_PROXY=true.
HARDEN_PRODUCTIONAuto-derived from NODE_ENVEnforced by default when NODE_ENV=production. Startup fails if any security control is not configured. Set HARDEN_PRODUCTION=false to opt out for evaluation.

See deployment guide § 2 Environment Variables, § Production security checklist, and § Hardened Production Mode for full details.


Rate limiting

All routes are rate-limited per IP. GET /health, GET /health/ready, and GET /metrics are always exempt.

Route groupDefault limit
/render, /render/html, /render/csv, /render/validate, /render/preview/*20 requests / minute
All other routes100 requests / minute

When the limit is exceeded the API returns:

429 Too Many Requests
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}

Limits are configurable via environment variables on the server:

VariableDefaultEffect
RATE_LIMIT_MAX100Global default for all routes
RATE_LIMIT_RENDER_MAX20Override for render and preview routes

Multi-instance note: Rate limiting uses in-memory counters per API process. In multi-instance deployments, limits are enforced independently per instance — the effective rate limit is multiplied by instance count. For stricter enforcement, configure rate limiting at the reverse proxy layer (nginx limit_req, HAProxy, Traefik, etc.).


Audit events

GET /audit-events returns a paginated, filterable list of audit events. Admin scope required.

Events are recorded automatically when templates are created/updated/restored/deleted, assets are uploaded/deleted, and editor session tokens are minted.

Query parameters

ParameterTypeDescription
limitintegerMax items to return (1-1000, default 50)
offsetintegerItems to skip (default 0)
eventstringFilter by event type: template_mutation, asset_mutation, editor_token_minted
operationstringFilter by operation: create, update, delete, upload, restore, mint
actorstringFilter by actor (named-user ID or operator-supplied label)
resourceTypestringFilter by resource type: template, asset, editor_token
resourceIdstringFilter by resource ID (template key or asset ID)
sincedate-timeISO 8601 lower bound (inclusive)
untildate-timeISO 8601 upper bound (inclusive)

Response

{
  "items": [
    {
      "id": "clz...",
      "timestamp": "2026-04-06T12:00:00.000Z",
      "event": "template_mutation",
      "operation": "update",
      "resourceType": "template",
      "resourceId": "loan-approval-letter",
      "actor": "alice",
      "credentialScope": "editor",
      "details": null
    }
  ],
  "total": 42,
  "limit": 50,
  "offset": 0
}

Example

# All template mutations by a specific user in the last 24 hours
curl -s "http://localhost:3000/audit-events?event=template_mutation&actor=alice&since=2026-04-05T00:00:00Z" \
  -H "X-Api-Key: $API_KEY_ADMIN"

Storage

Audit events are stored in the same database as templates and assets (no additional infrastructure). In file mode, events are appended to .audit-events.jsonl in the TEMPLATES_DIR.

Purging old events

DELETE /audit-events?before=<ISO 8601> deletes all audit events with a timestamp strictly before the given cutoff. Admin scope required.

ParameterTypeRequiredDescription
beforedate-timeyesISO 8601 cutoff — events older than this are deleted

Response:

{ "deleted": 157 }

Example:

# Purge events older than 90 days
curl -s -X DELETE "http://localhost:3000/audit-events?before=2026-01-06T00:00:00Z" \
  -H "X-Api-Key: $API_KEY_ADMIN"

See the runbook for recommended retention cron patterns.


Render usage analytics

Requires a database-backed storage mode (Postgres or SQL Server). Returns 503 usage_not_available in file mode. Requires admin scope or a named-user editor token. All queries are scoped to the caller’s resolved tenant.

GET /usage — rollup

Returns aggregated render counts, durations, output size, and page counts grouped by a chosen dimension.

Query parameterTypeNotes
fromISO-8601 datetimeRequired. Inclusive lower bound.
toISO-8601 datetimeRequired. Exclusive upper bound — [from, to).
groupByenumday (default), week, month, template, format, source, mode.
sourceenumproduction or preview. Optional filter.
templateKeystringOptional filter; max 256 chars.
modeenumsingle, batch, async-batch, transform, scheduled. Optional.

Window size is capped by USAGE_QUERY_MAX_WINDOW_DAYS (default 30). Wider windows return 400 usage_window_too_wide.

Response:

{
  "from": "2026-04-01T00:00:00Z",
  "to": "2026-04-08T00:00:00Z",
  "groupBy": "day",
  "buckets": [
    {
      "key": "2026-04-01",
      "count": 42,
      "totalDurationMs": 8500,
      "totalOutputBytes": "2048000",
      "totalPages": 105
    }
  ],
  "total": {
    "count": 42,
    "totalDurationMs": 8500,
    "totalOutputBytes": "2048000",
    "totalPages": 105
  }
}

totalOutputBytes is serialised as a string because BigInt values can exceed Number.MAX_SAFE_INTEGER on long windows.

ResponseCode
200 OKRollup returned.
400 Bad Requestusage_invalid_date, usage_window_too_wide, or missing from/to.
503 Service Unavailableusage_not_available — Postgres not configured.

GET /usage.csv — per-render export

Streams one CSV row per render within the window. Same filters as /usage (minus groupBy). Useful for invoicing, BI ingestion, and compliance audits.

No window-size cap — the row-count cap is the guard. Queries that would return more than USAGE_EXPORT_MAX_ROWS rows (default 50 000) return 413 usage_export_too_large with the actual count so the caller can narrow the window.

Response:

200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="usage-<tenantId>-<fromDate>-<toDate>.csv"

CSV columns (RFC 4180, header row included):

timestamp,tenantId,templateKey,format,source,mode,durationMs,outputSizeBytes,pageCount,itemIndex,actor,scope,requestId
ResponseCode
200 OKCSV stream.
400 Bad Requestusage_invalid_date.
413 Payload Too Largeusage_export_too_large — narrow the window.
503 Service Unavailableusage_not_available.

Error responses

All non-2xx responses use one of three JSON envelopes.

Standard error envelope

Used for 401, 403, 404, 409, 412, 428, 503, and 500 responses. error names the error class; message gives a human-readable explanation.

{ "error": "Not Found", "message": "Template \"x\" not found." }

Validation error envelope

Used for 400 (structural validation failures) and 422 (data validation failures). error names the error class. issues contains per-field details when present. message is also present when an explanatory sentence accompanies the field list. In practice, at least one of message or issues is always present.

{ "error": "Validation Failed", "issues": [{ "path": "/field", "message": "..." }] }

Rate-limit envelope (429)

Produced by the rate-limiting layer; includes a statusCode field.

{ "statusCode": 429, "error": "Too Many Requests", "message": "Rate limit exceeded, retry in 1 minute" }

Note on 200 validation-result bodies: Both POST /templates/:key/validate and POST /render/validate return a 200 OK body with { valid, issues }. This is a success response, not an error envelope, and is distinct from the validation error envelopes above.

Validation endpoints comparison

These two validation endpoints share the same { valid, issues } response shape but serve fundamentally different purposes:

POST /templates/:key/validatePOST /render/validate
PurposeValidate input data against a stored template’s schemaValidate a template definition for publish readiness
InputData payload only (template loaded from DB by key)Full inline TemplateDefinition + sample data
Steps1. Field mapping (data adapter) 2. JSON Schema validation1. Structural parse (Zod) 2. Blocked-asset detection 3. Trial HTML render
DB dependencyYes — loads template by keyNo — accepts inline definition
Use casePre-check a data payload before calling the render endpointPre-check a template before publishing it from the editor
Operation IDvalidateTemplateDatavalidateTemplateDefinition

Pagination (v0.54.0+)

All collection endpoints (GET /templates, GET /templates/:key/versions, GET /assets) return a paginated envelope:

{
  "items": [ ... ],
  "total": 142,
  "limit": 50,
  "offset": 0
}
Query parameterTypeDefaultMaxDescription
limitinteger501000Maximum number of items to return
offsetinteger0Number of items to skip

When limit and offset are omitted, the defaults apply and the response is still an envelope (not a plain array).

First-party UI behavior: Asset completeness paths (validation, missing-asset detection, publish checks) now iterate all pages automatically, so asset libraries of any size are handled correctly. Template and version list helpers still use a single limit=1000 request and warn in the browser console if truncation occurs.

Asset list special case: GET /assets?legacySvg=true annotates each item with a referencedBy array listing active templates that reference the asset. This annotation applies to items within the paginated page. The total reflects the filtered count.


Request correlation (v0.54.0+)

Every API response includes an X-Request-ID header with a server-generated UUID. The same value appears as reqId in structured log entries for that request.

  • The ID is always server-generated; client-supplied X-Request-ID headers are ignored.
  • Error responses (4xx, 5xx) also include the header.
  • Reverse proxies should forward (not strip) the X-Request-ID response header.

Capabilities

GET /capabilities

Returns a machine-readable snapshot of which render formats, AI features, and streaming behaviour are available on this server. Editors poll this endpoint to hide or show format buttons; callers should use it to negotiate before picking an output route.

Public — accepts X-Api-Key or X-Editor-Token but neither is required. No rate limiting.

GET /capabilities

Response:

{
  "formats": {
    "pdf": true,
    "html": true,
    "docx": true,
    "csv": true,
    "xlsx": true,
    "pptx": true
  },
  "ai": {
    "templateGeneration": true
  },
  "streaming": {
    "singleDoc": true,
    "mode": "child-process"
  }
}
FieldMeaning
formats.pptxGated by PPTX_ENABLED (default true). All other format flags are hardcoded true — they indicate route availability, not per-tenant entitlement.
ai.templateGenerationtrue when ANTHROPIC_API_KEY is set — POST /templates/generate is then registered.
streaming.singleDoctrue when /render and /render/preview/pdf stream PDF bytes as they are produced. True in every dispatch mode: in-process, child-process, container, and socket. The flag documents that the dispatcher layer does not buffer the full PDF — Fastify already chunks the outbound HTTP response.
streaming.modeActive RENDER_MODE on this instance: in-process, child-process (default), container, or socket.

Always returns 200 OK. No error envelopes.


Health endpoints

GET /health — liveness probe

Always returns 200 OK if the process is running. Does not check any dependencies. Use as a load-balancer liveness check.

{
  "status": "ok",
  "version": "0.51.0",
  "timestamp": "2026-03-23T10:00:00.000Z"
}

GET /health/ready — readiness probe

Verifies that storage, asset binary store, renderer, and (when RATE_LIMIT_STORE=redis) rate-limit Redis are each reachable within 2 seconds (checks run in parallel). Use as a Kubernetes readinessProbe or traffic-routing readiness check. In API-only mode (no render dispatcher configured, preview disabled), the renderer check always reports "ok". The rateLimitRedis field is only present when RATE_LIMIT_STORE=redis.

When RATE_LIMIT_FAIL_OPEN=true and Redis is unreachable, the probe still returns 200 with "status": "degraded" — the operator opted into degraded enforcement. When RATE_LIMIT_FAIL_OPEN=false (default), a Redis failure returns 503.

200 OK — all subsystems reachable:

{
  "status": "ok",
  "version": "0.53.0",
  "timestamp": "2026-04-04T10:00:00.000Z",
  "checks": { "storage": "ok", "assetBinaryStore": "ok", "renderer": "ok" }
}

200 OK — with Redis rate limiting (all healthy):

{
  "status": "ok",
  "version": "0.53.0",
  "timestamp": "2026-04-04T10:00:00.000Z",
  "checks": { "storage": "ok", "assetBinaryStore": "ok", "renderer": "ok", "rateLimitRedis": "ok" }
}

503 Service Unavailable — one or more subsystems unreachable or timed out:

{
  "status": "degraded",
  "version": "0.53.0",
  "timestamp": "2026-04-04T10:00:00.000Z",
  "checks": { "storage": "error", "assetBinaryStore": "ok", "renderer": "ok" }
}

Metrics endpoint

GET /metrics

Returns current application and process metrics in Prometheus text format 0.0.4.

Rate limiting is disabled. When METRICS_TOKEN is set, the endpoint requires Authorization: Bearer <METRICS_TOKEN> — callers without a valid token receive 401 Unauthorized. When METRICS_TOKEN is not set, the endpoint is unauthenticated (backward-compatible default). In either case, restrict access at the network layer (reverse proxy IP allow-list or firewall) in production — the output is not secret but is not intended as a public API.

Application metrics:

MetricTypeLabelsDescription
pulp_engine_http_requests_totalCountermethod, route, status_classTotal HTTP requests (infra routes excluded)
pulp_engine_http_request_duration_secondsHistogrammethod, routeRequest latency — covers HTML (<100 ms) through PDF (1–30 s)
pulp_engine_render_requests_totalCountertype (pdf/html), source (production/preview), status (success/failure)Render requests by outcome
pulp_engine_template_mutations_totalCounteroperation (create/update/delete/restore), status (success/conflict/not_found/duplicate/failure)Template write operations
pulp_engine_auth_failures_totalCounterreason (missing_key/invalid_key/insufficient_scope/invalid_token)Auth and authorization failures
pulp_engine_renderer_statusGauge(none)Render-path health: 1 = ready, 0 = unavailable

Default prom-client process metrics (CPU, memory, GC, event-loop lag) are also exported.


Logging

VariableValuesDefaultEffect
LOG_LEVELtrace | debug | info | warn | errorinfoPino log level for all server output

Structured JSON in production (NODE_ENV=production), pretty-printed in development. Use warn or error to reduce noise in high-throughput environments.


1. Seeding sample templates

In postgres and sqlserver modes, templates must be loaded into the store before they can be rendered. The seed script loads every JSON file from templates/ into the configured backend and is safe to re-run after changes.

# From the repo root
pnpm db:seed

File mode does not require seeding — the API reads templates directly from TEMPLATES_DIR on startup.

Confirm templates are available:

curl http://localhost:3000/templates

2. Render to PDF — POST /render

Returns a binary PDF stream.

Request

POST /render
Content-Type: application/json

{
  "template": "loan-approval-letter",
  "data": { ... },
  "options": {
    "paperSize": "A4",
    "orientation": "portrait"
  }
}
FieldTypeRequiredDescription
templatestringYesTemplate key (e.g. "loan-approval-letter")
versionstringNoExact saved version string to render (e.g. "1.0.3"). Must match a stored version exactly — no semver resolution is applied. Omit to render the latest version.
dataobjectYesRaw payload — field mappings transform this before rendering
options.paperSizestringNoOverride paper size: A4 A3 Letter Legal Tabloid
options.orientationstringNoOverride orientation: portrait landscape

Version pinning: if the requested version does not exist for the template, the API returns 404 { "error": "Not Found", "message": "Template \"x\" version \"1.0.3\" not found." }. Use GET /templates/:key/versions to list all available version strings.

Response

200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="loan-approval-letter.pdf"
Content-Length: <bytes>

<binary PDF>

Render errors — 422 application/json:

ShapeWhen
{ "error": "Validation Failed", "issues": [...] }Data payload fails the template’s inputSchema (RenderValidationError)
{ "error": "RenderError", "code": "template_expression_error", "message": "..." }Template contains an invalid Handlebars expression
{ "error": "RenderError", "code": "render_timeout" | "engine_crash", "message": "..." }PDF engine failed or timed out

curl

# Latest version (default)
curl -s -X POST http://localhost:3000/render \
  -H "Content-Type: application/json" \
  -d '{ "template": "loan-approval-letter", "data": { ... } }' \
  --output output.pdf

# Pinned to a specific version
curl -s -X POST http://localhost:3000/render \
  -H "Content-Type: application/json" \
  -d '{ "template": "loan-approval-letter", "version": "1.0.2", "data": { ... } }' \
  --output output-v1.0.2.pdf

3. Render to HTML — POST /render/html

Returns the intermediate HTML string. Useful for previewing layout without invoking Puppeteer, and for debugging field mapping and formatting issues.

Same request shape as POST /render. Response:

200 OK
Content-Type: text/html

<!DOCTYPE html><html>...</html>

Render errors — 422 application/json:

ShapeWhen
{ "error": "Validation Failed", "issues": [...] }Data payload fails the template’s inputSchema (RenderValidationError)
{ "error": "RenderError", "code": "template_expression_error", "message": "..." }Template contains an invalid Handlebars expression
curl -s -X POST http://localhost:3000/render/html \
  -H "Content-Type: application/json" \
  -d '{ "template": "loan-approval-letter", "data": { ... } }' \
  > output.html

3b. Export to CSV — POST /render/csv

Extracts a single table node from a template and renders it as RFC 4180 CSV. No Puppeteer or asset inlining — pure data extraction and formatting.

POST /render/csv
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "template": "monthly-report",
  "data": { ... },
  "tableId": "summary-table"     // optional — required when template has multiple tables
}

Response:

200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="monthly-report.csv"
X-Table-Id: summary-table

Name,Amount,Date
"Acme Corp",1234.56,2026-04-01
"Globex Inc",789.00,2026-04-02

Table selection:

  • If the template contains exactly one table node, it is selected automatically — tableId can be omitted.
  • If the template contains multiple tables, tableId is required. Omitting it returns 422 with the available table IDs listed in the error message.
  • If the specified tableId does not match any table in the rendered template, 422 is returned.

Error responses — 422 application/json:

ShapeWhen
{ "error": "RenderError", "code": "no_rendered_tables", "message": "..." }Template contains no table nodes, or specified tableId not found
{ "error": "RenderError", "code": "ambiguous_table", "message": "..." }Multiple tables found and no tableId specified
{ "error": "Validation Failed", "issues": [...] }Data payload fails the template’s inputSchema
{ "error": "RenderError", "code": "template_expression_error", "message": "..." }Template contains an invalid Handlebars expression or data path
# Single-table template — tableId not needed
curl -s -X POST http://localhost:3000/render/csv \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "invoice", "data": { "items": [...] } }' \
  -o invoice.csv

# Multi-table template — specify which table
curl -s -X POST http://localhost:3000/render/csv \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "monthly-report", "data": { ... }, "tableId": "summary-table" }' \
  -o summary.csv

3c. Export to XLSX — POST /render/xlsx

Extracts table nodes from a template and renders them as an Excel workbook. Uses ExcelJS for streaming buffer output with cell styling and multi-sheet support. No Puppeteer or asset inlining — pure data extraction and formatting.

POST /render/xlsx
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "template": "monthly-report",
  "data": { ... },
  "tableId": "summary-table"     // optional — see table selection below
}

Response:

200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="monthly-report.xlsx"
X-Table-Ids: summary-table

Table selection (differs from CSV):

  • If the template contains exactly one table node, it is selected automatically — tableId can be omitted.
  • If the template contains multiple tables and tableId is omitted, all tables are exported as separate sheets in a single workbook. This intentionally diverges from CSV (which returns ambiguous_table) because the XLSX format naturally supports multi-sheet workbooks.
  • If tableId is provided, only that table is exported as a single-sheet workbook. Returns 422 if the ID does not match.
  • If the template contains no table nodes, 422 is returned.

Workbook formatting:

  • Header row: bold font with light gray background fill
  • Data rows: numbers written as numeric cell types (not strings) for formula compatibility
  • Column widths: auto-sized based on content, capped at 60 characters
  • Sheet names: table ID, truncated to 31 characters (Excel limit)

Error responses — 422 application/json:

ShapeWhen
{ "error": "RenderError", "code": "no_rendered_tables", "message": "..." }Template contains no table nodes, or specified tableId not found
{ "error": "Validation Failed", "issues": [...] }Data payload fails the template’s inputSchema
{ "error": "RenderError", "code": "template_expression_error", "message": "..." }Template contains an invalid Handlebars expression or data path
# Single-table template — tableId not needed
curl -s -X POST http://localhost:3000/render/xlsx \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "invoice", "data": { "items": [...] } }' \
  -o invoice.xlsx

# Multi-table template — all tables as separate sheets
curl -s -X POST http://localhost:3000/render/xlsx \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "monthly-report", "data": { ... } }' \
  -o report.xlsx

# Multi-table template — single sheet
curl -s -X POST http://localhost:3000/render/xlsx \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "monthly-report", "data": { ... }, "tableId": "summary-table" }' \
  -o summary.xlsx

3d. Render to DOCX — POST /render/docx

Renders a template to a Word document (.docx). Walks the template AST directly — no browser or Puppeteer required. Uses the same RenderBodySchema as the PDF endpoint.

POST /render/docx
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "template": "loan-approval-letter",
  "data": { "customer": { "name": "Alice" }, "amount": 50000 },
  "options": { "paperSize": "Letter", "orientation": "portrait" }
}

Response:

200 OK
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
Content-Disposition: attachment; filename="loan-approval-letter.docx"

Supported template nodes:

  • Layout: Document, Section, Container, Columns (simulated via borderless table), PageBreak, Spacer, Divider
  • Content: Heading (1–6), Text (with Handlebars interpolation), RichText (bold/italic/underline/color/links/lists)
  • Data: Table (data-bound with headers/footers/formatting), Conditional, Repeater, TemplateRef
  • Media: Image (embedded binary via asset resolver — requires assetResolver callback at API layer)
  • Navigation: Table of Contents (TOC field — populates on open in Word)

Page setup from RenderConfig:

  • paperSize (A4, A3, Letter, Legal, Tabloid), orientation, margin
  • pageHeader / pageFooter — Puppeteer HTML is parsed for text content and pageNumber/totalPages field markers; complex HTML falls back to plain-text approximation

Known fidelity gaps vs PDF:

FeatureDOCX behavior
ColumnsSimulated via borderless table — page-break behavior differs
Custom CSSflexbox, opacity, border-radius ignored
ChartsRasterised from SVG via Resvg and embedded as images
Positioned nodesChildren rendered inline (text frames deferred)
Page headers/footersText + field markers extracted; complex HTML uses plain-text fallback

Error responses — 422 application/json:

ShapeWhen
{ "error": "RenderError", "code": "template_expression_error", "message": "..." }Invalid Handlebars expression or data path
{ "error": "ValidationError", "message": "...", "issues": [...] }Data payload fails the template’s inputSchema
curl -s -X POST http://localhost:3000/render/docx \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{ "template": "invoice", "data": { "items": [...] } }' \
  -o invoice.docx

Batch DOCX — POST /render/batch/docx

Same concurrency model as batch PDF. Returns base64-encoded DOCX bytes in a JSON envelope.

POST /render/batch/docx
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "items": [
    { "template": "invoice", "data": { ... } },
    { "template": "letter", "data": { ... }, "options": { "paperSize": "Letter" } }
  ]
}

Response — same shape as batch PDF but with docx field instead of pdf:

{
  "total": 2,
  "succeeded": 2,
  "failed": 0,
  "results": [
    { "index": 0, "template": "invoice", "success": true, "docx": "<base64>" },
    { "index": 1, "template": "letter", "success": true, "docx": "<base64>" }
  ]
}

3d2. Render to PPTX — POST /render/pptx

Renders a template to a PowerPoint deck (.pptx). Walks the template AST directly — no browser required. Uses the same RenderBodySchema as the PDF endpoint.

POST /render/pptx
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "template": "quarterly-review",
  "data": { "quarter": "Q1 2026", "sections": [...] }
}

Response:

200 OK
Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation
Content-Disposition: attachment; filename="quarterly-review.pptx"

Capabilities: headings, text, rich text, tables, charts (as images), images, containers, columns, conditionals, repeaters, templateRef, toc, and barcodes.

Known limitations (see current limitations):

FeaturePPTX behaviour
pivotTable nodesNot rendered
pageBreak inside columnsNot honoured
Header/footer totalPages markerNot substituted

A batch variant is available at POST /render/batch/pptx with the same envelope shape as batch DOCX, returning pptx (base64) per item.


3e. Batch render — POST /render/batch

Renders multiple templates to PDF in a single request. Returns a JSON envelope with base64-encoded PDFs and per-item error reporting. Failed items do not abort the batch — each item succeeds or fails independently.

POST /render/batch
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "items": [
    {
      "template": "invoice",
      "data": { "invoiceNumber": "INV-001", ... }
    },
    {
      "template": "receipt",
      "version": "2.1.0",
      "data": { "receiptId": "R-42", ... },
      "options": { "paperSize": "Letter" }
    }
  ]
}

Request body:

FieldTypeRequiredDescription
itemsarrayYes1 to BATCH_MAX_ITEMS render requests (default max: 50)
items[].templatestringYesTemplate key
items[].versionstringNoExact version string; omit to render latest
items[].dataobjectYesData payload for the template
items[].optionsobjectNopaperSize and orientation overrides

Response — 200 OK (always 200 if the batch itself is accepted):

{
  "total": 3,
  "succeeded": 2,
  "failed": 1,
  "results": [
    {
      "index": 0,
      "template": "invoice",
      "success": true,
      "pdf": "JVBERi0xLjQK..."
    },
    {
      "index": 1,
      "template": "nonexistent",
      "success": false,
      "error": "Not Found",
      "message": "Template 'nonexistent' not found"
    },
    {
      "index": 2,
      "template": "receipt",
      "success": true,
      "pdf": "JVBERi0xLjQK..."
    }
  ]
}

Result fields:

FieldPresentDescription
indexAlwaysPosition in the original items array (preserved ordering)
templateAlwaysTemplate key from the request
successAlwaystrue if PDF was generated
pdfOn successBase64-encoded PDF bytes (decode to get %PDF-… binary)
errorOn failureError type
codeOn failure (classified)Machine-readable error code (validation_failed, template_expression_error, asset_blocked, etc.)
messageOn failureHuman-readable error description

Concurrency: Items are rendered in parallel up to BATCH_CONCURRENCY (default: 5). This limiter gates admission to the render pipeline; the underlying Chrome page pool (MAX_CONCURRENT_PAGES = 5) controls Chromium resource allocation. A single batch request cannot starve concurrent non-batch renders when both defaults match.

Rate limit: RATE_LIMIT_BATCH_MAX (default: 5 requests/minute), separate from the per-render RATE_LIMIT_RENDER_MAX. Each item within a batch counts as one batch-pdf render in Prometheus metrics.

Error responses (request-level):

StatusWhen
400 Bad RequestEmpty items array, or exceeds BATCH_MAX_ITEMS
401 UnauthorizedMissing or unrecognised API key
403 ForbiddenKey scope does not include render
429 Too Many RequestsRate limit exceeded
curl -s -X POST http://localhost:3000/render/batch \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "template": "invoice", "data": { "invoiceNumber": "INV-001" } },
      { "template": "invoice", "data": { "invoiceNumber": "INV-002" } },
      { "template": "receipt", "data": { "receiptId": "R-42" } }
    ]
  }' | jq .

3e. PDF Transform utilities

Post-render PDF manipulation endpoints. These operate on PDF buffers (base64-encoded), not on templates directly. All endpoints require render or admin scope and return application/pdf on success.

Rate limit: RATE_LIMIT_PDF_UTIL_MAX (default: 20 requests/minute).

Size limits:

Environment variableDefaultDescription
PDF_UTIL_MAX_INPUT_SIZE_MB50Max size of a single PDF input
PDF_UTIL_MAX_TOTAL_SIZE_MB200Max total size across all inputs in one request
PDF_UTIL_MAX_MERGE_SOURCES50Max number of PDFs in a merge request
PDF_UTIL_MAX_OUTPUT_PAGES10000Max pages in the output PDF

Merge — POST /render/pdf/merge

Concatenate pages from multiple PDF documents into a single PDF.

POST /render/pdf/merge
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "sources": [
    { "data": "<base64-encoded PDF>" },
    { "data": "<base64-encoded PDF>" }
  ],
  "metadata": {
    "title": "Combined Report",
    "author": "Pulp Engine"
  }
}
FieldTypeRequiredDescription
sourcesarrayYes2 to PDF_UTIL_MAX_MERGE_SOURCES objects, each with a data field containing a base64-encoded PDF
metadata.titlestringNoPDF title metadata
metadata.authorstringNoPDF author metadata
metadata.subjectstringNoPDF subject metadata

Response: 200 OK with Content-Type: application/pdf — the merged PDF binary.

curl -s -X POST http://localhost:3000/render/pdf/merge \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{
    "sources": [
      { "data": "'$(base64 -w0 cover.pdf)'" },
      { "data": "'$(base64 -w0 report.pdf)'" },
      { "data": "'$(base64 -w0 appendix.pdf)'" }
    ],
    "metadata": { "title": "Full Report" }
  }' -o merged.pdf

Watermark — POST /render/pdf/watermark

Overlay a text or image watermark on every page (or a page range) of a PDF.

POST /render/pdf/watermark
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "pdf": "<base64-encoded PDF>",
  "watermark": {
    "type": "text",
    "content": "DRAFT",
    "fontSize": 72,
    "opacity": 0.2,
    "rotation": -45,
    "position": "diagonal"
  },
  "pageRange": { "start": 1, "end": 5 }
}

Text watermark fields:

FieldTypeRequiredDescription
type"text"Yes
contentstringYesWatermark text (max 500 chars)
fontSizenumberNoFont size in points (6–200, default 48)
color{r, g, b}NoRGB color, 0–1 range (default gray 0.6/0.6/0.6)
opacitynumberNo0–1 (default 0.3)
rotationnumberNoDegrees, -360 to 360 (default 0, or -45 for diagonal)
positionstring or {x, y}No"center", "diagonal", "header", "footer", or {x, y} coordinates

Image watermark fields:

FieldTypeRequiredDescription
type"image"Yes
datastringYesBase64-encoded PNG or JPEG (max 10 MB)
widthnumberNoDisplay width in points
heightnumberNoDisplay height in points
opacitynumberNo0–1 (default 0.3)
positionstring or {x, y}NoSame as text watermark

Optional: pageRange.start / pageRange.end (1-indexed) to limit which pages receive the watermark.

Response: 200 OK with Content-Type: application/pdf.

curl -s -X POST http://localhost:3000/render/pdf/watermark \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{
    "pdf": "'$(base64 -w0 report.pdf)'",
    "watermark": { "type": "text", "content": "CONFIDENTIAL", "opacity": 0.15, "position": "diagonal" }
  }' -o watermarked.pdf

Page insert — POST /render/pdf/insert

Insert all pages from one PDF into another at a specified position.

POST /render/pdf/insert
Content-Type: application/json
X-Api-Key: <render or admin key>

{
  "target": "<base64-encoded target PDF>",
  "insert": "<base64-encoded PDF pages to insert>",
  "position": { "at": "after", "page": 3 }
}
FieldTypeRequiredDescription
targetstringYesBase64-encoded target PDF
insertstringYesBase64-encoded PDF to insert
position.atstringYes"start", "end", "before", or "after"
position.pageintegerWhen at is "before" or "after"1-indexed page number in the target

Response: 200 OK with Content-Type: application/pdf.

curl -s -X POST http://localhost:3000/render/pdf/insert \
  -H "X-Api-Key: $API_KEY_RENDER" \
  -H "Content-Type: application/json" \
  -d '{
    "target": "'$(base64 -w0 contract.pdf)'",
    "insert": "'$(base64 -w0 appendix.pdf)'",
    "position": { "at": "end" }
  }' -o contract-with-appendix.pdf

Error responses (all PDF Transform endpoints)

StatusCodeWhen
400Invalid base64 encoding
401Missing or unrecognised API key
403Key scope does not include render
422invalid_pdfCorrupt or non-PDF input
422pdf_too_largeInput exceeds size limit
422too_many_pagesOutput exceeds page count limit
422invalid_page_rangePage range out of bounds
422unsupported_imageWatermark image is not PNG or JPEG
429Rate limit exceeded

3a. Publish-readiness validation — POST /render/validate

This endpoint is always registered in production. It does not require PREVIEW_ROUTES_ENABLED and never returns rendered HTML or PDF output. It is the endpoint the editor uses for its publish-readiness check.

Accepts a TemplateDefinition object directly (no database lookup) and checks both structural validity and renderability. Returns a structured { valid, issues } result — not HTML or PDF.

Request:

POST /render/validate
Content-Type: application/json
X-Api-Key: <API_KEY_EDITOR or API_KEY_ADMIN>

{
  "template": { /* TemplateDefinition object */ },
  "data": { "key": "value" }
}
FieldTypeRequiredDescription
templateobjectYesTemplateDefinition to validate
dataobjectYesSample data payload used for the renderability check

Success — 200 OK (template is valid and renderable):

{ "valid": true, "issues": [] }

Failure — 200 OK (structural or render issues found):

{
  "valid": false,
  "issues": [
    { "code": "INVALID_TEMPLATE", "message": "Required", "path": "name" },
    { "code": "validation_failed", "message": "Template validation failed: customerName: Required" },
    { "code": "template_expression_error", "message": "Parse error on line 1: ..." }
  ]
}

Response envelope: validation and renderability outcomes always return 200 OK with { valid, issues }. Check the valid field to determine success or failure. Authentication failures (401), scope failures (403), and rate-limit failures (429) still return the normal error status codes. Unexpected server errors during the render check return 500 — they are not reported as issues entries so that callers can reliably distinguish user-caused problems from infrastructure failures.

issues[].codeStepDescription
INVALID_TEMPLATE1 — structuralZod schema validation failure. path indicates the failing field.
validation_failed2 — render checkData payload does not satisfy the template’s inputSchema.
template_expression_error2 — render checkTemplate contains an invalid Handlebars expression.

Auth: editor or admin scope required. X-Editor-Token is also accepted. Rate limit: same as /render/preview/* (20 req/min by default; configurable via RATE_LIMIT_RENDER_MAX). Never returns HTML or PDF. This is a pure validation endpoint — it is safe to expose in production with a narrow surface area.


3b. Editor preview routes (internal use only)

These endpoints are intended for the Pulp Engine visual editor only. They accept a full TemplateDefinition object directly — no database lookup is performed. Do not use these routes as a general-purpose rendering API.

POST /render/preview/html

Returns the rendered HTML for an inline template definition.

Request:

POST /render/preview/html
Content-Type: application/json

{
  "template": { /* TemplateDefinition object */ },
  "data": { "key": "value" }
}

Success — 200 text/html:

<!DOCTYPE html><html>...</html>

Error — 400 application/json (template validation failure):

{
  "error": "Invalid Template",
  "issues": [
    { "path": "key", "message": "Invalid" },
    { "path": "document.children.0.id", "message": "Required" }
  ]
}

Error — 422 application/json (renderer failure after valid template):

{ "error": "RenderError", "message": "…renderer error text…" }

POST /render/preview/pdf

Returns a PDF for an inline template definition.

Request:

POST /render/preview/pdf
Content-Type: application/json

{
  "template": { /* TemplateDefinition object */ },
  "data": { "key": "value" },
  "options": { "paperSize": "A4", "orientation": "portrait" }
}

Success — 200 application/pdf:

Content-Disposition: inline; filename="preview.pdf"
<binary PDF>

Error — 400 application/json (template validation failure):

{
  "error": "Invalid Template",
  "issues": [
    { "path": "key", "message": "Invalid" },
    { "path": "document.children.0.id", "message": "Required" }
  ]
}

Error — 400 application/json (preview options validation failure):

{
  "error": "Invalid Options",
  "issues": [
    { "path": "paperSize", "message": "Invalid enum value. Expected 'A4' | 'A3' | 'Letter' | 'Legal' | 'Tabloid'" }
  ]
}

Error — 422 application/json (renderer failure after valid template):

{ "error": "RenderError", "message": "…renderer error text…" }

Error convention

400 Invalid Template is returned when the submitted template object fails structural validation against TemplateDefinitionSchema — missing required fields, unknown keys, invalid node types, unrecognised enum values, etc. The issues array mirrors the format used by POST /templates — each entry has a path (dot-separated) and a message (Zod error text). This check happens before the renderer is called.

400 Invalid Options is returned (PDF endpoint only) when the options object contains an invalid paperSize or orientation value, or unknown keys. Accepted values are the same as renderConfig.paperSize and renderConfig.orientation in the template schema.

422 RenderError is returned for renderer-layer failures that occur after both template and options pass validation: data does not satisfy the template’s inputSchema, invalid Handlebars expression syntax, or unexpected renderer exceptions. The message field contains the renderer’s exception message and is displayed directly in the editor’s preview panel.

Production availability

These routes validate the submitted TemplateDefinition against the same strict Zod schema used by POST /templates and PUT /templates/:key, then pass the validated definition to the renderer. No database lookup is performed.

Preview routes are not registered in production by default. POST /render/preview/html and POST /render/preview/pdf return 404 Not Found unless PREVIEW_ROUTES_ENABLED=true is set in the server environment.

EnvironmentPREVIEW_ROUTES_ENABLEDPreview routes
development or testany valueAlways registered
productionabsent or falseNot registered — 404
productiontrueRegistered — startup warning logged

When PREVIEW_ROUTES_ENABLED=true, the server logs a warning at startup. Defense-in-depth: also restrict /render/preview/* at the reverse proxy or network layer. The flag is an explicit, auditable opt-in — misconfiguration surfaces as 404 rather than silent exposure.

GET /render/preview/status

Returns the cached startup capability state of the preview renderer. This endpoint is always registered regardless of PREVIEW_ROUTES_ENABLED and is used by the editor to determine whether live preview is available before attempting a render.

Auth: editor, preview, or admin scope required. Rate limit: disabled.

{
  "available": true,
  "reason": null
}

When the renderer is unavailable, available is false and reason is one of:

  • "browser_unavailable" — Chromium could not be launched at startup (missing binary, sandbox error, etc.)
  • "routes_disabled" — preview routes are not registered in this environment (PREVIEW_ROUTES_ENABLED absent in production)

The response is determined once at startup and cached for the lifetime of the process. A restart is required to re-evaluate.


4. Validation errors

Template definition validation (persistence boundaries)

The following 400 responses are returned when a TemplateDefinition submitted to a write endpoint fails structural validation.

400 — Invalid Template (POST /templates, PUT /templates/:key, POST /render/preview/html, POST /render/preview/pdf — body.template fails TemplateDefinitionSchema):

{
  "error": "Invalid Template",
  "issues": [
    { "path": "key", "message": "Invalid" },
    { "path": "document.children.0.id", "message": "Required" }
  ]
}

Each item in issues has a path (dot-separated, e.g. "document.children.0.id") and a message (Zod error text).

400 — Key Mismatch (PUT /templates/:key only — body.key does not match the route :key parameter):

{
  "error": "Key Mismatch",
  "message": "Request body key \"my-template\" does not match route key \"other-key\"."
}

400 — Stored Version Invalid (POST /templates/:key/versions/:version/restore — stored historical definition fails current schema):

{
  "error": "Stored Version Invalid",
  "message": "The requested version does not conform to the current template schema and cannot be restored.",
  "issues": [
    { "path": "document.children.0.type", "message": "Invalid discriminator value. Expected ..." }
  ]
}

This error indicates the stored definition pre-dates the current schema. The version history is not modified. To recover: retrieve the definition via GET /templates/:key/versions/:version, fix the non-conforming fields, and save a new version via PUT /templates/:key.

Error shapes

422 — schema validation failure (data does not match the template’s inputSchema after field mapping):

{
  "error": "Validation Failed",
  "issues": [
    { "path": "/customer/name", "message": "must be string" },
    { "path": "/loan/amount",   "message": "must be number" }
  ]
}

404 — unknown template key:

{
  "error": "Not Found",
  "message": "Template \"does-not-exist\" not found."
}

Pre-validate before rendering

Use POST /templates/:key/validate to check a payload against the template schema without rendering. The payload is field-mapped first, then validated.

curl -X POST http://localhost:3000/templates/loan-approval-letter/validate \
  -H "Content-Type: application/json" \
  -d '{
    "applicantName": "Jane Smith",
    "loanAmount": 50000,
    "interestRate": 5.75,
    "termMonths": 60,
    "items": [{ "description": "Application fee", "amount": 250 }]
  }'

Response:

{ "valid": true, "issues": [] }

5. Template management

Concurrency model (v0.16.0)

Pulp Engine uses optimistic concurrency on all template mutations. GET /templates/:key returns an ETag header whose value is the current version string, double-quoted (e.g. ETag: "1.0.3"). All mutating operations — PUT, DELETE, and version restore — require you to echo this value back as an If-Match header.

If another writer modifies the template between your GET and your write, the server rejects your request with 412 Precondition Failed rather than silently overwriting their changes.

Typical flow:

# 1. Fetch the template and capture the ETag
GET /templates/loan-approval-letter
# Response: ETag: "1.0.3"

# 2. Modify and PUT, supplying the ETag as If-Match
PUT /templates/loan-approval-letter
If-Match: "1.0.3"
# Success: 200 OK, ETag: "1.0.4"
# Conflict: 412 Precondition Failed — someone else wrote first; GET again

Header requirements for If-Match:

  • Must be a single double-quoted string: "1.0.3"
  • Wildcards (*), weak ETags (W/"1.0.3"), and multi-value lists are rejected with 400
  • Omitting the header entirely returns 428 Precondition Required

Status codes for mutation endpoints:

StatusMeaning
428 Precondition RequiredIf-Match header is absent
400 Bad RequestIf-Match is malformed (*, W/"...", multi-value, unquoted)
412 Precondition FailedVersion mismatch — someone else wrote first; fetch the latest version and retry

412 response body:

{
  "error": "Precondition Failed",
  "message": "Template \"loan-approval-letter\" was modified after you last loaded it (expected v1.0.3, current v1.0.5). Reload and re-apply your changes."
}

POST /templates (create) never requires If-Match.


List all templates

GET /templates

Returns a paginated envelope of template summaries. Each item contains key, name, version, description. Supports ?limit= and ?offset= query parameters (see Pagination).

{
  "items": [
    { "key": "loan-approval-letter", "name": "Loan Approval Letter", "version": "1.0.4", "description": "..." }
  ],
  "total": 42,
  "limit": 50,
  "offset": 0
}

Get full template definition

GET /templates/:key

Returns the full TemplateDefinition JSON including document, inputSchema, and fieldMappings. Includes an ETag response header set to the current version string.

200 OK
ETag: "1.0.3"
Content-Type: application/json

Get the input schema

GET /templates/:key/schema

Returns the JSON Schema object that describes the adapted data shape (after field mapping). Use this to build or validate payloads programmatically.

Get a sample payload

GET /templates/:key/sample

Returns an auto-generated sample payload derived from the template’s inputSchema. Useful as a starting point when integrating a new template.

Get form-rendering metadata

GET /templates/:key/form-metadata[?version=…|label=…]

Returns the subset of template metadata the <pulp-engine-form> embed needs to auto-generate a fillable form. Also callable directly when you are building a form surface on top of Pulp Engine without using the embed.

{
  "key": "invoice-v2",
  "version": "1.4.0",
  "title": "Invoice",
  "description": "Simple invoice with line items and a total row.",
  "inputSchema": { /* JSON Schema object */ },
  "hasFieldMappings": false
}
FieldDescription
keyTemplate key (echo of :key).
versionResolved version. Reflects ?version= / ?label= if supplied, otherwise the latest.
titleTemplate name, if set. Used as the form heading.
descriptionTemplate description, if set. Rendered as intro copy above the form.
inputSchemaThe template’s inputSchema — the post-mapping data shape referenced by {{…}} in the template body. Rendered as the form when hasFieldMappings is false.
hasFieldMappingstrue when the template has a non-empty fieldMappings array. The v1 form embed refuses to render such templates because inputSchema describes the post-mapping shape, not the submission shape (see Form Embed v1 limitation).

Auth: same dual matrix as the other /templates/* read-only routes — either an admin/editor/render API key, or a short-lived editor token (X-Editor-Token).

ResponseCondition
200 OKMetadata returned.
400 Bad RequestBoth version and label supplied (invalid_template_ref).
404 Not FoundTemplate key or resolved version does not exist.
401 Unauthorized / 403 ForbiddenStandard auth failures.

Create a template

POST /templates
Content-Type: application/json

{ /* TemplateDefinition object */ }

Creates a new template. Returns 201 with a summary on success.

ResponseCondition
201 CreatedTemplate created successfully.
400 Bad RequestBody fails TemplateDefinition schema validation. See Invalid Template error shape in Section 4.
409 ConflictA template with the same key already exists.

Generate a draft template from a natural-language prompt (AI, opt-in)

POST /templates/generate
Content-Type: application/json

{
  "prompt": "a one-page invoice with a header, a 3-column line-item table, and a total row",
  "model": "claude-opus-4-7",   // optional — defaults to ANTHROPIC_MODEL
  "maxAttempts": 3              // optional — total model calls including repair rounds (1..3, default 3)
}

Calls Claude via the Anthropic SDK with a JSON Schema derived from TemplateDefinitionSchema as the forced tool input, runs the returned payload through the same Zod validator used by Create a template, and returns an unsaved draft on success. The endpoint is generate-only — the caller must POST /templates separately to persist the draft, which preserves the human review gate and keeps AI calls and template mutations as distinct audit events.

Opt-in via the ANTHROPIC_API_KEY environment variable. When unset, the route returns 503 Service Unavailable and GET /capabilities reports ai.templateGeneration: false. See .env.example for the full list of tunables (ANTHROPIC_MODEL, ANTHROPIC_MAX_TOKENS, ANTHROPIC_TIMEOUT_MS, RATE_LIMIT_AI_GENERATION_MAX).

Success response:

{
  "template": { /* complete TemplateDefinition — same shape as POST /templates body */ },
  "meta": {
    "attempts": 1,
    "model": "claude-opus-4-7"
  }
}

Note that token usage is not in the response body. Cost is private to the audit log — query GET /audit-events?event=template_generation to see per-call tokensIn, tokensOut, cacheCreatedIn, cacheReadIn, durationMs, and attempts.

ResponseCondition
200 OKValid draft produced. Pass body.template straight to POST /templates to persist it. body.sampleData (optional) contains preview data matching the template’s inputSchema. Source selection, in order: (1) Claude emits sampleData alongside the template in the same tool_use call and it passes AJV validation against inputSchema; (2) fallback — SampleService walks the schema (the same walker behind GET /templates/:key/sample, producing type-correct but semantically bland values); (3) absent if both paths fail. Which source fired is recorded as sampleDataSource: 'claude' | 'synthesised' | 'none' in the template_generation audit event. Clients should treat sampleData as optional and fall back to an empty data store on absence.
400 Bad Requestprompt is outside 10–4000 characters, or body contains unknown fields.
401 UnauthorizedNo credential supplied.
403 ForbiddenCredential scope is render-only. Requires admin or editor.
429 Too Many RequestsPer-actor rate limit exceeded (default 5/min — configurable via RATE_LIMIT_AI_GENERATION_MAX).
502 Bad GatewayUpstream failure: network error, Anthropic returned no matching tool_use block, or the model produced an invalid template that couldn’t be repaired within maxAttempts. The response body is intentionally generic (no prompt echo, no issue list) — check the audit log for the reason field (network_error / tool_use_error / validation_exhausted).
503 Service UnavailableANTHROPIC_API_KEY is not set. The route stays registered so the OpenAPI shape is identical across deployments — clients discover the feature via GET /capabilities.

422 Unprocessable Entity is intentionally not returned for generation failures — an LLM producing invalid output is an upstream failure, not a client-request-rejected semantic. 422 is reserved for future content-policy rejections where the prompt itself is the problem.

Cost model

Phase 0 baseline — claude-opus-4-6, template-only (bare tool shape), invoice prompt (schema size 94,815 bytes / ~7,500 input tokens):

Call typeInput (regular)Input (cache write/read)OutputTotal
First call (cold cache)467 tokens × $15/M10,209 tokens × $18.75/M2,495 tokens × $75/M~$0.39
Repeat call (within 5 min)467 tokens × $15/M10,209 tokens × $1.50/M2,495 tokens × $75/M~$0.21

Current defaults (claude-opus-4-7, wrapper tool returning { template, sampleData } in one call) shift the mix materially: output roughly doubles (template + realistic sample data), and the newer model’s per-token rate applies. One measured call against a moderately complex quarterly-report prompt produced 6,043 output tokens in a single attempt at ~48 s cold. Use the template_generation audit event to track actual token buckets in your deployment rather than relying on the Phase 0 numbers above — they are retained as a historical reference.

The system prompt (with both few-shot examples embedded) and the tool schema are wrapped in a cache_control: { type: 'ephemeral' } block. Within Anthropic’s 5-minute cache window, subsequent identical system blocks are read back at 10% of base input rate.

Steady-state savings from caching: ~45% on the 4.6 baseline. The input portion drops ~92%, but output cost dominates the total, so the end-to-end per-call savings are smaller than the often-quoted “10× cheaper input” figure. With the output growth on 4.7 + sampleData, the cache’s relative impact is slightly smaller.

At the default per-actor rate limit of 5/min, the worst-case burn on the Phase 0 figures was ~$1.23/min per actor; re-baseline against your own traffic before alerting thresholds. The audit log records all four token buckets per call, so operators can alert on sustained growth via the existing /audit-events surface.

Latency

Phase 0 measured ~27.5 seconds against 4.6 with the template-only response shape. With the current defaults (4.7 + sampleData in the same tool call) expect somewhat longer single-attempt latencies — one observed call landed at ~48 s cold. Re-measure against your own prompt mix:

  • p50 (guideline) 30–50s (single attempt, cold cache)
  • p95 (guideline) 60–75s
  • p99 (guideline) up to ANTHROPIC_TIMEOUT_MS
  • Worst case 3 attempts × 60s = 180s (a startup warning is emitted if ANTHROPIC_TIMEOUT_MS * 3 > 5 min, since many reverse proxies cut idle connections past that mark)

Callers should treat the request as a background operation — don’t block interactive UI on a synchronous wait.

Rate limit keying

The rate limit is per actor, not per IP. The keyGenerator resolves in this order:

  1. request.actor — non-null for named-user deployments (OIDC or EDITOR_USERS_JSON), giving per-user isolation
  2. request.credentialScope — used for shared-key deployments where actor is null. Admin and editor credentials land in independent buckets
  3. request.ip — fallback only; bounds abuse even without auth

Multiple callers sharing an egress IP (common for corporate deployments) won’t starve each other as long as they present distinct credentials. Covered by a regression test in templates-generate.route.test.ts.

Example end-to-end (curl)

# 1. Check the feature is enabled on this server
curl http://localhost:3000/capabilities \
  -H "X-Api-Key: $API_KEY_EDITOR"
# → { "formats": { ... }, "ai": { "templateGeneration": true } }

# 2. Generate a draft
curl -X POST http://localhost:3000/templates/generate \
  -H "X-Api-Key: $API_KEY_EDITOR" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"a one-page invoice with a header, a 3-column line-item table, and a total row"}' \
  > draft.json
# Inspect draft.template — it's a full TemplateDefinition

# 3. Persist it (the caller reviews first; this is a separate mutation)
jq '.template' draft.json | curl -X POST http://localhost:3000/templates \
  -H "X-Api-Key: $API_KEY_EDITOR" \
  -H "Content-Type: application/json" \
  -d @-

The round-trip is a one-liner in the generated TypeScript SDK as well:

import createClient from '@pulp-engine/sdk'

const api = createClient({ baseUrl: 'http://localhost:3000', apiKey: process.env.API_KEY_EDITOR! })
const { data, error } = await api.POST('/templates/generate', {
  body: { prompt: 'a one-page invoice with line items and a total' },
})
if (error) throw new Error('generation failed')
await api.POST('/templates', { body: data.template })

Update a template

PUT /templates/:key
Content-Type: application/json
If-Match: "1.0.3"

{ /* TemplateDefinition object */ }

Replaces the current definition and auto-bumps the patch version (e.g. 1.0.2 → 1.0.3). Returns the updated summary with an ETag header set to the new version.

The If-Match header is required and must contain the version string you received from the most recent GET. See Concurrency model for the full header specification.

ResponseCondition
200 OKTemplate updated. Response includes ETag: "1.0.4" and body contains currentVersion.
400 Bad RequestIf-Match is malformed; or body fails schema validation (Invalid Template); or body.key ≠ route :key (Key Mismatch). See Section 4.
404 Not FoundTemplate does not exist.
412 Precondition FailedTemplate was modified since you loaded it — reload and retry.
428 Precondition RequiredIf-Match header is absent.

Delete a template

DELETE /templates/:key
If-Match: "1.0.3"

Soft-deletes the template. Returns 204 No Content on success.

The If-Match header is required. See Concurrency model.

ResponseCondition
204 No ContentTemplate deleted successfully.
400 Bad RequestIf-Match is malformed.
404 Not FoundTemplate does not exist.
412 Precondition FailedTemplate was modified since you loaded it — reload and retry.
428 Precondition RequiredIf-Match header is absent.

List version history

GET /templates/:key/versions

Returns a paginated envelope of version summaries for the template, newest first. Supports ?limit= and ?offset= query parameters (see Pagination).

{
  "items": [
    { "version": "1.0.4", "createdAt": "2026-03-15T04:12:00.000Z" },
    { "version": "1.0.3", "createdAt": "2026-03-14T09:30:00.000Z" }
  ],
  "total": 4,
  "limit": 50,
  "offset": 0
}

Get a specific version definition

GET /templates/:key/versions/:version

Returns the full TemplateDefinition as it was at the specified version. Useful for audit, diffing, or restoring a past state. Returns 404 if the version does not exist.

Diff two stored versions

POST /templates/:key/diff
Content-Type: application/json

{
  "beforeVersion": "1.0.3",
  "afterVersion":  "1.0.4"
}

Computes a structural diff between two stored versions of the same template key and returns the raw TemplateDiff envelope from @pulp-engine/template-diff. Both versions must exist in the resolved tenant. Scope: editor or admin.

The response splits changes by axis so consumers can render targeted views (a metadata-only change shouldn’t redraw the whole document tree):

FieldShapeMeaning
templateKeystringEcho of the :key param.
metadataChangesPropertyChange[]key / version / name / description diffs.
renderConfigChangesPropertyChange[]Deep diff of renderConfig (paper, margins, fonts, etc.).
inputSchemaChangesPropertyChange[]Deep diff of the input JSON Schema.
fieldMappingChangesPropertyChange[]Index-based diff of fieldMappings (targetPath is not unique).
documentRootChangesPropertyChange[]Property changes on the root DocumentNode itself (style, pagination hints, meta).
documentChangesNodeChange[]Structural changes to nodes within the document tree.
diagnosticsDiffDiagnostic[]Non-fatal warnings (e.g. duplicate node ids).
summaryDiffSummaryAggregate counts — nodesAdded, nodesRemoved, nodesModified, nodesMoved, propertiesChanged.

Each PropertyChange is { path, before?, after? }before or after is omitted when the key exists only on one side of the diff (added or removed). Always check for undefined before consuming. Each NodeChange is { nodeId, nodeType, kind, fromPath?, toPath?, properties? } where kind is one of added / removed / modified / moved.

ResponseCondition
200 OKDiff computed. Body is the TemplateDiff envelope above.
404 Not FoundEither beforeVersion or afterVersion does not exist on this template in the resolved tenant. Message: "Version not found."

Restore a historical version

POST /templates/:key/versions/:version/restore
If-Match: "1.0.3"

Promotes the specified historical version to current by creating a new version record with a bumped patch number and the same definition content. This makes the restore auditable — the version history is preserved. Returns the new version summary with an ETag header set to the new version.

The If-Match header is required and must contain the current version string (not the version being restored). See Concurrency model.

ResponseCondition
200 OKRestore successful. Response includes ETag: "1.0.4" and body contains currentVersion.
400 Bad RequestIf-Match is malformed; or the stored version does not conform to the current schema (Stored Version Invalid). Version history is not modified. See Section 4 for error shape and recovery guidance.
404 Not FoundTemplate or version does not exist.
412 Precondition FailedTemplate was modified since you loaded it — reload and retry.
428 Precondition RequiredIf-Match header is absent.

Template labels

A label is a named pointer from a template to one of its versions — stable, draft, prod, checkout-ab-test, etc. POST /render (and the other render routes) accept an optional label in place of version; the server resolves the label to a concrete version at render time. Labels let callers pin production traffic to a promoted version while authors continue to publish new versions on the same template.

Model

  • Multiple labels can coexist on one template.
  • Label names must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$.
  • prod and staging are reserved and cannot be used as A/B variants.
  • Plain label — points to a version. trafficSplit and controlLabel are both null.
  • A/B variant label — carries trafficSplit (0–100) and controlLabel. A given render request is bucketed into the variant with probability trafficSplit / 100; the remainder falls through to the controlLabel. Variants cannot chain (the controlLabel must be a plain label).

Render precedence: explicit version > label > template’s currentVersion.

GET /templates/:key/labels

Lists all labels on a template. Returns editor or admin.

{
  "items": [
    { "label": "prod", "version": "1.2.3", "trafficSplit": null, "controlLabel": null, "updatedAt": "...", "updatedBy": "alice" },
    { "label": "checkout-test", "version": "1.3.0-beta", "trafficSplit": 20, "controlLabel": "prod", "updatedAt": "...", "updatedBy": "ci-bot" }
  ]
}

GET /templates/:key/labels/:label

Returns the full template definition at the labeled version. editor or admin. 404 distinguishes a missing template from a missing label.

PUT /templates/:key/labels/:label

Creates or re-points a label. admin only — editor tokens cannot promote.

PUT /templates/:key/labels/:label
If-Match: "1.2.3"
Content-Type: application/json

{
  "version": "1.2.3",
  "trafficSplit": 25,
  "controlLabel": "prod"
}
  • If-Match is optional. When present and the label already points at a different version, the call returns 412 LABEL_CONFLICT. Automated promoters that serialise their own state can omit it and get an unconditional upsert.
  • trafficSplit and controlLabel must be supplied together or not at all. controlLabel must exist on the same template and must itself be a plain label.
  • Optional X-PulpEngine-Test-Run-Id header is logged to the audit trail as a forensic breadcrumb (no server-side validation) — the CLI uses this to tie promotions to CI test runs.

Error responses: 400 (regex/variant validation), 404 (template or target version), 412 LABEL_CONFLICT (If-Match mismatch).

DELETE /templates/:key/labels/:label

Removes the label. admin only. Idempotent — deleting an absent label on an existing template still returns 204 No Content. 404 only if the template itself is missing.

renderConfig trust model

renderConfig fields have different trust levels. This matters because some fields are injected into the PDF renderer without sanitisation.

FieldTrust levelNotes
paperSizeAny callerEnum-validated; no injection surface
orientationAny callerEnum-validated; no injection surface
marginAny callerPassed to Puppeteer margin option
fontImportsTemplate config field (stored by editor/admin; included in preview template body)Always validated: https: only, private hosts blocked unconditionally — IPv4 (RFC 1918, loopback, link-local), IPv6 (loopback ::1, link-local fe80::/10, ULA fc00::/7), and IPv4-mapped IPv6 (::ffff:)
customCssTemplate config field (stored by editor/admin; included in preview template body) — the options override parameter accepts only paperSize/orientation@import and url() stripped unconditionally in all production paths (safe mode is the default and is never overridden by any route handler)
localeAny callerBCP 47 tag; no injection surface
pageHeaderTemplate config field (stored by editor/admin; included in preview template body)HTML element/attribute/CSS-property allowlist applied at render time; script, event-handler, and external-resource vectors stripped unconditionally
pageFooterTemplate config field (stored by editor/admin; included in preview template body)Same sanitization model as pageHeader

pageHeader and pageFooter are PDF-only fields passed to Puppeteer’s headerTemplate and footerTemplate options after HTML allowlist sanitization applied unconditionally at render time.

Sanitization strips all elements and attributes outside the explicit allowlist and restricts CSS to a property allowlist whose value patterns cannot encode url() or javascript::

  • Stripped elements: <script>, <img>, <a>, <link>, <iframe>, <object>, <embed>, <form>, <input>, <meta>, and all other non-listed elements
  • Stripped attributes: all event handlers (on*), id, href, src, data-*, and all non-listed attributes
  • Allowed elements: div, span, p, b, strong, em, i, br, table group elements (table, thead, tbody, tfoot, tr, td, th)
  • Allowed attributes: style on all allowed elements (restricted CSS property set); class on span only (Puppeteer magic class names: pageNumber, totalPages, date, title, url)
  • CSS properties allowed: typography (font-size, font-family, font-weight, color, letter-spacing, line-height, white-space), layout (width, height, text-align, vertical-align, display), spacing (padding-*, margin-*), background-color, flex properties, border-*. Properties outside this set (including background-image, content, position, cursor, animation) are stripped.

Sanitization is applied retroactively to all stored templates — no migration required.

Browser-layer backstop: All rendered HTML pages include a Content-Security-Policy meta tag with script-src 'none'; object-src 'none'. This blocks JavaScript execution at the Chromium level even if a future sanitizer bypass allows hostile content through. It does not apply to Puppeteer’s headerTemplate/footerTemplate (which are separate Chromium contexts).

Residual risk: The CSS property allowlist and value patterns are not a formally complete sanitizer. Novel CSS attack vectors and future Chromium renderer bugs are accepted residual risks. The network guard blocks outbound requests to loopback, RFC 1918 private ranges, link-local (IPv4 169.254.x and IPv6 fe80::/10), IPv4-mapped IPv6 (::ffff:), and ULA IPv6 (fc00::/7) addresses but does not prevent DNS-rebinding TOCTOU bypasses (the window between DNS resolution and Chromium’s TCP connect). These are materially reduced but not eliminated risks.

These fields can be set via:

  • POST /templates and PUT /templates/:key — require editor or admin scope
  • /render/preview/html and /render/preview/pdf — require preview or admin scope; not registered in production by default (PREVIEW_ROUTES_ENABLED controls availability)

They cannot be overridden per render request — PreviewOptionsSchema only allows paperSize and orientation. Both fields are capped at 10,000 characters. Payloads exceeding this limit are rejected with 400 Invalid Template.


6. Asset management

Assets are image files stored on the API host and served statically at /assets/*. They can be referenced by image nodes in templates via their URL (e.g. /assets/uuid-logo.png).

Accepted file types: PNG, JPEG, GIF, WebP. Maximum upload size: 10 MB. SVG is not accepted — SVG files can contain JavaScript and external entity references; use a raster format instead.

Uploads are validated against both the declared MIME type and the file’s actual content (magic bytes). If the declared MIME type does not match the detected file type, the upload is rejected with 415 Unsupported Media Type.

List all assets

GET /assets[?q=<substring>][&mimeType=<type>][&legacySvg=true]

Returns a paginated envelope of asset records, newest first. Supports ?limit= and ?offset= query parameters (see Pagination) and three optional query filters (combinable with AND logic):

ParameterTypeDescription
qstringCase-insensitive substring filter on originalName
mimeTypestringExact MIME type filter (case-insensitive), e.g. image/png
legacySvgbooleanWhen true, returns assets matching either legacy SVG detection signal: declared mimeType of image/svg+xml or filename ending with .svg. Use this to enumerate assets that may have been uploaded before v0.27.0 SVG rejection was enforced.
{
  "items": [
    {
      "id": "clz...",
      "filename": "a1b2c3d4-logo.png",
      "originalName": "logo.png",
      "mimeType": "image/png",
      "sizeBytes": 14320,
      "url": "/assets/a1b2c3d4-logo.png",
      "createdAt": "2026-03-17T09:00:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}

Legacy SVG audit (v0.34.0+): To enumerate all assets that match legacy SVG detection signals:

curl -s "http://localhost:3000/assets?legacySvg=true" \
  -H "X-Api-Key: $API_KEY_ADMIN"

See the runbook § Asset upload validation for the full remediation workflow.

Upload an asset

POST /assets/upload
Content-Type: multipart/form-data

file=<binary>

On success returns 201 with the created asset record (same shape as list entries).

ErrorMeaning
400 Bad RequestNo file field provided
413 Payload Too LargeFile exceeds 10 MB
415 Unsupported Media TypeFile type not accepted, or declared MIME type does not match detected file content
curl -s -X POST http://localhost:3000/assets/upload \
  -F "file=@logo.png"

Delete an asset

DELETE /assets/:id

Returns 204 No Content on success. Returns 404 if the asset record does not exist. The file is also removed from disk.


7. Scheduled delivery

Cron-driven render jobs with delivery to email, S3, or webhook targets. Requires SCHEDULE_ENABLED=true and a database-backed storage mode (postgres or sqlserver). All routes require admin scope. When scheduling is disabled or storage is file mode, every route returns 503 unavailable.

Minimum cron interval is one minute. URLs (data-source and webhook) pass the same SSRF guard as the render pipeline — loopback and private-range hosts are rejected outside of evaluation posture.

Create — POST /schedules

{
  "name": "Daily invoices",
  "enabled": true,
  "cronExpression": "0 9 * * *",
  "timezone": "Australia/Sydney",
  "templateKey": "invoice",
  "templateLabel": "prod",
  "format": "pdf",
  "dataSource": {
    "type": "url",
    "url": "https://billing.example.com/invoices/today",
    "headers": { "Authorization": "Bearer ..." },
    "timeoutMs": 10000
  },
  "deliveryTargets": [
    { "type": "email", "to": ["ops@example.com"], "subject": "Daily invoices", "attachmentName": "invoices-{{date}}.pdf" },
    { "type": "s3", "bucket": "acme-archive", "key": "invoices/{{date}}.pdf", "region": "ap-southeast-2" },
    { "type": "webhook", "url": "https://hooks.example.com/invoices", "secret": "shared-hmac-secret", "includeBody": false }
  ]
}
FieldNotes
cronExpressionRequired. Must fire at ≥ 1-minute intervals. Validated via cron-parser.
timezoneIANA name (default UTC).
templateVersion / templateLabelMutually exclusive. Omit both to track currentVersion.
formatpdf (default) or docx.
dataSource.typestatic (inline JSON in data) or url (fetched at fire time, 1–60 000 ms timeout).
deliveryTargetsMinimum one. Mix of email / s3 / webhook.

Responses mask dataSource.headers and webhook secret to "***".

StatusMeaning
201 CreatedSchedule created.
400invalid_cron, invalid_template_ref, invalid_template, or invalid_url.
503unavailable — flag or storage gate.

List / get / update / delete

RouteNotes
GET /schedules?limit=&offset=&enabled=Paginated. `enabled=true
GET /schedules/:idFull schedule with masked secrets.
PUT /schedules/:idFull replacement — same body schema as create.
PATCH /schedules/:idPartial update. dataSource and deliveryTargets are replaced, not merged. Cron is re-validated only when cronExpression or timezone changes.
DELETE /schedules/:id204 No Content. Execution history is deleted with the schedule.

nextFireAt is recomputed whenever cronExpression, timezone, or enabled changes.

Manual trigger — POST /schedules/:id/trigger

Enqueues an execution out-of-band. Returns 202 Accepted with the execution record (status: "pending"). Rate-limited at 5 requests / minute / tenant. Returns 409 duplicate if an execution already exists for the current fire-time (rare).

Execution history

  • GET /schedules/:id/executions?limit=&offset=&status= — paginated
  • GET /schedules/:id/executions/:execId — single execution

Execution status values: pending, rendering, delivering, completed, partially_failed, failed. Failed deliveries are retried with backoff and ultimately land in the delivery DLQ (see Admin routes).


8. Admin routes

Operator-facing endpoints for tenant lifecycle, named-user management, and dead-letter queue triage. All routes require admin scope; multi-tenant routes additionally require super-admin (scope=admin with tenantId=null — tenant-bound admin credentials are rejected 403 super_admin_only).

Tenants CRUD

MULTI_TENANT_ENABLED=true and STORAGE_MODE=postgres required. Otherwise every route returns 503 unavailable.

RouteMethodNotes
/admin/tenantsPOSTCreate. Body { id, name, metadata? }. id matches ^[a-z0-9][a-z0-9-]{0,62}$ and is immutable. 409 DUPLICATE_KEY on slug conflict.
/admin/tenantsGETPaginated list (limit ≤ 1000, offset).
/admin/tenants/:idGETReturns full row including archivedAt.
/admin/tenants/:idPATCHUpdate name and/or metadata. id is not patchable.
/admin/tenants/:id/archivePOSTSoft-archive. 204. Idempotent. Subsequent writes to this tenant return 409 tenant_archived; reads continue to work for audit/export.
/admin/tenants/:id/unarchivePOSTRestore write access. 204. Idempotent.
/admin/tenants/:idDELETENot implemented in v0.67.x — returns 501 Not Implemented. Use archive. Hard-delete with cascade is deferred pending a cascade policy for audit events, versions, DLQ history, and scheduled deliveries.

Archive/unarchive bust TenantStatusCache on the handling pod. Other pods see the state change within TENANT_STATUS_CACHE_TTL_MS (default 10 s).

Named-user management

Shipped in v0.60.0. Requires named-user identity mode — routes return 409 Conflict if neither EDITOR_USERS_JSON nor EDITOR_USERS_FILE is set.

Runtime mutations (POST/PUT/DELETE) persist to EDITOR_USERS_FILE when configured; otherwise the registry is in-memory and changes are lost on restart. EDITOR_USERS_JSON is read once at startup and is not mutated by these routes.

RouteMethodNotes
/admin/usersGETReturns { users: [...] }. key is redacted to keyHint (last 4 chars).
/admin/usersPOSTBody { id, displayName, key, role, tokenIssuedAfter?, tenantId? }. role is editor or admin. id matches ^[a-zA-Z0-9_-]+$. Keys must be unique across all users and scoped API_KEY_* values. 422 on collision or validation failure.
/admin/users/:idPUTPartial patch. Setting tokenIssuedAfter to an ISO timestamp revokes all sessions issued before it; setting it to null clears the epoch.
/admin/users/:idDELETE{ deleted: true, registrySize: N }. Existing tokens for the deleted user remain valid until expiry — no active revocation list. When the registry becomes empty the response carries X-PulpEngine-Warning because editor login is then unavailable.
/admin/users/reloadPOSTRe-reads EDITOR_USERS_FILE and replaces the registry. OIDC-auto-provisioned users are merged in, not clobbered. 404 if EDITOR_USERS_FILE is missing on disk; 409 if not configured.

Dead-letter queues

Failed async batch deliveries and scheduled-delivery dispatches land in persistent DLQs for operator triage. These endpoints are documented in the operator runbook because the replay/abandon workflow is operational rather than integration-surface. Summary:

PrefixPurpose
/admin/schedule-dlqFailed scheduled deliveries. GET (paginated, filter by status / scheduleId), GET /:id, POST /:id/replay, POST /:id/abandon. Replay rehydrates the current schedule config so operator fixes are picked up; returns 409 schedule_gone / schedule_mutated / render_artifact_expired / already_terminal when replay is no longer meaningful.
/admin/batch-dlqFailed async batch webhook deliveries. Same shape (GET, POST /:id/replay).

Both families require admin scope, SCHEDULE_ENABLED=true, and Postgres storage.


9. Language examples

All examples render loan-approval-letter to a PDF and save it to disk.

C#

using System.Net.Http.Json;

var client = new HttpClient { BaseAddress = new Uri("http://localhost:3000") };

var payload = new {
    template = "loan-approval-letter",
    data = new {
        applicantName    = "Jane Smith",
        applicantAddress = "12 Maple Street, Sydney NSW 2000",
        loanAmount       = 50000,
        interestRate     = 5.75,
        termMonths       = 60,
        settlementDate   = "2026-04-01",
        requiresGuarantor = false,
        items = new[] {
            new { description = "Application fee", amount = 250 }
        }
    }
};

var response = await client.PostAsJsonAsync("/render", payload);
response.EnsureSuccessStatusCode();

var pdf = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("output.pdf", pdf);

JavaScript (fetch)

const response = await fetch('http://localhost:3000/render', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    template: 'loan-approval-letter',
    data: {
      applicantName:    'Jane Smith',
      applicantAddress: '12 Maple Street, Sydney NSW 2000',
      loanAmount:       50000,
      interestRate:     5.75,
      termMonths:       60,
      settlementDate:   '2026-04-01',
      requiresGuarantor: false,
      items: [
        { description: 'Application fee', amount: 250 }
      ]
    }
  })
})

if (!response.ok) {
  const err = await response.json()
  throw new Error(`${response.status}: ${JSON.stringify(err)}`)
}

const buffer = Buffer.from(await response.arrayBuffer())
fs.writeFileSync('output.pdf', buffer)

PHP

<?php

$payload = json_encode([
    'template' => 'loan-approval-letter',
    'data'     => [
        'applicantName'     => 'Jane Smith',
        'applicantAddress'  => '12 Maple Street, Sydney NSW 2000',
        'loanAmount'        => 50000,
        'interestRate'      => 5.75,
        'termMonths'        => 60,
        'settlementDate'    => '2026-04-01',
        'requiresGuarantor' => false,
        'items'             => [
            ['description' => 'Application fee', 'amount' => 250],
        ],
    ],
]);

$ch = curl_init('http://localhost:3000/render');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
]);

$pdf    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 200) {
    throw new \RuntimeException("Render failed ($status): $pdf");
}

file_put_contents('output.pdf', $pdf);

VB.NET

Imports System.Net.Http
Imports System.Text
Imports System.Text.Json

Dim client As New HttpClient With { .BaseAddress = New Uri("http://localhost:3000") }

Dim payload = New With {
    .template = "loan-approval-letter",
    .data = New With {
        .applicantName    = "Jane Smith",
        .applicantAddress = "12 Maple Street, Sydney NSW 2000",
        .loanAmount       = 50000,
        .interestRate     = 5.75,
        .termMonths       = 60,
        .settlementDate   = "2026-04-01",
        .requiresGuarantor = False,
        .items = New() { New With { .description = "Application fee", .amount = 250 } }
    }
}

Dim body = JsonSerializer.Serialize(payload)
Dim response = Await client.PostAsync("/render", New StringContent(body, Encoding.UTF8, "application/json"))
response.EnsureSuccessStatusCode()

Dim pdf = Await response.Content.ReadAsByteArrayAsync()
Await File.WriteAllBytesAsync("output.pdf", pdf)

10. loan-approval-letter — full request example

This template demonstrates field mapping (raw keys → adapted keys), a conditional guarantor section, and a formatted fee table.

API request body (POST /render or POST /render/html)

{
  "template": "loan-approval-letter",
  "data": {
    "applicantName":     "Jane Smith",
    "applicantAddress":  "12 Maple Street, Sydney NSW 2000",
    "loanAmount":        50000,
    "interestRate":      5.75,
    "termMonths":        60,
    "settlementDate":    "2026-04-01",
    "requiresGuarantor": true,
    "guarantorName":     "Bob Smith",
    "branchName":        "Sydney CBD",
    "officerName":       "Alice Wong",
    "items": [
      { "description": "Application fee", "amount": 250 },
      { "description": "Valuation fee",   "amount": 400 },
      { "description": "Legal fee",       "amount": 800 }
    ]
  }
}

Editor preview panel (paste into the Sample Data textarea)

The preview panel takes only the data object — do not include the "template" wrapper.

{
  "applicantName":     "Jane Smith",
  "applicantAddress":  "12 Maple Street, Sydney NSW 2000",
  "loanAmount":        50000,
  "interestRate":      5.75,
  "termMonths":        60,
  "settlementDate":    "2026-04-01",
  "requiresGuarantor": true,
  "guarantorName":     "Bob Smith",
  "branchName":        "Sydney CBD",
  "officerName":       "Alice Wong",
  "items": [
    { "description": "Application fee", "amount": 250 },
    { "description": "Valuation fee",   "amount": 400 },
    { "description": "Legal fee",       "amount": 800 }
  ]
}

Field mapping notes:

Raw keyAdapted pathNotes
applicantNamecustomer.nameRequired
applicantAddresscustomer.addressOptional; defaults to ""
loanAmountloan.amountRequired; minimum 1000
interestRateloan.rateRequired
termMonthsloan.termMonthsRequired; integer
settlementDateloan.settlementDateOptional; ISO date string
requiresGuarantorloan.requiresGuarantorOptional; defaults to false
guarantorNameloan.guarantorNameOptional
itemsloan.itemsRequired; array of { description, amount }
branchNamebank.branchOptional; defaults to "Head Office"
officerNamebank.officerOptional; defaults to "Lending Officer"

What to expect in the output:

  • Loan amount, fees, and total formatted as A$... (AUD)
  • Settlement date formatted as 1 April 2026 (long date, en-AU locale)
  • Guarantor section appears only when requiresGuarantor: true
  • Fee table with alternating row shading and right-aligned amounts
  • Table header repeats on every printed page