Pulp Engine Document Rendering
Get started

Embeds — Integration Guide

Looking to embed rendered output (PDF/HTML) rather than an interactive surface? See embedding-guide.md.

Pulp Engine ships two companion custom elements:

  • <pulp-engine-editor> — the full template editor. For operators who let end users design and edit templates in-product.
  • <pulp-engine-form> — a fillable form derived from a template’s inputSchema. For end users who only need to enter data and receive a rendered PDF.

Both elements are iframe-isolated, share the same auth and CSP configuration, and are distributed by the Pulp Engine API server. Most of this guide covers the editor; the Form Embed section at the bottom documents the form element.

Quick Start

1. Load the embed script

Add the script tag pointing at your Pulp Engine API server:

<script src="https://your-pulp-engine-server.com/pulp-engine-embed.js"></script>

2. Add the editor element

<pulp-engine-editor
  api-url="https://your-pulp-engine-server.com"
  template-key="invoice-v2"
  token="et_..."
  allowed-nodes="heading,text,table,image,container,columns"
  theme="light"
  style="width: 100%; height: 700px;"
></pulp-engine-editor>

3. Listen for events

const editor = document.querySelector('pulp-engine-editor')
editor.addEventListener('saved', (e) => {
  console.log(`Template ${e.detail.key} saved as version ${e.detail.version}`)
})
editor.addEventListener('dirty-change', (e) => {
  // Show/hide a "You have unsaved changes" banner
  setBannerVisible(e.detail.isDirty)
})

Authentication

Your backend mints short-lived editor tokens by calling the Pulp Engine API. The browser never sees the API key.

[Your Backend] --POST /auth/editor-token--> [Pulp Engine API]
                                              |
                                        { token, expiresAt }
                                              |
[Your Frontend] <-- pass token via props -----+

Pass the token to the editor element:

<pulp-engine-editor
  api-url="https://pulp-engine.internal"
  template-key="invoice-v2"
  token="et_abc123..."
  token-expires-at="2026-04-10T12:00:00Z"
></pulp-engine-editor>

When the token nears expiry, mint a new one server-side and refresh:

const editor = document.querySelector('pulp-engine-editor')
editor.setToken(newToken, newExpiresAt)

Development only: API key attribute

For prototyping, you can pass the API key directly. Do not use in production — the key is visible in the DOM.

<pulp-engine-editor
  api-url="http://localhost:3000"
  api-key="your-editor-key"
  template-key="invoice-v2"
></pulp-engine-editor>

Server Configuration

EMBED_ALLOWED_ORIGINS (required for production)

By default, the Pulp Engine server blocks iframe embedding via CSP frame-ancestors 'none'. To allow embedding, set:

EMBED_ALLOWED_ORIGINS=https://app.acme.com https://staging.acme.com

Space-separated list of origins permitted to embed the editor. This sets frame-ancestors on /embed.html responses.

EMBED_CONNECT_ORIGINS (optional)

Space-separated list of extra origins the embedded editor is permitted to open fetch/WebSocket connections to (connect-src in the embed CSP). Only set this if the embedding host needs to proxy API calls through a domain distinct from the Pulp Engine server.

EMBED_CONNECT_ORIGINS=https://forms-proxy.acme.com

CORS

If your frontend is on a different origin than the Pulp Engine API, ensure CORS_ALLOWED_ORIGINS includes your frontend origin:

CORS_ALLOWED_ORIGINS=https://app.acme.com

Attributes

AttributeRequiredDescription
api-urlYesBase URL of the Pulp Engine API server
template-keyNoTemplate key to auto-load on init
tokenNo*Pre-minted editor token (recommended for production)
token-expires-atNoISO date string for token expiry
api-keyNo*Raw API key (dev/prototyping only)
allowed-nodesNoComma-separated list of allowed node types
themeNolight (default) or dark
previewNotrue (default) or false to hide preview

* Either token or api-key must be provided for authenticated servers.

Embed-mode defaults

The embed intentionally suppresses editor UI that is not part of the hosted contract. The following actions are not available inside the embed and cannot be turned on today:

  • Multiple tabs (the embed operates in single-template mode)
  • The template-picker “Open Template” button (toolbar and empty-state)
  • The “New Template” action (toolbar and empty-state)
  • Developer-only empty-state buttons (“Load Sample”, “Load JSON”)

When the host switches templates via setTemplate(key) or by sending a pulp-engine:set-template message, the currently-open template is replaced (not stacked in a new tab), and preview data is refreshed from the new template’s storage key.

If you need any of these actions exposed to end users, raise an issue describing the use case — promoting them to public contract is a separate release.


Events

All events are standard DOM CustomEvents dispatched on the <pulp-engine-editor> element. Use addEventListener() to subscribe.

