Pulp EngineDocument 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, the server refuses to boot unless ALLOW_NO_AUTH=true is set explicitly (the v0.82.0 backstop; independent of NODE_ENV, with NODE_ENV=test exempt). With ALLOW_NO_AUTH=true — local development only — 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 variable Scope Route access
API_KEY_ADMIN admin Full access — templates, assets, render, preview, validate
API_KEY_RENDER render POST /render/pdf (canonical; POST /render is a deprecated alias), 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/batch/async, POST /render/batch/async/docx, GET /render/batch/jobs/:id, POST /render/pdf/*
API_KEY_PREVIEW preview POST /render/preview/* only
API_KEY_EDITOR editor Template management, asset management, POST /render/preview/*, POST /render/validate — not production render

Authorization matrix:

Route group Paths Required scope
Render POST /render/pdf (canonical; POST /render is a deprecated alias), 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/batch/async, POST /render/batch/async/docx, GET /render/batch/jobs/:id, POST /render/pdf/* render or admin
Publish-readiness validation POST /render/validate editor or admin
Preview POST /render/preview/html, POST /render/preview/pdf preview, editor, or admin
Template management (read/write) GET, POST, PUT on /templates/* editor or admin
Template delete DELETE /templates/:key admin only
Template version restore POST /templates/:key/versions/:version/restore admin only
Asset management List, upload, details, facets, PATCH metadata, PUT content, versions, restore and DELETE under /assets editor or admin
Asset binary (private mode) GET /assets/:filename editor or admin (only when ASSET_ACCESS_MODE=private)
All other authenticated routes Any route not matched above admin

Error responses:

Response Meaning
401 Unauthorized Header missing, or key not recognised
403 Forbidden Key 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/pdf — and its deprecated POST /render alias — /render/html, /render/csv, /render/xlsx, /render/docx, /render/pptx, /render/batch, /render/batch/docx, /render/batch/pptx, the unified /render/jobs routes and legacy async batch routes /render/batch/async, /render/batch/async/docx, /render/batch/jobs/:id, or /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"
}
Field Type Description
authRequired boolean false only when neither API credentials nor EDITOR_USERS_JSON are configured (dev mode with no auth)
editorLoginAvailable boolean true 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"
}
Field Type Description
token string HMAC-signed session token for X-Editor-Token header
expiresAt string ISO 8601 expiry timestamp
actor string | null In named-user mode: server-derived user id. In shared-key mode: operator-supplied label, or null if not provided.
displayName string | null Human-readable name. Only set in named-user mode. null in shared-key mode.

Error responses:

Status Condition
400 Bad Request key field missing, auth is disabled (dev mode), actor exceeds 200 chars, or actor contains control characters
401 Unauthorized Key not recognised, has insufficient scope (render/preview only), or (named-user mode) key is not in user registry
503 Service Unavailable No 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:

Format Parts HMAC payload Used 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" }
]
Field Type Required Description
id string yes URL-safe unique identifier. Used as the actor value in tokens and audit records.
displayName string yes Human-readable name shown in the editor UI.
key string yes Personal login credential. Must be unique; must not duplicate any API_KEY_* value.
role "editor" | "admin" yes Maps directly to credential scope. admin users can restore versions and delete templates.
tokenIssuedAfter string (ISO 8601) no Tokens 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.

URL Description
GET /docs Interactive Swagger UI (Redoc-style)
GET /docs/json OpenAPI 3.0 spec — JSON format
GET /docs/yaml OpenAPI 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/pdf (and its deprecated POST /render alias) 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 28 content node types: container, columns, heading, text, image, table, chart, pivotTable, barcode, spacer, divider, pageBreak, conditional, repeater, richText, formField, signatureField, templateRef (with optional fieldMappings), toc, positioned, stickyHeader, custom, kpi, slot (layout definitions), grid, shape, checkbox and comb.

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/pdf ...

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

Variable Default Effect
CORS_ALLOWED_ORIGINS unset (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_ENABLED true Set false to disable Swagger UI — all /docs* return 404.
METRICS_TOKEN unset (open) When set, GET /metrics requires Authorization: Bearer <token>.
TRUST_PROXY false Set true when behind a reverse proxy that sets X-Forwarded-Proto. Required for REQUIRE_HTTPS to work.
REQUIRE_HTTPS false When true, POST /auth/editor-token rejects non-HTTPS requests with 400 Bad Request. Requires TRUST_PROXY=true.
HARDEN_PRODUCTION Auto-derived from NODE_ENV Enforced 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

The limiter pools requests by route class: general, render, batch, pdf-util, admin, ai-gen, auth and sandbox. Health and metrics probes are exempt. See /capabilities.limits or GET /usage/quota for the caller’s effective maxima and 60-second windows.

RATE_LIMIT_KEY_MODE=ip|tenant|credential selects the bucket identity. Unset preserves the existing IP buckets in single-tenant mode and tenant buckets in multi-tenant mode. Credential mode separates integrations behind one IP; auth and sandbox keep their IP protection. Stable identities are k:<database-key-id>, s:<static-key-name-or-16-hex-fingerprint>, u:<actor> and p:<provider>:<actor-or-scope>. Name static keys to preserve their bucket when rotating the secret.

RATE_LIMITS_JSON (or RATE_LIMITS_JSON_FILE) accepts entries such as {"tenantId":"acme","credential":"billing","routeClass":"render","max":60}. Credential overrides take priority over tenant and wildcard overrides. Database key restrictions.ratePerMinute has highest priority. Limits use RATE_LIMIT_STORE=redis and REDIS_URL for shared enforcement; memory counters are per process. RATE_LIMIT_FAIL_OPEN controls behavior when Redis is unavailable.

Responses expose X-RateLimit-Limit/Remaining/Reset and Retry-After; RATE_LIMIT_HEADER_STYLE=draft selects RateLimit-Limit/Remaining/Reset. Browser CORS exposes both families. Rate denials return 429 rate_limited.

Quotas

Configure boot-time rules with QUOTAS_JSON or QUOTAS_JSON_FILE (mutually exclusive):

[{"scope":"tenant","id":"acme","period":"day","maxRenders":1000,"maxPages":5000,"timezone":"UTC","includePreview":false,"includeScheduled":true,"warnAt":[0.8,1]}]

Rules support tenant or credential scope, literal identities or *, day/month calendar periods, an IANA timezone and positive maxRenders/maxPages. All matching rules apply together. Database key restrictions may also contain quotas without scope/id. Credential rules exclude schedules. Preview is opt-in; production and test-key renders count. PDF transforms and read-only batch queries do not count.

Single requests reserve one render, batch submissions reserve their whole item count atomically. Denied requests consume nothing. Failed renders and unprocessed/cancelled job items are refunded; webhook delivery failure does not refund a successful render. Refund totals are deduplicated across Redis replicas and restart recovery. Pages use actual PDF page counts; a request crossing a page limit completes and the next is denied. PDF requests subject to page quotas use buffered rendering even when streaming was requested, so counting finishes before response completion.

Successful admissions send X-Quota-Limit, X-Quota-Remaining, X-Quota-Reset (epoch seconds), and X-Quota-Scope for the most constrained rule. Denial returns 429 quota_exhausted, Retry-After until the calendar reset and a quota object containing scope, period, dimension, limit, used, remaining and resetAt. SDK errors preserve these details and do not retry before a long Retry-After. All scopes may call GET /usage/quota; super-admins select the tenant with X-PulpEngine-Tenant-Id. Thresholds emit quota.threshold, deduplicated per counter and period.

Redis is required for strict shared quotas. Memory mode seeds current windows from successful usage rows on database storage; file mode resets on restart. Diagnostic usage writes can be lost during outages. Rules are read at boot and require restart after changes.


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/updated/replaced/restored/deleted, and editor session tokens are minted.

Query parameters

Parameter Type Description
limit integer Max items to return (1-1000, default 50)
offset integer Items to skip (default 0)
event string Filter by event type: template_mutation, asset_mutation, editor_token_minted
operation string Filter by operation: create, update, delete, upload, replace, restore, mint
actor string Filter by actor (named-user ID or operator-supplied label)
resourceType string Filter by resource type: template, asset, editor_token
resourceId string Filter by resource ID (template key or asset ID)
since date-time ISO 8601 lower bound (inclusive)
until date-time ISO 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.

Parameter Type Required Description
before date-time yes ISO 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 PostgreSQL or SQL Server storage and the usage:read permission (admin and auditor roles by default). File mode returns 503 usage_not_available. Tenant endpoints resolve the caller’s tenant; JSON results omit tenant identifiers. The legacy billing CSV retains its existing tenantId column for compatibility. Only the explicit super-admin global endpoint returns tenant slugs in JSON.

GET /usage - rollup

Query parameter Notes
from, to Required ISO 8601 timestamps; inclusive from, exclusive to.
groupBy day (default), week, month, template, format, source, mode, credential, actor, scope, status, errorCode. Temporal buckets use UTC; weeks start Monday.
source production, preview, or test; optional.
templateKey, credentialId, actor, scope Optional exact filters. Actor and template identity are case-sensitive on both databases. A null actor groups under the empty string.
mode convert, single, batch, async-batch, async-job, transform, or scheduled.
status success (default), failed, or all.
stats percentiles adds exact p50/p95 durations for successful rows. Omit to avoid percentile scans.

Windows are capped by USAGE_QUERY_MAX_WINDOW_DAYS (default 92). Wider windows return 400 usage_window_too_wide. Invalid/reversed windows return usage_invalid_date or usage_invalid_window.

The existing {from,to,groupBy,buckets,total} contract is preserved. Each bucket contains key, count, failedCount, totalDurationMs, totalOutputBytes (decimal string), and totalPages. Count and all sums always include successful renders only, even with status=all; failed attempts contribute only to failedCount. Failure rate is failedCount / (count + failedCount), or zero when there are no attempts. stats=percentiles adds nullable p50DurationMs and p95DurationMs to every bucket and to total. Total percentiles are computed from all matching successful rows, rather than averaging bucket percentiles. No successful observations means both percentiles are null.

GET /usage/templates - per-template statistics

Accepts the same window and filters (without groupBy/stats), defaulting to status=all. Returns {items,total,limit,offset}; limit=1..100 (default 50). Each item contains templateKey, name, renders, failed, failureRate (a fraction from 0 to 1), p95DurationMs, and lastRenderedAt (successful render within this range, or null). Only active, undeleted document templates appear.

sort=renders|failures|failureRate|p95|lastRendered (default renders) and order=asc|desc (default desc) sort the complete result before pagination. Null durations/dates sort last, with template key as the stable tie-breaker. includeUnused=true includes templates with no matching attempts; unusedOnly=true selects only those templates. Templates with failed attempts are considered used. Counts and durations remain success-only.

GET /admin/usage - global statistics

Requires a super-admin credential. Tenant-bound administrators receive 403 super_admin_only before the global store is called. Accepts the same bounded window and filters, groupBy=tenant|day|week|month (default tenant), optional stats=percentiles, and limit=1..100/offset. Status defaults to all. Returns {from,to,groupBy,items,total,limit,offset,summary}. total is the number of matching buckets, and summary contains totals over the entire matching window. Tenant slugs are deliberate global bucket keys when grouping by tenant.

GET /usage.csv - billing export

Accepts the common filters and defaults to status=success. The existing CSV header, field order, formatting and success-only rows are preserved. Explicit status=failed|all exports diagnostic failures. No window cap applies to CSV; more than USAGE_EXPORT_MAX_ROWS rows (default 500000) returns 413 usage_export_too_large with the count. Narrow the range to retry.

timestamp,tenantId,templateKey,format,source,mode,durationMs,outputSizeBytes,pageCount,itemIndex,actor,scope,requestId,status,errorCode,templateVersion,label,correlationId,metadata,credentialId

Null cells are empty, BigInt byte values use decimal strings, and cells follow RFC 4180 quoting. Formula-like cells receive a leading apostrophe before quoting. Responses use text/csv; charset=utf-8 and an attachment filename. Usage endpoints send Cache-Control: private, no-store.

expand=lastRenders on template reads adds lastRenderedAt, renderCount24h and renderCount30d, all success-only. Last rendered is all-time; counts are rolling windows. File mode returns zeros for compatibility; clients should hide activity unless /capabilities reports usageAnalytics=true. The capabilities response also advertises usageMaxWindowDays for range pickers.

TypeScript: client.usage.rollup, .templates, .adminRollup, .exportCsv. Python sync/async: client.usage.rollup, .templates, .admin_rollup, .export_csv. Caller quota methods remain available on both SDKs.

Render log

Admin-only GET /renders searches successes and failures in database storage. File mode returns 503 usage_not_available. Required from and to are ISO 8601 timestamps defining [from,to), capped by USAGE_QUERY_MAX_WINDOW_DAYS. Rows are ordered by timestamp DESC, ID DESC; limit=1..200 (default 50) and offset return {items,total,limit,offset}. GET /renders/:id returns one row or 404 not_found, with tenant isolation and no public tenantId.

Filters: status=success|failed|all (default all), templateKey, format, mode, source, errorCode, correlationId, requestId, and up to three repeatable meta=key:value filters. Metadata filters use exact AND matching; values can include colons. Keys must be distinct and match the metadata key rule. Example:

GET /renders?from=2026-10-11T00:00:00Z&to=2026-10-12T00:00:00Z&status=failed&correlationId=inv-1234&meta=invoiceNo:1234

Entries include resolved templateVersion/label, IDs, status, error code and message, timing, format, source/mode, output byte count as a decimal string, page count, batch item index, actor/scope and metadata. Old rows default to success with null newly introduced fields. Missing templates record not_found. Interrupted streams, six production formats, batch/job items and scheduled render failures are recorded. Scheduled rows carry metadata.scheduleId. Preview failures are excluded unless RENDER_LOG_PREVIEW_FAILURES=true. Dry runs accept metadata but do not produce usage rows.

Production render bodies and batch items accept optional metadata, a map of at most ten string values (each at most 256 characters). Keys contain 1–40 letters, digits, underscores, dots or hyphens. Batch/job-level metadata is inherited by each item; item values override it and the merged map must still fit ten keys. This replaces the earlier job-only 20-key/500-character allowance. Metadata never enters template data or changes output. Async job status and completion callbacks echo job and per-item metadata plus correlationId. SDK render/batch params send correlation as a header and metadata in the body; binary results expose both response IDs. client.renders.list/get/iterate (Python: the same names) searches and paginates the log.

Writes are fire-and-forget diagnostics: an outage can lose rows. This is not an audit trail. Error messages can contain expression text; set RENDER_LOG_ERROR_MESSAGES=false to omit them. Render input data is never stored. See data retention and privacy before attaching business metadata. Every tenant admin can read these records.

For signed lifecycle and failure notifications, see event webhooks. Existing completion callbacks remain supported.

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/validate POST /render/validate
Purpose Validate input data against a stored template’s schema Validate a template definition for publish readiness
Input Data payload only (template loaded from DB by key) Full inline TemplateDefinition + sample data
Steps 1. Field mapping (data adapter) 2. JSON Schema validation 1. Structural parse (Zod) 2. Blocked-asset detection 3. Trial HTML render
DB dependency Yes — loads template by key No — accepts inline definition
Use case Pre-check a data payload before calling the render endpoint Pre-check a template before publishing it from the editor
Operation ID validateTemplateData validateTemplateDefinition

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 parameter Type Default Max Description
limit integer 50 1000 Maximum number of items to return
offset integer 0 — Number 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

Every response includes a server-generated X-Request-ID UUID, also bound as reqId in request logs. Client X-Request-ID is ignored. Forward the response header through proxies and include it in support reports.

Send X-Correlation-Id for your business/request identifier. It accepts 1–128 printable ASCII characters without spaces (^[\x21-\x7E]{1,128}$). Valid values are echoed, bound as correlationId in request logs, stored with renders/jobs and sent in event envelopes. Invalid/repeated headers are dropped without rejecting the request and increment pulp_engine_correlation_id_rejected_total. When OTel is active, the request span gains pulp_engine.correlation_id. Both ID response headers are exposed through CORS. They serve different purposes: retain both when diagnosing an integration.


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"
  }
}
Field Meaning
formats.pptx Gated by PPTX_ENABLED (default true). All other format flags are hardcoded true — they indicate route availability, not per-tenant entitlement.
ai.templateGeneration true when ANTHROPIC_API_KEY is set — POST /templates/generate is then registered.
streaming.singleDoc true 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.mode Active 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:

Metric Type Labels Description
pulp_engine_http_requests_total Counter method, route, status_class Total HTTP requests (infra routes excluded)
pulp_engine_http_request_duration_seconds Histogram method, route Request latency — covers HTML (<100 ms) through PDF (1–30 s)
pulp_engine_render_requests_total Counter type (pdf/html), source (production/preview), status (success/failure) Render requests by outcome
pulp_engine_template_mutations_total Counter operation (create/update/delete/restore), status (success/conflict/not_found/duplicate/failure) Template write operations
pulp_engine_auth_failures_total Counter reason (missing_key/invalid_key/insufficient_scope/invalid_token) Auth and authorization failures
pulp_engine_renderer_status Gauge (none) Render-path health: 1 = ready, 0 = unavailable

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


Logging

Variable Values Default Effect
LOG_LEVEL trace | debug | info | warn | error info Pino 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/pdf

Returns a binary PDF stream.

Canonical since v0.79.0. POST /render is a deprecated alias with the same handler and request/response as /render/pdf. Its planned sunset is 6 March 2027; removal also requires the notice windows in the API stability policy. New integrations should call POST /render/pdf.

Request

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

{
  "template": "loan-approval-letter",
  "data": { ... },
  "options": {
    "paperSize": "A4",
    "orientation": "portrait"
  }
}
Field Type Required Description
template string Yes Template key (e.g. "loan-approval-letter")
version string No Exact 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.
data object Yes Raw payload — field mappings transform this before rendering
options.paperSize preset or { width, height } No Override the document default; see Advanced page setup below.
options.orientation string No Override 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:

Shape When
{ "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/pdf \
  -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/pdf \
  -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/pdf. Response:

200 OK
Content-Type: text/html

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

Render errors — 422 application/json:

Shape When
{ "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 table and pivot nodes as CSV, TSV or a ZIP of separate files. The default remains a single comma-separated 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

CSV options: Request csv overrides renderConfig.csv field by field. Unknown keys and invalid values return 400 bad_request.

Option Values API default
delimiter ",", ";", "\t", `” “`
bom boolean; UTF-8 BOM once per file false
lineEnding crlf, lf crlf
quoting minimal, all, nonnumeric minimal
header include column headers true
includeFooter, includeHeaderRows include totals/banner rows false
values formatted, raw formatted
decimalSeparator ".", ","; raw numbers only period
tables one, all one
bundle zip, stacked; all-tables mode only zip

For EU Excel, send "csv":{"delimiter":";","bom":true}. For a numeric feed, send "csv":{"values":"raw","decimalSeparator":","}; amounts have no grouping or exponent and date presets use yyyy-MM-dd. Mandatory spreadsheet formula protection applies in every dialect. Formatted values continue to use the render locale.

Use "csv":{"tables":"all"} for a ZIP with one named file per table/pivot in document order. A tab delimiter returns text/tab-separated-values with .tsv; ZIP returns application/zip with .zip. All-tables responses carry X-Table-Ids, while one-table responses carry X-Table-Id; both are exposed to browser clients. tableId with tables:'all' returns 400 bad_request. bundle:'stacked' produces title/header/data blocks separated by a blank line; this is intended for people, rather than a single rectangular machine feed. ZIPs are built in memory. Async CSV jobs inherit template defaults and preserve ZIP/TSV media types and filenames; the synchronous csv/xlsx request fields are not job-submission fields.

CSV ZIP entry names use the XLSX sheet-name fallback chain below, with additional filename sanitisation. spreadsheet.exclude:true removes a table from automatic selection and both spreadsheet exports. Preview CSV accepts the same tableId and csv fields. TypeScript render.csv({template,data,csv}) exposes contentType; Python uses csv_options. CLI flags: --table-id, --csv-delimiter comma|semicolon|tab|pipe, --csv-bom, --csv-all; download extensions follow the response.

Table selection:

  • If the template contains exactly one table node, it is selected automatically — tableId can be omitted.
  • With tables:"one", multiple tables require tableId; otherwise 422 ambiguous_table lists the available IDs. With tables:"all", all included tables are exported.
  • If the specified tableId does not match any table in the rendered template, 422 is returned.

Error responses — 422 application/json:

Shape When
{ "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:

XLSX defaults to layout:'report' and cellTyping:'native'. Template renderConfig.xlsx supplies layout, cellTyping and fitToWidth (default true); request xlsx can override layout and cellTyping. Preview XLSX also accepts tableId and xlsx. Python uses xlsx_options; the CLI offers --table-id and --xlsx-layout report|data.

Column helper/value Excel value and format
currency number; locale/ISO-aware positive and negative currency format
number number with the requested fixed decimals, e.g. 0.00
round rounded number, General
date presets native date at midnight in the displayed time zone; locale date format
formatNumber, percent, formatDate native formats where the format code supports conversion
Unformatted numbers/booleans native number/boolean
Unformatted strings numeric only for canonical decimals with at most 15 significant digits; "00123" and long IDs stay text
Other helpers legacy numeric parsing fallback, otherwise protected text

Report layout emits a merged caption, styled merged banner rows, column headers, data and the enabled footer. Data layout omits caption/banner/footer rows. xlsx:{layout:'data',cellTyping:'text'} restores legacy cell values. Header/column styles, alignment and footer styling carry through. Widths are auto-sized and capped at 60 characters.

A simple footer sum, avg, min or max of that table column becomes a filter-aware SUBTOTAL formula, with a cached numeric result and recalculation enabled. Supported first-argument wrappers are currency, number, round and formatNumber. Grouped, filtered/limited, empty or non-numeric ranges fall back to evaluated values; count stays a value. Set spreadsheet.totalsAsFormulas:false to disable formulas. Formulas are generated from recognised template structure, never input data.

Table/pivot spreadsheet settings also include sheetName, freezeHeader, freezeColumns (0-10), autoFilter and exclude. Headers freeze by default; pivots also freeze their row dimensions. Table AutoFilter excludes the footer and defaults on; pivot AutoFilter defaults off. Print settings honour paper size, orientation and unit-converted margins, repeat header rows and fit to one page width. The page footer shows page X of Y; evaluation watermarks take precedence.

Sheets follow document order. Names fall back from interpolated spreadsheet.sheetName to caption, preceding heading, single-table template name and node ID. Invalid characters are replaced, History is renamed, names are limited to 31 characters and duplicates are numbered case-insensitively. Workbook title defaults to the template name. Excel displays decimal/group separators and month/day names in the viewer’s language; the workbook does not force an Excel LCID. Native Excel Table objects are not included in this wave.

Error responses — 422 application/json:

Shape When
{ "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

Document metadata and PDF bookmarks

renderConfig.metadata accepts title, author, subject, keywords (array), language (BCP-47), and creator. Title defaults to the template name; language defaults to en. Template title, author, subject and keyword strings can contain Handlebars bindings. creator identifies the generating application and defaults to DOCUMENT_CREATOR (Pulp Engine).

Single and batch render requests accept options.metadata, merged by field over template values. Request values are literal, so {{...}} is never evaluated in an override. options.bookmarks overrides renderConfig.bookmarks; bookmarks are disabled unless explicitly enabled. Invalid language tags return 400.

Format Metadata mapping
HTML Escaped title, language, author, description and keyword meta tags
PDF Title, author, subject, keywords, language, creator/producer; optional heading outline
DOCX Core properties and default run language; author defaults to configured creator
PPTX Title, author and subject; language is not mapped
XLSX Title, author, subject, keywords; evaluation watermark still takes precedence over subject
CSV Not applicable

PDF TOC page numbers and metadata are applied after rendering, including the streaming, container and socket paths. This post-processing buffers the PDF before exposing a readable; default API creator metadata also requires that buffer. Low-level PDF renders with neither metadata nor TOC keep true streaming. HTML preview title spans now show the effective document title.

Binding formatted text

Use a rich-text source for payload markup. The field is accepted by existing template CRUD and render endpoints:

{ "id": "terms", "type": "richText", "content": [{ "type": "paragraph", "children": [{ "type": "text", "text": "No terms supplied." }] }], "source": { "dataPath": "notes", "format": "markdown", "whenEmpty": "fallback" } }

dataPath resolves in the current repeater/template-reference scope. content remains required. Missing/null/blank values fall back by default, or hide with whenEmpty: "hide". Non-string values always fall back with formatted_content_not_a_string. A missing default source reports formatted_content_missing.

Markdown supports CommonMark plus strike: paragraphs, headings (h5/h6 become level 4), quotes, lists, links, code and line breaks. Raw HTML remains literal; images and GFM tables use text fallback with issues. HTML permits paragraphs/divs, headings, quotes, lists, safe links and inline bold/italic/underline/strike/super/subscript/highlight/code. Only guarded colours and the supported inline mark styles survive; block styles read text alignment. Active elements and their contents are dropped. Event attributes, unsafe links, relative URLs and unsafe styles are discarded. Plain text uses blank lines for paragraphs and single newlines for breaks.

Literal-text guarantee: payload {{secret}} and block expressions print literally and never compile. Conversion caps are 200,000 characters and 2,000 blocks; excessive input reports formatted_content_truncated. Malformed markup degrades without failing the render. Dry runs return warnings.formattedContent entries with node IDs; format-aware validation reports corresponding warning issues. PPTX records formatted_content_degraded warnings and returns their count in x-render-warnings.

Feature HTML/PDF DOCX PPTX CSV/XLSX
Marks, headings, quotes, lists Native Native Native text/bullets Omitted
Links Native Native Safe external and supported internal links Omitted
Legal numbering Native counters Native multilevel numbering Arabic per level, warning Omitted
Sanitized formatted source Native Native Native with degradation warnings Omitted

Rich-text marks also accept strike, script: "super"|"sub", guarded highlight, fontSize and fontFamily. Paragraphs/headings accept align, indent, spaceBefore and spaceAfter. Lists nest to four levels, with numbering and start options. Old shorter HTML lists retain historical defaults; specify numbering for cross-format agreement.

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 (inline typography, headings/quotes, safe links, four-level lists and formatted sources)
  • Data: Table (data-bound with headers/footers/formatting), Conditional, Repeater, TemplateRef
  • Media: Uploaded /assets/<filename> images, base64 image data URIs, and allowed HTTP(S) images are resolved by the API and embedded. PNG, JPEG, GIF and BMP are supported; inline SVG includes a PNG fallback. WebP and unknown formats produce placeholders and warnings.
  • Navigation: Table of Contents (cached heading titles are visible immediately; desktop Word can update page numbers when fields are refreshed). Documents without a TOC do not request field updates.

Page setup from RenderConfig:

  • paperSize (all presets or custom physical dimensions), orientation, margin (including gutter and mirrored margins)
  • pageHeader / pageFooter — Puppeteer HTML is parsed for text content and pageNumber/totalPages field markers; complex HTML falls back to plain-text approximation

Format fidelity: See the generated format support matrix. Format-aware validation returns warning details; binary responses include X-Render-Warnings.

Error responses — 422 application/json:

Shape When
{ "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

DOCX and DOCX preview responses include x-render-warnings: count=N. Both SDKs expose this count (warningCount in TypeScript, warning_count in Python); the editor shows a download notice when it is nonzero. Positioned content, sticky headers and table sparklines can emit degradation warnings.

Stored legacy .svg assets are blocked with HTTP 422 asset_blocked in production DOCX and PPTX. Interactive previews keep a placeholder and warning. Binary reads verify tenant ownership. Remote images use DNS-pinned SSRF checks, a 10-second timeout and a 10 MiB per-image limit; redirects are rejected. BLOCK_REMOTE_RESOURCES=true denies remote origins unless listed in ALLOWED_REMOTE_ORIGINS, matching PDF policy. This setting is recommended for deployments that do not need remote images.

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, native tables and supported editable charts, images, containers, columns, conditionals, repeaters, templateRef, toc, and barcodes.

Format fidelity: See the generated format support matrix. PowerPoint pivot tables are native.

PPTX is stable. renderConfig.officeCharts defaults to "native"; request options.officeCharts overrides it. Set "image" to retain the previous PNG output. Native types are bar, horizontalBar, groupedBar, stackedBar, line, area, stackedArea, pie, donut, scatter and combo (including a secondary axis). Each embeds editable workbook data. Gauge, funnel, waterfall, treemap, heatmap and the other extended types use images. Charts above 500 points, reference lines and styling that cannot be preserved natively also use images, with PPTX_NATIVE_CHART_FALLBACK. Native Office styling can differ from the PDF; data and colours are preserved.

Tables and pivots continue across slides, repeat header bands by default, keep the footer with the last data row and support paginationHints.breakInside: "avoid" when the table fits an empty slide. Captions gain “ (continued)“; repeatHeader: false disables repeated table headers. Continuation slides participate in pageNumber/totalPages. Oversized rows and tables inside columns retain overflow warnings. Sparkline columns show their last value and direction as text.

The TOC lists resolved headings up to maxDepth, with optional numbering; TOC slide links and page numbers remain deferred. Safe rich-text hyperlinks are native. Font families are referenced by name; the viewing machine must provide the fonts. DOCX charts retain SVG with explicit dimensions and a PNG fallback; DOCX ignores officeCharts and does not contain editable chart workbooks.

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:

Field Type Required Description
items array Yes 1 to BATCH_MAX_ITEMS render requests (default max: 50)
items[].template string Yes Template key
items[].version string No Exact version string; omit to render latest
items[].data object Yes Data payload for the template
items[].options object No paperSize 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:

Field Present Description
index Always Position in the original items array (preserved ordering)
template Always Template key from the request
success Always true if PDF was generated
pdf On success Base64-encoded PDF bytes (decode to get %PDF-… binary)
error On failure Error type
code On failure (classified) Machine-readable error code (validation_failed, template_expression_error, asset_blocked, etc.)
message On failure Human-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):

Status When
400 Bad Request Empty items array, or exceeds BATCH_MAX_ITEMS
401 Unauthorized Missing or unrecognised API key
403 Forbidden Key scope does not include render
429 Too Many Requests Rate 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 .

3e1. Render jobs

POST /render/jobs accepts all six formats: PDF, HTML, CSV, XLSX, DOCX and enabled PPTX. Use either template, data, optional version or label, bucketKey, tableId and options at top level, or an items array. Mixing the two shapes returns 400. Requires render/admin scope.

{"format":"pdf","template":"invoice","label":"prod","data":{"invoiceNumber":"INV-001"},"reference":"INV-001","metadata":{"customer":"acme"}}

Submission returns 202 with {jobId,kind,format,status,createdAt,pollUrl,reference?}, Location and Retry-After (default 2 seconds). A webhook is optional: omit it for polling behind NAT. When present, use webhook: {url,secret} with a secret of at least 16 characters. URLs pass the SSRF guard.

Route Behaviour
GET /render/jobs/:id Status, timestamps, progress, reference/metadata, webhook delivery counters and per-item outcomes
GET /render/jobs Tenant-scoped summaries; filter status, since, until, reference, limit, offset
GET /render/jobs/:id/items/:index/content Completed item bytes as an attachment; 409 for unfinished items, 404 after expiry
POST /render/jobs/:id/cancel Pending: immediate 200 cancelled. Processing: 202 cancellation requested. Terminal: 409 already_terminal
DELETE /render/jobs/:id Purge terminal metadata and blobs; active jobs return 409 job_not_completed

States are pending, processing, completed, failed, cancelled. Progress reports total/completed/failed counts. In-flight items finish during cancellation and remain downloadable; queued items become cancelled. Poll responses contain content URLs without base64. ?include=content adds base64 up to JOB_INLINE_CONTENT_MAX_BYTES (default 10 MiB); larger responses return 413. Non-terminal polls send Retry-After. The API never returns tenant identifiers or webhook credentials.

output: {store:true} also archives successful items and adds documentId/downloadUrl, using the document archive described above. A missing archive returns 503 before submission. Short-lived item downloads work in file mode too.

Completed jobs and partial results survive restart on PostgreSQL and SQL Server with filesystem or S3 item storage. File mode is process-local. Interrupted execution retains the existing startup orphan policy: old active rows fail with job_abandoned_at_startup; inputs are not persisted for automatic resumption. Jobs expire after WEBHOOK_JOB_RETENTION_SECONDS (default one hour, maximum 24 hours), plus the sweep interval. Archive outputs for longer retention. Shared filesystem volumes or S3 are required across replicas. Admission limits remain per instance; async work still uses the render worker timeout.

Optional webhooks fire on completed, failed and cancelled jobs. Payloads contain kind, the real terminal status, reference/metadata and item summaries. Their pollUrl uses /render/jobs/:id. Exhausted deliveries enter the configured batch-webhook DLQ; poll-only jobs have no webhook to replay.

Deprecated route Migration
POST /render/batch/async Submit format:"pdf" with items to /render/jobs
POST /render/batch/async/docx Submit format:"docx" with items to /render/jobs
GET /render/batch/jobs/:id New submissions use /render/jobs/:id

Legacy submissions still require webhookUrl/webhookSecret and return the original legacy pollUrl. Legacy polling keeps its envelope and base64 pdf/docx fields. Deprecation and successor headers guide migration.

Webhook signatures

Sign the exact raw request bytes: HMAC-SHA256(secret, timestamp + "." + rawBody). Read X-PulpEngine-Timestamp (Unix seconds) and X-PulpEngine-Signature: sha256=<hex>. Verify before JSON parsing, with a timestamp tolerance and constant-time comparison. All six official SDKs accept multiple comma-separated signatures and expose verification helpers. Re-serializing parsed JSON changes the signing input.

Idempotent submissions

Send Idempotency-Key (1-255 printable ASCII characters) for job submissions, legacy async submissions, template creation/generation/version restore, schedule creation/trigger and asset upload. Single synchronous renders honour it only for output.store=true plus output.respondWith="link". Ordinary byte renders ignore the header.

The scope includes resolved tenant, credential identity, HTTP method, concrete path and key. A canonical JSON fingerprint includes body and query; reordered object keys are equivalent. Multipart fingerprints include filename, MIME type and buffered bytes. Same key and fingerprint replay the original JSON status/body and selected headers, with Idempotent-Replayed: true. Different input returns 422 idempotency_key_reused; concurrent work returns 409 idempotency_key_in_use with Retry-After: 1. Invalid keys return 400 idempotency_key_invalid.

The default retention is 24 hours. Responses above the configured replay limit retain a completed marker and return 409 idempotency_replay_unavailable on retry. Streams, 5xx, 408 and 429 release the lease. Other 4xx outcomes are retained. Authentication, validation and rate limiting still run on each request. Select shared Redis or database storage for multiple replicas; memory state disappears on restart. An expired lock may be acquired again, so size IDEMPOTENCY_LOCK_TTL_SECONDS above your longest accepted request. Lease fencing protects newer records from delayed completions; it cannot undo a side effect already executed after a lock expires.

SDK retries reuse one generated key across attempts of a call, honour Retry-After and stop retrying once streaming starts. Retrying pure synchronous renders can count usage twice. Unsafe POSTs such as DLQ replay and user reload are never automatically retried.


3e. PDF Transform utilities

For template-time marks, letterhead and cover/appendix pages, see Watermarks & stationery.

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 variable Default Description
PDF_UTIL_MAX_INPUT_SIZE_MB 50 Max size of a single PDF input
PDF_UTIL_MAX_TOTAL_SIZE_MB 200 Max total size across all inputs in one request
PDF_UTIL_MAX_MERGE_SOURCES 50 Max number of PDFs in a merge request
PDF_UTIL_MAX_OUTPUT_PAGES 10000 Max 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"
  }
}
Field Type Required Description
sources array Yes 2 to PDF_UTIL_MAX_MERGE_SOURCES objects, each with a data field containing a base64-encoded PDF
metadata.title string No PDF title metadata
metadata.author string No PDF author metadata
metadata.subject string No PDF 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:

Field Type Required Description
type "text" Yes
content string Yes Watermark text (max 500 chars)
fontSize number No Font size in points (6–200, default 48)
color {r, g, b} No RGB color, 0–1 range (default gray 0.6/0.6/0.6)
opacity number No 0–1 (default 0.3)
rotation number No Degrees, -360 to 360 (default 0, or -45 for diagonal)
position string or {x, y} No "center", "diagonal", "header", "footer", or {x, y} coordinates

Image watermark fields:

Field Type Required Description
type "image" Yes
data string Yes Base64-encoded PNG or JPEG (max 10 MB)
width number No Display width in points
height number No Display height in points
opacity number No 0–1 (default 0.3)
position string or {x, y} No Same 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 }
}
Field Type Required Description
target string Yes Base64-encoded target PDF
insert string Yes Base64-encoded PDF to insert
position.at string Yes "start", "end", "before", or "after"
position.page integer When 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)

Status Code When
400 — Invalid base64 encoding
401 — Missing or unrecognised API key
403 — Key scope does not include render
422 invalid_pdf Corrupt or non-PDF input
422 pdf_too_large Input exceeds size limit
422 too_many_pages Output exceeds page count limit
422 invalid_page_range Page range out of bounds
422 unsupported_image Watermark image is not PNG or JPEG
429 — Rate limit exceeded

3f. Attachments and e-invoices (Wave 67)

Production PDFs can embed up to ten files, including invoice XML. Saved templates keep references, never file bytes. Request attachments append to saved attachments and replace an entry with the same filename. Data paths use adapted data after mappings and calculations; $ includes the complete raw request payload. Missing required fields return 422 attachment_source_missing; optional: true skips them. Executable filenames, duplicate request filenames and oversized files are rejected.

curl "$API/render/pdf" -H "X-API-Key: $KEY" -H 'Content-Type: application/json' -d '{
  "template":"invoice", "data":{"invoice":{"number":"INV-1"}},
  "options":{"pdf":{"attachments":[
    {"filename":"source.json","source":{"dataPath":"$","as":"json"}},
    {"filename":"lines.csv","source":{"render":"csv","tableId":"line-items"}}
  ]}}
}' --output invoice.pdf

A template can set renderConfig.eInvoice to { "standard":"factur-x", "profile":"EN 16931", "source":{"xmlPath":"invoiceXml"} }. Omit profile to detect it from the CII guideline ID. Supported profiles are MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED and XRECHNUNG. MINIMUM and BASIC WL omit line items and are insufficient for a complete EN 16931 invoice. The engine forces PDF/A-3b, embeds factur-x.xml (xrechnung.xml for XRECHNUNG), and preserves archival metadata. Legacy ZUGFeRD 1.0/2.0 XML is not supported.

# A request can supply base64 CII XML instead of the configured data path.
curl "$API/render/pdf" -H "X-API-Key: $KEY" -H 'Content-Type: application/json'   --data @hybrid-request.json --output hybrid.pdf
# hybrid-request.json: {"template":"invoice","data":{},"options":{"pdf":{"eInvoice":{"xml":"BASE64_CII_XML"}}}}

# Utilities accept an existing base64 PDF. Factur-X input must already be PDF/A-3b.
curl "$API/render/pdf/attach" -H "X-API-Key: $KEY" -H 'Content-Type: application/json'   -d '{"pdf":"BASE64_PDF","attachments":[{"filename":"note.txt","data":"SGVsbG8="}]}' --output attached.pdf
curl "$API/render/pdf/facturx" -H "X-API-Key: $KEY" -H 'Content-Type: application/json'   -d '{"pdf":"BASE64_PDFA3","xml":"BASE64_CII_XML"}' --output hybrid.pdf

For generated XML, use source: { "modelPath":"einvoice" }. The private @pulp-engine/einvoice package exports EInvoice, EINVOICE_JSON_SCHEMA, calculateInvoice, buildCii and buildUbl. The Electronic Invoice starter pack wraps that schema at einvoice; its visual invoice and machine output use the same model. Its displayed totals are required and checked against calculated totals. See the mixed-VAT model.

Amounts accept decimal strings or finite JSON numbers; prefer strings to avoid precision lost before submission. Calculations use decimal arithmetic with half-up rounding to two places. Optional caller totals must match calculated line sums, document allowances/charges, VAT breakdowns and payable amount; failures include details: [{rule,path,message}], such as BR-CO-15. This first model supports document-level allowances/charges; line-level adjustments and preceding-credit references are outside its scope. External validation remains authoritative for code lists and complete profile rules.

curl "$API/render/einvoice" -H "X-API-Key: $KEY" -H 'Content-Type: application/json'   -d '{"template":"electronic-invoice","label":"prod","data":{"einvoice":{...}},"syntax":"ubl","profile":"XRECHNUNG","validate":true}'   --output invoice.xml
pulp-engine render --template electronic-invoice --data invoice-data.json --format xml   --syntax ubl --profile XRECHNUNG --validate-invoice --output invoice.xml

POST /render/einvoice supports version, label and bucketKey resolution. CII supports the six Factur-X profiles; UBL supports EN 16931, XRECHNUNG and PEPPOL-BIS-3. UBL generation requires a model source. validate: true uses the operator’s EINVOICE_VALIDATOR_URL; an unavailable or unconfigured validator returns 503, and a rejected invoice returns 422 with up to twenty rule messages. Local model checks always run; embedding arbitrary CII checks XML structure and profile identity, without certifying all business rules. The editor’s Check with sample data checks the current unsaved template and uses external validation when advertised by the server.

Attachments and hybrids apply to single, legacy, synchronous batch, asynchronous jobs and scheduled PDFs. Use JSON or ZIP batch packaging to retain each invoice. Merged PDF packaging and book/source assembly reject embedded attachments because page copying would discard them; attach files to the finished assembly with a utility route. options.pdf.eInvoice: false suppresses an invoice, and embedInPdf: false retains XML-only configuration. PDF preview omits attachments and hybrids. Other formats ignore saved PDF/e-invoice defaults and reject explicit options.pdf with 400 unsupported_render_option.

Finishing runs after mutating plugin hooks: attachments/hybrid, archival checks, optimisation or encryption, then signing. Generic attachments can be encrypted; hybrid invoices cannot. PDF/A-2b rejects attachments. stream=true with attachments or a hybrid returns 400 stream_incompatible_option. Success headers include X-PDF-Attachments, X-EInvoice-Profile, and X-PDF-Conformance: PDF/A-3b; all are exposed to browsers. /capabilities advertises pdf.attachments and eInvoice.{profiles,syntaxes,validator}. Utility routes use the operator’s PDF-util byte limits; production bodies use RENDER_BODY_LIMIT_BYTES.

TypeScript: client.pdfTransform.attach(...), .facturX(...), and client.render.eInvoice(...) (XML text). Python: client.pdf_transform.attach(...), .factur_x(...), and client.render.e_invoice(...) (BinaryResult). Both preserve rule details on errors.

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 { valid, issues } on the legacy path, or { valid, issues, skippedFormats } when the request opts into format-aware checks via formats[] — never HTML or PDF output.

Scope of “renderability”: behaviour depends on whether the request body includes a formats array.

  • Without formats (legacy default): the renderability step exercises the HTML path only. Format-specific failures that surface during DOCX, PPTX, CSV, XLSX, or PDF rendering are not caught. Use as a structural + HTML preflight; for format-specific assurance call the corresponding render route directly. Response shape: { valid, issues } (byte-for-byte stable since pre-v0.78.0).
  • With formats (v0.78.0+): each requested format is exercised through its real renderer. Issues are tagged with the originating format for format-specific failures (e.g. no_rendered_tables, ambiguous_table, PPTX_PIVOT_UNSUPPORTED); structural failures are emitted once with no format tag (they apply to every format). Capability-disabled formats appear in skippedFormats rather than as issues — a format in that array was not validated, not validated successfully. PDF never boots Chromium: when requested, an HTML preflight runs and an info-severity PDF_VALIDATED_VIA_HTML issue is emitted to acknowledge transitive coverage. Response shape: { valid, issues, skippedFormats }.

Request:

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

{
  "template": { /* TemplateDefinition object */ },
  "data": { "key": "value" }
}
Field Type Required Description
template object Yes TemplateDefinition to validate
data object Yes Sample 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. The body is { valid, issues } on the legacy path or { valid, issues, skippedFormats } on the format-aware path. Check the valid field to determine success or failure — note that valid is “no error-severity issue was raised”, so info-severity entries (e.g. PDF_VALIDATED_VIA_HTML) keep valid:true; skippedFormats is independent of valid (a format in that list was simply not validated). 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[].code Step Description
INVALID_TEMPLATE 1 — structural Zod schema validation failure. path indicates the failing field.
validation_failed 2 — render check Data payload does not satisfy the template’s inputSchema.
template_expression_error 2 — render check Template 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.

