# pdfs.build Embedded Editor

> Embed the pdfs.build AI report designer directly in your React app. Server-issued session tokens, composable components, full theming control, and per-tenant billing attribution. Canonical HTML version: https://pdfs.build/embed

## 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`, `@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 />
```

| Term | Meaning |
| --- | --- |
| 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.

```ts
// 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 })
}
```

```tsx
// 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>
  )
}
```

Note: 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
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 https://pdfs.build/docs.md 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. See "Tenant and user identifiers" below. |
| externalUserId | string | Required, 1–200 chars. See "Tenant and user identifiers" below. |
| expiresInSeconds | integer | Token lifetime, 60–3600. Defaults to 900 (15 minutes). |

```python
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:

```json
{
  "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 distinguish a customer and an end-user: 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 — the session already resolved to one when it was created.

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

## Install

```bash
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.

```tsx
<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>
```

| Part | Role |
| --- | --- |
| 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:

```tsx
// 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.

```tsx
<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.

| Hook | Returns |
| --- | --- |
| 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 |

```tsx
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>
  )
}
```

Note: `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:

| Prop | Effect |
| --- | --- |
| 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.

```tsx
<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 https://pdfs.build/docs.md for the rest of the platform.