Eventdetail fieldsDescription
readyEditor loaded and authenticated
template-loadedkey, versionTemplate fetched and rendered
dirty-changeisDirtyUnsaved changes state changed
savedkey, versionTemplate saved successfully
save-errormessageSave failed
errormessage, codeAuth/API/runtime error

Error codes

CodeMeaning
AUTH_FAILEDToken exchange or API key validation failed
AUTH_EXPIREDEditor session expired — provide a new token
TEMPLATE_LOAD_FAILEDCould not fetch the specified template

Methods

MethodDescription
save()Programmatically trigger a save
setToken(token, expiresAt)Refresh the auth token
setTemplate(key)Load a different template

Node Constraints (allowed-nodes)

Control which block types your customers can add. Existing blocks of disallowed types remain visible and editable — they just cannot be duplicated or added.

<pulp-engine-editor
  allowed-nodes="heading,text,table,image,container,columns"
  ...
></pulp-engine-editor>

Always-allowed types

document, section, and column are structural necessities and are always permitted regardless of constraints.

Constrainable types

All other types can be restricted: container, columns, heading, text, image, table, chart, pivotTable, richText, spacer, divider, pageBreak, conditional, repeater, signatureField, positioned, toc.

Custom plugin nodes

Plugin-registered custom types use the custom:<customType> format:

allowed-nodes="heading,text,custom:barcode,custom:qrCode"

Enforcement points

Constraints are enforced at four levels:

  1. Palette: disallowed types hidden from the block palette
  2. Inline add: disallowed types hidden from canvas ”+ Add block” dropdowns
  3. Paste: disallowed nodes stripped from pasted trees (allowed root with disallowed children → children stripped; disallowed root → paste blocked)
  4. Store mutations: addNode, addNodeToBranch, duplicateNode reject disallowed types

TypeScript SDK (@pulp-engine/editor-embed)

For programmatic control with full type safety:

import { createEditor, loadPulpEngineEmbed } from '@pulp-engine/editor-embed'

// Load the custom element definition from your server
await loadPulpEngineEmbed('https://pulp-engine.internal')

// Create and mount the editor
const editor = createEditor({
  apiUrl: 'https://pulp-engine.internal',
  templateKey: 'invoice-v2',
  token: tokenFromBackend,
  allowedNodes: ['heading', 'text', 'table', 'image'],
})

editor.on('saved', ({ key, version }) => {
  showToast(`Saved ${key} v${version}`)
})

editor.on('dirty-change', ({ isDirty }) => {
  setUnsavedBanner(isDirty)
})

editor.mount(document.getElementById('editor-container')!)

// Later: cleanup
editor.unmount()

Framework Examples

React

import { useEffect, useRef } from 'react'
import { createEditor, loadPulpEngineEmbed } from '@pulp-engine/editor-embed'

function TemplateEditor({ templateKey, token }: { templateKey: string; token: string }) {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    let editor: ReturnType<typeof createEditor> | null = null

    loadPulpEngineEmbed('https://pulp-engine.internal').then(() => {
      editor = createEditor({
        apiUrl: 'https://pulp-engine.internal',
        templateKey,
        token,
        allowedNodes: ['heading', 'text', 'table', 'image'],
      })
      editor.mount(containerRef.current!)
    })

    return () => { editor?.unmount() }
  }, [templateKey, token])

  return <div ref={containerRef} style={{ width: '100%', height: '700px' }} />
}

Vue 3

<template>
  <div ref="container" style="width: 100%; height: 700px" />
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { createEditor, loadPulpEngineEmbed } from '@pulp-engine/editor-embed'

const props = defineProps<{ templateKey: string; token: string }>()
const container = ref<HTMLElement>()
let editor: ReturnType<typeof createEditor> | null = null

onMounted(async () => {
  await loadPulpEngineEmbed('https://pulp-engine.internal')
  editor = createEditor({
    apiUrl: 'https://pulp-engine.internal',
    templateKey: props.templateKey,
    token: props.token,
    allowedNodes: ['heading', 'text', 'table', 'image'],
  })
  editor.mount(container.value!)
})

onUnmounted(() => editor?.unmount())
</script>

Vanilla HTML

<script src="https://pulp-engine.internal/pulp-engine-embed.js"></script>

<pulp-engine-editor
  id="my-editor"
  api-url="https://pulp-engine.internal"
  template-key="invoice-v2"
  token="et_abc123"
  allowed-nodes="heading,text,table,image"
  style="width: 100%; height: 700px;"
></pulp-engine-editor>

<script>
  document.getElementById('my-editor').addEventListener('saved', (e) => {
    alert(`Saved version ${e.detail.version}`)
  })
</script>

Form Embed (<pulp-engine-form>)

<pulp-engine-form> reads a template’s inputSchema and renders a matching web form. End users fill in the fields, click submit, and receive a rendered PDF (or DOCX / PPTX / CSV / XLSX, depending on the template’s renderer). No template-authoring surface is exposed.