Optional query parameter annotate=true adds preview-only block destinations and a hidden link index for editor navigation. It defaults to false. The editor reads page counts and positions from PDF.js, including streamed responses; it does not require an X-Render-Pages header. The same flag is available on POST /render/preview/html for click-to-select. It is absent from production /render* routes and ignored by the PDF-only sandbox. Preview links are capped at 5,000 occurrences; a pe-anchors-truncated destination signals the limit.

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": "Expected a supported paper preset or physical custom dimensions" }
  ]
}

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.

Environment PREVIEW_ROUTES_ENABLED Preview routes
development or test any value Always registered
production absent or false Not registered — 404
production true Registered — 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:

Status Meaning
428 Precondition Required If-Match header is absent
400 Bad Request If-Match is malformed (*, W/"...", multi-value, unquoted)
412 Precondition Failed Version 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, currentVersion, description. Supports ?limit= and ?offset= query parameters (see Pagination).

{
  "items": [
    { "key": "loan-approval-letter", "name": "Loan Approval Letter", "currentVersion": "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 stored sample data, a valid default dataset, or a generated payload derived from the template’s inputSchema. Useful as a starting point when integrating a new template.

Shared sample datasets

Datasets belong to a template and survive new versions. Use synthetic data, not production PII: every authorized teammate can read these samples. Admin and editor credentials can list, read, write and delete them; render-only credentials cannot.

Method Path Result
GET /templates/:key/datasets?limit=25&offset=0&tag=smoke Paginated metadata summaries with current-schema validation.valid and issueCount; no payload data
GET /templates/:key/datasets/:name Full dataset, validation.issues, and revision ETag
PUT /templates/:key/datasets/:name Upsert {data, title?, description?, tags?, expectInvalid?, isDefault?, assertions?}; 201 on create, 200 on update
DELETE /templates/:key/datasets/:name Idempotent 204

Names match ^[a-z0-9][a-z0-9_-]{0,63}$. PUT accepts optional If-Match: "<revision>"; "0" creates only. A stale revision returns 412 without changing data. One dataset can be default; selecting another clears the earlier default and increments its revision. Invalid data is stored with validation warnings so teams can retain edge cases while schemas evolve. The default cap is 25 (DATASETS_MAX_PER_TEMPLATE); an extra create returns 409 dataset_limit_exceeded. Oversize bodies return 413 using PREVIEW_BODY_LIMIT. Unsupported plugin stores return 501.

GET /templates/:key/sample chooses a valid stored version sample, then a valid default dataset, then synthesized data. X-Sample-Source is stored, dataset, or synthesised. The versioned sample PUT/DELETE contract is unchanged.

const saved = await client.templates.datasets.put(
  'invoice',
  'empty-order',
  {
    data: { items: [] },
    tags: ['edge'],
    isDefault: false,
    assertions: {
      pageCount: { max: 2 },
      contains: ['Invoice'],
      noUnresolvedBindings: true,
    },
  },
  0,
)
await client.templates.datasets.get('invoice', 'empty-order')

Python exposes templates.list_datasets, get_dataset, put_dataset and delete_dataset; pass expected_revision= for conditional writes. CLI pull --datasets writes datasets/<key>/<name>.json as {meta,data}. push --datasets uses the saved revision and records the returned revision locally; --force permits overwrites. Generate deterministic local samples with pulp-engine datasets generate invoice --mode large --rows 500 --name large-500 [--push]. Modes: typical, minimal, empty, max, large, unicode.

Run dataset checks

POST /templates/:key/tests/run accepts {version? | label? | template?, datasets?: string[]}. Inline template is the editor’s unsaved definition; it must match the route key and cannot be combined with a version or label. The server validates it and returns no definition, dataset data, document bytes or tenant identifier.

Checks are stored in a dataset’s nullable assertions: pageCount (number or {min,max}), contains, notContains, maxBytes, noUnresolvedBindings and tableRows ([{tableId,count?,min?,max?}]). Server text checks use literal HTML text; CLI fixtures additionally support regex and PDF text. An invalid schema sample or expectInvalid dataset is skipped. Other dry-run failures block that case. All content checks are reported, with one render per needed format. PDF checks use the same tenant asset isolation and resource policy as PDF rendering.

The response is {testRunId, results:[{dataset,status,durationMs,checks:[{kind,status,message?}]}], passed,failed,skipped,truncated}. Runs use render rate limits and at most TEMPLATE_TESTS_MAX_DATASETS datasets (default 10), with concurrency 2. They record template_test_run audit events. /capabilities.templateTests reports backend support. TS uses templates.runTests; Python uses templates.run_tests. The server does not enforce test success when promoting labels; X-PulpEngine-Test-Run-Id is an optional breadcrumb for CI.

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
}
Field Description
key Template key (echo of :key).
version Resolved version. Reflects ?version= / ?label= if supplied, otherwise the latest.
title Template name, if set. Used as the form heading.
description Template description, if set. Rendered as intro copy above the form.
inputSchema The template’s inputSchema — the post-mapping data shape referenced by {{…}} in the template body. Rendered as the form when hasFieldMappings is false.
hasFieldMappings true 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).

Response Condition
200 OK Metadata returned.
400 Bad Request Both version and label supplied (invalid_template_ref).
404 Not Found Template key or resolved version does not exist.
401 Unauthorized / 403 Forbidden Standard auth failures.

Create a template

POST /templates
Content-Type: application/json

{ /* TemplateDefinition object */ }

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

Response Condition
201 Created Template created successfully.
400 Bad Request Body fails TemplateDefinition schema validation. See Invalid Template error shape in Section 4.
409 Conflict A 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.

Response Condition
200 OK Valid 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 Request prompt is outside 10–4000 characters, or body contains unknown fields.
401 Unauthorized No credential supplied.
403 Forbidden Credential scope is render-only. Requires admin or editor.
429 Too Many Requests Per-actor rate limit exceeded (default 5/min — configurable via RATE_LIMIT_AI_GENERATION_MAX).
502 Bad Gateway Upstream 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 Unavailable ANTHROPIC_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 type Input (regular) Input (cache write/read) Output Total
First call (cold cache) 467 tokens × $15/M 10,209 tokens × $18.75/M 2,495 tokens × $75/M ~$0.39
Repeat call (within 5 min) 467 tokens × $15/M 10,209 tokens × $1.50/M 2,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 with the TypeScript SDK as well:

import { PulpEngineClient } from '@pulp-engine/sdk'

const client = new PulpEngineClient({
  baseUrl: 'http://localhost:3000',
  apiKey: process.env.API_KEY_EDITOR!,
})

// generate() returns an unsaved draft; create() persists it.
const draft = await client.templates.generate({
  prompt: 'a one-page invoice with line items and a total',
})
await client.templates.create(draft.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.

Response Condition
200 OK Template updated. Response includes ETag: "1.0.4" and body contains currentVersion.
400 Bad Request If-Match is malformed; or body fails schema validation (Invalid Template); or body.key ≠ route :key (Key Mismatch). See Section 4.
404 Not Found Template does not exist.
412 Precondition Failed Template was modified since you loaded it — reload and retry.
428 Precondition Required If-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.

Response Condition
204 No Content Template deleted successfully.
400 Bad Request If-Match is malformed.
404 Not Found Template does not exist.
412 Precondition Failed Template was modified since you loaded it — reload and retry.
428 Precondition Required If-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"
}

Each side accepts exactly one of beforeVersion / beforeLabel and afterVersion / afterLabel. For example, { "beforeLabel": "prod", "afterVersion": "1.0.4" } compares a release with a saved revision. Both or neither on a side returns 400 bad_request; unknown labels or versions return 404.

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):

Field Shape Meaning
templateKey string Echo of the :key param.
metadataChanges PropertyChange[] key / version / name / description diffs.
renderConfigChanges PropertyChange[] Deep diff of renderConfig (paper, margins, fonts, etc.).
inputSchemaChanges PropertyChange[] Deep diff of the input JSON Schema.
fieldMappingChanges PropertyChange[] Index-based diff of fieldMappings (targetPath is not unique).
documentRootChanges PropertyChange[] Property changes on the root DocumentNode itself (style, pagination hints, meta).
documentChanges NodeChange[] Structural changes to nodes within the document tree.
diagnostics DiffDiagnostic[] Non-fatal warnings (e.g. duplicate node ids).
summary DiffSummary Aggregate 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.

Response Condition
200 OK Diff computed. Body is the TemplateDiff envelope above.
404 Not Found Either 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.

