Skip to content
Embedded plan

Embedded editor

Ship the AI report designer inside your own product — your layout, your theme, your customers.

Overview

The embedded editor puts the full pdfs.build designer — AI chat, live preview, schema and sample-data editors, version checkpoints — inside your own React application. Your users never see pdfs.build branding, never create a pdfs.build account, and never leave your product.

Availability Embedded (Scale Plus) plan
Packages @pdfsbuild/core and @pdfsbuild/react
Auth Server-issued emb_ session tokens. Your prs_ API key never reaches the browser.
Requires React 18+ and a bundler. The SDK is client-only.

How it fits together

Three moving parts. Your backend holds the API key and decides who may edit what; the browser only ever holds a short-lived token scoped to a single template.

  Your backend                      pdfs.build                Your frontend
  ────────────                      ──────────                ─────────────
  1. POST /v2/.../templates   ──▶   template created
     (once per doc type)             externalId: invoice-42

  2. POST /v2/.../embed-sessions  ──▶  emb_ token
     tenant, user                        (15 min, one template)
                                              │
                                              └──▶  3. PdfReportProvider
                                                       <PdfReportEditor />
Template The document type — its source, schema, and sample data. Created once, reused for every session and render.
Session A short-lived grant letting one of your users edit one template. Created per editor open.
Provider The React component that holds the token and mounts the editor.

Quick start

A complete Next.js integration. The route handler runs on your server and is the only place your API key appears.

app/api/pdfs-session/route.ts
import { auth } from '@/lib/auth'