The element is iframe-isolated like the editor. All schema-to-form logic lives inside /form.html served from the Pulp Engine API; the host page only needs the wrapper script.

Quick Start

<script src="https://your-pulp-engine-server.com/pulp-engine-form.js"></script>

<pulp-engine-form
  api-url="https://your-pulp-engine-server.com"
  template-key="invoice-v2"
  api-key="your-render-key"
  submit-label="Generate invoice"
  style="width: 100%; height: 600px;"
></pulp-engine-form>

<script>
  const form = document.querySelector('pulp-engine-form')
  form.addEventListener('ready', (e) => {
    console.log('Form loaded. hasFieldMappings:', e.detail.hasFieldMappings)
  })
  form.addEventListener('success', (e) => {
    console.log(`Generated ${e.detail.filename} (${e.detail.size} bytes)`)
  })
  form.addEventListener('error', (e) => {
    console.error(`Form error ${e.detail.code}: ${e.detail.message}`)
  })
</script>

On mount, the form fetches GET /templates/:key/form-metadata, renders a React form from the returned inputSchema, and submits to POST /render when the user clicks the submit button. The resulting blob is either downloaded in-iframe (result-mode="download", default) or posted back to the host page as a Blob (result-mode="blob").

Authentication

The form embed calls /render and /templates/:key/form-metadata directly from inside the iframe. Both endpoints require credentials — pick one of:

  • api-key attribute — pass an admin or render-scoped API key. Visible in the DOM; use only on trusted internal pages (intranets, operator portals, back-office flows). Not safe for public end-user pages.
  • token attribute — pass an editor token (X-Editor-Token). v1 editor tokens cannot call /render, so this is forward-compatible plumbing rather than a current public-user auth path. Today, the form embed’s end-user use case is internal/trusted pages with an API key.

Multi-tenant deployments must also pass tenant-id (forwarded as X-PulpEngine-Tenant-Id).

Public end-user form delivery: if your use case is an anonymous external user filling out a form on a marketing site, front the form with your own backend — proxy /templates/:key/form-metadata and /render behind a narrow per-template endpoint, and point api-url at the proxy. The Pulp Engine render-scoped key stays server-side.

Server Configuration

The form embed uses the same CSP, CORS, and embedding rules as the editor. If you have already configured EMBED_ALLOWED_ORIGINS and CORS_ALLOWED_ORIGINS for <pulp-engine-editor>, no additional server configuration is needed. See Server Configuration above.

Attributes

AttributeRequiredDescription
api-urlYesBase URL of the Pulp Engine API server (or your proxy). Used for both metadata and render calls.
template-keyYesTemplate key to generate a form for.
api-keyNo*Admin or render-scoped API key. Sent as X-Api-Key. Trusted internal pages only.
tokenNo*Editor token. Sent as X-Editor-Token. Kept for forward compatibility; cannot call /render in v1.
embed-urlNoOverride the origin that serves /form.html and /pulp-engine-form.js (for split-origin proxy deployments). Defaults to api-url.
versionNoPin a specific template version. Mutually exclusive with label.
labelNoResolve a named label (e.g. stable, prod). Mutually exclusive with version.
tenant-idNoMulti-tenant slug, forwarded as X-PulpEngine-Tenant-Id.
result-modeNodownload (default) or blob. Controls how the generated output is delivered.
submit-labelNoOverride the default “Generate PDF” button label.
themeNolight (default) or dark.
hidden-fieldsNoJSON object literal. Values merged into the submission and hidden from the UI. Example: hidden-fields='{"locale":"en-AU","channel":"web"}'

* Either api-key or token must be provided on authenticated servers.

Events

Standard DOM CustomEvents dispatched on the <pulp-engine-form> element:

Eventdetail fieldsDescription
readyhasFieldMappingsMetadata fetched and form rendered. hasFieldMappings: true means the template refused to render — see v1 Limitation.
changeUser edited a field. Fires on every form input.
submitUser clicked submit; render request dispatched.
successcontentType, filename, size, blob?Render completed. blob is populated only when result-mode="blob".
errorcode, message, status?Metadata fetch, validation, or render failed.

Error codes

CodeMeaning
unsupported_mapped_templateTemplate has fieldMappings; form embed refuses to render it (v1).
unsupported_schema_rootTemplate inputSchema is not an object with properties.
metadata_fetch_failedGET /templates/:key/form-metadata returned non-2xx. Check status for HTTP status and message for the server detail.
network_errorFetch threw before a response arrived (offline, DNS, CORS preflight).
Server render codesOn render failure, the server’s own code is forwarded verbatim — e.g. validation_failed, asset_blocked, template_expression_error. See Render execution error codes.