Response Condition
200 OK Restore successful. Response includes ETag: "1.0.4" and body contains currentVersion.
400 Bad Request If-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 Found Template or version does not exist.
412 Precondition Failed Template was modified since you loaded it — reload and retry.
428 Precondition Required If-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/pdf (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 > explicit label > configured RENDER_DEFAULT_LABEL > template’s currentVersion (HEAD). The reserved pseudo-label @latest bypasses the default label. Production schedules and unversioned nested refs use the same default policy; inline preview and validation use HEAD.

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.

Master layouts

Save a template with kind: "layout" to share letterheads, footers and document settings. A layout has named slot nodes; each child section targets a slot with slot: "body" (the default when omitted). A slot that is its section’s only child accepts entire child sections. Inline slots inside containers, columns or positioned blocks wrap each assigned section in a container. Slots can be required and restrict allowedNodeTypes.

A child uses layout: {"templateKey":"letterhead","label":"prod"}. Omit the pin for live latest, or use an exact version. Label and version are mutually exclusive. Layouts resolve within the request tenant; variants do not apply. options.layout selects a different layout for a render. Production children should pin the prod label and promote it after reviewing dependent validation. X-PulpEngine-Resolved-Layout: letterhead@1.0.0 identifies the resolved revision.

Layout configuration supplies defaults; child values override them. Locale merges per key, font imports form a union, CSS is concatenated with child CSS last, and margins replace as a complete object. layoutPolicy.lockedRenderConfig ignores child overrides for the listed settings and emits layout_override_ignored in validation. Lock customCss too when child CSS must not restyle inherited content. Input schema object properties merge with child properties taking precedence and required fields forming a union; other schemas combine with allOf. Layout field mappings run before child mappings.

  • GET /templates?kind=layout lists layouts.
  • GET /templates/:key/effective?version=... (or label=...) returns the merged child definition.
  • GET /templates/:key/dependents includes references[].via: "layout" in the shared dependency contract.
  • POST /templates/:key/dependents/validate accepts {definition?,limit?} and validates up to 50 dependent revisions with their stored sample data. A supplied definition substitutes a draft layout even for pinned children, so reviewers can assess a future promotion. truncated identifies incomplete results.

Deleting a referenced layout or changing its kind returns 409 layout_in_use. Layout chains and slots inside repeaters or conditionals are rejected. A component reference still renders its content without applying the referenced template’s own layout.

Promotion review and protected labels

Operators protect labels with PROMOTION_POLICY_JSON. Direct PUT, DELETE and schedule writes to a protected label return 409 approval_required, including requests made with an admin API key. Automatic default-label seeding skips protected labels. Bundle imports that would create or repoint a protected label return the same conflict before importing anything; dry runs remain available. Remap those labels to an unprotected staging label, then request promotion. Create a promotion request instead:

POST /templates/invoice/promotion-requests
Content-Type: application/json

{"label":"prod","version":"1.0.5","note":"Reviewed customer wording","testRunId":"ci-187"}

Requests record the current label version as their optimistic guard. List them with GET /templates/:key/promotion-requests?status=pending&label=prod&limit=50&offset=0, or use GET /promotion-requests?status=pending for the tenant inbox. The inbox respects readable template/folder bindings. Lists return {items,total,limit,offset}; limit is at most 200.

POST /templates/:key/promotion-requests/:id/approve accepts {comment?}; /reject requires {comment}. Decisions require named-user authentication and labels:approve. API keys and shared-key identities cannot approve. Each user counts once towards the configured quorum; self-approval is refused when the policy requires another person. Approval atomically records the decision, moves the label and writes its history and audit. A changed starting pointer makes the request stale and returns 409 promotion_request_stale. /cancel accepts {} and is available to the requester or an admin. Rejected, cancelled, applied and stale requests cannot be decided again.

requireTestRun requires a nonempty test-run ID; it does not authenticate a CI report. When allowBreakGlass is enabled for that label, a super-admin can send X-PulpEngine-Break-Glass: <reason> on a direct label mutation. The reason is audited. Tenant-bound admins cannot bypass policy.

Scheduled promotion, history and exact cut-over

POST /templates/:key/labels/:label/schedule accepts {version, effectiveAt?, effectiveLocal?, timeZone?, note?}. version:null schedules removal. Use an RFC 3339 effectiveAt with an offset, or an effectiveLocal plus IANA timeZone, exclusively. For example:

{"version":"1.0.5","effectiveLocal":"2027-01-01T00:00","timeZone":"Pacific/Auckland","note":"January release"}

The server rejects daylight-saving gaps and ambiguous local times (use an explicit offset for an ambiguous time), times less than 30 seconds ahead, dates over five years ahead, and A/B variant labels. An optional If-Match: "1.0.4" captures the expected pointer; without it, scheduling captures the current pointer. A later intervening move makes application fail rather than overwrite that move. Protected labels use an approved promotion request carrying effectiveAt, or the same local-time pair. Approval accepts the schedule; the event records actual application separately.

DELETE /templates/:key/labels/:label/schedule/:eventId cancels a pending event. Schedule and cancel require admin scope plus label promotion permission. GET /templates/:key/labels/:label/history?from&to&status&limit&offset returns immediate moves, removals, seeded history and scheduled events. Dates are normalized to UTC; cancelled and failed events do not participate in historical resolution. Initial migration seeds each existing label at its recorded update time; earlier history is unavailable.

The applier runs in all three storage modes. The live pointer can lag by up to LABEL_SCHEDULE_POLL_SECONDS (15 seconds by default). For exact cut-over, pass {template:"invoice",label:"prod",asOf:"2027-01-01T00:00:00Z",data:{}} to any of the six render formats, jobs or a batch item. GET /templates/:key/labels/:label?asOf=... performs the same lookup. Due scheduled events count before the poller applies them; asOf excludes A/B bucketing. Responses echo X-PulpEngine-Resolved-As-Of. asOf requires an explicit label and cannot accompany a version or inline definition. A date before available history returns 422 as_of_before_history. Scheduled report runs resolve at their original fire time, including late catch-up runs; their report clock remains independent.

The existing contract enforcement policy applies to immediate, approved and scheduled pointer writes. Applying leases expire after five minutes. Pointer, event and audit writes share a database transaction; file mode uses a recoverable journal and cross-process lock. History is retained independently of audit retention.

Template review comments

GET /templates/:key/comments?version&nodeId&limit&offset and POST /templates/:key/comments read or create comments. Creation takes {version,body,nodeId?,parentId?,mentions?}; body is limited to 4,000 characters and mentions are user IDs. Replies target a live root thread on the same saved version and inherit its node ID. PATCH /templates/:key/comments/:id accepts {body?,resolved?,mentions?}; only the author can edit the body or mentions. Resolve/reopen targets the root thread. DELETE soft-deletes the author’s comment (an admin can also delete). These operations require template write permission; reading requires template read permission. Comments do not change the AST or create template versions. Promotion and mention notifications use the domain event bus as promotion.requested, promotion.decided and comment.mentioned.

TypeScript clients expose promotionRequests, comments, and labels.schedule/cancelSchedule/history; Python sync and async clients expose promotion_requests, comments, and labels.schedule/cancel_schedule/history. Render requests accept asOf (as_of in Python); batch item dictionaries use asOf.

pulp-engine promote --template invoice --label prod --version 1.0.5 --request --note "Please review"
pulp-engine review list --status pending
pulp-engine review approve --template invoice --id REQUEST_ID --comment "Checked" --token-cmd "trusted-token-command"
pulp-engine promote --template invoice --label staging --version 1.0.5 --at-local 2027-01-01T00:00 --tz Pacific/Auckland
pulp-engine labels history --template invoice --label prod
pulp-engine promote --template invoice --label staging --cancel EVENT_ID

renderConfig trust model

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

Field Trust level Notes
paperSize Any caller Enum-validated; no injection surface
orientation Any caller Enum-validated; no injection surface
margin Any caller Passed to Puppeteer margin option
fontImports Template 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:)
customCss Template 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)
locale Any caller BCP 47 tag; no injection surface
pageHeader Template 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
pageFooter Template 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 image types: PNG, JPEG, GIF, WebP and sanitized SVG; fonts use TTF, OTF, WOFF and WOFF2. Maximum upload size is 10 MB, with a separate 2 MiB SVG input limit. Newly sanitized SVG is allowed; existing unsanitized SVG remains quarantined.

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):

Parameter Type Description
q string Case-insensitive substring filter on originalName
mimeType string Exact MIME type filter (case-insensitive), e.g. image/png
legacySvg boolean When true, returns assets matching either legacy SVG detection signal: declared mimeType of image/svg+xml or filename ending with .svg, and svgSanitized is false. 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).

Error Meaning
400 Bad Request No file field provided
413 Payload Too Large File exceeds 10 MB
415 Unsupported Media Type File 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, 404 when absent, or 409 asset_in_use with referencedBy when current or label-pinned templates use it. ?force=true explicitly overrides the guard and records the referencing keys in the audit trail. Version binaries are deleted too.


Asset library operations

All management endpoints require editor/admin credentials, including in public binary-delivery mode. Responses explicitly omit internal tenant identifiers.

Endpoint Behavior
GET /assets/{id}/details Metadata, referencedBy, dynamic-reference template warnings and scan completeness
PATCH /assets/{id} Display name (originalName), stable slug, default altText, tags and folder
PUT /assets/{id}/content Multipart replacement; same MIME type, immutable filename and URL; optional If-Match: "2"
GET /assets/{id}/versions Paginated retained historical metadata
POST /assets/{id}/versions/{version}/restore Restore old bytes with a new increasing version number
GET /assets/facets Folder and tag counts

The list adds repeatable tag (AND), folder, recursive, sort=createdAt|updatedAt|name|sizeBytes, order=asc|desc and include=usage. Upload accepts multipart fields folder, comma-separated tags and altText. Metadata limits: 20 lowercase tags of 40 characters, 500-character alt text, and eight folder segments of 64 characters. Slugs match ^[a-z0-9][a-z0-9._-]{0,99}$; uploads derive a name from the original filename and suffix collisions. Renaming a literally referenced slug returns 409 unless force=true.

Bind an image by name with {{asset 'acme-logo'}} or by a data-bound id with {{asset customer.logoAssetId}}. Resolution is tenant-scoped, in id, slug, filename order. fallbackSrc tries a second safe image when the primary is empty, unsafe or an unresolved library reference; hideWhenEmpty: true omits the image when neither works. objectPosition: '50% 0%' keeps the top edge with fit: 'cover'. HTML/PDF cannot detect a remote HTTPS 404 because Chromium fetches that URL directly.

Replacement increments version, contentHash, updatedAt and updatedBy; default retained history is ten versions (ASSET_VERSION_RETENTION, 0-100). MIME changes return 415 asset_mime_mismatch; stale If-Match returns 412. The private proxy sends a SHA-256 ETag and Cache-Control: private, no-cache and supports 304. Public filesystem/S3 delivery requires revalidation. PDF reads current bytes on every render. Existing URLs embedded in external systems may still depend on their caches honoring revalidation.

Where-used covers current and label-pinned versions, image sources and fallbacks, header/footer images, CSS asset URLs and literal name references. Data-bound references cannot be determined statically and are flagged. Scans cap at 10,000 active templates; incomplete scans require an explicit force for deletion. Browsing uses a 60-second tenant cache, invalidated by template and label mutations. Deletion always scans fresh. ASSET_DELETE_GUARD=false restores the older unguarded behavior.

SVG upload uses a closed XML element/attribute allow-list, removes active elements, events and external references, rejects DTD/entities, and verifies Resvg parsing before storing only serialized sanitized bytes. It caps input at ASSET_SVG_MAX_BYTES (default 2 MiB), 10,000 elements and 128 levels. ASSET_SVG_UPLOADS_ENABLED=false disables new SVG uploads. Inlining sanitizes again. Legacy unsanitized SVG remains blocked at proxy/static/inline/validate boundaries. SVG responses have nosniff and CSP default-src 'none'; style-src 'unsafe-inline'; sandbox. Public S3 bypasses API response headers; prefer private access for SVG or enforce equivalent bucket/CDN response headers. Convert SVG text to outlines when exact Office font appearance matters.

DOCX embeds SVG with a PNG fallback; PPTX uses rasterized SVG. Cover/none crops share a bounded rasterizer and per-render cache; contain retains aspect ratio. WebP still produces Office placeholders and warnings. CSV/XLSX omit image nodes. Mutation events include asset:updated, asset:replaced and the actual filename in asset:deleted; audits record update, replace, restore and forced deletion details.

7. Scheduled delivery

Cron-driven render jobs with delivery to email, S3, or webhook targets. Requires a database-backed storage mode (postgres or sqlserver). All routes require admin scope. Route availability vs execution are separate switches: the CRUD routes work whenever storage is database-backed; executions only fire when SCHEDULE_ENABLED=true starts the engine. With SCHEDULE_ENABLED=false, POST /schedules still returns 201 and the schedule simply never fires — check the engine flag first when debugging “my scheduled report never arrived”. Only file mode (no schedule store) returns 503 unavailable from these routes.

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 }
  ]
}
Field Notes
cronExpression Required. Must fire at ≥ 1-minute intervals. Validated via cron-parser.
timezone IANA name (default UTC).
templateVersion / templateLabel Mutually exclusive. Omit both to track currentVersion.
format pdf (default) or docx.
dataSource.type static (inline JSON in data) or url (fetched at fire time, 1–60 000 ms timeout).
deliveryTargets Minimum one. Mix of email / s3 / webhook.

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

Status Meaning
201 Created Schedule created.
400 invalid_cron, invalid_template_ref, invalid_template, or invalid_url.
503 unavailable — flag or storage gate.

List / get / update / delete

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

nextFireAt is recomputed whenever cron, timezone, enabled state or any business-calendar timing field 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).


Business calendars (Wave 78)

Native PostgreSQL and SQL Server schedules accept calendarId, holidayRule, businessDayOfMonth, skipDates, validFrom, validUntil and maxRuns. Schedule writes accept renderOptions: null to clear optional render overrides. runCount is read-only and counts admitted automatic executions, including executions that later fail. Manual triggers do not count. Timing edits preserve that counter. Importing or cloning schedules resets their counters and leaves them disabled until reviewed.

Calendar administration requires admin scope, as do schedule previews:

Method Route Result
GET / POST /schedules/calendars Paginated list / create (201)
GET / PUT / PATCH / DELETE /schedules/calendars/{id} Read / replace / edit / delete (204)
POST /schedules/calendars/{id}/import?mode=merge Raw text/calendar; merge (default) or replace holiday dates
POST /schedules/preview Draft timing fields, cron, timezone and optional count (default 10, maximum 50)
GET /schedules/{id}/preview?count=10 Preview a saved schedule

Calendar bodies contain name, optional description, weekendDays (0=Sunday through 6=Saturday, default [0,6]) and holidays, an array of at most 2,000 unique { "date": "YYYY-MM-DD", "name": "optional label" } entries. Dates are local to each schedule’s IANA timezone. Calendar edits and imports atomically recompute all dependent schedules. Deleting an in-use calendar returns 409; foreign calendars return 404 when read and 400 when referenced by a schedule. Calendar and schedule responses omit tenant identifiers.

For a month-end finance pack, use the normal schedule body plus:

{
  "cronExpression": "0 7 * * *",
  "timezone": "Pacific/Auckland",
  "calendarId": "YOUR_CALENDAR_ID",
  "businessDayOfMonth": -1,
  "holidayRule": "skip",
  "validFrom": "2027-01-01T00:00:00Z",
  "validUntil": "2027-12-31T23:59:59Z",
  "maxRuns": 12
}

Nth business days range from 1 to 23, or -1 to -23 from month end. Both cron day-of-month and day-of-week must be *; month filters still apply. With holidays on 1 and 4 January 2027, N=3 yields 7 January at 07:00 NZDT. skipDates adds schedule-specific blackout dates. Without an Nth rule, holidayRule is skip, next-business-day or previous-business-day; rolling keeps local wall-clock time across DST and deduplicates overlapping fires. Schedules without a calendar, Nth rule or rolling rule keep legacy all-days cron behavior. Nth and rolling rules without a calendar use Monday-Friday.

Previews return {fires,skipped,exhausted?}. Each fire has fireAt, nominalAt and adjustment, with a reason/name for rolled fires and a business-day index when applicable. Saved draft previews may include scheduleId to use that schedule’s durable counter and last fire; clients cannot override runCount. Dates in active windows are UTC instants, inclusive at either end. Every planned fire must be later than both the planning time and the last fire.

Planning is bounded to 5,000 cron candidates, three years ahead and a 14-day roll span. Hitting a planning cap returns no fires; sparse annual previews may need a smaller count. Create/update rejects an enabled definition with no future fire. Existing schedules that exhaust their window or automatic limit remain enabled but become dormant (nextFireAt: null) and produce a schedule / dormant audit event. Extend the window or increase maxRuns and save to resume them.

The iCalendar subset imports each all-day DTSTART;VALUE=DATE and SUMMARY. Timed and recurring events are ignored and counted; multi-day spans are not expanded. The 2 MiB upload limit and 2,000-date calendar limit also apply to imports. URL subscriptions and recurrence expansion are deferred. File mode returns 503 for calendar storage endpoints.

TypeScript: client.schedules.calendars.list/create/get/replace/update/delete/importIcal, client.schedules.preview(draft) and client.schedules.previewFires(id, count). Python uses the same calendar CRUD names, import_ical, preview and preview_fires on the synchronous schedules resource.


8. Admin routes

API keys

Database storage supports tenant-bound, self-service API keys at /admin/api-keys (admin scope). File/plugin storage returns 503 service_unavailable; use environment/file-configured credentials there.

Method Route Result
GET /admin/api-keys/ {items,total,limit,offset}; filters status, scope, mode, q, limit, offset.
POST /admin/api-keys/ Create {name,scope,mode?,description?,expiresAt?,restrictions?}; HTTP 201 {apiKey,secret}.
GET /admin/api-keys/:id Metadata only.
PATCH /admin/api-keys/:id Change name, description, expiry or restrictions. Scope and mode are immutable.
POST /admin/api-keys/:id/revoke Idempotent revocation.
POST /admin/api-keys/:id/rotate HTTP 201 {apiKey,secret,previous:{id,expiresAt}}; optional {overlapSeconds} (0-604800, default 86400).

Keys have the format pe_live_<12-character public ID>_<43-character secret> or pe_test_.... The database stores only SHA-256 of the complete key. Create and rotate reveal the secret once with Cache-Control: no-store; save it immediately in your secret manager. Responses show the prefix, status, owner, expiry, restrictions and last-used details, and omit the hash and tenant ID. Last-used updates are coalesced to one write per minute per key/process and are diagnostic, not a durable access log.

Rotation clones the current name, scope, mode and restrictions inside a transaction. The new key has no expiry; set an expiry after rotation if required. The old key expires at the earlier of its existing expiry and the requested overlap deadline. Deploy the new credential to callers, confirm it is in use, then revoke the old key. Zero overlap disables the old secret immediately. Management invalidates this replica’s cache; other replicas converge within API_KEYS_CACHE_TTL_MS (default 10 seconds). An in-flight request already authenticated may finish. Unknown IDs cache for 5 seconds. Static credentials are checked first and retain their existing behavior.

restrictions accepts up to 100 templateKeys, labels and cidrs. Template and label restrictions require render scope. Each batch item’s root template is checked before rendering begins. Label-restricted calls must explicitly name an allowed label; version-pinned and HEAD calls are refused. Inline definitions cannot satisfy a template-key allowlist. Referenced content inside an allowed root remains part of that template’s trusted definition. PDF transforms have no template and are unaffected by template allowlists. CIDRs match Fastify request.ip, including IPv4-mapped IPv6 addresses. Configure TRUST_PROXY to your trusted ingress when it forwards client addresses; never trust arbitrary proxy headers.

Test keys return X-PulpEngine-Key-Mode: test (CORS-exposed), classify production render usage as source=test, and always stamp PDF, HTML, DOCX, PPTX and XLSX, including licensed deployments. CSV stays data-only. Upgrade render workers before APIs so isolated workers understand the JSON-safe watermark: "test" option; mixed old workers may omit the mark. Preview usage remains preview. /usage accepts credentialId and groupBy=credential ((static) for legacy credentials), and /usage.csv appends credentialId. JWT callers use their bounded actor ID as the credential ID.

Archived tenants retain metadata reads, while key creation, updates, rotation and revocation return 409 tenant_archived. DB keys are tenant-bound, including admin keys; super-admin remains environment-only. They cannot sign editor sessions and are rejected by /auth/editor-token. Use named-user login or the interactive OIDC flow for editor sessions. Revoked and unknown keys return the same unauthorized classification and message; request IDs remain unique. Expired keys return api_key_expired. Administration emits api_key audit events with safe metadata, never the secret or hash. The key-management editor UI is a later audit wave.

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.

Route Method Notes
/admin/tenants POST Create. Body { id, name, metadata? }. id matches ^[a-z0-9][a-z0-9-]{0,62}$ and is immutable. 409 DUPLICATE_KEY on slug conflict.
/admin/tenants GET Paginated list (limit ≤ 1000, offset).
/admin/tenants/:id GET Returns full row including archivedAt.
/admin/tenants/:id PATCH Update name and/or metadata. id is not patchable.
/admin/tenants/:id/archive POST Soft-archive. 204. Idempotent. Subsequent writes to this tenant return 409 tenant_archived; reads continue to work for audit/export.
/admin/tenants/:id/unarchive POST Restore write access. 204. Idempotent.
/admin/tenants/:id?purge=true&confirm=<id> DELETE Permanently erase an archived tenant after its grace period; returns 202 with a durable operation. Super-admin only.

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 none of EDITOR_USERS_JSON, EDITOR_USERS_FILE, or EDITOR_USERS_DB=true (postgres/sqlserver, v0.81.0+) is set.

Runtime mutations (POST/PUT/DELETE) persist to the authoritative store: the DB-backed editor_users table in postgres/sqlserver mode (v0.81.0+, HA-safe and shared across instances), or EDITOR_USERS_FILE in file mode. Without any persistence store (no DB, no file, no EDITOR_USERS_JSON seed) 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.

