Pulp Engine API Usage Guide
Base URL: http://localhost:3000 (configurable via HOST and PORT env vars)
For a full request collection, open api-test.http in VS Code with the REST Client extension.
Authentication
All requests (except GET /health, GET /health/ready, GET /metrics, and — in public mode — GET /assets/:filename) must include:
X-Api-Key: <your-key>
GET /health and GET /health/ready are always exempt — they are intended for load balancer probes and health checks.
GET /metrics is exempt by default — when METRICS_TOKEN is not set, Prometheus scrapers can reach it without an API key; restrict access at the network layer. When METRICS_TOKEN is set, the endpoint requires Authorization: Bearer <METRICS_TOKEN>.
GET /assets/:filename is public by default (ASSET_ACCESS_MODE=public) so the PDF renderer can fetch embedded images at render time. When ASSET_ACCESS_MODE=private, this route requires admin or editor scope — the PDF renderer inlines assets server-side and the visual editor fetches with X-Editor-Token.
If no credentials are configured on the server (local dev / CI), requests without the header are accepted and no authentication is performed.
Scoped credentials (v0.14.0)
Four named keys provide distinct privilege levels. Use the most restrictive key for each integration.
| Environment variable | Scope | Route access |
|---|---|---|
API_KEY_ADMIN | admin | Full access — templates, assets, render, preview, validate |
API_KEY_RENDER | render | POST /render, POST /render/html, POST /render/csv, POST /render/xlsx, POST /render/docx, POST /render/pptx, POST /render/batch, POST /render/batch/docx, POST /render/batch/pptx, POST /render/pdf/* |
API_KEY_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, POST /render/html, POST /render/csv, POST /render/xlsx, POST /render/docx, POST /render/pptx, POST /render/batch, POST /render/batch/docx, POST /render/batch/pptx, POST /render/pdf/* | render or admin |
| Publish-readiness 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 | POST /assets/upload, GET /assets, DELETE /assets/:id | 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:
- Generate a new strong secret and set
API_KEY_ADMIN=<secret>in your environment. - For any integration that only renders documents, create a separate secret and set
API_KEY_RENDER=<secret>. - 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 (noVITE_API_KEYrequired) - Everything else → use
API_KEY_ADMIN
- Integrations that only render documents → use
- Remove
API_KEYfrom 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:
- On first load the editor calls
GET /auth/statusto determine whether auth is required and whether a login form is available. - If auth is required the editor shows a “Sign in” form. The operator enters the value of
API_KEY_EDITOR(orAPI_KEY_ADMIN). - The editor calls
POST /auth/editor-tokenwith that key. On success the server returns an HMAC-signed session token (default 8 hours; configurable viaEDITOR_TOKEN_TTL_MINUTES). - The token is stored in
sessionStorage(cleared on tab close) and sent asX-Editor-Tokenon every subsequent API call. - If a call returns
401(token expired or rotated key), the editor automatically returns to the login form.
No VITE_API_KEY or frontend build-time environment variable is needed. The API_KEY_EDITOR value is entered interactively at runtime and never embedded in the JS bundle.
The editor scope cannot call any of the production render routes (POST /render, /render/html, /render/csv, /render/xlsx, /render/docx, /render/pptx, /render/batch, /render/batch/docx, /render/batch/pptx, /render/pdf/*), nor can it delete templates, restore versions, promote labels, or call admin routes — those operations require admin scope.
Named-user mode: Users configured with "role": "admin" receive admin scope from their X-Editor-Token. A named-user admin token therefore has the same route access as an admin-scoped X-Api-Key — it can delete templates and restore versions in addition to all editor operations. Users configured with "role": "editor" receive only editor scope and are blocked from admin-only routes with 403 Forbidden.
HTTPS requirement: POST /auth/editor-token transmits the API_KEY_EDITOR value over the network. In production, the API must be served behind HTTPS (TLS termination via reverse proxy). On plain HTTP a network observer can capture the key at login time.
Dev mode: when no credentials are configured (local dev), GET /auth/status returns { authRequired: false } and the editor skips the login gate entirely.
OIDC / SSO (opt-in)
When the server is configured with OIDC_DISCOVERY_URL plus OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_COOKIE_SECRET, the editor login gate can exchange an identity provider’s authorization code for an editor session token via the PKCE auth-code flow. The presence of OIDC_DISCOVERY_URL is itself what enables OIDC — there is no separate enable flag. First-time logins auto-provision the user from sub/email/name claims, with subsequent silent refresh. The embedded editor supports the same flow via a code-exchange endpoint. See oidc-guide.md for configuration and provider-specific notes (Okta, Azure AD, Keycloak, Auth0).
Multi-tenant mode (opt-in)
When MULTI_TENANT_ENABLED=true (Postgres or SQL Server), all authenticated routes resolve a tenant from either the API key (API_KEYS_JSON entries are tenant-scoped), the signed tenant claim inside X-Editor-Token, or — for super-admin routes — an explicit X-PulpEngine-Tenant-Id header. Cross-tenant access is rejected. The /admin/tenants CRUD endpoints are reserved for super-admin keys. See tenant-isolation-guarantees.md.
Auth endpoints (public — no X-Api-Key required)
GET /auth/status
Returns whether authentication is required and whether the editor login is available.
200 OK
Content-Type: application/json
{
"authRequired": true,
"editorLoginAvailable": true,
"identityMode": "shared-key"
}
| 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-tokenaccepts only personal user keys — shared API keys are rejected (HTTP 401).- The
actorfield in the request body is silently ignored; actor is always server-derived from the registry. - The
actorvalue in issued tokens is the user’sid, 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-Keymachine 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 withiat < 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 aniatbefore 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_EDITORimmediately invalidates all outstanding tokens. Users will see401and must log in again with the new key. - Near-zero-downtime key rotation: Set
API_KEY_EDITOR_PREVIOUS=<old key>andAPI_KEY_EDITOR=<new key>and restart. Outstanding editor session tokens signed with the old key continue to verify through the rollover window (up toEDITOR_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 asX-Api-Key. Direct callers still using the old key viaX-Api-Keymust switch to the new key. RemoveAPI_KEY_EDITOR_PREVIOUSafterEDITOR_TOKEN_TTL_MINUTESelapses and restart again.API_KEY_ADMIN_PREVIOUSprovides 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 /renderandPOST /render/htmlshow onlyApiKeyAuth— editor session tokens cannot call production render.DELETE /templates/{key}andPOST /templates/{key}/versions/{version}/restoreshow onlyApiKeyAuth— these are admin-only operations.- All other protected routes accept both auth methods.
Schema coverage: Request and response schemas are documented for all routes. The TemplateDefinition body fields (key, version, name, document, etc.) are shown in the spec as the expected shape; full structural validation of the document node tree is enforced at runtime by the Zod schema in validation/template-definition.schema.ts. The schema accepts 20 content node types: container, columns, heading, text, image, table, chart, pivotTable, barcode, spacer, divider, pageBreak, conditional, repeater, richText, signatureField, templateRef (with optional fieldMappings), toc, positioned, and custom.
Preview routes: The preview routes (POST /render/preview/html, POST /render/preview/pdf) appear in the spec only when PREVIEW_ROUTES_ENABLED=true (always in dev; opt-in in production).
curl
# Render with render-scoped key
curl -H "X-Api-Key: $API_KEY_RENDER" http://localhost:3000/render ...
# Manage templates with admin-scoped key
curl -H "X-Api-Key: $API_KEY_ADMIN" http://localhost:3000/templates
C#
client.DefaultRequestHeaders.Add("X-Api-Key", "<your-key>");
JavaScript (fetch)
headers: {
'Content-Type': 'application/json',
'X-Api-Key': '<your-key>',
}
PHP
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Api-Key: <your-key>',
]);
VB.NET
client.DefaultRequestHeaders.Add("X-Api-Key", "<your-key>")
Security configuration (v0.25.0)
Five optional env vars tighten the default-open surfaces. All have backward-compatible defaults.
| 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
All routes are rate-limited per IP. GET /health, GET /health/ready, and GET /metrics are always exempt.
| Route group | Default limit |
|---|---|
/render, /render/html, /render/csv, /render/validate, /render/preview/* | 20 requests / minute |
| All other routes | 100 requests / minute |
When the limit is exceeded the API returns:
429 Too Many Requests
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded, retry in 1 minute"
}
Limits are configurable via environment variables on the server:
| Variable | Default | Effect |
|---|---|---|
RATE_LIMIT_MAX | 100 | Global default for all routes |
RATE_LIMIT_RENDER_MAX | 20 | Override for render and preview routes |
Multi-instance note: Rate limiting uses in-memory counters per API process. In multi-instance deployments, limits are enforced independently per instance — the effective rate limit is multiplied by instance count. For stricter enforcement, configure rate limiting at the reverse proxy layer (nginx
limit_req, HAProxy, Traefik, etc.).
Audit events
GET /audit-events returns a paginated, filterable list of audit events. Admin scope required.
Events are recorded automatically when templates are created/updated/restored/deleted, assets are uploaded/deleted, and editor session tokens are minted.
Query parameters
| 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, 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 a database-backed storage mode (Postgres or SQL Server). Returns 503 usage_not_available in file mode.
Requires admin scope or a named-user editor token. All queries are scoped to
the caller’s resolved tenant.
GET /usage — rollup
Returns aggregated render counts, durations, output size, and page counts grouped by a chosen dimension.
| Query parameter | Type | Notes |
|---|---|---|
from | ISO-8601 datetime | Required. Inclusive lower bound. |
to | ISO-8601 datetime | Required. Exclusive upper bound — [from, to). |
groupBy | enum | day (default), week, month, template, format, source, mode. |
source | enum | production or preview. Optional filter. |
templateKey | string | Optional filter; max 256 chars. |
mode | enum | single, batch, async-batch, transform, scheduled. Optional. |
Window size is capped by USAGE_QUERY_MAX_WINDOW_DAYS (default 30). Wider
windows return 400 usage_window_too_wide.
Response:
{
"from": "2026-04-01T00:00:00Z",
"to": "2026-04-08T00:00:00Z",
"groupBy": "day",
"buckets": [
{
"key": "2026-04-01",
"count": 42,
"totalDurationMs": 8500,
"totalOutputBytes": "2048000",
"totalPages": 105
}
],
"total": {
"count": 42,
"totalDurationMs": 8500,
"totalOutputBytes": "2048000",
"totalPages": 105
}
}
totalOutputBytes is serialised as a string because BigInt values can exceed
Number.MAX_SAFE_INTEGER on long windows.
| Response | Code |
|---|---|
200 OK | Rollup returned. |
400 Bad Request | usage_invalid_date, usage_window_too_wide, or missing from/to. |
503 Service Unavailable | usage_not_available — Postgres not configured. |
GET /usage.csv — per-render export
Streams one CSV row per render within the window. Same filters as /usage
(minus groupBy). Useful for invoicing, BI ingestion, and compliance audits.
No window-size cap — the row-count cap is the guard. Queries that would return
more than USAGE_EXPORT_MAX_ROWS rows (default 50 000) return
413 usage_export_too_large with the actual count so the caller can narrow the
window.
Response:
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="usage-<tenantId>-<fromDate>-<toDate>.csv"
CSV columns (RFC 4180, header row included):
timestamp,tenantId,templateKey,format,source,mode,durationMs,outputSizeBytes,pageCount,itemIndex,actor,scope,requestId
| Response | Code |
|---|---|
200 OK | CSV stream. |
400 Bad Request | usage_invalid_date. |
413 Payload Too Large | usage_export_too_large — narrow the window. |
503 Service Unavailable | usage_not_available. |
Error responses
All non-2xx responses use one of three JSON envelopes.
Standard error envelope
Used for 401, 403, 404, 409, 412, 428, 503, and 500 responses. error names the error class; message gives a human-readable explanation.
{ "error": "Not Found", "message": "Template \"x\" not found." }
Validation error envelope
Used for 400 (structural validation failures) and 422 (data validation failures). error names the error class. issues contains per-field details when present. message is also present when an explanatory sentence accompanies the field list. In practice, at least one of message or issues is always present.
{ "error": "Validation Failed", "issues": [{ "path": "/field", "message": "..." }] }
Rate-limit envelope (429)
Produced by the rate-limiting layer; includes a statusCode field.
{ "statusCode": 429, "error": "Too Many Requests", "message": "Rate limit exceeded, retry in 1 minute" }
Note on 200 validation-result bodies: Both
POST /templates/:key/validateandPOST /render/validatereturn 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 (v0.54.0+)
Every API response includes an X-Request-ID header with a server-generated UUID. The same value appears as reqId in structured log entries for that request.
- The ID is always server-generated; client-supplied
X-Request-IDheaders are ignored. - Error responses (4xx, 5xx) also include the header.
- Reverse proxies should forward (not strip) the
X-Request-IDresponse header.
Capabilities
GET /capabilities
Returns a machine-readable snapshot of which render formats, AI features, and streaming behaviour are available on this server. Editors poll this endpoint to hide or show format buttons; callers should use it to negotiate before picking an output route.
Public — accepts X-Api-Key or X-Editor-Token but neither is required. No
rate limiting.
GET /capabilities
Response:
{
"formats": {
"pdf": true,
"html": true,
"docx": true,
"csv": true,
"xlsx": true,
"pptx": true
},
"ai": {
"templateGeneration": true
},
"streaming": {
"singleDoc": true,
"mode": "child-process"
}
}
| 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
Returns a binary PDF stream.
Request
POST /render
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 | string | No | Override paper size: A4 A3 Letter Legal Tabloid |
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 \
-H "Content-Type: application/json" \
-d '{ "template": "loan-approval-letter", "data": { ... } }' \
--output output.pdf
# Pinned to a specific version
curl -s -X POST http://localhost:3000/render \
-H "Content-Type: application/json" \
-d '{ "template": "loan-approval-letter", "version": "1.0.2", "data": { ... } }' \
--output output-v1.0.2.pdf
3. Render to HTML — POST /render/html
Returns the intermediate HTML string. Useful for previewing layout without invoking Puppeteer, and for debugging field mapping and formatting issues.
Same request shape as POST /render. Response:
200 OK
Content-Type: text/html
<!DOCTYPE html><html>...</html>
Render errors — 422 application/json:
| 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 a single table node from a template and renders it as RFC 4180 CSV. No Puppeteer or asset inlining — pure data extraction and formatting.
POST /render/csv
Content-Type: application/json
X-Api-Key: <render or admin key>
{
"template": "monthly-report",
"data": { ... },
"tableId": "summary-table" // optional — required when template has multiple tables
}
Response:
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="monthly-report.csv"
X-Table-Id: summary-table
Name,Amount,Date
"Acme Corp",1234.56,2026-04-01
"Globex Inc",789.00,2026-04-02
Table selection:
- If the template contains exactly one table node, it is selected automatically —
tableIdcan be omitted. - If the template contains multiple tables,
tableIdis required. Omitting it returns422with the available table IDs listed in the error message. - If the specified
tableIddoes not match any table in the rendered template,422is 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 —
tableIdcan be omitted. - If the template contains multiple tables and
tableIdis omitted, all tables are exported as separate sheets in a single workbook. This intentionally diverges from CSV (which returnsambiguous_table) because the XLSX format naturally supports multi-sheet workbooks. - If
tableIdis provided, only that table is exported as a single-sheet workbook. Returns422if the ID does not match. - If the template contains no table nodes,
422is returned.
Workbook formatting:
- Header row: bold font with light gray background fill
- Data rows: numbers written as numeric cell types (not strings) for formula compatibility
- Column widths: auto-sized based on content, capped at 60 characters
- Sheet names: table ID, truncated to 31 characters (Excel limit)
Error responses — 422 application/json:
| 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
3d. Render to DOCX — POST /render/docx
Renders a template to a Word document (.docx). Walks the template AST directly — no browser or Puppeteer required. Uses the same RenderBodySchema as the PDF endpoint.
POST /render/docx
Content-Type: application/json
X-Api-Key: <render or admin key>
{
"template": "loan-approval-letter",
"data": { "customer": { "name": "Alice" }, "amount": 50000 },
"options": { "paperSize": "Letter", "orientation": "portrait" }
}
Response:
200 OK
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
Content-Disposition: attachment; filename="loan-approval-letter.docx"
Supported template nodes:
- Layout: Document, Section, Container, Columns (simulated via borderless table), PageBreak, Spacer, Divider
- Content: Heading (1–6), Text (with Handlebars interpolation), RichText (bold/italic/underline/color/links/lists)
- Data: Table (data-bound with headers/footers/formatting), Conditional, Repeater, TemplateRef
- Media: Image (embedded binary via asset resolver — requires
assetResolvercallback at API layer) - Navigation: Table of Contents (TOC field — populates on open in Word)
Page setup from RenderConfig:
paperSize(A4, A3, Letter, Legal, Tabloid),orientation,marginpageHeader/pageFooter— Puppeteer HTML is parsed for text content andpageNumber/totalPagesfield markers; complex HTML falls back to plain-text approximation
Known fidelity gaps vs PDF:
| Feature | DOCX behavior |
|---|---|
| Columns | Simulated via borderless table — page-break behavior differs |
| Custom CSS | flexbox, opacity, border-radius ignored |
| Charts | Rasterised from SVG via Resvg and embedded as images |
| Positioned nodes | Children rendered inline (text frames deferred) |
| Page headers/footers | Text + field markers extracted; complex HTML uses plain-text fallback |
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
Batch DOCX — POST /render/batch/docx
Same concurrency model as batch PDF. Returns base64-encoded DOCX bytes in a JSON envelope.
POST /render/batch/docx
Content-Type: application/json
X-Api-Key: <render or admin key>
{
"items": [
{ "template": "invoice", "data": { ... } },
{ "template": "letter", "data": { ... }, "options": { "paperSize": "Letter" } }
]
}
Response — same shape as batch PDF but with docx field instead of pdf:
{
"total": 2,
"succeeded": 2,
"failed": 0,
"results": [
{ "index": 0, "template": "invoice", "success": true, "docx": "<base64>" },
{ "index": 1, "template": "letter", "success": true, "docx": "<base64>" }
]
}
3d2. Render to PPTX — POST /render/pptx
Renders a template to a PowerPoint deck (.pptx). Walks the template AST directly
— no browser required. Uses the same RenderBodySchema as the PDF endpoint.
POST /render/pptx
Content-Type: application/json
X-Api-Key: <render or admin key>
{
"template": "quarterly-review",
"data": { "quarter": "Q1 2026", "sections": [...] }
}
Response:
200 OK
Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation
Content-Disposition: attachment; filename="quarterly-review.pptx"
Capabilities: headings, text, rich text, tables, charts (as images), images, containers, columns, conditionals, repeaters, templateRef, toc, and barcodes.
Known limitations (see current limitations):
| Feature | PPTX behaviour |
|---|---|
pivotTable nodes | Not rendered |
pageBreak inside columns | Not honoured |
Header/footer totalPages marker | Not substituted |
A batch variant is available at POST /render/batch/pptx with the same envelope
shape as batch DOCX, returning pptx (base64) per item.
3e. Batch render — POST /render/batch
Renders multiple templates to PDF in a single request. Returns a JSON envelope with base64-encoded PDFs and per-item error reporting. Failed items do not abort the batch — each item succeeds or fails independently.
POST /render/batch
Content-Type: application/json
X-Api-Key: <render or admin key>
{
"items": [
{
"template": "invoice",
"data": { "invoiceNumber": "INV-001", ... }
},
{
"template": "receipt",
"version": "2.1.0",
"data": { "receiptId": "R-42", ... },
"options": { "paperSize": "Letter" }
}
]
}
Request body:
| 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 .
3e. PDF Transform utilities
Post-render PDF manipulation endpoints. These operate on PDF buffers (base64-encoded), not on templates directly. All endpoints require render or admin scope and return application/pdf on success.
Rate limit: RATE_LIMIT_PDF_UTIL_MAX (default: 20 requests/minute).
Size limits:
| Environment 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 |
3a. Publish-readiness validation — POST /render/validate
This endpoint is always registered in production. It does not require
PREVIEW_ROUTES_ENABLEDand never returns rendered HTML or PDF output. It is the endpoint the editor uses for its publish-readiness check.
Accepts a TemplateDefinition object directly (no database lookup) and checks both structural validity and renderability. Returns a structured { valid, issues } result — not HTML or PDF.
Request:
POST /render/validate
Content-Type: application/json
X-Api-Key: <API_KEY_EDITOR or API_KEY_ADMIN>
{
"template": { /* TemplateDefinition object */ },
"data": { "key": "value" }
}
| 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 OKwith{ valid, issues }. Check thevalidfield to determine success or failure. Authentication failures (401), scope failures (403), and rate-limit failures (429) still return the normal error status codes. Unexpected server errors during the render check return500— they are not reported asissuesentries 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
TemplateDefinitionobject directly — no database lookup is performed. Do not use these routes as a general-purpose rendering API.
POST /render/preview/html
Returns the rendered HTML for an inline template definition.
Request:
POST /render/preview/html
Content-Type: application/json
{
"template": { /* TemplateDefinition object */ },
"data": { "key": "value" }
}
Success — 200 text/html:
<!DOCTYPE html><html>...</html>
Error — 400 application/json (template validation failure):
{
"error": "Invalid Template",
"issues": [
{ "path": "key", "message": "Invalid" },
{ "path": "document.children.0.id", "message": "Required" }
]
}
Error — 422 application/json (renderer failure after valid template):
{ "error": "RenderError", "message": "…renderer error text…" }
POST /render/preview/pdf
Returns a PDF for an inline template definition.
Request:
POST /render/preview/pdf
Content-Type: application/json
{
"template": { /* TemplateDefinition object */ },
"data": { "key": "value" },
"options": { "paperSize": "A4", "orientation": "portrait" }
}
Success — 200 application/pdf:
Content-Disposition: inline; filename="preview.pdf"
<binary PDF>
Error — 400 application/json (template validation failure):
{
"error": "Invalid Template",
"issues": [
{ "path": "key", "message": "Invalid" },
{ "path": "document.children.0.id", "message": "Required" }
]
}
Error — 400 application/json (preview options validation failure):
{
"error": "Invalid Options",
"issues": [
{ "path": "paperSize", "message": "Invalid enum value. Expected 'A4' | 'A3' | 'Letter' | 'Legal' | 'Tabloid'" }
]
}
Error — 422 application/json (renderer failure after valid template):
{ "error": "RenderError", "message": "…renderer error text…" }
Error convention
400 Invalid Template is returned when the submitted template object fails structural
validation against TemplateDefinitionSchema — missing required fields, unknown keys,
invalid node types, unrecognised enum values, etc. The issues array mirrors the format
used by POST /templates — each entry has a path (dot-separated) and a message
(Zod error text). This check happens before the renderer is called.
400 Invalid Options is returned (PDF endpoint only) when the options object contains
an invalid paperSize or orientation value, or unknown keys. Accepted values are the
same as renderConfig.paperSize and renderConfig.orientation in the template schema.
422 RenderError is returned for renderer-layer failures that occur after both template
and options pass validation: data does not satisfy the template’s inputSchema, invalid
Handlebars expression syntax, or unexpected renderer exceptions. The message field
contains the renderer’s exception message and is displayed directly in the editor’s
preview panel.
Production availability
These routes validate the submitted TemplateDefinition against the same strict Zod schema
used by POST /templates and PUT /templates/:key, then pass the validated definition to
the renderer. No database lookup is performed.
Preview routes are not registered in production by default. POST /render/preview/html
and POST /render/preview/pdf return 404 Not Found unless PREVIEW_ROUTES_ENABLED=true
is set in the server environment.
| 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_ENABLEDabsent 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 with400 - 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, version, description. Supports ?limit= and ?offset= query parameters (see Pagination).
{
"items": [
{ "key": "loan-approval-letter", "name": "Loan Approval Letter", "version": "1.0.4", "description": "..." }
],
"total": 42,
"limit": 50,
"offset": 0
}
Get full template definition
GET /templates/:key
Returns the full TemplateDefinition JSON including document, inputSchema, and fieldMappings. Includes an ETag response header set to the current version string.
200 OK
ETag: "1.0.3"
Content-Type: application/json
Get the input schema
GET /templates/:key/schema
Returns the JSON Schema object that describes the adapted data shape (after field mapping). Use this to build or validate payloads programmatically.
Get a sample payload
GET /templates/:key/sample
Returns an auto-generated sample payload derived from the template’s inputSchema. Useful as a starting point when integrating a new template.
Get form-rendering metadata
GET /templates/:key/form-metadata[?version=…|label=…]
Returns the subset of template metadata the <pulp-engine-form> embed needs to auto-generate a fillable form. Also callable directly when you are building a form surface on top of Pulp Engine without using the embed.
{
"key": "invoice-v2",
"version": "1.4.0",
"title": "Invoice",
"description": "Simple invoice with line items and a total row.",
"inputSchema": { /* JSON Schema object */ },
"hasFieldMappings": false
}
| 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:
request.actor— non-null for named-user deployments (OIDC orEDITOR_USERS_JSON), giving per-user isolationrequest.credentialScope— used for shared-key deployments whereactoris null. Admin and editor credentials land in independent bucketsrequest.ip— fallback only; bounds abuse even without auth
Multiple callers sharing an egress IP (common for corporate deployments) won’t starve each other as long as they present distinct credentials. Covered by a regression test in templates-generate.route.test.ts.
Example end-to-end (curl)
# 1. Check the feature is enabled on this server
curl http://localhost:3000/capabilities \
-H "X-Api-Key: $API_KEY_EDITOR"
# → { "formats": { ... }, "ai": { "templateGeneration": true } }
# 2. Generate a draft
curl -X POST http://localhost:3000/templates/generate \
-H "X-Api-Key: $API_KEY_EDITOR" \
-H "Content-Type: application/json" \
-d '{"prompt":"a one-page invoice with a header, a 3-column line-item table, and a total row"}' \
> draft.json
# Inspect draft.template — it's a full TemplateDefinition
# 3. Persist it (the caller reviews first; this is a separate mutation)
jq '.template' draft.json | curl -X POST http://localhost:3000/templates \
-H "X-Api-Key: $API_KEY_EDITOR" \
-H "Content-Type: application/json" \
-d @-
The round-trip is a one-liner in the generated TypeScript SDK as well:
import createClient from '@pulp-engine/sdk'
const api = createClient({ baseUrl: 'http://localhost:3000', apiKey: process.env.API_KEY_EDITOR! })
const { data, error } = await api.POST('/templates/generate', {
body: { prompt: 'a one-page invoice with line items and a total' },
})
if (error) throw new Error('generation failed')
await api.POST('/templates', { body: data.template })
Update a template
PUT /templates/:key
Content-Type: application/json
If-Match: "1.0.3"
{ /* TemplateDefinition object */ }
Replaces the current definition and auto-bumps the patch version (e.g. 1.0.2 → 1.0.3). Returns the updated summary with an ETag header set to the new version.
The If-Match header is required and must contain the version string you received from the most recent GET. See Concurrency model for the full header specification.
| 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"
}
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 (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}$. prodandstagingare reserved and cannot be used as A/B variants.- Plain label — points to a version.
trafficSplitandcontrolLabelare bothnull. - A/B variant label — carries
trafficSplit(0–100) andcontrolLabel. A given render request is bucketed into the variant with probabilitytrafficSplit / 100; the remainder falls through to thecontrolLabel. Variants cannot chain (thecontrolLabelmust be a plain label).
Render precedence: explicit version > label > template’s currentVersion.
GET /templates/:key/labels
Lists all labels on a template. Returns editor or admin.
{
"items": [
{ "label": "prod", "version": "1.2.3", "trafficSplit": null, "controlLabel": null, "updatedAt": "...", "updatedBy": "alice" },
{ "label": "checkout-test", "version": "1.3.0-beta", "trafficSplit": 20, "controlLabel": "prod", "updatedAt": "...", "updatedBy": "ci-bot" }
]
}
GET /templates/:key/labels/:label
Returns the full template definition at the labeled version. editor or
admin. 404 distinguishes a missing template from a missing label.
PUT /templates/:key/labels/:label
Creates or re-points a label. admin only — editor tokens cannot promote.
PUT /templates/:key/labels/:label
If-Match: "1.2.3"
Content-Type: application/json
{
"version": "1.2.3",
"trafficSplit": 25,
"controlLabel": "prod"
}
If-Matchis optional. When present and the label already points at a different version, the call returns412 LABEL_CONFLICT. Automated promoters that serialise their own state can omit it and get an unconditional upsert.trafficSplitandcontrolLabelmust be supplied together or not at all.controlLabelmust exist on the same template and must itself be a plain label.- Optional
X-PulpEngine-Test-Run-Idheader is logged to the audit trail as a forensic breadcrumb (no server-side validation) — the CLI uses this to tie promotions to CI test runs.
Error responses: 400 (regex/variant validation), 404 (template or target
version), 412 LABEL_CONFLICT (If-Match mismatch).
DELETE /templates/:key/labels/:label
Removes the label. admin only. Idempotent — deleting an absent label on
an existing template still returns 204 No Content. 404 only if the
template itself is missing.
renderConfig trust model
renderConfig fields have different trust levels. This matters because some fields are
injected into the PDF renderer without sanitisation.
| 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:
styleon all allowed elements (restricted CSS property set);classonspanonly (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 (includingbackground-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 /templatesandPUT /templates/:key— requireeditororadminscope/render/preview/htmland/render/preview/pdf— requireprevieworadminscope; not registered in production by default (PREVIEW_ROUTES_ENABLEDcontrols availability)
They cannot be overridden per render request — PreviewOptionsSchema only allows
paperSize and orientation. Both fields are capped at 10,000 characters. Payloads
exceeding this limit are rejected with 400 Invalid Template.
6. Asset management
Assets are image files stored on the API host and served statically at /assets/*. They can be referenced by image nodes in templates via their URL (e.g. /assets/uuid-logo.png).
Accepted file types: PNG, JPEG, GIF, WebP. Maximum upload size: 10 MB. SVG is not accepted — SVG files can contain JavaScript and external entity references; use a raster format instead.
Uploads are validated against both the declared MIME type and the file’s actual content (magic bytes). If the declared MIME type does not match the detected file type, the upload is rejected with 415 Unsupported Media Type.
List all assets
GET /assets[?q=<substring>][&mimeType=<type>][&legacySvg=true]
Returns a paginated envelope of asset records, newest first. Supports ?limit= and ?offset= query parameters (see Pagination) and three optional query filters (combinable with AND logic):
| 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. 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. Returns 404 if the asset record does not exist. The file is also removed from disk.
7. Scheduled delivery
Cron-driven render jobs with delivery to email, S3, or webhook targets.
Requires SCHEDULE_ENABLED=true and a database-backed storage mode (postgres
or sqlserver). All routes require admin scope. When scheduling is disabled
or storage is file mode, every route returns 503 unavailable.
Minimum cron interval is one minute. URLs (data-source and webhook) pass the same SSRF guard as the render pipeline — loopback and private-range hosts are rejected outside of evaluation posture.
Create — POST /schedules
{
"name": "Daily invoices",
"enabled": true,
"cronExpression": "0 9 * * *",
"timezone": "Australia/Sydney",
"templateKey": "invoice",
"templateLabel": "prod",
"format": "pdf",
"dataSource": {
"type": "url",
"url": "https://billing.example.com/invoices/today",
"headers": { "Authorization": "Bearer ..." },
"timeoutMs": 10000
},
"deliveryTargets": [
{ "type": "email", "to": ["ops@example.com"], "subject": "Daily invoices", "attachmentName": "invoices-{{date}}.pdf" },
{ "type": "s3", "bucket": "acme-archive", "key": "invoices/{{date}}.pdf", "region": "ap-southeast-2" },
{ "type": "webhook", "url": "https://hooks.example.com/invoices", "secret": "shared-hmac-secret", "includeBody": false }
]
}
| 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 cronExpression, timezone, or enabled
changes.
Manual trigger — POST /schedules/:id/trigger
Enqueues an execution out-of-band. Returns 202 Accepted with the execution
record (status: "pending"). Rate-limited at 5 requests / minute / tenant.
Returns 409 duplicate if an execution already exists for the current
fire-time (rare).
Execution history
GET /schedules/:id/executions?limit=&offset=&status=— paginatedGET /schedules/:id/executions/:execId— single execution
Execution status values: pending, rendering, delivering, completed,
partially_failed, failed. Failed deliveries are retried with backoff and
ultimately land in the delivery DLQ (see Admin routes).
8. Admin routes
Operator-facing endpoints for tenant lifecycle, named-user management, and
dead-letter queue triage. All routes require admin scope; multi-tenant
routes additionally require super-admin (scope=admin with tenantId=null
— tenant-bound admin credentials are rejected 403 super_admin_only).
Tenants CRUD
MULTI_TENANT_ENABLED=true and STORAGE_MODE=postgres required. Otherwise
every route returns 503 unavailable.
| 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 | DELETE | Not implemented in v0.67.x — returns 501 Not Implemented. Use archive. Hard-delete with cascade is deferred pending a cascade policy for audit events, versions, DLQ history, and scheduled deliveries. |
Archive/unarchive bust TenantStatusCache on the handling pod. Other pods see
the state change within TENANT_STATUS_CACHE_TTL_MS (default 10 s).
Named-user management
Shipped in v0.60.0. Requires named-user identity mode — routes return
409 Conflict if neither EDITOR_USERS_JSON nor EDITOR_USERS_FILE is set.
Runtime mutations (POST/PUT/DELETE) persist to EDITOR_USERS_FILE when
configured; otherwise the registry is in-memory and changes are lost on
restart. EDITOR_USERS_JSON is read once at startup and is not mutated by
these routes.
| 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 | Re-reads EDITOR_USERS_FILE and replaces the registry. OIDC-auto-provisioned users are merged in, not clobbered. 404 if EDITOR_USERS_FILE is missing on disk; 409 if not configured. |
Dead-letter queues
Failed async batch deliveries and scheduled-delivery dispatches land in persistent DLQs for operator triage. These endpoints are documented in the operator runbook because the replay/abandon workflow is operational rather than integration-surface. Summary:
| 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", payload);
response.EnsureSuccessStatusCode();
var pdf = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("output.pdf", pdf);
JavaScript (fetch)
const response = await fetch('http://localhost:3000/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: 'loan-approval-letter',
data: {
applicantName: 'Jane Smith',
applicantAddress: '12 Maple Street, Sydney NSW 2000',
loanAmount: 50000,
interestRate: 5.75,
termMonths: 60,
settlementDate: '2026-04-01',
requiresGuarantor: false,
items: [
{ description: 'Application fee', amount: 250 }
]
}
})
})
if (!response.ok) {
const err = await response.json()
throw new Error(`${response.status}: ${JSON.stringify(err)}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
fs.writeFileSync('output.pdf', buffer)
PHP
<?php
$payload = json_encode([
'template' => 'loan-approval-letter',
'data' => [
'applicantName' => 'Jane Smith',
'applicantAddress' => '12 Maple Street, Sydney NSW 2000',
'loanAmount' => 50000,
'interestRate' => 5.75,
'termMonths' => 60,
'settlementDate' => '2026-04-01',
'requiresGuarantor' => false,
'items' => [
['description' => 'Application fee', 'amount' => 250],
],
],
]);
$ch = curl_init('http://localhost:3000/render');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
]);
$pdf = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new \RuntimeException("Render failed ($status): $pdf");
}
file_put_contents('output.pdf', $pdf);
VB.NET
Imports System.Net.Http
Imports System.Text
Imports System.Text.Json
Dim client As New HttpClient With { .BaseAddress = New Uri("http://localhost:3000") }
Dim payload = New With {
.template = "loan-approval-letter",
.data = New With {
.applicantName = "Jane Smith",
.applicantAddress = "12 Maple Street, Sydney NSW 2000",
.loanAmount = 50000,
.interestRate = 5.75,
.termMonths = 60,
.settlementDate = "2026-04-01",
.requiresGuarantor = False,
.items = New() { New With { .description = "Application fee", .amount = 250 } }
}
}
Dim body = JsonSerializer.Serialize(payload)
Dim response = Await client.PostAsync("/render", New StringContent(body, Encoding.UTF8, "application/json"))
response.EnsureSuccessStatusCode()
Dim pdf = Await response.Content.ReadAsByteArrayAsync()
Await File.WriteAllBytesAsync("output.pdf", pdf)
10. loan-approval-letter — full request example
This template demonstrates field mapping (raw keys → adapted keys), a conditional guarantor section, and a formatted fee table.
API request body (POST /render or POST /render/html)
{
"template": "loan-approval-letter",
"data": {
"applicantName": "Jane Smith",
"applicantAddress": "12 Maple Street, Sydney NSW 2000",
"loanAmount": 50000,
"interestRate": 5.75,
"termMonths": 60,
"settlementDate": "2026-04-01",
"requiresGuarantor": true,
"guarantorName": "Bob Smith",
"branchName": "Sydney CBD",
"officerName": "Alice Wong",
"items": [
{ "description": "Application fee", "amount": 250 },
{ "description": "Valuation fee", "amount": 400 },
{ "description": "Legal fee", "amount": 800 }
]
}
}
Editor preview panel (paste into the Sample Data textarea)
The preview panel takes only the data object — do not include the "template" wrapper.
{
"applicantName": "Jane Smith",
"applicantAddress": "12 Maple Street, Sydney NSW 2000",
"loanAmount": 50000,
"interestRate": 5.75,
"termMonths": 60,
"settlementDate": "2026-04-01",
"requiresGuarantor": true,
"guarantorName": "Bob Smith",
"branchName": "Sydney CBD",
"officerName": "Alice Wong",
"items": [
{ "description": "Application fee", "amount": 250 },
{ "description": "Valuation fee", "amount": 400 },
{ "description": "Legal fee", "amount": 800 }
]
}
Field mapping notes:
| Raw 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