Result modes

  • download (default) — the iframe creates an in-iframe <a download> and clicks it, kicking off a browser download directly from the iframe’s origin. The host page receives a lightweight success event with contentType, filename, and size. No Blob is transferred.
  • blob — the iframe posts the full Blob back to the host via structured clone. The host owns the URL lifecycle (typically URL.createObjectURL + URL.revokeObjectURL). Use this when the host needs to inline the PDF into its own UI (e.g. a viewer, an attachment flow) rather than dropping it into the user’s Downloads folder.

v1 Limitation: field mappings

Templates with fieldMappings are refused by the form embed in v1. The ready event fires with hasFieldMappings: true and an error event with code: 'unsupported_mapped_template' is also dispatched; the form shows an inline message and no submit button.

Why: a template’s inputSchema describes the post-mapping data shape (what the template body’s {{…}} references). fieldMappings is a one-way transform from raw submission → post-mapping shape. The v1 form only knows the post-mapping schema, so it cannot honestly generate a form matching what the template expects end users to submit.

Detecting at build time: call GET /templates/:key/form-metadata before you render the embed and check hasFieldMappings. If true, either (a) author a companion template without mappings that produces the same PDF, or (b) build a custom form on your side that submits directly to /render.

Supported JSON Schema subset

The v1 form renders templates whose inputSchema uses these keywords. Anything outside this subset is either ignored or rendered as a generic free-text field.

  • Types: string, number, integer, boolean, object, array
  • Common: required, enum, default, title, description
  • String: minLength, maxLength, pattern, format (email, uri, date, date-time)
  • Number: minimum, maximum, multipleOf
  • Array: items (primitive item schemas), minItems, maxItems
  • Object: nested properties, required[]

Out of scope in v1: oneOf / anyOf / allOf, if / then / else, $ref, patternProperties, dependencies, tuple items. Client-side validation is best-effort; the server re-validates the submission on /render and surfaces violations via the error event.

TypeScript SDK (@pulp-engine/form-embed)

import { createForm, loadFormScript } from '@pulp-engine/form-embed'

await loadFormScript({ embedUrl: 'https://pulp-engine.internal' })

const form = createForm({
  apiUrl: 'https://pulp-engine.internal',
  templateKey: 'invoice-v2',
  apiKey: adminKey,            // trusted internal pages only
  resultMode: 'blob',
  submitLabel: 'Generate invoice',
  hiddenFields: { channel: 'web' },
})

form.on('ready', ({ hasFieldMappings }) => {
  if (hasFieldMappings) {
    alert('This template is not supported by the form embed.')
  }
})

form.on('success', ({ blob, filename }) => {
  const url = URL.createObjectURL(blob!)
  window.open(url, '_blank')
  setTimeout(() => URL.revokeObjectURL(url), 60_000)
})

form.on('error', ({ code, message, status }) => {
  console.error(`[${code}] ${message}`, status)
})

form.mount(document.getElementById('form-container')!)

loadFormScript is idempotent — multiple calls resolve immediately once the element is registered.

Framework Examples

React

import { useEffect, useRef } from 'react'
import { createForm, loadFormScript } from '@pulp-engine/form-embed'

function InvoiceForm({ templateKey, apiKey }: { templateKey: string; apiKey: string }) {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    let form: ReturnType<typeof createForm> | null = null

    loadFormScript({ embedUrl: 'https://pulp-engine.internal' }).then(() => {
      form = createForm({
        apiUrl: 'https://pulp-engine.internal',
        templateKey,
        apiKey,
        resultMode: 'download',
      })
      form.mount(containerRef.current!)
    })

    return () => { form?.unmount() }
  }, [templateKey, apiKey])

  return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
}

Vue 3

<template>
  <div ref="container" style="width: 100%; height: 600px" />
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { createForm, loadFormScript } from '@pulp-engine/form-embed'

const props = defineProps<{ templateKey: string; apiKey: string }>()
const container = ref<HTMLElement>()
let form: ReturnType<typeof createForm> | null = null

onMounted(async () => {
  await loadFormScript({ embedUrl: 'https://pulp-engine.internal' })
  form = createForm({
    apiUrl: 'https://pulp-engine.internal',
    templateKey: props.templateKey,
    apiKey: props.apiKey,
    resultMode: 'download',
  })
  form.mount(container.value!)
})

onUnmounted(() => form?.unmount())
</script>

Vanilla HTML

<script src="https://pulp-engine.internal/pulp-engine-form.js"></script>

<pulp-engine-form
  id="my-form"
  api-url="https://pulp-engine.internal"
  template-key="invoice-v2"
  api-key="your-render-key"
  result-mode="download"
  style="width: 100%; height: 600px;"
></pulp-engine-form>

<script>
  document.getElementById('my-form').addEventListener('success', (e) => {
    console.log('Downloaded', e.detail.filename)
  })
</script>