Route Method Notes
/admin/users GET Returns { users: [...] }. key is redacted to keyHint (last 4 chars).
/admin/users POST Body { 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/:id PUT Partial patch. Setting tokenIssuedAfter to an ISO timestamp revokes all sessions issued before it; setting it to null clears the epoch.
/admin/users/:id DELETE { 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/reload POST Reloads the in-memory cache from the authoritative store — EDITOR_USERS_FILE in file mode, the editor_users table in postgres/sqlserver mode (v0.81.0+). OIDC-auto-provisioned users are preserved, not clobbered. In file mode, 404 if EDITOR_USERS_FILE is missing on disk. 409 if named-user mode is 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:

Prefix Purpose
/admin/schedule-dlq Failed 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-dlq Failed 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/pdf", 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/pdf', {
  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/pdf');
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/pdf", 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 key Adapted path Notes
applicantName customer.name Required
applicantAddress customer.address Optional; defaults to ""
loanAmount loan.amount Required; minimum 1000
interestRate loan.rate Required
termMonths loan.termMonths Required; integer
settlementDate loan.settlementDate Optional; ISO date string
requiresGuarantor loan.requiresGuarantor Optional; defaults to false
guarantorName loan.guarantorName Optional
items loan.items Required; array of { description, amount }
branchName bank.branch Optional; defaults to "Head Office"
officerName bank.officer Optional; 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

Locale and time zone render options

Every production and preview render format, batch item and async batch item accepts options.locale (a BCP 47 string or locale settings object) and options.timeZone (an IANA zone). CSV and XLSX accept the same options. Dry runs exercise these settings too. String locales replace language/number/currency/date tags; objects merge individual fields. These overrides never update the template. For example: {"options":{"locale":"de-DE","timeZone":"Europe/Berlin"}}.

Invalid values return 400 invalid_locale or invalid_time_zone, with an indexed issue path such as items[2].options.timeZone. Schedule create/update validates these options. Execution uses explicit render time zone, template time zone, then the schedule’s cron time zone. This changes dates for existing non-UTC schedules that previously rendered in the server zone.

Calendar dates never shift; instants convert to the effective zone; offset-free ISO date-times retain their supplied fields. See the formatting reference for the input-semantics table, examples and native Excel formats. TS and Python SDK render options support locale/time zone; CLI flags are --locale and --time-zone.

Render clock and report globals

Every render, preview, validation and sandbox request accepts optional ISO date-time now (with a time-zone offset), for example "2026-03-31T23:30:00Z". Invalid values return 400 bad_request. Without an override the API captures one instant per request. Batch bodies accept now, and an item’s own now takes precedence; all other items share the batch instant. Scheduled runs use their execution start time. This affects document content, while usage/audit records retain actual server time.

Use {{@render.now}}, {{@render.today}}, {{@render.templateName}} or other report globals in content and headers. Field mappings and JEXL conditions use $render. Today is the calendar date in the effective time zone. Label-based rendering includes the requested label and resolved version. No tenant/actor identity is exposed. Preview/validation use preview:true.

The date token in page headers/footers uses this clock whenever now is explicit or a locale is configured. RelativeDate uses it by default; base= overrides it. Date helpers and relative condition values are listed in the Expression reference.

TypeScript render and batch methods accept now: string | Date; dates serialize as UTC ISO strings. Python methods accept strings or timezone-aware datetime; naive datetimes are rejected. CLI rendering accepts --now <iso>. Template-testing precedence is case > fixture > 2026-01-01T00:00:00.000Z.

Durable async jobs persist the submission clock before worker execution and retain it in stored results. The existing restart policy may abandon interrupted work; clock persistence does not add automatic worker resumption. File-mode async jobs remain nondurable.

Uploaded fonts and global typography

Upload .ttf, .otf, .woff or .woff2 using POST /assets/upload. Font signatures determine the canonical font/* MIME type; common browser aliases and application/octet-stream are accepted for recognized fonts. Collections (.ttc) and MIME mismatches return 415. Extensionless font filenames receive a canonical extension.

GET /assets?kind=font and ?kind=image filter before pagination. GET /assets/{id}/font-info returns {family, subfamily, weight, style, format, embeddable} for a tenant-owned font; embeddable:false indicates the OS/2 Restricted embedding flag. Metadata responses do not contain tenant identifiers.

{
  "renderConfig": {
    "fonts": [{"family":"Acme Sans","src":"/assets/your-upload.ttf","weight":"400","style":"normal"}],
    "defaultFont": {"family":"Acme Sans, sans-serif","size":"11pt","lineHeight":"1.4","color":"#123456"}
  }
}

Use the upload response’s filename after /assets/. Registry aliases allow letters, digits, spaces, underscores and hyphens (1-64 characters). Up to 32 unique family/weight/style faces are allowed. Only referenced families are emitted, including nested templates and custom CSS references. Defaults inherit through normal CSS and document styles; explicit node styles and variants take precedence.

Font source Trust and delivery
fonts[].src Tenant-owned /assets/ paths only; server inlines bytes into the marked font stylesheet for PDF and HTML, including public asset mode. Works with remote resources blocked and isolated workers without a network.
fontImports Existing remote HTTPS imports and origin controls; needs internet access. Global scripts in the full Docker font pack no longer require imports.

FONT_INLINE_MAX_BYTES defaults to 6 MiB raw font bytes per render. Missing or non-font assets return 422 font_asset_missing; exceeding the budget returns 422 font_budget_exceeded. /render/validate reports missing registry assets as issues. Fonts and images share the worker request-frame limit.

POST /render/pdf/watermark accepts watermark.fontAsset on text watermarks, with the same tenant ownership and byte budget. Supply a font covering all watermark characters. TOC number stamps reuse the default family’s embedded 400/normal face.

The TypeScript SDK provides assets.list({kind:'font'}), assets.fontInfo(id) and text watermark fontAsset. Python provides assets.list(kind='font'), assets.font_info(id) and watermark dictionaries containing fontAsset.

GET /capabilities includes optional fonts: {pack, families}. Docker images advertise a static list for their build-time pack; non-container installs report host with an empty list. PDF glyph fallback uses installed fonts. Office recipients need installed fonts except DOCX’s embedded regular TTF/OTF faces. Upload only fonts whose licence allows the intended use and distribution; an embedding flag is information, not a licence grant.

Chart captions in Office exports

Chart title/subtitle and footnote render as native paragraphs in DOCX and native text shapes in PPTX. Formatted axes, negative values, reference lines and combo line overlays use the same chart SVG as HTML/PDF before Office raster fallback. Chart JSON remains runtime-validated; there are no new endpoints or SDK request fields.

Response headers and contract stability

The OpenAPI contract names shared response and request models and documents response headers. Optional headers appear only when relevant to the response:

Header Meaning
X-Request-ID Server-generated UUID for log correlation, including errors.
X-Render-Trace-Id, X-Render-Duration-Ms, X-Render-Size-Bytes, X-Render-Pages Render trace, duration, byte count and available PDF page count.
X-Render-Memory-Delta-Bytes-Approx Approximate process memory change; concurrent work can affect it.
X-PulpEngine-Resolved-Label, X-PulpEngine-Resolved-Version, X-PulpEngine-Resolved-Source Effective label, saved version and resolution branch (version, label, current, variant, variant-fallback, default-label, default-fallback).
X-PulpEngine-Bucket, X-PulpEngine-Bucket-Key Effective A/B label bucket and key, when variants apply.
Content-Disposition Download filename for binary output.
x-render-warnings DOCX/PPTX warning count, formatted count=N.
ETag Quoted template version; send it as If-Match when editing.
x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset Rate-limit allowance, remaining requests and seconds until reset.
Retry-After Seconds to wait after throttling or capacity shedding.
Deprecation, Sunset, Link Structured deprecation date, planned retirement and migration/policy links.

These headers are exposed to browser callers through CORS. Clients must tolerate new response fields, enum values and error codes; see API stability. The deprecated POST /render alias adds deprecation headers even on errors. Canonical /render/pdf responses do not receive those headers. Operators can monitor pulp_engine_deprecated_requests_total{method,route,scope}; tenant and actor identifiers are confined to sampled logs.

Verify webhook deliveries

Batch completion callbacks are described under the async batch operations. Scheduled delivery uses the OpenAPI 3.0 x-webhooks.scheduleDelivery extension. Their payload models are BatchWebhookPayload and ScheduleWebhookPayload.

Verify X-PulpEngine-Signature (sha256=<hex>) over X-PulpEngine-Timestamp + "." + rawBody before parsing JSON. The SDK helpers reject signatures outside a five-minute timestamp tolerance by default. Preserve the received bytes: parsing and reserialising JSON changes the signature. Deduplicate jobId or executionId as well; timestamp checks do not prevent replay inside the tolerance window.

import { verifyWebhookSignature } from '@pulp-engine/sdk'
// These values come from your HTTP framework; obtain rawBody before JSON parsing.
const valid = await verifyWebhookSignature(
  secret,
  timestampHeader,
  rawBody,
  signatureHeader,
)
if (!valid) throw new Error('Invalid webhook signature')
from pulp_engine import verify_webhook_signature
# Obtain raw_body as bytes before your framework parses JSON.
if not verify_webhook_signature(secret, timestamp_header, raw_body, signature_header):
    raise ValueError("Invalid webhook signature")

Document storage is opt-in (DOCUMENT_STORE_ENABLED=true) and requires PostgreSQL or SQL Server metadata plus private filesystem or S3 bytes. File and plugin storage modes return 503 documents_not_available before rendering when output.store is requested. Requests without output retain their existing behavior.

All six render endpoints accept the same archive options:

{
  "template": "invoice",
  "data": { "invoiceNumber": "INV-42" },
  "output": {
    "store": true,
    "respondWith": "link",
    "ttlSeconds": 604800,
    "linkTtlSeconds": 3600,
    "maxDownloads": 5,
    "filename": "invoice-42.pdf",
    "metadata": { "customer": "acme" }
  }
}

respondWith: "link" returns HTTP 201 with documentId, format, sizeBytes, pageCount, sha256, expiresAt, downloadUrl, and downloadUrlExpiresAt. The signed URL works without an API key. With the default respondWith: "bytes", the response contains the final rendered bytes plus X-PulpEngine-Document-Id, Content-Location, and X-PulpEngine-Document-Url; these headers are exposed through CORS. Stored output is buffered. Explicit ?stream=true together with output.store returns 400 stream_incompatible_store.

Document TTLs are capped by operator and tenant policies; link TTLs are also capped by document expiry. maxDownloads is 1 through 1000. Metadata supports at most 16 exact-match string values, with keys up to 64 and values up to 512 characters. Filenames are sanitized literal names. PDF metadata includes a page count; other formats return null.

Endpoint Behavior
GET /documents Tenant-scoped {items,total,limit,offset}; filters: templateKey, format, source, scheduleId, createdFrom, createdTo, and metadata[customer]=acme.
GET /documents/{id} Public metadata; internal tenant and blob keys are omitted.
GET /documents/{id}/content Download with an admin/render API key or the complete signed query string.
POST /documents/{id}/links `{ttlSeconds?,disposition?:“attachment”
POST /documents/{id}/links/revoke Increments linkVersion, invalidating all previously issued links.
DELETE /documents/{id} Deletes bytes first, then metadata; returns 204.

Metadata, downloads, and link management require admin or render scope; editor scope alone is insufficient. Link issuance, revocation, and deletion are blocked for archived tenants. Archived tenants can still render and archive output, following existing render behavior.

Treat signed URLs as bearer credentials: do not publish them in analytics, referral URLs, or public logs. Document responses use Cache-Control: private, no-store, Referrer-Policy: no-referrer, and X-Content-Type-Options: nosniff. HTML always downloads as an attachment with Content-Security-Policy: sandbox. Invalid, tampered, revoked, and deleted links return 403 document_link_invalid; expired links/documents and exhausted download limits return 410 document_link_expired. With S3 redirects enabled, non-HTML downloads return a private presigned URL valid for at most 60 seconds, after atomically counting the download. HTML remains on the API proxy.

Synchronous PDF/DOCX/PPTX batch and asynchronous PDF/DOCX batch requests accept top-level output. Stored items return documentId, downloadUrl, expiresAt, and downloadUrlExpiresAt instead of base64. Links may expire before a retained job envelope; issue a fresh link while the document still exists.

Scheduled PDF/DOCX runs automatically archive when storage is available and expose documentId on executions. DLQ replay reads the stored bytes; older executions still use their temporary artifact. Email targets accept deliveryMode: "attachment" (default) or "link"; link mode requires available document storage and PUBLIC_BASE_URL, appends the URL and expiry, and sends no attachment. The editor supports execution downloads and email delivery mode changes. PATCH /schedules/{id} also accepts emailDeliveryModes: {"1":"link"} to change one email target without resubmitting masked secrets; it cannot be combined with deliveryTargets.

TypeScript:

const stored = await client.render.pdf({
  template: 'invoice',
  data,
  output: { store: true, respondWith: 'link' },
})
const page = await client.documents.list({ metadata: { customer: 'acme' } })
const bytes = await client.documents.download(stored.documentId)
await client.documents.revokeLinks(stored.documentId)

Python:

from pulp_engine import OutputOptions
stored = client.render.pdf("invoice", data, output=OutputOptions(respond_with="link"))
page = client.documents.list(metadata={"customer": "acme"})
content = client.documents.download(stored.document_id)
client.documents.revoke_links(stored.document_id)

Both SDKs follow an S3 download redirect without forwarding API credentials. PDF stream methods accept byte responses; use ordinary pdf methods for link responses. Optional dedupe, caller-selected S3 destinations, and object-key templating are deferred from the initial archive implementation.

Large payloads

Single render JSON defaults to 10 MiB, batches/jobs to 50 MiB, and dataset uploads to 100 MiB. Limits count bytes after decompression. Send Content-Encoding: gzip (also x-gzip, deflate or br) with the encoded Content-Length; identity is supported. A 413 names the effective limit and its operator setting.

Supply exactly one of data, dataUrl, or dataRef on each production render or batch/job item. Inline data remains an object. URL responses may be objects or arrays; arrays become { "items": [...] }. URL fetching is disabled by default. When enabled, it uses bounded reads, DNS pinning, no redirects, production HTTPS, and optional exact-origin policy. dataHeaders accepts up to 16 string headers (4096 characters each); framing/connection headers are forbidden. Headers remain in request/worker memory and do not enter durable job records.

POST /datasets?ttlSeconds=3600
Content-Type: application/json
Content-Encoding: gzip
X-Api-Key: <render-key>

<gzipped JSON>

The 201 response contains id, canonical JSON sizeBytes/sha256, createdAt and expiresAt. Render with { "template":"invoice", "dataRef":"ds_<uuid>" }. GET /datasets/{id} returns metadata; DELETE returns 204. Uploads require render/admin scope and remain tenant-private. Unknown, expired or cross-tenant references return 404 dataset_not_found. Each render parses fresh data so hooks cannot mutate a cached object used by another render. TTL is not extended by reads. Metadata caches never outlive deletion checks.

Both SDKs expose datasets.create/get/delete, data URL/ref arguments, and compressRequests (TypeScript) / compress_requests (Python), with auto (over 64 KiB), always and never. Python sync and async transports share this policy. CLI accepts .json.gz, --data-url, --data-ref, and --gzip.

Render options

The same strict options contract applies to all six formats, batches, jobs, previews, sandbox PDF and new schedule create/update requests. Unknown keys are rejected before render. Existing stored schedules project known keys during execution.

{
  "template":"invoice",
  "data":{"number":1042,"customer":{"name":"Zo\u00eb/AG"}},
  "options":{
    "filename":"Invoice-{{number}}-{{customer.name}}",
    "disposition":"attachment",
    "margins":{"top":"1in"},
    "locale":"en-NZ",
    "timeZone":"Pacific/Auckland",
    "metadata":{"title":"Invoice {{number}}","subject":"Customer invoice"},
    "watermark":{"text":"DRAFT","opacity":0.15,"color":"#808080"}
  }
}

Filename/title/subject expressions see mapped data and the effective render clock/locale. Author and keyword request overrides remain literal for compatibility. Metadata retains the existing creator/language fields and size limits. Request options override template configuration; partial margins and metadata merge field by field. Margins accept 0 or nonnegative mm/cm/in/pt/px lengths (up to three integer/fraction digits), never CSS fragments. Legacy template renderConfig.watermark accepts 1-64 plain characters, opacity 0.05-1, and a six-digit hex color. The additive request watermark accepts the richer shape described in Watermarks & stationery.

Template defaults live in renderConfig.output.filename and .disposition. Filename templates are limited to 200 characters; final names remove unsafe characters, normalize whitespace, handle Windows reserved names, cap at 200 including the extension, and append one format extension. Headers include an accent-normalized ASCII fallback and an RFC 5987 Unicode filename. Default disposition is inline for PDF and attachment otherwise. CORS exposes Content-Disposition. Buffered/streaming responses, previews, batch items and job downloads use the compiled name. Stored document output.filename remains a literal archive override. Signed archive downloads default to attachment unless the request/template explicitly sets disposition; stored HTML always downloads as a sandboxed attachment. Scheduled delivery uses the compiled filename; email attachmentName can override it.

TypeScript binary results expose filename; render.download(format, params) also exposes HTML download metadata while render.html() keeps returning a string. Python BinaryResult.filename parses the same header. CLI adds --filename, --title and --watermark; without --output, it uses the server filename.

Structured page chrome and numbering

renderConfig.pageChrome accepts default, first, even and last sets. Each set has an optional header and footer: a band, or "none" to hide it. Resolution is per band: last > first > even > default. Omitted bands inherit; explicit "none" suppresses them. On a one-page document last wins over first. Default is the odd-page band when even is configured.

{
  "pageNumbering": { "format": "decimal", "startAt": 1 },
  "pageChrome": {
    "default": {
      "header": { "left": [{ "type": "text", "content": "{{company.name}}" }] },
      "footer": { "right": [{ "type": "text", "content": "Page {{@pageNumber}} of {{@totalPages}}" }] }
    },
    "first": { "header": { "left": [{ "type": "image", "src": "/assets/logo.png", "height": "18mm", "alt": "Company logo" }], "height": "30mm" } },
    "even": { "header": { "right": [{ "type": "text", "content": "Confidential" }] } },
    "last": { "footer": { "center": [{ "type": "text", "content": "End of document" }] } }
  }
}

Bands have left/center/right arrays (maximum five text items per slot, 1,000 characters per text). An image occupies its entire slot. Image sources must be local /assets/ paths; they use the same tenant-scoped binary resolver and SVG block as body images. PDF stamped bands and DOCX image bands require PNG/JPEG. Do not combine page chrome or numbering with legacy pageHeader/pageFooter. A structured header cannot coexist with a stickyHeader node.

Optional band style is {fontFamily, fontSize, fontWeight, fontStyle, color}; size uses points, colour uses six-digit hex. A rule:{width,color?} draws below a header / above a footer. height reserves the corresponding page margin in mm/cm/in/pt, including first/even overrides. Last-page bands cannot change the page margin, so last.*.height is rejected. Margin-only fonts are loaded explicitly before PDF capture. Uploaded TTF/OTF faces are embedded by the stamper; unavailable non-uploaded families fall back to Helvetica with warnings for characters it cannot encode. CSS bands also support allowed font imports.

Print tokens are Handlebars data variables, distinct from payload fields: {{@pageNumber}} is the formatted folio, {{@totalPages}} counts non-excluded pages, and {{@sectionPages}} counts the current numbering run. Ordinary data expressions use the adapted document scope. Text is escaped for its output sink; quotes, backslashes and </style> remain literal text.

pageNumbering.format is decimal, lower-roman, upper-roman, lower-alpha or upper-alpha. Roman covers 1-3999 and otherwise falls back to decimal; alpha is bijective (z, aa, ab). startAt is an integer 0-99999, default 1. A section’s optional pageNumbering has mode continue (default), restart or exclude. startAt is allowed only with restart; keepChrome is allowed only with exclude and defaults false. Section format applies onward. Restart, exclude, format changes and the first section after exclusion force a new page. Exclusion pauses the sequence; following continue resumes it. A run starts at the document or a restart. Empty/unresolved section destinations log a warning and continue. Only host-template numbering controls template references.

Format Chrome Numbering
PDF All variants, images, rules, embedded uploaded fonts All tokens, restart/exclude, TOC folios and viewer labels on the post-layout path
HTML Inline first/default header and last/default footer Formatted start label; totals/section totals preview as 1
DOCX Default/first/even parts, images, Word font references; last falls back with a warning Word PAGE / NUMPAGES / SECTIONPAGES and numbering section properties
PPTX All variants and images using final slide count Document format/start-at; section numbering warns because sections do not define slides
CSV/XLSX Not applicable Not applicable

PDF format-only counters remain in CSS. Non-default start-at, section numbering, section-page tokens and last-page variants use a buffered post-layout pass inside the worker; streaming responses then emit the completed PDF. This costs first-byte latency and memory proportional to the PDF. TOC, labels, chrome and metadata share one pdf-lib load/save. Format-only CSS output does not add a viewer /PageLabels tree unless another layout post-pass needs labels. The native evaluation footer remains visible on every page.

DOCX has one margin geometry per Word section, rather than per first/even variant. Word NUMPAGES includes excluded covers, and SECTIONPAGES refers to Word sections (including boundaries needed for format/exclusion). Continuing a Word sequence across excluded pages follows Word’s pagination behaviour; use explicit restarts for cover/front-matter/body packs that need exact folios. Office fonts still depend on the Wave 4 embedding/install rules. Generated XML is tested; Word/LibreOffice visual verification remains a release check.

Organising templates

Folder, tags, owner and trash state are tenant-scoped row metadata, separate from the versioned template definition. Native file, PostgreSQL and SQL Server stores support them. Older plugin adapters return 501 not_implemented for unsupported organisation methods; folder storage being unavailable returns 503.

GET /templates and template list reads inside /batch accept:

Parameter Behaviour
q Case-insensitive substring of key, name or description, or an exact tag. %, _ and [ match literally. Maximum 200 characters.
key Repeat for up to 50 exact keys.
folderId, folderPath Choose one. folderId=root or folderPath=/ selects unfiled templates. Paths match case-insensitively.
recursive Include descendants of the selected folder.
tag Repeat for tags that must all match. Tags are trimmed, lowercased and deduplicated; maximum 20.
owner Exact actor or me. me without a named actor returns 400.
label Require a named version label.
state active (default), deleted or all.
sort, order name (default), key, createdAt or updatedAt; asc (default) or desc. Key breaks ties.
limit, offset, expand Existing pagination and expansions remain supported.

List items use currentVersion and include createdAt, updatedAt, folderId, folderPath, tags, owner, deletedAt and deletedBy. They never expose tenantId.

PATCH /templates/:key/meta accepts {folderId, tags, owner}. Omitted fields stay unchanged, null clears folder or owner, and tags replace the whole set. It needs no If-Match, creates no version, and records a template_mutation audit event with operation meta. Name and description remain versioned: update the definition with PUT and If-Match; SDK rename helpers perform GET then PUT.

Normal DELETE /templates/:key moves the template to trash and requires If-Match. Admin POST /templates/:key/undelete restores it, including versions, labels and samples, and is idempotent. A removed folder sends the restored template to Unfiled. Admin DELETE /templates/:key?permanent=true permanently removes a trashed template, needs no If-Match and returns 204. Active templates return 409 template_not_in_trash. For compatibility, creating a trashed key still purges the old history, with a purge audit row whose reason is recreated.

GET/POST /template-folders and PATCH/DELETE /template-folders/:id manage folders. List responses use {items,total,limit,offset}; items include id, parentId, name, path, templateCount and creation/update facts. Moves rewrite descendant paths atomically. Names allow 1-60 characters without slashes, trees allow eight levels, and paths allow 512 characters. Admins can delete folders with no active templates or child folders. GET /template-tags?q= returns paginated {tag,count} items from active templates. Tags match ^[a-z0-9][a-z0-9 _.-]{0,39}$.

Creation assigns the named actor as owner. Database organisation writes and their audit inserts share a transaction. File mode restores prior metadata when a synchronous audit write fails.

The TypeScript SDK exposes templates.list/listAll/updateMeta/undelete/purge/rename/tags and folders.list/listAll/create/update/delete/ensurePath. Python sync and async clients offer the same operations in snake_case, including list_all, update_meta and ensure_path.

pulp-engine pull --folder /Finance --tag finance --layout folders
pulp-engine pull --folder /Finance --no-subfolders
pulp-engine push ./templates --folder /Finance/Q3 --recursive
pulp-engine push ./templates --layout folders
pulp-engine templates list --state deleted
pulp-engine templates restore invoice
pulp-engine templates purge invoice --yes

Pull visits every page. Folder layout mirrors the full remote path under the output directory, using reversible percent escaping for Windows filename restrictions. Push decodes those segments and can prefix them with --folder. Existing flat layouts remain valid.

Apply Prisma migration 20261015000000_template_organisation or SQL Server migration 016_template_organisation.sql before upgrading. File-to-database migration preserves folder hierarchy, tags, owner and deletion metadata, reuses destination folders by path and skips existing template keys.

A statement run in one PDF

Store a template with a repeater over statements. Give the repeater itemPagination: { "breakBetween": "oddPage", "repeatLeadingChildren": 1, "restartPageNumbering": true }. Make its first child a statement banner and its remaining children the statement content, such as a table over lines. The PDF starts each statement on an odd page, repeats the banner on continuation pages, and restarts its page count. A footer containing Page <span class="pageNumber"></span> of <span class="totalPages"></span> uses the statement’s page range. Structured pageChrome supports {{@pageNumber}} and {{@totalPages}} too.

Send one POST /render request with { "template": "statements", "data": { "statements": [...] } }. The default stream and ?stream=false use the same pagination pass. This avoids separate batch renders and a merge. Duplex blank pages are unmarked and count toward the preceding statement; TOC numbers remain document-absolute.

For a flat sales array, a table’s groups: [{ "by": "region" }, { "by": "rep" }] adds nested grouping. Set band rows and subtotals on the levels, column aggregate: "sum", and showFooter: true for totals. The same template can be rendered through /render/html, /render/docx, /render/pptx, /render/xlsx and /render/csv; see template compatibility for format-specific pagination.

Document themes and brand kits

Themes are additive on formatVersion: 1. Save a brand kit as a normal versioned template with kind: "theme", inputSchema: {}, a specimen document, and renderConfig.theme. Kits use the existing version, label, restore and audit routes. GET /templates?kind=theme lists kits; kind=document lists documents. An omitted kind filter returns both. Public summaries always include kind and omit tenant identifiers.

GET /templates/:key/theme?label=prod returns { key, version, label?, theme }. Use version=1.0.0 to pin a revision, or omit both to use current. Label and version are mutually exclusive. The route accepts the same API key or editor token as other template reads.

{
  "renderConfig": {
    "themeRef": { "key": "acme-brand", "label": "prod" },
    "theme": {
      "colors": { "muted": "#777777" },
      "styles": { "caption": { "kind": "paragraph", "style": { "fontSize": "9pt", "color": "$muted" } } }
    }
  }
}

Add styleRef: "caption" to a text node. Any NodeStyle string can contain $colorToken or $fontToken, including shorthands and table row/column styles. Divider colours also support tokens. Lookup checks colors before fontFamilies. Rich-text leaf and sparkline colours still require literal colours.

The cascade is base renderer styles, theme font defaults, implicit reserved style, the named style’s ancestors and then inline properties. Reserved names are body, heading1 through heading6, text variants, table and pivotTable. Local theme maps and style properties override the kit; a local chart palette replaces its palette. fontFaces are merged with registered document fonts. headingScale is between 1 and 3; heading size is base size multiplied by scale to the power 6 - level.

Render requests accept options.theme: { key, label?, version? } in single, preview, batch, jobs and schedule render options. It replaces the saved kit reference; local theme values still override it. Subtemplates use the host’s merged theme. Successful single/preview renders expose X-PulpEngine-Resolved-Theme: key@version. Label promotion affects the next render without changing dependent templates; pin a version when output must remain fixed.

Dry runs add themeWarnings for unknown styles/tokens and inheritance cycles. Save rejects unsafe values, duplicate colour/font token names, invalid names, local cycles, more than 200 styles or inheritance deeper than ten. A kit cannot reference another kit. Missing kits return 422 theme_not_found; document targets return 422 theme_ref_invalid. Both resolution and asset access stay inside the request tenant.

TypeScript: client.templates.list({ kind: 'theme' }), client.templates.getTheme('acme-brand', { label: 'prod' }), and client.render.html({ template: 'invoice', data: {}, options: { theme: { key: 'acme-brand' } } }). Python sync and async clients provide templates.list(kind="theme"), templates.get_theme("acme-brand", label="prod") and RenderOptions(theme=ThemeRef(key="acme-brand")) from pulp_engine.types. Existing CLI template push/pull and version/label operations also work for kits.

Null and empty values

Templates can opt into data preparation with dataOptions. Defaults remain off:

{"dataOptions":{"applySchemaDefaults":true,"schemaDefaultWhen":"nullish","coerceTypes":true}}

Preparation applies field mappings, fills schema defaults, then validates and optionally coerces types. Renderers, dry-run, template validation and CLI validate --data share this path. The caller’s payload is preserved. Schema defaults traverse properties and object schemas in array items; they do not follow $ref, anyOf, oneOf or allOf.

schemaDefaultWhen and mapping defaultWhen accept undefined (the default), nullish (also null), or empty. Blank means undefined, null, whitespace text, an empty array or an empty plain object. Zero and false are present. The exists condition still regards empty strings and collections as present; use isEmpty for blank checks. Coercion follows AJV rules, including converting null to zero, empty text or false for a non-nullable type. Declare a nullable type such as ["number","null"] to preserve null.

Table columns can set nullText, for example "nullText":"—". HTML/PDF, DOCX and PPTX display this for blank cells. CSV/XLSX retain empty cells. Pivots continue to use emptyCell.

Nodes can set hideWhenEmpty to a scoped data path. Automatic true is supported on text, heading, image, barcode, table, repeater, chart and pivotTable. Text checks its interpolated visible content; collection nodes require a nonempty array. Hidden headings leave the TOC; hidden tables leave CSV/XLSX discovery. Sections ignore this option because they represent pages.

Dry-run and binding diagnostics

Send dryRun: true to any render endpoint to prepare data and evaluate expressions without producing output. The response includes validation errors, up to 50 expression errors and optional warnings:

{"warnings":{"unresolvedBindings":[{"path":"customer.nmae","reason":"missing","severity":"warning","nodeId":"name","nodeType":"text","nodePath":"Page 1 > Text block 1","occurrences":1,"suggestions":["customer.name"]}],"undeclaredFields":[],"truncated":false}}

Missing fields and collections are warnings. Explicit nulls are informational. Repeated misses are combined by node and path. Warnings are capped at 200, undeclared payload paths at 100. Handlebars each samples 25 entries; undeclared-field coverage samples 25 array items and stops at depth six. truncated indicates these limits. Guards (if, unless, default, coalesce) permit intentional omissions; only the branch actually used is inspected. Custom block helper bodies are opaque. Referenced-template diagnostics carry templateKey.

Set request strictBindings: true, or template dataOptions.strictBindings: true, to fail on missing bindings. A request value overrides the template default. Strict dry-run returns valid: false; a render returns HTTP 422 unresolved_bindings with up to 50 unresolvedBindings. Nulls never fail strict checks. PDF/HTML check during HTML generation; Office formats use an HTML preflight. CSV/XLSX count only bindings belonging to discovered tables and pivots.

POST /render/validate accepts bindingChecks: "off" | "warn" | "strict". Format-aware requests default to warn; requests without formats default to off and keep the legacy response shape. Missing paths produce unresolved_binding issues with nodeId; undeclared fields produce informational undeclared_field issues. Only error-severity issues make validation invalid.

HTML and PDF previews expose X-PulpEngine-Binding-Warnings through CORS. The value is the unique missing-binding count. Preview renders remain permissive unless the request explicitly enables strict checks.

TypeScript SDK render parameters accept strictBindings, and dry-run results expose warnings. Python uses strict_bindings and DryRunWarnings. CLI render --dry-run --strict-bindings prints missing paths, locations and suggestions and exits 1 for an invalid result. Template-testing fixtures accept expect.dryRun: strict for CI binding checks.

Plugin expression capabilities and preview

GET /capabilities preserves expressions.functions as an array of supported JEXL names for existing clients. Additive arrays expressions.helpers and expressions.pluginFunctions describe plugin registrations: name, kind, category, summary, example, params, returns and owning plugin. Built-in helper metadata ships in @pulp-engine/html-renderer/helper-catalog. The plugin arrays are empty without active plugins. Both SDKs already expose health.capabilities(); the TypeScript response type is regenerated from OpenAPI.

POST /render/preview/expression accepts {kind: "handlebars" | "jexl", expression, data, fieldMappings?, locale?}. Expression length is limited to 10,000 characters and the request uses the preview body limit, authentication/scopes, route gating and render rate limit. The server applies mappings before evaluating. Success returns {result}; undefined becomes null. Invalid requests return 400 and evaluation failures return 422 with template_expression_error.

For editor item previews, optional template, nodeId and non-negative itemIndex select the innermost repeater item after server mapping and schema defaults. Outer repeaters select their first item. The validated template also supplies render globals. These editor-only preview routes have no SDK resource wrapper.

All six format preview routes and sandbox PDF errors preserve available location and suggestion fields. A location identifies node id/type/path and optional line/column. Referenced-template locations may refer to a node outside the editor’s current tree.

Expression preview responses include itemCount and itemIndex when a repeater frame is selected. These describe the filtered sample items after server field mappings and data views, so the editor can navigate samples even when a plugin function is unavailable locally.

PDF passwords, permissions and archival output

POST /render/pdf accepts options.pdf.security with userPassword, ownerPassword, userPasswordPath and permissions. The path resolves against the raw payload, before adapters, using own properties only. Missing/non-string values return 422 pdf_password_missing; password values contain 1-127 UTF-8 bytes without control characters. Owner and user passwords must differ. Templates store only renderConfig.pdf.permissions and/or userPasswordPath, never password values. Request permissions override saved flags individually. If only permissions are supplied, opening needs no password and a random owner password protects the settings. Reader permission flags remain advisory.

{"template":"payslip","data":{"employee":{"pdfPassword":"example-only"}},"options":{"pdf":{"security":{"userPasswordPath":"employee.pdfPassword","permissions":{"print":"low","copy":false}}}}}

Printing is full (default), low or none; copy, modify, annotate, fillForms and assemble default to true. Accessibility extraction stays enabled. Protection runs after mutating plugins and before storage/delivery, returns X-PDF-Encryption: AES-256, and counts pages before encryption. The same options work for synchronous batches, async jobs and schedules. Schedule responses mask literal passwords as ***; updates preserve a saved password when passed ***, and reject that sentinel if no password exists. Previews remain readable and never apply encryption.

POST /render/pdf/protect accepts {pdf: "<base64>", userPassword?, ownerPassword?, permissions?} and returns PDF bytes. Protect, merge, watermark and insert reject encrypted input with 422 pdf_already_encrypted.

Set renderConfig.pdf.pdfa or options.pdf.pdfa to 2b or 3b for archival output. Responses carry X-PDF-Conformance: PDF/A-2b or PDF/A-3b. Conversion follows TOC/table stamping and embeds XMP, an sRGB OutputIntent and document IDs; unembedded fonts fail closed. Encryption conflicts with PDF/A (400 incompatible_pdf_options). Explicit stream=true rejects security/PDF-A (400 stream_incompatible_option); otherwise these outputs buffer automatically. Numbered TOC output also buffers. Request PDF options on non-PDF output return 400 unsupported_render_option; saved PDF defaults do not alter other export formats.

GET /capabilities reports pdf: {protection: boolean, pdfa: ["2b", "3b"]}. qpdf absence disables protection and requests return 503. Mutating plugins must load PDFs with updateMetadata:false, preserve Info/XMP agreement, embedded fonts and output intents, and validate their resulting archival output. The API marker guard catches removed metadata, not every compliance violation.

TypeScript: client.pdfTransform.protect({pdf,userPassword,permissions}); Python: client.pdf_transform.protect(pdf,user_password=...,permissions=...). The CLI supports render --pdfa 2b|3b and --pdf-password-path employee.pdfPassword with PDF output. Direct TemplateRenderer callers must apply protectPdf themselves after rendering; security policy without a trusted output coordinator raises unsupported_render_option.

Draft vs live

Saving creates a new HEAD version. A label points at a specific saved version and changes only through promotion. Configure RENDER_DEFAULT_LABEL=prod to make unpinned production renders follow that label; unset preserves HEAD behavior. fallback (default) uses HEAD when the label is missing and increments pulp_engine_default_label_fallback_total{templateKey}; strict returns 409 template_not_released. Explicit version/label wins; label:"@latest" forces HEAD. Responses expose the resolved source/version/label. Strict mode prevents deleting the configured default label (409 default_label_protected). New templates seed it at v1 unless RENDER_DEFAULT_LABEL_SEED_ON_CREATE=false.

Before enabling this policy, follow the rollout procedure. Existing prod pointers can be behind HEAD, so changing the default can roll production output back.

Send X-PulpEngine-Change-Note on create, update or restore. Encode UTF-8 with encodeURIComponent (TS) or urllib.parse.quote(note, safe="") (Python), at most 8 KB encoded and 1000 UTF-16 code units decoded, trimmed. Newlines are allowed; other controls are rejected. CHANGE_NOTE_REQUIRED=true requires a nonblank note on update/restore; otherwise restore supplies Restored from v…. Version lists (including expanded/batch lists) return nullable changeNote. Audit details include the version and note. PATCH /templates/:key/versions/:version with { "changeNote": "Reviewed totals" } amends a note only for the original author or an admin, with an audit event.

With SERVER_DRAFTS_ENABLED=true, named editor/admin actors can GET, PUT and DELETE /templates/:key/draft. PUT takes { "baseVersion": "1.0.3", "definition": { ... } }, validates the definition and uses last-write-wins without moving HEAD or labels. GET returns the draft or null; DELETE returns 204. Actor-less credentials receive 403 draft_identity_required. GET /templates/:key/drafts?limit=50&offset=0 lists owner/baseVersion/updatedAt; GET /templates/:key/drafts/:owner is read-only review. Drafts are tenant-scoped, private for writes, and removed on permanent template deletion. There is currently no age-based draft retention sweep.

GET /capabilities exposes lifecycle:{defaultRenderLabel,defaultLabelMode,changeNoteRequired,serverDrafts}. Older storage plugins can omit the optional note/draft methods; server drafts are then unavailable and the capability is false. Native stores always include nullable notes; consumers should accept omission from older plugins.

Both sync SDKs expose templates.labels, templates.draft (get/put/delete/list/review), note options on create/update/restore, and setVersionNote / set_version_note. CLI: push --message "Correct totals", labels audit --label prod --dry-run, and diff --template invoice --version prod..1.0.5. Console diffs include version, author, date and notes across all pages in the range; JSON diff output remains a structural envelope.

Schedule authoring and multiple outputs (Wave 31)

Schedules remain admin-scoped and require PostgreSQL or SQL Server. Named admin sessions can author them in the editor without handling an API key. /capabilities.scheduling reports available, engineEnabled and delivery.email|s3|webhook; disabling the engine still permits saving definitions.

POST /schedules/cron-preview accepts {cronExpression, timezone?, count?} and returns {valid,error?,nextRuns,minIntervalSeconds?}. It uses the execution engine’s cron parser and IANA zone, rejects intervals below 60 seconds and defaults to five UTC ISO timestamps. POST /schedules/data-source/test accepts {dataSource,scheduleId?} and returns a bounded JSON preview, top-level keys and timing. URL sources use the same SSRF protections, redirect policy, timeout and 5 MiB cap as execution. Supply scheduleId to preserve masked header values for an existing source. Testing does not modify a schedule.

Create/update accepts format: 'pdf'|'docx'|'xlsx'|'csv'|'pptx'|'html' or an ordered, unique formats array of one to six entries. If both appear, format must equal the first entry. Legacy rows return formats:null; a format-only PATCH clears an older format list. tableId selects a table for CSV/XLSX; CSV requires it when the resolved template has multiple tables. packaging is attachments (default) or zip. All requested formats must render successfully before delivery. Each original format has its own usage row; ZIP is packaging, not a seventh renderer. HTML is self-contained. PDF-only render options require PDF in the selection. PowerPoint requires PPTX_ENABLED=true.

For separate files, email sends all attachments in one message, S3 uploads each file, and webhook files[] supplies format, filename, MIME and size alongside the legacy first-file fields. With includeBody, each file has base64 bytes; otherwise neither representation includes bytes. A multi-file attachment-name/S3-key override keeps its basename and changes the extension per format. ZIP sends one .zip. Multi-format/ZIP email requires attachment mode; existing single-output link mode remains available. Single unbundled outputs use document archives when configured; multi-file/ZIP retries currently use temporary artifacts and can expire. DLQ replay restores the complete saved manifest without rerendering.

GET responses mask webhook secrets and URL headers as ***. PUT/PATCH preserves a missing or masked webhook secret only for the same full destination URL, and a masked header only for the same source origin and header name (case-insensitive). Changed destinations or missing saved credentials require a real replacement (schedule_secret_required). Omitted header names are removed. POST /schedules/{id}/duplicate copies secrets on the server, starts disabled and returns masked data. Lists accept templateKey and templateLabel. GET /schedules/recent-failures?sinceHours=24&limit=50&offset=0 returns tenant-scoped failed/partially failed executions with safe schedule names and errors.

POST /schedules/{id}/test accepts {to:['reviewer@example.com']} (one to five recipients). Save edits first: it renders the stored definition, sends only to these override email addresses with a [TEST] subject prefix, makes one delivery attempt, and returns the result synchronously without creating an execution row. It does not run the schedule’s other delivery targets. The endpoint requires an enabled dispatcher and SMTP, permits five calls per minute per tenant, and bounds render waiting to 65 seconds. A timeout stops the request from dispatching; it cannot interrupt a renderer already running. Usage diagnostics and a test audit event are recorded without secrets.

TypeScript adds schedules.previewCron, testDataSource, test, duplicate and recentFailures; Python provides the matching snake_case methods. Apply PostgreSQL migration 20261012120000_schedule_output_options or SQL Server migration 022_schedule_output_options.sql before rollout. Secrets retain existing storage protection until FA-70.

Smart scheduled delivery (Wave 32)

Schedules accept parameters (up to 25 named definitions) and deliveryCondition. A definition has type: string|number|boolean|date|dateRange and exactly one of value or relative. Relative date presets include lastMonth, thisWeek, quarterToDate, yearToDate, lastNDays and nDaysAgo; the last two take n. Calendar periods use weekStartsOn:0|1 and optional fiscalYearStartMonth:1..12. Date parameters can pick start or end. Ranges include inclusive start/end dates, exclusive-end UTC startInstant/endInstant and days. Every period resolves from the execution fire time in the schedule’s IANA zone, including catch-up runs and DST transitions.

For example, parameters:{period:{type:"dateRange",relative:{preset:"lastMonth"}}} resolves August on a UTC run anchored to September 1. POST /schedules/:id/trigger still accepts no body; optionally send {asOf:"2026-09-01T00:00:00Z",parameters:{region:{value:"NZ"}},ignoreDeliveryCondition:true}. Overrides must name existing definitions and preserve their types. Executions expose trigger, asOf, resolved parameters, and nullable skipReason, without tenant IDs. Parameters are returned unmasked; store credentials in masked data-source headers.

URL data sources support method:GET|POST, query fields, a JSON body (POST only), and arrayKey for array responses. URL tokens must be dotted scalar paths; hosts stay literal, substituted path values are URI-encoded and final destinations retain SSRF checks. Query/header/body values can use params, run and schedule tokens. An exact JSON leaf such as "{{params.threshold}}" retains the parameter’s JSON type. Test-fetch accepts parameters and timezone alongside its existing body fields.

Set deliveryCondition:{skipIfEmpty:"invoices"} or {deliverIf:"invoices|length > 0"}. Conditions run after fetching and before rendering. False/empty runs record skipped without rendering, usage charges or delivery; runtime expression errors fail the run. Templates can use {{@render.parameters.period.start}}, {{@render.run.date}} and $render.parameters in expressions. @render.now equals the anchored asOf.

Delivery templates support run, params, data, schedule, output, and document (the latter only with link delivery). Subject/body/fromName/attachmentName and S3 keys and metadata use Handlebars; legacy {{date}} means {{run.date}}. Single-line fields strip line breaks, attachment names retain the real extension, and S3 substitutions replace slashes/control characters with hyphens. Absolute/traversal keys are rejected. Malformed templates, unknown roots/helpers/parameters return delivery_template_invalid with field issues; parameter, condition and source errors have corresponding codes.

Email adds bcc, replyTo, fromName, bodyHtml, or bodyTemplate:{templateKey,label?}. Interpolated HTML values are escaped, including triple braces; administrator-authored HTML itself is trusted. Body templates render through the same tenant-scoped HTML pipeline and inline assets. Test email also resolves body templates, but sends only to its override recipients. deliveryMode:auto attaches at or below linkThresholdBytes (default SCHEDULE_EMAIL_MAX_ATTACHMENT_BYTES, 10,000,000 bytes) and otherwise uses an archived signed link. Link/auto requires document storage and PUBLIC_BASE_URL; multi-format and ZIP email still requires attachment mode. S3 adds contentDisposition and bounded metadata. Execution delivery results include a secret-free resolved snapshot; DLQ replay reuses it, preserving the original object key. Webhooks add run and parameters, and document when a link exists.

Subjects, object keys and filenames may be logged by mail/object-store infrastructure; choose data tokens accordingly. Apply both new database migrations before rollout.

Streaming audit export and credential attribution

Admin callers can stream GET /audit-events/export?format=csv|ndjson using list filters (event, operation, actor, resourceType, resourceId, since, until). The default window is 30 days; the cap is AUDIT_EXPORT_MAX_ROWS. Results sort by timestamp then ID ascending and omit tenant IDs. CSV columns are id,timestamp,event,operation,resourceType,resourceId,actor,credentialScope,details; details is JSON and formula-leading cells are escaped. Each successful request creates an audit_export row containing the filter, format and estimated row count. A concurrent increase beyond the cap aborts an already-started stream; retry with narrower filters.

TypeScript: const stream = await client.auditEvents.export({ format: 'ndjson', actor: 'alice' }). Python: for line in client.audit_events.export(format='ndjson', actor='alice'): ...; close the iterator when stopping early. NDJSON is one complete event per line. CSV records may span lines when a field contains a newline, so use a CSV parser.

API-key calls now stamp actor and createdBy as apikey:env:API_KEY_ADMIN#<12 hex SHA256> (or the appropriate environment variable), apikey:json:<name>, apikey:json:#<fingerprint>, or apikey:<DB key ID>. API_KEYS_JSON accepts an optional unique name of 1-64 letters/digits/underscores/hyphens. Fingerprints change when the secret changes; names remain stable. Two keys for the same scope have distinct identities. This also separates per-actor AI rate-limit buckets. Named editor actors retain their user IDs.

Governance event catalogue additions: user_admin with operations create, update, delete, reload, provision; and audit_export with operation export. User rows live in the affected user’s tenant partition, contain role plus redacted changes (role, displayName, tokenIssuedAfter, tenantAttribution), and never contain keys/key hints. Reload emits one registry event per resulting partition. Audit writes are best effort after the user mutation; failures increment pulp_engine_audit_write_failures_total and log a redacted warning.

Admin console endpoints

GET /admin/system requires admin scope and returns version, identityMode, storageMode, renderMode, multiTenant, session: {actor, superAdmin}, availability flags in surfaces, and the readiness licence block. It deliberately omits the session tenant binding. Capability flags reflect route/store availability rather than guessed client configuration. The TypeScript and Python SDKs expose this as client.admin.system().

POST /auth/console-session exchanges {key, actor?} for {token, expiresAt, actor, scope: "admin", superAdmin, tenantId}. Accepted keys are admin-scope environment/JSON credentials or active database admin keys; personal user keys use /auth/editor-token instead. The endpoint is limited to 10 attempts per minute per IP. Send the returned token as X-Console-Token. Console tokens cannot authorize template or render routes; attempts return 403 console_scope_restricted. Allowed authenticated paths are /admin/*, /audit-events and its subpaths, /usage, /usage.csv, and /capabilities. Auth bootstrap endpoints authenticate their own submissions. Public health and static assets remain public.

Console tokens use a distinct console:v1: HMAC domain and cannot be used as editor tokens. The TTL is the smaller of EDITOR_TOKEN_TTL_MINUTES and CONSOLE_SESSION_TTL_MINUTES (default 60); there is no refresh endpoint. EDITOR_TOKEN_ISSUED_AFTER also invalidates older console sessions. Static credential removal/binding changes invalidate sessions after server reload. Database credentials are rechecked for scope, tenant, expiry, revocation, rotation and CIDR restrictions using the API-key registry cache (same cross-instance cache bound as direct key authentication). Operator labels on database sessions retain the authenticated API-key identifier as a prefix.

Security change: with multi-tenancy enabled, a tenant-bound admin lists, edits and deletes only users attributed to its tenant (tenantId omitted means the default tenant). Creating a user without attribution binds it to the caller’s effective tenant. Explicit creation or movement into another tenant returns 403 tenant_mismatch; access to another tenant’s user ID returns a generic 404. Super-admins retain global user administration. authMethod is returned as key or oidc. Mutations emit user_admin; a PUT containing only tokenIssuedAfter emits operation revoke_sessions.

The sync SDKs provide auth.consoleSession(key, actor?) (TypeScript) / auth.console_session(key, actor=...) (Python). Treat returned tokens as credentials. Use request-option headers for X-Console-Token; console sessions do not replace production API keys.

Fine-grained roles and grants

Access control documents named-user roles, folder/template grants, custom roles and OIDC group mappings. Authenticated GET /auth/me describes the principal without exposing a tenant ID. Template list/detail reads support expand=permissions. Role administration uses /admin/roles and /admin/role-bindings, tagged AccessControl in OpenAPI. Existing unmodified API-key scopes retain their previous route matrix. User role responses now also include viewer, author, publisher, scheduler, auditor and none; regenerate strict clients that assumed only admin/editor.

Report parameters (Wave 37)

Declare up to 50 typed report options independently of payload data. Names must match ^[A-Za-z_][A-Za-z0-9_]{0,63}$ and be unique. Types are string, number, integer, boolean, date and inclusive dateRange ({ from, to }, YYYY-MM-DD). Use multiple: true for arrays of string/number/integer/date values, with allowSelectAll for prompts. Booleans and ranges cannot be multiple.

{
  "parameters": [
    { "name": "country", "type": "string", "required": true,
      "default": { "value": "NZ" },
      "allowedValues": { "static": [{ "value": "NZ", "label": "New Zealand" }] } },
    { "name": "period", "type": "dateRange", "default": { "preset": "lastMonth" } },
    { "name": "region", "type": "string", "dependsOn": ["country"],
      "allowedValues": { "source": { "type": "url", "url": "https://reports.example/regions/{{@params.country}}",
        "itemsPath": "items", "valueField": "id", "labelField": "name", "enforce": true } } }
  ]
}

Defaults are { value }, { expression } or { preset, edge? }. Expressions see already resolved predecessors in $params and the render clock in $render. Dependencies name earlier declarations, keeping cascades acyclic. Static lists (maximum 1,000 options) always enforce membership; dynamic lists enforce it only when enforce is true. Providers and URL sources share the SSRF guard, per-render cache and fetch limits with sub-reports. Source failures are generic and do not include provider credentials. Optional values can be absent; required empty values fail. Numeric and boolean strings are coerced; dates are strictly checked. Constraints support min, max, pattern and maxLength.

visibility: hidden removes a prompt but allows callers to supply the value. visibility: internal uses server defaults and rejects supplied values. Internal parameters are omitted from public descriptors. Parameters do not merge into data; use {{@params.country}}, $params.country in expressions and $params.country in structured condition paths. Root parameters remain available inside repeaters, sub-reports, headers and footers in all six formats.

Render, preview, validate, sandbox and batch item bodies accept parameters: { country: "NZ", period: { preset: "lastMonth" } }. Unknown and internal supplied names return 422 validation_failed with parameters.<name> paths. Dry-run returns resolvedParameters and puts failures in validation.errors.

Presets Meaning
today, yesterday One local calendar day
thisWeek, lastWeek Week containing today / previous complete week
thisMonth, lastMonth, monthToDate Calendar month / previous month / month through today
thisQuarter, lastQuarter, quarterToDate Fiscal-aligned quarter / previous quarter / quarter through today
thisYear, lastYear, yearToDate Calendar year / previous year / year through today
thisFiscalYear, previousFiscalYear, fiscalYearToDate Fiscal year / previous fiscal year / fiscal year through today
last7Days, last30Days, last90Days Inclusive trailing days, including today
last12Months Twelve completed calendar months

For a date parameter, edge: start (default) or end chooses one boundary. Ranges reject edge. renderConfig.locale.fiscalYearStartMonth is 1-12 (default 1); weekStartsOn is 0-6 (Sunday-Saturday, default Monday). With fiscal start April and now: 2026-10-01T03:00:00Z in Pacific/Auckland, thisFiscalYear is 2026-04-01 through 2027-03-31. The same timestamp with lastMonth gives September in Auckland and August in America/Los_Angeles. The effective render time zone and captured now determine every preset.

POST /templates/:key/parameters/resolve accepts { version?, label?, values?, now?, options? } (version and label are exclusive). Admin, editor and render credentials can resolve prompts within their resource grants. The response is { key, version, parameters }, with each public descriptor containing its resolved value, options and issues. An unavailable option source returns 200 with options: null and a data_source_error issue; malformed supplied names return 422. URLs, headers, bodies, provider config and tenant IDs are omitted. POST /render/preview/parameters accepts { template, values?, now?, options? } for unsaved drafts and follows the preview gate and permissions. Responses use Cache-Control: no-store. form-metadata also exposes safe descriptors.

Schedules for templates with declarations store these raw values and presets in parameters. Create/update validate them; execution resolves against the captured fire time (manual runs may set asOf). resolvedParameters in the execution response records the values retained in its existing runContext JSON; retries reuse those values. Earlier schedules whose templates have no declaration keep the older { type, value | relative } parameter wrappers. Existing schedule JSON columns already support the new shape, so this wave requires no migration. Durable async jobs retain private per-item parameter snapshots alongside their captured clock; these snapshots are not included in public job responses.

TypeScript: client.templates.resolveParameters(key, { label, values }) and client.render.html({ template, data, parameters }). Python uses client.templates.resolve_parameters(key, label=..., values=...) and client.render.html(key, data, parameters=...). The CLI accepts repeatable --param country=NZ --param period=@lastMonth --param 'regions=["NZ","US"]'. JSON values are parsed; other values remain strings. Duplicate names and unknown @preset names are rejected.

Per-row sub-reports (Wave 37)

templateRef supports mutually exclusive label and version pins. A label follows its tenant-scoped pointer and deliberately ignores A/B bucketing. applyTargetContract: true applies the target’s own field mappings and validates its input schema after the node’s data path, source merge and mappings. Contract errors use subReport.<nodeId>[<row index chain>].<field> and return 422. /render/validate warns with template_ref_unresolved for a missing reference.

URL sources inside repeaters interpolate per item. Identical interpolated method/URL/headers/body requests share a fetch within one render. Static sources and plugin providers without instanceKey retain enclosing-scope singleton behavior; plugins may implement instanceKey(config, parentData) for per-row instances. Live conditional branches, positioned and sticky-header content, custom children and nested reports are traversed, to depth five. A per-render session isolates overlapping renders. DATA_SOURCE_MAX_CONCURRENCY defaults to 5 and DATA_SOURCE_MAX_FETCHES to 200 distinct requests per render; the latter returns 422 data_source_error when exceeded. Preview stops fetching and falls back to parent data for remaining instances. Limits apply per render, not per batch, and also cover parameter option sources.

Reusable components

Create a template with kind: "component" for the team catalogue. GET /templates?kind=component&q=address&expand=labels supports existing pagination, folder and permission filters. Existing definitions default to document; theme remains reserved for brand kits.

A templateRef selects templateKey and either label or version. For example, { "type": "templateRef", "id": "footer", "templateKey": "legal", "label": "prod" } follows that release pointer. Neither pin means the current version, independently of the top-level default release policy. Reference labels never apply A/B bucketing. Resolution is memoised per render or batch job and isolated by tenant. The same composition map supplies all six formats.

Component props use inputSchema and fieldMappings, after optional dataPath scoping. Matching parent fields pass through automatically. Report parameters are a separate prompt contract; components inherit resolved root @params. Enable applyTargetContract to enforce a child input contract at render time. Without it, missing required props are advisory. Component page settings do not replace the parent’s render configuration.

GET /templates/:key/dependents?transitive=true&limit=50&offset=0 returns permission-visible dependents with pins, definition versions, depth and impact. It scans current, labelled and reachable historical pinned definitions within the request tenant. Bounds are 10,000 templates, 50,000 distinct definitions and five dependency hops. truncated: true means incomplete; onSave means every hop follows latest. Counts include reachable labelled definitions and describe possible consumers rather than recent render traffic.

POST /render/validate reports template_ref_unresolved as an error, including the missing-template, label or version reason; template_ref_props_unmapped is a warning. Existing non-strict renders retain missing-reference output behavior and log unresolved references. Deleting a pinned label returns 409 label_in_use; an administrator can override it with ?force=true. Separate strict default-label protection still applies.

TypeScript exposes templates.list({kind: 'component', q}) and templates.dependents(key, {transitive: true}); Python accepts equivalent keyword arguments. CLI promote prints the count pinned to the promoted label before updating it. Impact-read failures remain advisory.

Environment promotion (Admin routes)

Admin-only GET /admin/bundles/export accepts keys=a,b or all=true, history=current|all, withDeps=true, and include=assets,labels,samples,datasets,schedules. Defaults include assets, labels, samples and datasets. Optional version or label pins one selected root. Current-history exports also carry versions referenced by labels and pinned dependencies. Layouts, themes, components and uploaded fonts travel with their references. Platform tenant identifiers are excluded; customer sample/definition contents retain their meaning.

The attachment is application/vnd.pulp-engine.bundle+gzip (.pebundle), gzip NDJSON with a manifest, asset records, dependency-ordered templates, versions, labels, datasets, schedules and a checksum end record. Definitions have canonical SHA-256 hashes excluding their version string. Unknown record kinds are skipped with warnings. Dynamic assets and dependencies beyond five levels require review; dependency cycles cannot be exported.

POST /admin/bundles/import accepts one multipart file field named bundle; BUNDLE_MAX_BYTES defaults to 250 MB. Imports validate and plan before writing. Query options: dryRun, onConflict=fail|skip|newVersion, preserveVersions=true, labelMap=staging:prod, allowMissingDeps=false, and requireCompatible=false. Fail mode returns 409 with plan and no writes; missing dependencies return 422 unless explicitly allowed. Fast-forward preserves explicit version strings. New-version mode auto-bumps the head. Asset validation uses the normal upload policy, including SVG sanitization or rejection when SVG uploads are disabled. Matching canonical name/size/SHA-256 binaries are reused. Each template and its historical samples commit atomically; partial failures return completed resources. Schedules import disabled with redacted credentials.

pulp-engine export --templates invoice --out invoice.pebundle --env staging
pulp-engine import invoice.pebundle --dry-run --label-map staging:prod --env prod
pulp-engine promote --from-env staging --to-env prod --template invoice --label prod --source-label staging --require-compatible

Cross-environment promotion rejects equal resolved API URLs, including environment-variable overrides. push --strict-deps checks target references before writing. GET /templates/:key/dependencies?version|label inspects transitive template/asset dependencies up to depth five; /dependents reports current and labelled users and their pins. Scans are tenant scoped and respect template read permissions.

Data contracts

GET /templates/:key/schema, GET /templates/:key/sample and POST /templates/:key/validate accept either version or label. Both together return 400; unknown pins return 404. No pin selects HEAD. Variant labels deterministically select their own revision. X-PulpEngine-Template-Version reports the resolved revision.

GET /templates/:key/bindings?version|label lists normalized paths, usage locations/scopes, unknown and unused fields, dynamic expressions and the effective schema. Templates without a schema receive a best-effort inferred schema tagged x-pulp-inferred. POST /templates/infer-schema accepts {sample, requiredWhen?:"present"|"never"} up to 1 MB and returns a Draft-07 schema. All array elements contribute to inference; formats, nullable/mixed values and integer/number widening are considered. pulp-engine infer-schema sample.json --out schema.json runs offline.

GET /templates/:key/compatibility?base=label:prod&candidate=current compares current, label:NAME or version:VERSION selectors.

Change Verdict
New required fields, narrower types/enums, tighter constraints, newly forbidden additional properties Breaking
New optional fields, widened types/enums, required fields made optional Additive
Removed fields or bindings Informational
Added bindings without an input schema; mapping changes affecting required targets Breaking

Label PUT returns a compatibility verdict and records it in audit details. ?requireCompatible=true or LABEL_CONTRACT_GATE=enforce rejects breaking promotions with 409 breaking_contract_change; default off permits them. Variants compare against their control label. Store guards prevent stale verdicts from moving concurrently changed labels. Bundle label mappings use the same gate.

Static analysis is best effort: computed lookups and malformed expressions appear in dynamic; it does not execute expressions or prove arbitrary JSON Schema equivalence. Explicit schemas give more reliable gates than inferred bindings. Add --require-compatible to CLI promotion or use diff --contract --template invoice --base label:prod --candidate current --exit-code in CI (exit 1 for breaking).

Codegen supports typescript, python, csharp, java and go, with --version/--label or --from-bindings. C# targets modern records/System.Text.Json, Java targets Java 17 records with Jackson annotations, and Go emits JSON-tagged structs. DTOs represent nested objects, arrays, optional/nullable values and scalar types; unsupported unions use JSON-value fallbacks. Validate payloads against the source schema for constraints and enums.

Both synchronous SDKs expose bundles, dependency discovery, bindings, schema inference, compatibility comparisons and pin-aware schema/sample/validate methods. TypeScript errors preserve plan/compatibility; Python errors expose the same properties.

Template presence and advisory locks

/capabilities.collaboration reports presence, locks and sharedStore. When enabled, editor/admin credentials can use the following template-scoped endpoints, with the same tenant selection and resource permissions as templates:

Method Path Payload / result
PUT /templates/:key/presence `{sessionId:uuid,mode:‘viewing’
GET /templates/:key/presence Snapshot with items, currentVersion and optional lock
DELETE /templates/:key/presence/:sessionId Leaves presence and releases the same session’s lock; 204
PUT /templates/:key/lock {sessionId:uuid,note?}; returns {lock}, or 409 template_locked
DELETE /templates/:key/lock?sessionId=... Holder release; 403 template_lock_not_owner for others
DELETE /templates/:key/lock?force=true Admin-only forced release; 204

Snapshots expose hashed presence ids and cosmetic display names; they never echo another session’s UUID or tenant identifier. Actor identity comes from the credential. A heartbeat can renew only its own actor/session lock. Lists return at most 50 rows, with a total; stores bound each template to 500 active sessions. Unknown templates return 404. A lock never prevents a template PUT.

TEMPLATE_PRESENCE_ENABLED=false removes these routes. TTL defaults to 90 seconds; lock idle/max lifetimes default to five minutes/eight hours. Redis is selected when app.rateLimitRedis is configured through RATE_LIMIT_STORE=redis. Without Redis, presence and locks are per replica. Redis updates use atomic Lua ownership checks; all keys include a tenant/template hash and a common Redis Cluster hash slot. Both synchronous SDKs expose heartbeat/list/leave and acquire/release methods.

Advanced page setup

renderConfig.paperSize and options.paperSize accept A3, A4, A5, A6, B5 (ISO), Letter, Legal, Tabloid, Executive, Statement, EnvelopeDL, EnvelopeC5 and Envelope10, or { "width": "80mm", "height": "200mm" }. Custom lengths use mm, cm, in or pt, with each dimension between 10 and 1500 mm. Orientation swaps custom dimensions too. Use landscape for a conventional horizontal envelope.

Document margin supports mirrored: true and gutter: "10mm". In duplex mode left is the inside margin and right is outside; the gutter adds to inside. Without mirroring the gutter adds to left. Left/right margins must use physical units when either feature is enabled. Page 1 is recto in left-to-right documents.

Landscape appendix

Each top-level SectionNode can contain pageSetup:

{
  "id": "appendix", "type": "section", "label": "Appendix",
  "pageSetup": {
    "orientation": "landscape",
    "margin": { "left": "15mm", "right": "15mm" },
    "startOn": "oddPage",
    "pageChrome": "none"
  },
  "children": []
}

Supported fields are paperSize, orientation, partial four-side margin, startOn (newPage, oddPage, evenPage, continue), and pageChrome (none or default/first/even ChromeSets). Omitted values inherit the document default. Request paper/orientation overrides change that default, so an orientation-only appendix inherits a request’s Letter size. Explicit section values win. Mirroring and gutter remain document-level. Section margins use mm/cm/in/pt; px is rejected.

A changed layout forces a page break on entry and exit. continue avoids an additional break only when the effective layout matches the previous section. Numbering and TOC destinations continue through native mixed-size pages; odd/even starts insert a blank when needed. Section-first chrome means the first page of that section. Hidden chrome is restored from the document default after the section. Section chrome cannot coexist with legacy pageHeader/pageFooter; use structured chrome. Referenced templates’ internal page setup is ignored in format version 1.

Format Page setup
PDF Sizes, section layout, mirrored margins, gutter, chrome, parity and print production
HTML Physical @page and named-page rules for browser printing; no bleed
DOCX Native sections, sizes, margins, gutter, mirror setting and explicit headers/footers; no print production
PPTX Uses pptxLayout; section setup produces page_setup_ignored in render warnings
XLSX / CSV Section page setup and print production do not apply

renderConfig.printProduction or per-request options.printProduction accepts { "bleed": "3mm", "cropMarks": true }. Bleed uses physical units and is at most 20 mm. It grows each PDF page and all its margins by the same amount, preserving the trim layout. PDF rendering exposes --pe-bleed for artwork extending into the bleed. Preview and HTML output stay at trim size.

PDFs contain a TrimBox at the finished size and a BleedBox around it. Crop marks add 8.5 mm of media outside the bleed on every edge, with eight 0.25 pt registration-black marks. Both buffered and streaming requests support this; production post-passes buffer a stream. This is available through single render, batch, preview and sandbox options. Both SDKs accept custom paper and print-production models.

Watermarks & stationery

Add optional decorations to renderConfig to produce the finished document in one render call:

{
  "watermarks": [{
    "text": "DRAFT {{ref}}", "fontFamily": "Arial", "fontSize": "48pt",
    "color": "#999999", "opacity": 0.15, "rotation": -45,
    "position": "center", "layer": "behind", "pages": "all",
    "when": {"path": "status", "operator": "eq", "value": "draft"},
    "labels": ["staging"]
  }],
  "background": {"image": "/assets/paper.png", "fit": "cover", "pages": "all"},
  "stationery": {"first": "/assets/letterhead.pdf", "rest": "/assets/continuation.pdf", "layer": "under"},
  "pdfInserts": {"prepend": ["/assets/cover.pdf"], "append": ["/assets/terms.pdf"]}
}

Template watermark text is compiled against mapped data and escaped once. when uses a condition clause/group; labels matches the resolved render label. Background and stationery also accept when. Assets must use stored /assets/... paths, including tenant prefixes or /assets/~ref/slug; external URLs and traversal are rejected. Watermarks accept exactly one of text (1-200 characters) and image (PNG, JPEG or WebP). Backgrounds require PNG/JPEG. Each template has at most three watermarks. Image watermarks accept imageWidth (default 60%); text accepts fontWeight:'normal'|'bold'. Rotation is -90..90 degrees, opacity 0.02..1, position center/top/bottom, layer behind/front and pages all/first. Background fit is cover/contain/stretch and pages also accepts notFirst.

For a caller-supplied mark, use options.watermark:{"text":"COPY {{ref}}","rotation":0} on single renders, synchronous batches or asynchronous jobs/batches. Request text is literal, including {{ref}}; request marks are added to mandatory template marks. when and labels are template-only and rejected in request options. The CLI’s --watermark remains a literal text convenience.

Upload unencrypted PDFs through /assets/upload. The default upload page cap is 50; configure ASSET_PDF_MAX_PAGES (1..500). Forged, encrypted, malformed or over-cap PDF uploads return 415 unsupported_media_type. PDF assets are served as attachments. Asset usage scans and deletion guards include decoration references and label-pinned definitions.

PDF watermarks use Chromium and remain streamable. Backgrounds and stationery are painted on generated body pages after TOC/table stamping, then inserts are added, before PDF/A conversion and protection. Each insert list permits at most five PDFs. stream=true silently buffers composition; accounting records streamed:false. Layout references buffer conservatively because their effective decorations are resolved later. Stationery uses the first page of each PDF, stretched to each body’s MediaBox, and emits STATIONERY_ASPECT_RATIO when the aspect ratio differs by more than 2%. Background paints below under-stationery. PDF under-layers force transparent html/body backgrounds.

Cover and appendix pages retain their own size and receive no body decorations. Generated body page numbers and TOC text exclude inserts; named destinations and TOC links still target the correct body pages. Inserted pages lose annotations and page actions; body annotations and destinations remain. Composition retains the existing 50 MB per-PDF, 200 MB total and 10,000 output-page limits.

Missing or wrong-type assets produce 422 page_decoration_asset_invalid, also reported by /render/validate. PDF parsing/limit failures retain their existing PDF error codes. /capabilities.features.pageDecorations describes supported features per format.

Format Watermarks Background Stationery / PDF inserts
PDF Text/image, conditions, labels, layers, all/first All fits/selectors Supported
HTML Text/image layers and first/all print positioning Ignored Ignored
DOCX Native header VML text; floating PNG/JPEG images; all/first Floating page image with fit/selectors Ignored with warnings
PPTX Master objects; front/first requests warn and apply behind/on all slides Master image, stretched; other fits/selectors warn Ignored with warnings
XLSX / CSV New template decorations ignored Ignored Ignored

DOCX image opacity and WebP are unsupported and produce degradation warnings. PPTX watermark images require PNG/JPEG. Word/PowerPoint reference fonts by name; install them on the viewing machine. Existing XLSX text request watermarks remain its legacy print-header feature. The old template renderConfig.watermark remains supported.

For existing PDF bytes, use the post-render watermark utility.

Bursting statements to individual customers

A schedule can fetch one data source and send a separate document for each record. For example, add these fields to a PDF statement schedule:

{
  "dataSource": {"type":"static","data":{"company":"Acme","customers":[{"accountNo":"A001","name":"Alice","email":"alice@example.com"},{"accountNo":"B002","name":"Bob","email":"bob@example.com"}]}},
  "burst": {"itemsPath":"customers","keyPath":"accountNo","mergeParent":true,"concurrency":2,"maxItems":1000,"onOverflow":"fail"},
  "deliveryTargets": [{"type":"email","to":[],"toPath":"email","subject":"Statement {{burst.key}}","attachmentName":"{{item.accountNo}}.pdf"}]
}

With mergeParent:true (default), each template sees parent fields plus the item’s fields; itemAs:"customer" instead places the record under customer. Primitive records become {value:...}. itemCondition is evaluated before the limit, and filtered records retain their original indexes. Empty results are skipped. The default limit is 1,000; exceeding it fails before rendering unless onOverflow:"truncate" is selected. Customer keys must be unique.

Token Value
item.* Original item, for delivery text and filenames
burst.index Original zero-based data index
burst.key Customer key, or original index as a string
burst.count Number of selected records
@render.burst The same metadata inside document templates

toPath and ccPath are item-relative paths to strings (comma/semicolon separated) or string arrays. Static and dynamic addresses are combined and deduplicated, with a maximum of 50 recipients. Missing or invalid addresses fail that item without retry or DLQ. S3 object keys must contain an item.* or burst.* token. Recipient paths require a burst schedule.

POST /schedules/burst/preview accepts {dataSource,burst,deliveryTargets,parameters?} and returns counts and the first three resolved recipients/subjects/keys without rendering or sending. It shares the five-per-minute trigger rate bucket. Create, PUT and PATCH accept burst:null to disable bursting.

Execution burstSummary reports total, filtered, truncated, succeeded and failed counts. Zero failures means completed; zero successes means failed; mixed results mean partially_failed. GET /schedules/:id/executions/:execId/items?status=failed&limit=20&offset=0 returns public item details and original recipients. Terminal items are skipped after a crash; an item interrupted during sending can be delivered again (at-least-once). Its saved recipients and bytes are reused. A heartbeat protects active executions from stall recovery. Ten consecutive failures abort remaining items by default; set abortAfterConsecutiveFailures:0 to disable this guard.

Plan temporary disk for selected items multiplied by average output size, plus concurrent rendering buffers. Successful item files are removed after five minutes; failed artifacts remain for replay until execution retention purges them. With document storage enabled, individual unbundled outputs are also retained as documents. Document expiry and privacy erasure remain authoritative. Execution and document retention are separate: recipients and resolved delivery text remain in execution item rows until execution history is purged. Purging an execution cascades its item rows and removes temporary artifacts. DLQ replay targets the original item and uses frozen delivery fields with live webhook credentials.

Batch ZIP and merged PDF packages

All six batch formats support packaging:"zip"; PDF also supports packaging:"merged-pdf". Omit packaging for the existing JSON contract. Items can carry reference (1-128 characters), filename (1-200) and PDF bookmark title (1-200). The server sanitizes filenames and adds -2, -3, etc. for collisions. JSON, NDJSON, asynchronous results and ZIP manifests echo references.

{
  "packaging":"merged-pdf",
  "packageOptions":{"filename":"statements.pdf","outline":true,"pageLabels":"per-record","onItemError":"skip","metadata":{"title":"Monthly statements"}},
  "items":[
    {"template":"statement","reference":"A001","title":"Alice","data":{"name":"Alice"}},
    {"template":"statement","reference":"B002","title":"Bob","data":{"name":"Bob"}}
  ]
}

ZIP entries stream in completion order; the final manifest.json is authoritative and records input index, reference, template, success, filename, byte count, SHA-256 or error. Archives use UTF-8 names and ZIP32 (65,535 entries and 4 GiB); a disconnected or truncated archive is incomplete. If every item fails, the response is JSON 422 batch_items_failed. ZIP includes successful items when others fail.

Merged PDFs preserve input order, per-record bookmarks, nested source bookmarks and links within each source document. pageLabels:"per-record" restarts viewer labels for each record; it does not rewrite printed page numbers. Outlines default on. Item errors fail the package with 422 and failures[] unless onItemError:"skip" is chosen; no successes still fails. Existing PDF utility size/page limits apply. X-Batch-Total, X-Batch-Succeeded, X-Batch-Failed and X-Batch-Failed-Indexes describe the result. Packaging cannot be combined with NDJSON.

output:{store:true,respondWith:"link"} stores one package and returns its document link. Item-mode /render/jobs also accepts packaging; poll the package result, then use GET /render/jobs/:id/package (409 until ready). Private package bytes follow the existing job retention and tenant isolation. Packaging writes no additional render usage rows.

TypeScript: client.batch.package('pdf', items, {packaging:'zip'}) returns bytes, filename, content type and counts; client.jobs.downloadPackage(id) downloads an asynchronous package. Python provides batch.package, jobs.download_package, schedules.preview_burst and schedules.list_execution_items, including asynchronous batch/package download helpers. Both SDKs retain failures[] on errors.

CLI: pulp-engine batch --template statement --data customers.ndjson --format pdf --out statements/ --reference-field accountNo --filename-field filename --title-field name. Directory mode chunks to the advertised server maximum, writes a manifest and exits 1 for item failures. Use an .zip or .pdf output for a single package; packaged input must fit one server batch, and larger jobs should use directory output. --fail-fast stops after a failed chunk.

Languages, translation catalogues and direction

A template may include i18n: { defaultLanguage: "en", languages: ["en", "fr", "ar"], languagePath: "customer.language", strings: { en: { title: "Invoice", count: "{n, plural, one {# item} other {# items}}" }, fr: { title: "Facture" } } }. Use {{t "title"}} and {{t "count" n=items.length}} in content, table headings, metadata or page chrome. Referenced catalogues merge before the root, which wins collisions. Regional fallback is requested language, its parent tags, then the authored default; ICU uses the source message’s language for plurals.

Language precedence is request options.locale (string or {language}), valid adapted data at i18n.languagePath, renderConfig.locale.language, i18n.defaultLanguage, then en. Invalid data tags fall back and produce invalid_language diagnostics. The selected language is exposed as @render.language and drives formatting. Set renderConfig.direction or options.direction to auto/ltr/rtl; explicit request direction wins. Nodes accept lang, dir and style.direction, with inheritance. Logical alignment uses start/end; physical left/right remains available.

GET /templates/:key/i18n?format=json|csv&languages=en,fr returns a canonical-language catalogue and current-version ETag. The default language is always included. PUT /templates/:key/i18n accepts a catalogue plus optional merge: true, requires a strong If-Match: "1.0.0", and creates a version through the existing update service. Missing, weak and stale conditions return 428, 400 and 412 respectively. Change-note policies apply through X-PulpEngine-Change-Note. Both routes require template read/write permissions in the resolved tenant. PUT JSON only; convert CSV with the editor, shared helpers or CLI.

BCP 47 tags and every ICU source are validated: at most 50 language tables, 5,000 keys per language and 4,000 characters per message. Keys match ^[A-Za-z][A-Za-z0-9_.-]{0,127}$. Invalid ICU errors identify the message path. /render/validate reports absent literal catalogue keys as unknown_translation_key errors and missing rendered translations as missing_translation warnings. Missing translations render their key; strictBindings: true rejects them with 422 unresolved_bindings.

plural uses Intl cardinal or type="ordinal" rules. spellNumber uses the selected language and supported authored fallback. {{amountInWords 1234.56 currency="USD" minor="fraction"}} returns One thousand two hundred thirty-four dollars and 56/100 in English; decimal minor units avoid floating-point cents. Options include lang, case="sentence|upper|lower" and minor="words|fraction". Reserved catalogue keys pe.currency.<CODE>.major and .minor accept ICU {n} for unit names. Built-in currency names and the connector are English; unsupported spellers return digits with a diagnostic when no supported fallback exists.

Format Language and RTL behavior
HTML/PDF Root/per-node lang/dir, browser shaping, logical alignment/spacing and mirrored flow columns; PDF chrome is sanitized and wrapped with language/direction.
DOCX Native paragraph bidi, strong RTL text runs, language metadata, bidiVisual tables and mirrored columns; LTR subtrees remain LTR.
PPTX Native presentation/paragraph/run direction/language, mirrored table columns and columns regions.
XLSX rightToLeft sheet views, RTL reading order and logical cell alignment.
CSV Translated text and headings; no visual direction/layout.

Charts and absolute coordinates keep physical geometry. Use fonts covering the requested scripts. Translation diffs appear under i18nChanges; independent language/key edits survive three-way merge. See the Wave 46 verification and boundaries.

Image pages, private thumbnails and email HTML

POST /render/image accepts the normal render request plus image: {format:“png”|“jpeg”|“webp”,dpi:150,pages:{start:1,end:1},width?,quality?,background?,packaging?:“auto”|“zip”}. Page ranges are one-based; an omitted end selects the remaining pages. Width overrides DPI. One selected page returns image bytes; multiple pages return a ZIP with images and manifest.json. X-Render-Pages reports total PDF pages and X-PulpEngine-Pages-Rendered reports the selection. Images use the completed PDF, including decorations, and PDF.js rasterization can differ slightly from Chromium anti-aliasing. dryRun is supported; output.store is rejected for these targets.

GET /templates/:key/thumbnail?version=1.0.0&width=160&format=png accepts width 160/320/640 and png/webp. Admin, editor and render scopes are accepted. Responses are tenant-private, sample/version-aware, ETag-enabled and cached for 24 hours. They are fetched with credentials, never served from a public asset URL.

POST /render/email accepts email: {width?:640,images?:“cid”|“data-uri”|“absolute”,includePageChrome?:false}. It returns html, text, attachments (cid, filename, contentType, contentBase64) and warnings. CID is the default. Send each attachment with its CID when composing a MIME email. Absolute asset URLs require EMAIL_ASSET_BASE_URL; generated images still use CID attachments. The preview variants are /render/preview/image and /render/preview/email. Capability flags formats.image and formats.email control editor availability.

POST /render/html additionally accepts html: {selfContained:true}, forcing strict asset inlining in public mode too. Without that flag, existing HTML output is unchanged. Scheduled email bodyTemplate accepts templateKey, optional mutually exclusive version/label, and attachOutput (default true); frozen HTML, text and CID images survive DLQ replay. TypeScript and Python expose render.image, render.email and templates.thumbnail. CLI render supports –format png/jpeg/webp, –dpi, –width, –quality and –pages.

See the output target report for limitations and pending external email-client validation.

Optional anchorId names document destinations. Literal names match ^[A-Za-z][A-Za-z0-9_]{0,39}$; expressions such as row_{{id}} are resolved in scope. Text, heading, image, barcode and table-column definitions accept link:{href,title?}. Rich-text hrefs also accept Handlebars. Resolved http(s), mailto, tel and valid #anchor targets are allowed. Unsafe destinations retain content without a link. Use {{urlEncode id}} for URL components; the payload controls the resulting domain/path. Internal targets omitted by a conditional leave plain content.

Format Links and body page fields
HTML External/internal links; page number/total/reference placeholders show 1/1/#.
PDF URI and GoTo annotations; body fields resolve through repeated Chromium layout in the body font.
DOCX Native bookmarks, linked text/pictures and PAGE/NUMPAGES/PAGEREF fields. Cross-reference results require Word field updates; online/alternative viewers can show stale or empty values.
PPTX Native external and backward internal slide links; forward targets report link_unresolved. Body page fields are omitted with page_ref_unsupported.
XLSX External column hyperlinks and tooltips; internal links remain plain values; body page fields are omitted.
CSV Links have no effect on bytes; body page fields are omitted.

Body syntax is {{@pageNumber}}, {{@totalPages}} and {{pageRef "terms"}} (or a variable holding the anchor name). PDF values use the same page labels and counted totals as page chrome. Invalid helper arguments return 422 template_expression_error with node location. Missing targets display ?? and fail strict binding renders. Documents with references usually need two PDF prints, capped at three; unstable layouts log page_refs_unstable. These streams buffer the final PDF, as TOC/post-processed streams do. Templates without fields retain one print. No endpoints or SDK request fields change. See Wave 49 verification.

Browser render tokens

Enable RENDER_TOKENS_ENABLED=true and set a separate RENDER_TOKEN_SECRET of at least 32 hexadecimal characters. RENDER_TOKEN_SECRET_PREVIOUS verifies old tokens during rotation. Default limits are 60 minutes (RENDER_TOKEN_MAX_TTL_MINUTES, maximum 1440) and 100 renders (RENDER_TOKEN_MAX_RENDERS). Tokens are disabled by default; mint/revoke then return 503 service_unavailable and the token header is ignored.

Mint on a trusted backend using a render/admin API key:

const server = new PulpEngineClient({
  baseUrl,
  apiKey: process.env.PULP_RENDER_KEY,
})
const issued = await server.renderTokens.create({
  templates: ['certificate'], label: 'prod', formats: ['pdf'],
  maxRenders: 3, ttlSeconds: 900, allowFormMetadata: true,
  fixedData: { customer: { id: 'c_123' } }, subject: 'cust_123',
})
// Send issued.token to this customer through your authenticated application.
const browser = new PulpEngineClient({ baseUrl, renderToken: issued.token })
const pdf = await browser.render.pdf('certificate', {
  customer: { name: 'Ada' },
})
// Later, on the backend:
await server.renderTokens.revoke(issued.tokenId)

HTTP equivalents are POST /render-tokens with X-Api-Key, POST /render/pdf with X-Render-Token, and DELETE /render-tokens/:tokenId with X-Api-Key. Python provides client.render_tokens.create(...) and .revoke(token_id) for backend use. Responses contain no tenant identifier.

Mint option Scope and default
templates 1-20 existing template keys; minting credential permissions apply
label / version Optional, mutually exclusive revision pin; conflicting request pins are rejected
formats PDF, HTML, CSV, XLSX, DOCX, PPTX; default PDF
maxRenders Lifetime budget, default 10 bounded by configured maximum
ttlSeconds 60 through configured maximum; default 900 bounded by maximum
allowedOrigins Optional list of up to 10 HTTP(S) origins
fixedData Raw data merged over browser data before mappings/validation; objects recurse, arrays/scalars replace
allowFormMetadata Default false; enables scoped form-metadata reads without spending quota
subject Optional audit actor suffix, at most 128 characters

Tokens are opaque AES-256-GCM credentials (rt1) with HKDF-derived keys and a 6 KiB cap. Their claims bind tenant, template/revision, formats, budget, expiry and minting credential. Fixed values are encrypted and authenticated; the browser cannot inspect or alter them. Prototype-polluting keys are discarded during merging. Audit records retain fixed-data key names only. Do not put tokens in URLs, analytics or persistent browser storage.

Only single synchronous render routes and optionally form metadata accept these credentials. Batch, transforms/merge, previews, validation, template management, token minting and capabilities are denied. Inline data is required; external data sources, raw definitions and dry runs are rejected. A revoked/expired/tampered/unknown-secret token returns 401. Constraint failures return 403 render_token_constraint. After three successful budget admissions, a budget of three returns 429 render_token_exhausted; X-Render-Token-Remaining counts down 2, 1, 0. Admitted failures also consume quota, without refunds. Tenant and template rate limits still apply.

Use Redis (RATE_LIMIT_STORE=redis, REDIS_URL) for shared counters and revocations across replicas. Memory counters are local to one API process and disappear on restart, invalidating its outstanding tokens. Database-key revocation/expiry invalidates its child tokens through the existing credential registry; role and template restrictions are rechecked. Removing an environment/JSON minting key invalidates its child tokens when the process reloads credentials.

Origin binding limits browser misuse; a non-browser client can forge Origin and a stolen token remains a bearer credential. Origin-bound requests must provide an allowed Origin or the API’s own origin; missing Origin is rejected, including metadata GETs. Same-origin form iframe GETs usually omit Origin, so omit allowedOrigins for that integration and rely on scoped claims and your embed policy. For direct cross-origin fetch, configure CORS_ALLOWED_ORIGINS for the host. For iframes, configure EMBED_ALLOWED_ORIGINS; split-origin API hosting also needs EMBED_CONNECT_ORIGINS. CSP/CORS do not replace credential scope. Refresh short-lived tokens through your authenticated backend as needed.

Tenant lifecycle operations

Multi-tenant PostgreSQL and SQL Server deployments provide GET /admin/tenants/:id/purge-preview, DELETE /admin/tenants/:id?purge=true&confirm=<id>&retainAudit=true&retainUsage=false, GET /admin/tenants/:id/operations and GET /admin/tenants/:id/operations/:opId. These require a super-admin. File/plugin storage returns 503. Default and sandbox tenants cannot be purged; archive first, then wait TENANT_PURGE_GRACE_HOURS (24 by default). The DELETE returns 202 and a Location polling URL. Failed purges can be resubmitted with the same confirmation and resume with cumulative counts.

GET /admin/tenants/:id/export?include=templates,assets,schedules,users,audit,usage&auditSince=<ISO> streams a ZIP, including archived tenants. Its versioned manifest hashes every entry. Metadata tables use NDJSON paths (templates/index.ndjson, templates/versions.ndjson, assets/index.ndjson, schedules.ndjson, editor-users.ndjson, audit-events.ndjson, render-usage.ndjson); binary filenames are encoded under assets/files/. Credentials and schedule secrets are excluded or redacted. This tenant ZIP is distinct from the single-template bundle format.

POST /admin/tenants/import accepts multipart bundle, id, name, activate=false. Compressed and expanded bundles are limited to 128 MiB. The target must be new; imported versions retain their strings and attribution, asset strings are rewritten, users with global identity collisions are skipped, and schedules remain disabled. Replace redacted secrets before enabling schedules. POST /admin/tenants/:id/clone accepts {targetId,name,include?,activate?}; groups are templates/assets/schedules. Same-instance clone copies secrets with new tenant encryption context and never copies users, audit or usage. Targets remain archived unless activation was requested.

Use pulp-engine tenant export|import|clone|purge|status; purge requires --confirm <id> or matching interactive input. Both SDK admin resources expose CRUD, preview, purge, operations, wait, streaming export, import and clone.

Static grids, shapes and form controls

The document schema accepts grid, shape, checkbox and comb nodes. A grid has columnWidths, id-bearing rows[].cells[], cell colSpan/rowSpan and normal content children. Grid rows expose height, repeatAsHeader and keepTogether; cells expose verticalAlign and style. Grid-level border, cellPadding and caption define defaults. Widths are positive physical lengths, percentages or auto; limits are 63 columns and 500 rows. Headers are contiguous from row zero, cannot contain tables/grids, and no grid cell may contain a page break or sticky header.

Table columns gain repeatedValues: "show" | "suppress" | "merge". Merge/suppress runs are hierarchical and reset at group boundaries. CSV always retains all values; XLSX retains suppressed values and uses native merges for merge mode. PPTX degrades merge to suppression with a warning. Page-aware total and continuation bands reject merge mode. Sparkline columns require Show.

Shapes accept rect/roundRect/ellipse/line/arrow, physical width/height, RGB fill/stroke, strokeWidth, dash, radius, rotation (±360), flip, arrowEnds and alt. A line/arrow may have one zero dimension. Checkboxes accept a checked condition, overriding checkedExpression, Handlebars label, variant and physical size. Combs accept Handlebars value, 1–64 cells, physical cell dimensions, align, uppercase and border. Dimensions are bounded at 10,000 px. Word checkboxes are toggleable content controls except radio; Word and PowerPoint combs and grids are native tables. PowerPoint shapes are editable; its grid cells flatten supported content to text and warn once for unsupported children. Standalone PowerPoint checkboxes are images plus labels. See the per-format table.

Convert HTML to PDF

When CONVERT_HTML_ENABLED=true, POST /render/convert/pdf accepts exactly one of html or an allowlisted url, optional data, locale, page options and dryRun. Render/admin credentials are required. It returns PDF bytes by default; ?stream=false buffers the result.

{"html":"<title>Invoice</title><p>{{customer.name}}</p><p>{{currency total}}</p>","data":{"customer":{"name":"Acme"},"total":42},"locale":{"currency":"en-NZ"},"options":{"paperSize":"A4","margin":{"top":"20mm","right":"15mm","bottom":"20mm","left":"15mm"},"filename":"invoice.pdf","disposition":"inline"}}

The standard Handlebars helpers are available when data is supplied; interpolation escapes values by default. Without data, HTML passes through without template compilation. The document title becomes PDF title metadata. Page headers/footers compile against the same data and use Chromium pageNumber and totalPages spans. They retain the worker’s existing sanitization and licence watermark. preferCssPageSize:true honors CSS @page sizes. Tenant-owned /assets/ images and CSS url(/assets/...) references are inlined strictly; inaccessible assets fail.

JavaScript is disabled before content loads, including scripts before the CSP, event handlers and client-side chart libraries. The first head element is a restrictive CSP and refresh metadata is removed. Request/DNS guards and the configured isolation mode still apply. URL mode fetches HTML in the API, checks the exact origin on every redirect, permits at most three redirects and requires a bounded HTML response. Chromium receives the fetched document and final base URL; remote subresources continue to obey render egress rules. Container mode has no network.

For wkhtmltopdf migrations, move header/footer HTML into options.pageHeader/pageFooter, replace its header variables with Chromium spans, and move script-generated content to server-side Handlebars. For DocRaptor, document_content maps to html and document_url maps to url; JavaScript-based layout requires migration. Conversion is PDF-only and bypasses TemplateDefinition. Usage has production source, convert mode and no template key. Template-mutating pre/post hooks are skipped; render events and observe-only hooks use __convert__.

Production inline definitions

Enable INLINE_TEMPLATES_ENABLED to send {definition: <TemplateDefinition>, data: {...}} to production render routes, replacing template. The definition cannot accompany template/version/label/bucketKey selectors. It uses strict schema validation, production data-source semantics and the production PDF lane. Template, theme and layout references remain tenant-scoped. Inline templateRef data sources require INLINE_TEMPLATES_ALLOW_DATA_SOURCES=true. These routes require render/admin scope, independently of preview enablement.

All six document formats, image and email exports accept the same source selection. Synchronous batches, legacy asynchronous batches and /render/jobs accept one shared top-level definition with source-free items; mixed sources are rejected. Per-render plugins run normally with templateSource:'inline'. Usage keys are inline:<definition.key>; metrics cardinality remains bounded. Inline responses expose X-PulpEngine-Template-Source:inline and the first sixteen hex characters of the canonical SHA-256 in X-PulpEngine-Definition-Hash, without saved-label/version headers.

TypeScript: client.render.pdf({definition, data}), client.render.fromHtml({html, data}), client.render.fromHtmlStream({html, data}). Python: client.render.pdf(definition=definition, data=data) and client.render.from_html(html, data=data). Batch methods accept a shared definition in their options. CLI: pulp-engine render --definition template.json --data data.json --format pdf, or --html invoice.html / --url https://allowed.example/invoice for PDF conversion. Exactly one source flag is required; raw conversion can omit data.

AI assistance for an open template

These editor/admin endpoints require an Anthropic key and AI_EDITOR_ASSIST_ENABLED=true. They return proposals; they never save templates. POST /templates/edit uses the AI generation rate bucket, while expression and sample generation use the AI edit bucket.

Endpoint Request Result
POST /templates/edit {definition,prompt,selectionIds?,sampleData?,model?,maxAttempts?} {template,operations,summary,changedNodeIds,warnings,meta}
POST /nodes/expression `{description,kind:“condition” “value”,inputSchema,sampleData?,model?,maxAttempts?}`
POST /templates/generate-sample {inputSchema,hint?,count?:1..5,model?,maxAttempts?} {scenarios:[{name,data}]}

Edits preserve the key/version and validate the complete resulting definition. Selected-node requests confine edits to those subtrees. Unknown IDs, duplicate IDs, illegal parents, invalid expressions and schema-invalid data enter the bounded repair loop. Context objects are limited to 64 KB each; definitions are limited to 200 KB. Only up to 8 KB of sample context enters edit/expression prompts. Async JSON Schema is unsupported.

POST /templates/generate also accepts inputSchema and sampleData (64 KB each). A supplied schema is preserved. Supplied data must validate against the resulting schema; it becomes the returned preview sample. Responses may include warnings when sample data was synthesized.

TypeScript: client.templates.edit(...), client.templates.generateSample(...), client.nodes.expression(...). Python: client.templates.generate(prompt, input_schema=..., sample_data=...), client.templates.edit(definition,prompt), client.templates.generate_sample(schema), client.nodes.expression(description,kind,schema). Python generation also retains its existing dictionary form.

Import an existing document

POST /templates/import accepts one multipart file plus optional mode=auto|ai|deterministic, JSON-string inputSchema/sampleData, hint (2,000 characters) and uploadAssets=true|false.

curl "$PULP_API/templates/import" -H "X-Api-Key: $PULP_API_KEY" \
  -F 'file=@invoice.html' -F 'mode=auto'

Automatic mode converts HTML and DOCX deterministically, including Handlebars bindings. PDF and PNG/JPEG/GIF/WebP use AI and require a key plus AI_TEMPLATE_IMPORT_ENABLED=true. SVG and hosted URLs are unsupported. Defaults: 10 MB upload, 10 PDF pages, 8,192 AI output tokens. HTML must be UTF-8; DOCX expanded content is limited to 50 MB. Images are decoded within 32 million pixels and reduced before AI submission. Password-protected/corrupt PDFs and DOCX are rejected.

The response is {template,sampleData,warnings,meta:{mode,source,pages?,attempts?,model?}} without tenant identifiers. Templates remain unsaved. Embedded raster assets are retained as data URIs unless the caller explicitly requests uploadAssets=true, which stores them for the current tenant. Remote image URLs are retained unfetched with warnings. Review fidelity and warnings before persisting the returned template.

TypeScript: client.templates.importDocument(bytes,{filename:'invoice.docx',mode:'auto'}). Python: client.templates.import_document(bytes,'invoice.docx',mode='auto'). CLI: pulp-engine import invoice.html --offline --out drafts; omit --offline to use the API. --schema, --sample, --mode and --strict are supported; strict mode writes the draft then exits 1 if warnings exist. Existing ZIP environment-bundle imports keep their original behavior.

Agent integrations (MCP)

Use the stdio @pulp-engine/mcp package or opt-in POST /mcp (MCP_HTTP_ENABLED=true) for template discovery, input contracts and document rendering. Writes and promotion are separately gated; HTTP inner calls retain the caller’s existing permissions. See MCP setup and tool reference and no-code integration setup.

Pivot matrix exports

pivotTable supports nested column dimensions, column subtotals, date buckets, calculated measures and parent/previous-column show-as modes in HTML/PDF, DOCX, PPTX, CSV and XLSX. All formats share the same header geometry and aggregate values; DOCX uses native horizontal/vertical merging, PPTX uses horizontal spans with blank vertical continuation cells, and XLSX stores percentages as numeric ratios. See pivot behavior and the Sales Pivot (Matrix) starter.

Long-document typesetting (Wave 62)

Any node may define up to four runningValues: [{name: "customer", value: "{{name}}"}]. Names start with a lowercase letter and contain at most 32 letters, digits or underscores. Definitions are evaluated in node scope, per repeater item (or group-mode group), and per table group for TableGroupLevel.runningValues. Values are plain text, trimmed and capped at 200 Unicode characters. Empty values emit no marker and retain the previous value.

Structured pageChrome text accepts {{running "customer"}}, {{running "customer" policy="last"}} and {{running "customer" default="None"}}. first selects the first value on the page or a previous value; last selects the last value on or before the page; start selects the incoming value, falling back to the first value on the page. Before the first marker, the default or an empty string is used. The helper returns empty outside structured chrome; legacy pageHeader/pageFooter reject it. For a dictionary range, combine {{running "entry"}} - {{running "entry" policy="last"}}.

PDF resolves named destinations after pagination and stamps running text through the existing single post-pass. Buffered and streaming requests share the same behavior. Text too wide for a chrome slot is shortened with an ellipsis. Missing destinations are skipped with a warning. IPC/container dispatch transports plain JSON marks. HTML preview and browser-print chrome use the first resolved values; inspect the exported PDF for actual page-specific results. DOCX uses native Word STYLEREF fields with transparent source text in clipped, zero-flow frames. Word implements first/last selection; start approximates first. If a name has no source at all, its default is literal text. Word may search forward when a first-page source is absent; PDF defines the exact default/carry semantics. PowerPoint bakes values per slide; content that automatically overflows a slide can have approximate attribution.

Rich text can insert { "type": "note", "kind": "footnote", "children": [{ "type": "text", "text": "Excludes GST" }] }. kind defaults to footnote; endnote is also supported. Each body has 1-50 text or link children, supports formatting and bindings, and cannot contain another note. {{footnote "Excludes GST"}} and {{endnote sourceText}} work in text, headings and table header, header-row, body and footer content. Bodies are resolved once; payload braces remain literal. Attribute and TOC contexts do not consume note numbers.

Optional settings:

{
  "notes": {
    "placement": "sectionEnd",
    "numbering": "symbol",
    "endnoteNumbering": "lower-roman",
    "restart": "section",
    "labels": { "footnotes": "Notes", "endnotes": "Sources" }
  }
}

Place this object under renderConfig. Placement defaults to documentEnd, restart to document, footnote numbering to decimal and endnote numbering to lower-roman. Both kinds also accept lower-alpha and symbol. Symbols cycle through * / dagger / double dagger / section / parallel / pilcrow, then double. An empty heading hides it. Endnotes always appear at document end. HTML/PDF/PPTX deduplicate identical resolved bodies within a numbering scope; DOCX creates one native note per reference.

Format Running values Notes
HTML First values in preview/print chrome Superscript links, section/document lists and backlinks
PDF Actual per-page first/last/start values Clickable references and lists; page-bottom reflow is outside v1
DOCX STYLEREF, first/last; start approximates first Native footnotes at page bottom and native endnotes; Word controls numbering and ignores the notes config
PPTX Per-slide baked values, approximate on automatic overflow Superscript runs and section/document blocks
CSV / XLSX No running chrome Note markers and bodies omitted

Templates without these fields retain existing HTML bytes. The legacy text variant footnote remains a font-size preset, labelled Small print in the editor.

Offline hyphenation and flow columns

Optional renderConfig.hyphenation accepts { "enabled": true, "language": "de-DE", "minWordLength": 6, "hyphenateCaps": false }. Language precedence is explicit hyphenation language, document language, metadata language, render locale, then en-US; explicit node lang overrides the inherited pattern language. Bundled packs are en-US, en-GB, de, de-CH, nl, da, fr, it, es, pt and pl. Region variants use their available base pack. An unavailable pack skips insertion and emits one warning per render.

HTML/PDF insert U+00AD discretionary hyphens after binding resolution, with request-local word caches and cached pattern modules. style.hyphens: "auto" enables one subtree even when the document setting is off; none/manual suppress automatic insertion. style.textWrap accepts wrap, balance or pretty. CSS uses manual hyphens, so Chromium does not depend on installed dictionaries. Markup/attributes/entities, URLs, email, identifiers, short words, capitals by default and existing soft hyphens are preserved. Raw malformed/unbalanced markup is returned unchanged. Text copied from PDFs can retain soft hyphens and line-end splits; no ActualText repair is claimed.

Containers and sections accept columnCount (clamped integer 1-6), columnGap (default 1em), columnRule, columnFill (balance/auto). Direct children accept columnSpan: "all". Format generation remains 1.

Feature DOCX fidelity Other formats
Hyphenation Native Word settings and default run language; no inserted soft hyphens. Word controls dictionaries, minimum word length and per-node suppression. Offline deterministic HTML/PDF patterns; ignored in PPTX/CSV/XLSX.
Flow columns Continuous Word sections with repeated page geometry and inherited chrome; direct-child spanning headings work. Word balances and drops divider colour/style. Nested flows and table-cell flows use one column. CSS print flow in HTML/PDF; PPTX uses one column and emits PPTX_UNSUPPORTED_STYLE; spreadsheet formats ignore layout.

See the verification report for Chromium fragmentation, native Word, licence provenance and the measured performance-target miss.

PDF pipelines and multipart uploads (Wave 64)

POST /render/pdf/process takes sources, operations and optional output. Each source has an optional unique id and title, and exactly one of data (base64 PDF), assetId, documentId, or render: {template, data, version?, label?, options?}. Asset, document and template lookups use the requesting tenant. Encrypted inputs are rejected. There are at most 50 sources by default, including at most five render sources. Rendered sources use production template resolution, asset inlining and trust settings; each records batch-item usage, followed by one transform usage row for the finished pipeline.

{
  "sources": [
    {"id":"invoice","title":"Invoice","render":{"template":"invoice","data":{"number":"INV-101"}}},
    {"id":"terms","title":"Terms","assetId":"terms-pdf-asset"}
  ],
  "operations": [
    {"op":"append","sources":["terms"],"bookmarks":"per-source"},
    {"op":"stamp","text":"Page {page} of {pages}","position":"bottom-center"},
    {"op":"metadata","title":"Invoice pack"}
  ]
}

Source references are zero-based indexes or source IDs. Page selectors are one-based and preserve order and repetitions: 1-3,7,10-last, odd, even, last, or {start:2,end:5}. extract "3,1,2" also reorders pages. Selectors cannot contain zero, descending ranges or pages beyond the current document.

Operation Fields and behavior
append sources, optional bookmarks: none / preserve / per-source; preserves source navigation by default.
insert source, position: {at: start / end / before / after, page?}, optional bookmarks.
delete, extract pages; deleting every page fails. Extraction can repeat pages.
rotate degrees: 90 / 180 / 270, optional pages; adds to existing rotation.
watermark Existing text/image watermark shape, optional pages; preserves legacy watermark positioning.
stamp text or image: {data} / {assetId}, position, optional pages, margin, fontSize, color, opacity, rotation, width, height, fontAssetId, numberFormat, bates.
metadata title, author, subject, keywords, language.
bookmarks entries: [{title,pageIndex,children?}], optional replace (default true); pageIndex is zero-based.
flatten Flattens AcroForm fields; other annotations remain.
protect userPassword, ownerPassword, permissions; must be last.
split Exactly one of every, ranges, byOutline:true; terminal except for a final protect.

Stamp anchors are top, middle or bottom combined with left, center or right, plus center, or visual {x,y} coordinates in PDF points. Anchors respect CropBox offsets and quarter-turn page rotation. Tokens are {page}, {pages} and {bates}. A Bates recipe is:

{"op":"stamp","position":"bottom-right","bates":{"prefix":"ABC","start":101,"digits":6}}

An 87-page run returns X-PulpEngine-Bates-Range: ABC000101-ABC000187. Place stamps after page edits when the identifiers must describe the final page order. Helvetica is the default; upload a tenant font asset and set fontAssetId for text outside WinAnsi. Unencodable text returns pdf_text_unencodable (422). Font assets retain the configured font-size and embedding-permission checks.

A PDF response includes X-Render-Pages. Split responses default to ZIP with one PDF per part and manifest.json; output.splitAs: "json" returns base64 parts, each with filename, source page range and exact pageNumbers order. For a merged mail-merge PDF with top-level customer bookmarks, use {"op":"split","byOutline":true} to produce one named file per customer.

POST /render/pdf/process/upload accepts multipart named PDF files plus one spec JSON field. In that spec, use {part:"invoice"} to refer to the uploaded field named invoice. JSON source references can be mixed with uploaded sources. Duplicate, missing or unreferenced file fields are rejected. Files must start with %PDF-. The SDK methods are TypeScript pdfTransform.process(params) / processUpload(files, spec) and Python pdf_transform.process(sources, operations) / process_files(paths, operations). Python returns BinaryResult for PDF, ZIP and JSON split bytes.

Defaults: 20 operations, five render sources, 500 split parts, 50 MB per input, 200 MB total and 10,000 output pages. Operators configure PDF_UTIL_MAX_OPERATIONS, PDF_UTIL_MAX_RENDER_SOURCES, PDF_UTIL_MAX_SPLIT_PARTS and the existing PDF utility size/page limits. Invalid ordering is 400 bad_request; invalid selections are 422 invalid_page_range; destructive or excessive split operations are 422 pdf_operation_invalid. Existing merge, watermark and insert endpoints retain their contracts and use the same pipeline core.

CLI examples (all PDF subcommands use multipart):

pulp-engine pdf merge invoice.pdf terms.pdf -o pack.pdf
pulp-engine pdf split pack.pdf --by-outline -o customers.zip
pulp-engine pdf extract pack.pdf --pages '3,1,2' -o reordered.pdf
pulp-engine pdf rotate scan.pdf --degrees 90 -o upright.pdf
pulp-engine pdf stamp pack.pdf --text 'Page {page} of {pages}' --position bottom-center
pulp-engine pdf bates pack.pdf --prefix ABC --start 101 --digits 6
pulp-engine pdf run pipeline.json -o output.pdf

For pdf run, use sources such as {file:"invoice.pdf",id:"invoice"}; paths resolve relative to the spec file. Other source kinds retain their API meaning. Merge/insert/extract/split copy pages and preserve outlines, named destinations, local links and viewer page labels. Merged books and copied-page outputs do not retain source structure trees and are untagged. The pipeline loads its base once, caches additional immutable sources and saves at the end; referencing source zero for insertion loads its original bytes separately.

PDF report books (Wave 64)

POST /render/pdf/book assembles 1..BOOK_MAX_PARTS parts (default 50). A part has title?, numbering?, inToc? plus either {template,data,version?,label?,options?} or pdf: {data|assetId|documentId}. Each part retains its page setup. The book is all-or-nothing; failures include part: {index, template?} using the zero-based original part index.

{
  "parts": [
    {"template":"cover","data":{},"title":"Cover","numbering":{"mode":"exclude"}},
    {"template":"summary","data":{},"title":"Summary","numbering":{"mode":"restart","format":"lower-roman"}},
    {"template":"schedule-letter-landscape","data":{},"title":"Schedule","numbering":{"mode":"restart","startAt":1,"format":"decimal"}},
    {"title":"Terms","pdf":{"assetId":"terms-pdf-asset"}}
  ],
  "toc":{"afterPart":0,"numbering":{"mode":"exclude"}},
  "stamp":{"text":"Page {page} of {pages}","position":"bottom-right"},
  "metadata":{"title":"Board pack"},
  "filename":"board-pack.pdf"
}

numbering: "continuous" is the default. Part-level restarts and formats are honoured; restarts inside a template part are flattened while its excluded sections remain excluded. numbering: "per-part" restarts every part at 1 (or its explicit startAt) and uses that part’s counted total. Excluded pages have no folio and are skipped by a book stamp. FA-35 pageChrome tokens use book labels: @pageNumber is the final folio, @totalPages the counted book (or part) total, and @sectionPages the part’s counted page count. Internal TOC nodes also receive the final folios. Legacy pageHeader/pageFooter retain their local Chromium counters and return book_legacy_page_numbers with affected original part indexes; use pageChrome or a book stamp for continuous folios.

Bookmarks default to one titled top-level entry per part with its existing outlines nested. toc: {} inserts synthetic contents after part zero; afterPart is a zero-based part index. A custom TOC template receives {book:{title,entries:[{title,page,pageLabel,level}]}}; page is the one-based physical page and pageLabel the printed folio (empty for excluded pages). Contents pagination renders placeholders, then final labels, and permits one retry. Failure to converge returns 422 book_toc_unstable. TOC rows are not hyperlinked; bookmarks provide navigation.

The response includes X-Render-Pages, X-PulpEngine-Book-Parts (original index and physical range, with toc: for synthetic contents) and X-Render-Warnings. output.store uses the existing document archive options. Template pre-render hooks run per part; post-render hooks receive the finished book once before final protection. Usage records each part plus the assembly. Protect the finished book through security; encrypted per-part inputs are unsupported. Books share PDF utility page/size caps and the batch concurrency limit. Books currently produce PDF only and are untagged after page copying.

Clients: TypeScript render.book(params), Python render.book(parts, ...), and pulp-engine render --book book.json -o pack.pdf. Python exposes the part ranges as BinaryResult.book_parts, and pipeline Bates ranges as BinaryResult.bates_range.

Stamp anchors position new marks relative to the visible page. Stamps draw over existing content; reserve space at the chosen anchor when designing the source PDF.

Fillable fields and existing PDF forms (Wave 65)

Add a formField content node with fieldType set to text, multiline, checkbox, radio, dropdown, date or signature. name, label, tooltip and defaultValue accept Handlebars. Names are sanitised to letters, numbers, underscores, dots and hyphens; dots create a field hierarchy. In repeaters, use names such as qty_{{[@index]}}. Repeated names with the same type share a value and multiple widgets; incompatible types return form_field_conflict.

{"id":"customer-name","type":"formField","fieldType":"text","name":"applicant.name","label":"Name","defaultValue":"{{customer.name}}","required":true,"maxLength":100,"width":"60mm","height":"7mm","fontSize":0}

Radio and dropdown nodes require 1-50 unique {value,label?} options. Radio supports horizontal/vertical layout. Other settings include readOnly, labelPosition (above, left, none) and tooltip (the PDF accessible name). Text defaults to 60 x 7 mm, multiline to 60 x 20 mm, signature to 60 x 15 mm, and checkbox/radio to 4 x 4 mm. The default field font size is 12 pt; font size 0 selects automatic sizing. Date fields are plain text; no PDF JavaScript is generated. signatureField.emitPdfField:true adds an empty standard PDF signature widget beside the existing signing-service anchor.

options.pdf.forms selects fillable (default), flatten (paint defaults and remove fields) or static (empty printable boxes). Normal templates without fields skip the PDF form pass. Appearances use the template body font when available, falling back to the bundled OFL Pulp Form Sans for Latin/Japanese/CJK characters. Unsupported glyphs return pdf_text_unencodable; use a font containing the required characters. The pass runs before encryption and PDF/A conversion. Word uses a native checkbox content control and static bordered text for other fields; PowerPoint uses a placeholder plus PPTX_FORM_FIELD_STATIC; CSV/XLSX ignore fields.

Filling existing PDF forms

Upload the authority’s original PDF through /assets/upload, then inspect it with GET /assets/:id/form-fields. PDF uploads retain the original form layout while stripping automatic actions and JavaScript name trees; encrypted uploads are rejected. PDFs are served as attachments in private mode and are excluded from image selection.

For a stateless call, POST /render/pdf/form-fields?limit=100&offset=0 accepts {pdf:"<base64>"} and returns {items,total,limit,offset,xfa}. Fields include fully qualified name, type, value, options, read-only/required flags and widgets with one-based pages and PDF-point rectangles [x0,y0,x1,y1].

POST /render/pdf/fill accepts {pdf,fields,flatten?,strict?}. Text expects strings; checkboxes accept booleans or Yes/On/Off; radio and dropdown expect an option’s stored export value, not its display label. Editable dropdowns also accept custom text. Option lists accept string arrays. Signature and button values are unsupported. The PDF response includes X-PulpEngine-Form-Filled and X-PulpEngine-Form-Unmatched counts. Default permissive filling skips invalid values; strict:true returns 422 form_field_mismatch with unmatched names and per-field reasons (type_mismatch, invalid_option, read_only, too_long, unsupported). Hybrid XFA is stripped when filling; XFA-only and encrypted forms cannot be filled. Inspection reports XFA-only status without attempting a fill. JavaScript calculations are never executed: supply computed totals explicitly. Rich-text formatting and comb layout are not reproduced specially. Flattening signed forms is rejected.

A saved form-backed template uses an empty document plus:

{"pdfForm":{"assetId":"uploaded-pdf-id","flatten":false,"fieldBindings":[{"field":"applicant.name","valuePath":"customer.name"},{"field":"consent","expression":"","checkedWhen":"customer.accepted"}]}}

Each binding must have exactly one valuePath or Handlebars expression; checkedWhen optionally overrides it with a checkbox boolean expression. Bindings must have unique field names (maximum 2000). Data adaptation and inputSchema validation run before the PDF asset is read. Missing/null values retain the form’s current value. Rendering skips HTML and Chromium, records pdf_form_fill, and supports saved versions, labels, batches and scheduled reports. pdfForm.flatten sets the template default; request forms:'fillable' or 'flatten' overrides it. static applies to designed boxes; for existing forms use flatten to produce a static filled document. Other output formats return 422 pdf_form_template_format_unsupported. Dry-run reports missing source field names and missing data paths. Save rejects a foreign, missing or non-PDF asset with 400; asset usage includes the template’s pdfForm.assetId.

TypeScript: client.pdfTransform.listFormFields(pdf), fillForm({pdf,fields,flatten:true}), and client.assets.formFields(id). Python: client.pdf_transform.list_form_fields(pdf), fill_form(pdf, fields, flatten=True) and client.assets.form_fields(id). CLI: pulp-engine pdf fields form.pdf --json and pulp-engine pdf fill form.pdf --values values.json --flatten -o filled.pdf.

Form PDFs embed the complete fallback font so recipients can enter new Unicode characters. The bundled face adds approximately 3.5 MB per output after PDF compression. A registered body font is used when it covers the values; static mode for designed fields skips font embedding.

When a PDF book includes an existing interactive form, flatten that part with options.pdf.forms: 'flatten' first. Book assembly rejects an unflattened existing form instead of losing its field tree. Designed form nodes remain supported through their deferred post-pass.

File size optimisation

Set options.pdf.optimize: {preset: "email"} for 150 DPI, JPEG quality 80 and Fast Web View, or preset: "print" for 300 DPI, quality 90 and no linearisation. imageMaxDpi (72-600), jpegQuality (40-95), opaquePngToJpeg and linearize override the preset. Request values override saved defaults field by field. Library images and payload data images larger than 128 KB are resized without upscaling, using the largest absolute displayed width or printable page width. Alpha is preserved. Small JPEGs pass through; opaque PNG conversion is opt-in. CSS background images and remote images are outside this pass.

PDF renders apply image resizing, render/TOC/PDF-A, mutating hooks, compaction, encryption and signing in that order. Optimisation plus encryption uses one qpdf operation. Signed output skips linearisation. File finishing requires buffering; stream=true returns 400 stream_incompatible_option. Image decode and optional compaction failures keep original bytes, log a warning and set X-PDF-Optimized: skipped; encryption and signing still fail closed.

sizeBudgetKb overrides the deployment warning budget. Oversized PDFs still succeed, with X-Render-Size-Budget-Exceeded: <final bytes> and a metric/log warning. X-PDF-Optimized reports compact, compact,linearized or skipped. X-Render-Pages is captured before object streams or encryption hide the page tree.

Output Optimisation / signing
PDF, batch, async batch, schedules Image and file optimisation; signing supported.
PDF preview Image resizing only; unsigned and unencrypted.
Self-contained/private HTML Image resizing from template defaults only.
DOCX, PPTX, XLSX, CSV, other non-PDF requests Explicit options.pdf returns 400 unsupported_render_option.

Signing

options.pdf.sign: {certificateId: "company-seal", reason: "Approved {{customer.name}}", location: "Auckland", timestamp: true, visibleAt: "approval"} applies a deployment-owned digital seal. A signatureField with seal: {width: "60mm", height: "20mm"} provides the optional visible location. Its DocuSign anchor remains in the text layer. Only the certificate identifier and public descriptive fields belong in templates; keys and passphrases stay with the operator.

Signatures use SHA-256, ETSI.CAdES.detached, signing-certificate-v2 and a full certificate chain: PAdES-B-B without a timestamp, or PAdES-B-T with a verified RFC 3161 timestamp over the signature value. There is no CMS signing-time attribute. These are baseline signatures; LTV, qualified seals and multiple incremental signatures are outside v1. Previously signed input is rejected to preserve existing signatures. Signing and encryption cannot be combined. PDF/A and optimisation can precede signing.

POST /render/pdf/sign accepts {pdf: "<base64>", certificateId, reason?, location?, contactInfo?, timestamp?, visible?: {page: 1, rect: [x1,y1,x2,y2]}}. Coordinates are PDF points from the page’s lower left. This utility requires an admin credential and allowUtility: true on that certificate. Template-bound render signing is available to render credentials. Source signing is rejected inside PDF assembly; apply the utility to the final assembled PDF.

Success carries X-PDF-Signature: PAdES-B-B|PAdES-B-T and writes a document_signed audit row with hashes, certificate ID and request identity. A timestamp failure returns JSON 502 timestamp_failed, never unsigned PDF bytes. Capabilities list only certificates bound to the caller’s tenant, with id, subjectCn and notAfter. Reader trust depends on its trust store and chain/revocation policy: test CAs need explicit trust; AATL/EUTL recognition alone does not guarantee every reader’s approval.

TypeScript: client.pdfTransform.sign({pdf, certificateId}); Python: client.pdf_transform.sign(pdf, certificate_id). Render options use the same public settings in both SDKs.

Invoice XML supplied through xmlPath or inline base64 is trusted caller content; the engine does not reconcile it with visual PDF totals. Generic attachment content is distributed without antivirus scanning; the filename extension denylist is the only executable-content guard.

Data sources & connections (Wave 69)

Templates can pull JSON before field mappings and input-schema validation in PDF (including streams), HTML, DOCX, PPTX, XLSX and CSV. Render data is optional and defaults to {}. Supply at most one of data, dataUrl and dataRef. The existing dataset and URL request forms remain supported.

{
  "key": "customer-report",
  "version": "1.0.0",
  "name": "Customer report",
  "inputSchema": {},
  "dataSources": [{
    "name": "customer",
    "type": "url",
    "connection": "crm",
    "url": "customers/{{customerId}}",
    "targetPath": "customer"
  }],
  "document": { "id": "doc", "type": "document", "children": [{
    "id": "body", "type": "section", "children": [{ "id": "name", "type": "text", "content": "{{customer.name}}" }]
  }] }
}

An administrator creates crm at POST /admin/connections with baseUrl: "https://crm.example.com/api/", auth: {"type":"bearer"} and a write-only secret. A render of this template with {"data":{"customerId":42}} fetches /api/customers/42. A connection base URL ending in / permits relative child paths. Absolute template URLs, protocol-relative URLs, traversal and paths outside the base prefix are rejected. Optional allowedPathPrefixes further restricts access within that base. Template headers cannot override Authorization or the connection’s API-key header.

Sources have unique names and non-overlapping dotted targetPaths. They run in parallel against independent clones of the same raw payload; one source cannot depend on another’s result. URL, headers and POST body support raw-payload Handlebars and params.*. Root results may be objects or arrays. A templateRef source still requires an object; referenced templates may also pull their own root sources through the same render session.

select uses RFC 6901 JSON Pointer, for example /data/items. Pagination applies selection to each page and concatenates arrays:

{"name":"orders","type":"url","connection":"crm","url":"orders","targetPath":"orders","select":"/data/items","paginate":{"mode":"cursor","cursorPointer":"/nextCursor","param":"cursor","maxPages":10}}

Use paginate: {"mode":"linkHeader"} for RFC 8288 rel="next" links. Next links must stay on the same origin and within connection restrictions. Page limits default to 10, with a hard cap of 50; reaching the limit returns collected rows and reports truncated: true. Response bytes are summed across pages (DATA_SOURCE_MAX_RESPONSE_BYTES, default 10 MiB). Each source has a hard 60-second ceiling; its configured timeout or connection timeout can be lower. The API injects DNS-pinned safeFetch and disables redirects. Private origins need the operator’s explicit DATA_SOURCE_ALLOWED_ORIGINS setting; production requires HTTPS.

If the payload already contains a target, including null or an empty array, skipIfPresent defaults to true and skips that fetch. Set false to force a pull. onError defaults to fail; empty substitutes []. Preview always degrades and reports the failure. Dry-run responses add dataSources entries with name, status (fetched, skipped, failed), duration, page/byte counts, optional HTTP status, truncation and sanitized error text. Caches live within one render and include tenant, connection and interpolated request identity.

Connection routes use the existing secret keyring and require configured encryption; missing keys return 503 service_unavailable. File, PostgreSQL and SQL Server stores are supported; plugin storage without a connection store returns 501 not_implemented.

Route Purpose
GET /admin/connections Admin paginated {items,total,limit,offset} catalogue
POST /admin/connections Create settings and an encrypted secret
GET /admin/connections/:id Read credential-free settings and hasSecret
PATCH /admin/connections/:id Update settings; omitted secret keeps it, null clears it
DELETE /admin/connections/:id Delete the connection
POST /admin/connections/:id/test {path?}; returns only {ok,status,latencyMs,contentType}
GET /data/connections Editor/admin paginated choices: name, baseUrl, authType
POST /render/preview/data-sources Editor/admin {template,data?,parameters?,name}; bounded sample and HTTP status

Authentication supports none, bearer, basic (username in settings), API key (headerName in settings), and OAuth 2.0 client credentials (tokenUrl, clientId, optional scope). Passwords, bearer/API tokens and client secrets belong only in secret. Default headers are non-sensitive settings. OAuth tokens are cached by tenant, connection ID and updatedAt and refreshed 60 seconds before expiry. Responses that reflect a connection’s protected credential are rejected. Audit events use data_connection and record metadata only.

Schedules accept dataSource: {"type":"template","data":{"customerId":42}} (data optional) and connection on URL sources. Root template data is pulled before delivery conditions and burst planning and reused across output formats. A schedule URL still returns an object, or wraps arrays under arrayKey. Portable tenant archives omit connections; recreate them by the same names and provision secrets in the destination environment.

In the editor’s Mappings tab, Data sources appears above Field Mappings. Test fetch shows HTTP status and the first 50 rows or a bounded 64 KiB preview. Infer schema at targetPath adds inferred fields while preserving existing schema types and constraints. Source changes participate in undo, diff and merge; simultaneous source-list edits require conflict resolution. Embed hosts can lock dataSources.

TypeScript: client.connections.create/list/get/update/delete/test/available. Python sync and async: client.connections.create/list/get/update/delete/test/available; Python methods accept dictionaries for input and return credential-free dictionaries. These SDK render methods also accept omitted data.

SQL data sources

Enable the SQL flag and create an encrypted shared connection through /admin/connections. Supported kinds are postgres, mysql and sqlserver, with host, port, database, username, ssl: {mode, ca?} and a write-only secret password. Connection tests execute SELECT 1. See the worked SQL example.

A source uses type: "sql", connection: "reporting" and config: {query, params?, maxRows?, onRowLimit?}. Write named placeholders as :name; values bind through the driver, including scalar arrays expanded into placeholders. A parameter takes exactly one of path, value or template, plus an optional type (string, number, boolean, date, string[], number[]). Paths may read params.customerId. Empty arrays become SQL NULL lists. Queries must be one SELECT/WITH statement. Some databases need explicit SQL casts where a placeholder has no type context.

Results are {rows, rowCount, truncated} (with column metadata). Root sources generally select /rows; sub-reports bind rows. Decimal and bigint values are strings, dates use UTC ISO strings, and binary columns are rejected. The default query limit is 10000 rows; exceeding it fails unless onRowLimit: "truncate". SQL Server decimal queries require aliases and support for a trailing FOR JSON PATH clause, which preserves all decimal digits. Stored procedures and queries already ending in FOR JSON are unsupported for this path.

Inline SQL requires editor or admin credentials. /render/preview/data-sources returns at most 50 sample rows, columns and inferredSchema; it audits the actor, connection name and SHA-256 query hash. Never put passwords in templates. An editor can read whatever the connection’s database user can read, so a least-privilege read-only user is mandatory.

Non-JSON input data

Send a JSON envelope in dataInput, exclusive with data, dataUrl and dataRef:

{"template":"import-orders","dataInput":{"format":"csv","content":"id,name,amount\n00123,Acme,10.50\n"}}

CSV yields {rows:[{id:"00123",name:"Acme",amount:10.5}]}. Use rowsKey, csv.delimiter, csv.header, csv.inferTypes and csv.columnTypes to control mapping. XML strips the root by default, uses @name attributes and #text, and forces paths in xml.arrayPaths to arrays. For <order id="42"><lines><line>Widget</line></lines></order>, arrayPaths:["order.lines.line"] yields {"@id":"42","lines":{"line":["Widget"]}}. Namespace removal, root retention and value parsing are optional. XML’s default preserves text values.

XLSX requires encoding:"base64". The first sheet maps to rows; xlsx.sheets:["Orders","Lines"] produces separate sheet keys. Options include headerRow, range and inferTypes. Dates become ISO strings, rich text flattens, and formulas use cached values; missing formula results produce null and a warning. DOCTYPE is rejected; decoded bytes, rows, cells and inflated ZIP sizes are bounded.

POST /data/convert accepts the envelope directly and returns {data,stats} for admin, editor, render and preview credentials. Render, validate, preview and batch intake converts before hooks, bucket hashing and input-schema validation. Detached jobs receive normalized JSON. URL sources and schedule URL sources accept format and formatOptions; JSON remains the default. Static schedules can also accept dataInput, which is normalized on intake. See shared CSV/XML/XLSX fixtures. TypeScript exports DataInput.csv/xml/xlsx and client.data.convert; Python exports pulp_engine.data_input.csv/xml/xlsx and sync/async client.data.convert. CLI --data infers CSV/TSV/XML/XLSX and supports --data-format, --rows-key, repeatable --sheet and --array-path.

Durable async jobs retain converted pushed data as private JSON snapshots in the tenant-scoped job blob before acceptance. The snapshots follow job retention and are excluded from public result envelopes. Restart recovery preserves the snapshot; the existing job-abandonment policy still governs interrupted execution.

Delivery connections and signatures

Delivery credentials are separate from shared data-source connections. Administrators use POST /connections, paginated GET /connections?type=sftp, GET/PATCH/DELETE /connections/{id}, POST /connections/{id}/test and GET /connections/providers. Bodies contain {type,name,config,secrets}. Secrets are encrypted, write-only and returned only as a key map of ***; PATCH merges secret keys and null removes one. Connection kind is immutable. Deletion returns connection_in_use while a schedule or retained envelope references the connection. PostgreSQL and SQL Server are supported; writes require configured encryption.

The credential-free data-source picker moved from GET /connections to GET /data/connections; data-source administration remains under /admin/connections. Updated SDKs preserve client.connections for data sources and expose delivery credentials through client.connections.delivery.

Target Connection config Write-only secrets Target fields
sftp host, username, optional port, basePath, hostKeyFingerprint password or privateKey, optional passphrase remotePath, optional atomic, overwrite: replace/fail/rename
azure-blob accountName, optional endpoint accountKey, sasToken or connectionString container, blobName, optional accessTier, metadata
sharepoint entraTenantId, clientId, siteId, optional driveId clientSecret folderPath, optional filename, driveId, conflictBehavior
slack {} webhookUrl or botToken optional channel, text, attachFile (bot token and channel required for files)
teams {} Workflows webhookUrl optional title, text
s3 region, optional bucket, endpoint accessKeyId, secretAccessKey, optional sessionToken existing S3 fields plus optional connectionId
plugin provider-defined provider-defined provider, optional connectionId, config
esign signing provider connection signing provider credentials connectionId, optional subject, recipients

Every new first-party channel requires connectionId. S3 connections override global credentials. SFTP/Azure/SharePoint paths support {templateKey}, {date}, {executionId}, {ext} and existing delivery bindings such as {{data.customerId}}; substitutions are safe path segments. Resolved paths are retained for delivery replay. SFTP atomic replacement requires the server’s POSIX rename extension. SharePoint uses app-only Microsoft Graph authentication: grant Sites.Selected and the explicit site access needed for the library. Larger uploads use sessions with 320-KiB-aligned chunks. Slack file delivery uses the external upload flow; Teams uses a Workflows Adaptive Card.

PDF e-signature workflow

Enable ESIGN_ENABLED, configure an externally reachable PUBLIC_API_URL, database storage and credential encryption. A template can declare:

{"esign":{"signers":[{"role":"buyer","emailPath":"buyer.email","namePath":"buyer.name","routingOrder":1}],"emailSubject":"Please sign {{contractId}}"}}

Signer paths resolve against the data used by the actual PDF render, including field mappings. A signature node’s signerRole and anchorText can use repeater bindings. Only rendered branches contribute fields; anchors must be nonempty and unique. required defaults to true for signature/initials and false for other tab types. Dry-run responses include signatureFields.

POST /esign/envelopes accepts {templateKey,version?,label?,data,connectionId,recipients?,subject?,message?,onCompleted?:{targets},metadata?}. Render scope is required, including normal template/label restrictions and quotas. Other envelope management operations require administrator access. Use GET /esign/envelopes with paging/status/template filters, GET /esign/envelopes/{id}, POST /esign/envelopes/{id}/void with {reason}, and GET /esign/envelopes/{id}/documents/signed or /certificate after completion. SDKs expose client.esign.

The first-party signing provider is docusign. Connection config contains integrationKey, userId, accountId, basePath (for example https://demo.docusign.net/restapi) and oauthHost (account-d.docusign.com or account.docusign.com). Secrets contain rsaPrivateKey and optionally connectHmacKey. Obtain JWT consent for signature/impersonation and configure the account’s Connect HMAC key when using HMAC. Provider access tokens are cached for at most 50 minutes and separated by credential/config fingerprint.

Callbacks authenticate a random envelope token and optional raw-body HMAC, then fetch authoritative provider status. The posted body cannot mark an envelope completed. Completion events are esign:completed for plugins and esign.completed for domain subscriptions; sent/declined/voided events are also available. Completion targets receive the provider’s completed PDF. Delivery retries are at least once; downstream systems should deduplicate by execution/envelope identity. When document storage is enabled, completed PDFs are archived under its retention policy and completion targets receive a signed download link when PUBLIC_BASE_URL is configured. Otherwise signed files are proxied from the provider and follow its retention policy. Completion-only target secrets and bound data are cleared after a terminal workflow finishes.

Scheduled e-sign targets require a single unpackaged PDF. Burst schedules are rejected. A pending envelope is persisted before a provider send and (executionId,targetIndex) prevents a second send on retry. An uncertain response leaves creation_unknown; it is never automatically resent. DocuSign callbacks or optional polling can find an accepted envelope by its transaction ID; DocuSign retains that lookup for seven days. Older unresolved sends require operator reconciliation. Dropbox Sign and Acrobat Sign are deferred; signing plugins can implement the same contract.

Repeated nodes retain their node ID as the provider-label prefix and receive an anchor-derived suffix. This avoids DocuSign data-label replication. Each required checkbox gets its own single-checkbox tab group.

Self-service reports

The portal API exposes permitted publications under /portal/reports, owned outputs under /portal/runs, and personal email schedules under /portal/subscriptions. Enable PORTAL_ENABLED and use a named session. Administrators manage GET/PUT /templates/:key/portal. OpenAPI’s Portal tag and both SDKs’ client.portal describe the full contract.

Shared catalogue and tenant policy

These features use native multi-tenant PostgreSQL or SQL Server storage. Ordinary credentials stay bound to their tenant. Super-admin callers select a tenant with X-PulpEngine-Tenant-Id; TypeScript tenantId, Python tenant_id, and CLI --tenant send that header. Tenant-scoped JSON projections omit internal tenant identifiers.

Method and path Access Result
GET /catalogue/templates?limit=50&offset=0 admin/editor Published-only page with inherited, forked, update_available or shadowed status. Names/descriptions come from the release, including when a newer draft exists.
GET /catalogue/templates/{key}?version=? admin/editor Published definition; an optional pin must have applied release-label history.
POST /templates/{key}/fork template write/create permission {version?} forks a published release, returns 201, ETag and catalogueSource:{key,baseVersion,forkedAt}. Existing keys return 409.
POST /templates/{key}/upgrade template write permission Requires If-Match:"<currentVersion>"; accepts targetVersion?, dryRun?, conflictDefault?, allowConflicts?, mergedDefinition?. A dry run returns the merge report. Conflicts return 409 with the same report under report. Applied upgrades patch-bump the local version and atomically advance the base and audit.
POST /admin/catalogue/upgrade super-admin {key,apply,tenants?} reports per-tenant up_to_date, clean, applied or conflict. Non-forks are conflict with reason:catalogue_not_forked; bulk apply changes clean merges only. Tenant slugs appear only in this explicit super-admin result.
GET/PATCH /tenant/settings tenant admin / ops admin {settings,effective,revision,readOnly} and a strong numeric ETag. PATCH requires If-Match:"<revision>".
GET/PATCH /admin/tenants/{id}/settings super-admin Full operator policy for the selected tenant, with the same revision contract.
GET /tenant/quota admin/render/editor UTC month, successful/reserved render and page usage, nullable limits, mode and reset time.
GET /tenant/branding any authenticated scope Display name, primary colour and an optional owned image asset reference/URL.

POST /admin/tenants accepts seedFromCatalogue:true or {keys:[?]}. Releases are checked first, then tenant creation, every fork and seed audit commit together. A failed seed leaves no partially created tenant. The catalogue defaults to off; inheritance additionally requires TEMPLATE_CATALOGUE_FALLBACK=true. Local keys always win, even when their requested local version/label is missing. Inherited renders set X-PulpEngine-Template-Source:catalogue. Explicit catalogue @latest means latest published release, never HEAD. Explicit pins and alternate labels cannot expose drafts or future scheduled releases. The same resolver covers every output format and composition. Usage, archive and request audit stay attributed to the requesting tenant.

Settings PATCH uses JSON Merge Patch: null removes an override; arrays replace. Inline secrets and unknown properties return 400 with Zod issues. Missing revisions return 428; malformed strong ETags return 400; stale revisions return 412. Tenant admins may change branding, email sender within permitted domains, and bounded positive retention. Operator-only features, quotas, connections, sender-domain rules and asset residency return 403 with offending paths. In single-tenant mode the resolved environment settings are read-only. Changes audit only the changed setting paths.

Features are effective only within global flags. A disabled tenant feature returns 403 feature_disabled_for_tenant. Hard monthly limits return 429 quota_exceeded plus Retry-After; legacy operator/credential limits retain quota_exhausted. Soft limits allow output and send X-PulpEngine-Quota-Remaining. Thresholds deduplicate per tenant, dimension, month and percentage and emit tenant.quota_threshold webhooks plus tenant:quota_threshold plugin events. Quotas do not replace billing analytics.

Email settings reference an encrypted, owned delivery connection of type email, with config {host,port?,secure?,requireTLS?,fromName?} and optional paired username/password secrets. Production transport requires TLS; tenant endpoints follow the private-host allowlist and DNS pinning rules. Sender and reply-to overrides apply at delivery. allowedFromDomains accepts exact domains and explicit wildcard subdomains; a wildcard does not include its apex. Without an explicit list, an operator-selected sender establishes the permitted sender domain. S3 settings reference an owned s3 connection, whitelist buckets and optionally prefix relative object keys. Schedule save rejects disallowed buckets with 400 bucket_not_allowed, and delivery rechecks the policy.

TypeScript: client.catalogue.list/get, client.templates.fork/upgrade, client.catalogue.bulkUpgrade, client.tenant.getSettings/updateSettings/getQuota/getBranding, and client.admin.tenants.getSettings/updateSettings. Python exposes matching snake_case methods on sync and async clients. Pass raw currentVersion/revision values; clients quote If-Match. CLI catalogue list|fork|upgrade previews upgrades by default; --apply commits them and --all-tenants targets the super-admin bulk operation. Configuration environments accept tenant or tenantId.

The editor Catalogue tab opens releases read-only and offers Customise. Forks show source versions and update availability. The upgrade dialog offers a choice for each conflict and a visual comparison before applying. Save local changes first. Embedded editors can opt into <pulp-engine-editor branding="auto">; authenticated branding/logo requests stay on the configured API, and explicit brand values take precedence.

Governed AI and narrative blocks (Wave 77)

AI generation, import, editing, expression assistance and rewrite use the configured provider and tenant policy. Model overrides use logical IDs from AI_ALLOWED_MODELS; AI_MODEL_MAP_JSON translates them for the provider. /capabilities.ai adds provider, region and narrative; endpoint URLs, header values and project IDs are private. See deployment configuration.

GET /usage/ai?from=...&to=...&groupBy=day|month|feature|model|provider uses a half-open UTC window and returns {from,to,groupBy,buckets,total,budget}. Each bucket has key, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCostUsd; total has the same counts and cost. Costs are null unless every contributing logical model has operator pricing. budget contains monthlyTokenBudget, usedThisMonth, mode. GET /usage/ai.csv exports provider-attempt rows with the existing export cap. Both require usage administration permissions, omit tenant identifiers and return 503 usage_not_available with file storage. The legacy render usage CSV contract is unchanged.

An additive format-1 node creates commentary from explicit root facts:

{
  "id": "sales-summary", "type": "aiNarrative",
  "instruction": "Summarize the sales figures without inventing trends.",
  "facts": [{"name":"sales","value":"{{formatNumber sales}}","description":"Reported sales"}],
  "fallback": "Reported sales: {{formatNumber sales}}.",
  "maxWords": 80, "tone": "neutral", "language": "en-US",
  "verifyNumbers": true, "cacheTtlDays": 30, "disclosure": "caption", "variant": "body"
}

Instructions are static, limited to 1000 characters, and never evaluated as Handlebars. Supply 1–25 uniquely named facts ([A-Za-z][A-Za-z0-9_]{0,39}); each value is interpolated once from adapted root data and limited to 500 characters. Fallback is required and follows ordinary text-node binding behavior. Tones: neutral, formal, concise, executive, friendly; word bounds: 10–400. Language is a BCP 47 tag and defaults to the report date language, then English. Cache TTL is 0–365 days; 0 bypasses persistent cache. Disclosure is none (default) or caption.

Production bodies accept aiNarratives: "generate" | "cache-only" | "fallback", default generate. The option is also available inside options for SDK and schedule callers; a top-level value takes precedence. Editor previews force cache-only, and the sandbox has no resolver. Repeaters, tables and referenced-template scopes always use fallback; hidden branches do not transmit facts. At most 10 root nodes resolve per render, four calls run concurrently per process, each call has a 15-second maximum and the phase has a 30-second budget. Identical fact sets reuse tenant cache across node IDs and template versions. Prompt version, logical/mapped model, provider and verification policy also participate in the cache identity.

Successful output is literal text: braces cannot trigger another Handlebars evaluation, HTML is rejected by generation validation and escaped by renderers, and numbers must be present in the instruction or facts at their displayed rounding precision. One repair is allowed. Disabled AI, cache misses, timeouts, exhausted budgets, provider failures, scope restrictions, limits and failed verification all render fallback. Numeric checks do not establish the correctness of qualitative claims; review summaries before relying on them.

Format Narrative output
HTML / PDF Escaped paragraphs with .pe-ai-narrative, data-ai-status, node style and optional disclosure
DOCX Native styled paragraphs and literal text runs
PPTX Native text streaks / positioned text with style and variant
CSV / XLSX Omitted; no model calls

Single renders expose X-PulpEngine-AI-Narratives: generated=1, cached=2, fallback=0 and X-PulpEngine-AI-Tokens: in=10, out=5. Dry runs make no model calls and add nondeterministic plus narratives:[{nodeId,status:"cached"|"would_generate"|"fallback",reason?}].

POST /nodes/narrative/preview accepts {node,data,locale?,force?} with editor/admin authorization and the ai-edit rate bucket. It returns {text,status,reason?,facts,verification:{passed,ungroundedNumbers},usage} and populates cache for subsequent preview renders. force:true regenerates and consumes tokens. Global disable returns 503, tenant disable 403, and exhausted hard budgets 429 ai_token_budget_exceeded with Retry-After.

TypeScript: client.nodes.previewNarrative(...), client.usage.ai(...), client.usage.exportAiCsv(...); Python sync/async: client.nodes.preview_narrative(...), client.usage.ai(...), client.usage.export_ai_csv(...). Python render options accept ai_narratives, serialized as aiNarratives.

Verifying webhook signatures

Read the raw request bytes before JSON parsing. Verify X-PulpEngine-Signature against HMAC-SHA256 of X-PulpEngine-Timestamp + "." + rawBody. Require the sha256= prefix, compare in constant time, and reject timestamps more than 300 seconds from your clock. Rotation can supply comma-separated signatures; accept any matching signature. Deduplicate the event/job ID after verification.

All six official SDKs provide verifiers; see the SDK guide. The shared golden vectors include UTF-8/newline preservation, rotation, tampering, wrong keys, stale/future timestamps and malformed headers. Their fixed now is for reproducible tests; receivers use their current clock. The server’s canonical header constants are exported from webhook-delivery.ts.

Office-authored templates

Enable OFFICE_TEMPLATES_ENABLED and upload a standard DOCX, XLSX or PPTX through POST /templates/office (multipart file plus JSON-string meta). Replacement uses PUT /templates/{key}/office with If-Match. Tags and private original downloads live below /templates/{key}/office. Native/PDF production, batch and scheduled renders use the existing endpoints. See Office templates for metadata, formats, examples and private-source backup.

Legacy report imports

POST /templates/import also accepts application/json containing format, source and optional options. It returns an unsaved ordinary template, a fidelity report, sample data and extracted assets. Existing multipart document imports continue to use the same URL. TypeScript exposes templates.import(); Python exposes templates.import_definition(). See the migration guide for mappings, errors, limits and examples.