Embedded editor
Ship the AI report designer inside your own product — your layout, your theme, your customers.
@pdfsbuild/react 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.
@pdfsbuild/core
and
@pdfsbuild/react emb_ session tokens. Your prs_ API key never reaches the browser. 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.
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 })
} '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.
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}. |
/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:
-
externalUserIdgives 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 theirexternalUserIddiffers. -
externalTenantIdis 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.
/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 |
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 [email protected] or see the API reference for the rest of the platform.