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’sinputSchema. 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
Recommended: Server-side token minting
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
| Attribute | Required | Description |
|---|---|---|
api-url | Yes | Base URL of the Pulp Engine API server |
template-key | No | Template key to auto-load on init |
token | No* | Pre-minted editor token (recommended for production) |
token-expires-at | No | ISO date string for token expiry |
api-key | No* | Raw API key (dev/prototyping only) |
allowed-nodes | No | Comma-separated list of allowed node types |
theme | No | light (default) or dark |
preview | No | true (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.
| Event | detail fields | Description |
|---|---|---|
ready | — | Editor loaded and authenticated |
template-loaded | key, version | Template fetched and rendered |
dirty-change | isDirty | Unsaved changes state changed |
saved | key, version | Template saved successfully |
save-error | message | Save failed |
error | message, code | Auth/API/runtime error |
Error codes
| Code | Meaning |
|---|---|
AUTH_FAILED | Token exchange or API key validation failed |
AUTH_EXPIRED | Editor session expired — provide a new token |
TEMPLATE_LOAD_FAILED | Could not fetch the specified template |
Methods
| Method | Description |
|---|---|
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:
- Palette: disallowed types hidden from the block palette
- Inline add: disallowed types hidden from canvas ”+ Add block” dropdowns
- Paste: disallowed nodes stripped from pasted trees (allowed root with disallowed children → children stripped; disallowed root → paste blocked)
- Store mutations:
addNode,addNodeToBranch,duplicateNodereject 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-keyattribute — 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.tokenattribute — 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-metadataand/renderbehind a narrow per-template endpoint, and pointapi-urlat 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
| Attribute | Required | Description |
|---|---|---|
api-url | Yes | Base URL of the Pulp Engine API server (or your proxy). Used for both metadata and render calls. |
template-key | Yes | Template key to generate a form for. |
api-key | No* | Admin or render-scoped API key. Sent as X-Api-Key. Trusted internal pages only. |
token | No* | Editor token. Sent as X-Editor-Token. Kept for forward compatibility; cannot call /render in v1. |
embed-url | No | Override the origin that serves /form.html and /pulp-engine-form.js (for split-origin proxy deployments). Defaults to api-url. |
version | No | Pin a specific template version. Mutually exclusive with label. |
label | No | Resolve a named label (e.g. stable, prod). Mutually exclusive with version. |
tenant-id | No | Multi-tenant slug, forwarded as X-PulpEngine-Tenant-Id. |
result-mode | No | download (default) or blob. Controls how the generated output is delivered. |
submit-label | No | Override the default “Generate PDF” button label. |
theme | No | light (default) or dark. |
hidden-fields | No | JSON 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:
| Event | detail fields | Description |
|---|---|---|
ready | hasFieldMappings | Metadata fetched and form rendered. hasFieldMappings: true means the template refused to render — see v1 Limitation. |
change | — | User edited a field. Fires on every form input. |
submit | — | User clicked submit; render request dispatched. |
success | contentType, filename, size, blob? | Render completed. blob is populated only when result-mode="blob". |
error | code, message, status? | Metadata fetch, validation, or render failed. |
Error codes
| Code | Meaning |
|---|---|
unsupported_mapped_template | Template has fieldMappings; form embed refuses to render it (v1). |
unsupported_schema_root | Template inputSchema is not an object with properties. |
metadata_fetch_failed | GET /templates/:key/form-metadata returned non-2xx. Check status for HTTP status and message for the server detail. |
network_error | Fetch threw before a response arrived (offline, DNS, CORS preflight). |
| Server render codes | On 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 lightweightsuccessevent withcontentType,filename, andsize. NoBlobis transferred.blob— the iframe posts the fullBlobback to the host via structured clone. The host owns the URL lifecycle (typicallyURL.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>