export async function POST() {
// Your own authorization — decide who may edit what.
const user = await auth()
if (!user) return new Response('Unauthorized', { status: 401 })

const templateId = `invoice-${user.orgId}`
const res = await fetch(
`https://api.pdfs.build/v2/organizations/${user.orgId}/templates/${templateId}/embed-sessions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PDFS_BUILD_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
externalTenantId: user.orgId,
externalUserId: user.id,
}),
},
)

const { token } = await res.json()
// Return only the token — never the API key or the full session.
return Response.json({ token })
}
app/reports/designer.tsx
'use client'

import { PdfReportProvider, PdfReportEditor } from '@pdfsbuild/react'
import '@pdfsbuild/react/styles.css'

export function ReportDesigner() {
return (
<div style={{ height: '100vh' }}>
<PdfReportProvider
fetchSessionToken={async () => {
const res = await fetch('/api/pdfs-session', { method: 'POST' })
const { token } = await res.json()
return token
}}
onUnauthorized={() => router.refresh()}
theme={{ primary: '#6366f1' }}
>
<PdfReportEditor />
</PdfReportProvider>
</div>
)
}

No templateId on the client. The session token is already scoped to exactly one template, and the editor opens that template — passing an id in the browser would be ignored.

Give the provider a container with a real height. It fills its parent, so a parent of height auto collapses the editor to nothing.

Provision a template

A session needs a template to point at. Create one per document type with the REST API, using an externalId you derive from your own records — then you never have to store a pdfs.build identifier. Drafts are editable, so there is no need to publish before embedding.

Python — get-or-create
import os, requests

API = "https://api.pdfs.build"
HEADERS = {"Authorization": f"Bearer {os.environ['PDFS_BUILD_API_KEY']}"}

def get_or_create_template(org_id: str, external_id: str):
existing = requests.get(
f"{API}/v2/organizations/{org_id}/templates/{external_id}",
headers=HEADERS,
)
if existing.status_code == 200:
return existing.json()

created = requests.post(
f"{API}/v2/organizations/{org_id}/templates",
headers=HEADERS,
json={
"externalId": external_id,
"name": "Invoice",
"code": '#let render(company: "") = [Invoice for #company]',
"schema": {
"type": "object",
"properties": {"company": {"type": "string"}},
},
"sampleData": {"company": "Acme Corp"},
},
)
created.raise_for_status()
return created.json()

Creating with an externalId that already exists returns 409 external_id_conflict, which is what makes the get-or-create pattern above safe to run on every request. See the API reference for the full request body.

Choosing a template granularity

Pattern When to use it
One template per tenant Each customer designs their own invoice. externalId like invoice-{tenantId}. Most common.
One shared template You control the design; customers only supply data. Embed read-only or skip the editor entirely.
One per tenant per doc type Customers design several document types. externalId like {tenantId}-{docType}.
POST /v2/organizations/:organizationId/templates/:externalId/embed-sessions

Create a session

Always from your backend — never the browser, because the API key must stay server-side. Addressing matches the rest of the public v2 API: externalId is the template's org-scoped external ID when one is set, its internal ID otherwise. Create a fresh session each time the editor opens.

Request body

Field Type Description
externalTenantId string Required, 1–200 chars. An opaque identifier you choose — see below.
externalUserId string Required, 1–200 chars. An opaque identifier you choose — see below.
expiresInSeconds integer Token lifetime, 60–3600. Defaults to 900 (15 minutes).
requests.post(
f"https://api.pdfs.build/v2/organizations/{org_id}/templates/{external_id}/embed-sessions",
headers=HEADERS,
json={"externalTenantId": tenant_id, "externalUserId": user_id},
).json()

Response

{
"sessionId": "ses_...",
"token": "emb_...",
"templateId": "550e8400-e29b-41d4-a716-446655440000",
"expiresAt": "2026-07-15T12:15:00.000Z"
}

Return only token to the browser. templateId in the response is always the resolved internal id. The token is restricted to the selected template and the editor APIs — it cannot list your other templates, mint API keys, or read your organization settings.

Errors

Status Code Meaning
403 embedded_plan_required The organization is not on the Embedded plan.
403 organization_scope_mismatch The organization in the URL doesn't match the API key's organization.
400 Invalid body: missing tenant/user id, or expiresInSeconds outside 60–3600.
404 Template not found in the API key's organization.

Tenant and user identifiers

externalTenantId and externalUserId are opaque identifiers that you choose — we never interpret them, and they don't have to be anything we'd recognize. Use whatever your own system already uses to tell a customer and an end-user apart: a database id, a UUID, or a hash. They should not be email addresses, names, or any other personal data — a stable pseudonymous id is all the platform needs, and it keeps you from handing us PII you don't have to.

Each one buys you something concrete:

  • externalUserId gives every end-user their own private chat thread on a template. Two users editing the same template never see each other's conversation history because their externalUserId differs.
  • externalTenantId is the axis your AI-spend breakdown is grouped by, so you can see which customer consumed what. It's also stored on every billable turn for attribution.
DELETE /v2/organizations/:organizationId/embed-sessions/:sessionId

Revoke a session

Sessions expire on their own, so revoking is only needed when you want a token dead sooner — a user signs out, loses access, or you detect abuse. Keep the sessionId from the create call if you plan to do this. No template id needed here — the session already resolved to one when it was created.

requests.delete(
f"https://api.pdfs.build/v2/organizations/{org_id}/embed-sessions/{session_id}",
headers=HEADERS,
).raise_for_status()

Install

npm install @pdfsbuild/core @pdfsbuild/react

Import @pdfsbuild/react/styles.css once, anywhere in your app. All SDK styles live in @layer pdfs and are scoped to .pdfs-root, so they will not leak into the rest of your interface and your own styles win by default.

Provider reference

PdfReportProvider scopes one editor instance — one set of stores, one API client, one document compiler. Multiple providers on the same page stay fully isolated, so you can mount two editors side by side.

Prop Purpose
apiUrl API base URL. Defaults to https://backend.pdfs.build — only set it to target a non-production environment.
sessionToken The emb_ token, when you already have it at render time.
fetchSessionToken Async token source, resolved once on mount. Prefer this — it keeps the token out of your server-rendered HTML.
onUnauthorized Called when the token is rejected or has expired. Fetch a fresh session here.
theme Design-token overrides. See Theming.
dark Render the editor with the built-in dark palette.
onBack When set, the toolbar shows a back button that calls this.
billingUrl Link target for the credit-gate's upgrade action. Hidden when unset.
updatePayAsYouGo Enables activating pay-as-you-go from the credit gate.
startCreditCheckout Starts a credit-pack checkout from the credit gate.
renderCreditGate Full override for the credit-exhausted panel.
wasmUrls Self-host the compiler WASM instead of loading it from a CDN.
components Component slot overrides for deeper visual customization.

There is deliberately no templateId prop in the embedded flow — the session decides the template.

Composable parts

PdfReportEditor is a convenience layout built entirely from public parts. Assemble your own when you want a different arrangement, or drop pieces you do not want to offer.

<PdfReportProvider ...>
<EditorToolbar />
<div className="flex flex-1">
<EditorChat>
<EditorChat.Messages />
<EditorChat.Input />
</EditorChat>
<EditorPreview>
<EditorPreview.Tab value="preview" />
<EditorPreview.Tab value="data" />
</EditorPreview>
</div>
</PdfReportProvider>
EditorToolbar Back navigation, template rename, publish, PDF export.
EditorChat AI chat thread and composer. Omit it to disable AI editing entirely.
EditorPreview Tabbed live preview, data/schema editors, and checkpoint history.

Common shapes

// Data-entry only — your team designed the template, customers fill it in.
<PdfReportEditor chat={false} previewTabs={['preview', 'data']} />

// Preview only — read-only document viewer.
<PdfReportEditor chat={false} toolbar={false} previewTabs={['preview']} />

// Full designer.
<PdfReportEditor />

Theming

The SDK ships a neutral light theme. Override any token to match your product, and pass dark to switch palettes.

<PdfReportProvider
theme={{
primary: '#6366f1',
primaryForeground: '#ffffff',
background: '#fafafa',
border: '#e5e7eb',
radius: '0.5rem',
}}
dark={resolvedTheme === 'dark'}
>

Available keys: background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, input, ring, success, warning, radius, plus sidebar, chart, and font tokens.

For anything the tokens do not reach, override the CSS variables on .pdfs-root in your own stylesheet. Because the SDK's rules sit in @layer pdfs, unlayered host styles beat them without needing !important.

Headless hooks

For full UI ownership, read state straight from the hooks and render whatever you like.

useEditor() documentCode, isGenerating, error, sendMessage, cancelGeneration
usePreview() artifact, isCompiling, error, recompile
useEditorChat() messages, waitingForAnswer, pendingReplyKind, gateMessage, blocker
useTemplateData() schema, sampleData, sampleDataJson, setSchema, setSampleData, setSampleDataJson
useCheckpoints() checkpoints, revert
useUsage() lastUsage
Custom composer
function MyComposer() {
const { sendMessage, isGenerating, cancelGeneration } = useEditor()
const [text, setText] = useState('')

return (
<form onSubmit={(e) => {
e.preventDefault()
sendMessage(text)
setText('')
}}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button disabled={isGenerating}>Send</button>
{isGenerating && <button onClick={cancelGeneration}>Stop</button>}
</form>
)
}

sendMessage needs a mounted chat runtime, which <EditorChat> hosts. Building a fully custom chat UI? Keep an <EditorChat> in the tree — its Messages and Input sub-parts are optional, so it can render nothing. Without one, sendMessage logs [pdfs.build] sendMessage dropped and sets a store error instead of sending.

Billing & attribution

AI spend from an embedded editor is attributed to the externalTenantId and externalUserId you passed when creating the session. That is how you attribute cost back to your own customers. Those values are recorded for accounting only — they never grant access and are never trusted as authorization claims, so your backend remains the sole authority on who may open which template.

Credits are consumed in a fixed order: monthly subscription credits first, then any non-expiring purchased packs, then pay-as-you-go overage. PAYG is opt-in and exists only on the Embedded plan.

When credits run out, the editor renders a credit gate instead of accepting new AI turns. You choose what that looks like:

billingUrl Points the default panel's upgrade action at your own billing page.
updatePayAsYouGo Lets the user activate or adjust PAYG without leaving the editor.
startCreditCheckout Starts a credit-pack purchase from the panel.
renderCreditGate Replaces the panel entirely with your own component.

Session expiry

Tokens last 15 minutes by default and never more than an hour. A long editing session will outlive its token, so handle expiry deliberately rather than hoping users are quick.

<PdfReportProvider
fetchSessionToken={fetchToken}
onUnauthorized={() => {
// Remount the provider with a fresh token.
setSessionKey((n) => n + 1)
}}
key={sessionKey}
>

Raising expiresInSeconds to the 3600 maximum reduces how often this fires, at the cost of a longer-lived credential in the browser. Work saved to the template is persisted server-side as the user goes, so an expired session interrupts editing but does not lose committed changes.

Bundler setup

Environment What to do
Vite Add @pdfsbuild/core and @pdfsbuild/react to optimizeDeps.exclude — the document compiler loads a web worker by URL and pre-bundling breaks it.
Next.js The SDK is client-only: mark the provider subtree 'use client'. Add both packages to transpilePackages when consuming from npm.
WASM The compiler fetches its WASM from a CDN by default. To self-host, pass wasmUrls={{ compiler: '/compiler_bg.wasm' }} and serve the file yourself.
CSP The default setup needs the CDN host in connect-src and script-src. Self-hosting the WASM removes that requirement.

Troubleshooting

Symptom Cause
Editor renders as a zero-height sliver The provider fills its parent. Give an ancestor an explicit height.
403 embedded_plan_required The organization behind the API key is not on the Embedded plan.
404 on session create templateId does not resolve in the key's organization. Check the externalId and that the key belongs to the right org.
Editor opens the wrong template The session decides the template, not the client. Check the templateId sent from your backend.
sendMessage does nothing, console logs 'sendMessage dropped' No <EditorChat> is mounted, so there is no chat runtime to send through.
Compiler never initializes in Vite dev The packages were pre-bundled. Add them to optimizeDeps.exclude.
Styles look wrong or unstyled @pdfsbuild/react/styles.css is not imported, or a CSS layer ordering conflict is overriding it.

Still stuck? Email hello@pdfs.build or see the API reference for the rest of the platform.