# pdfs.build Embedded Editor

> A white-label embedded document editor for your SaaS. 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**: Platform plan
- **Packages**: `@pdfsbuild/core`, `@pdfsbuild/react`
- **Auth**: Server-issued `emb_` session tokens. Your `prs_` API key never reaches the browser.
- **Requires**: React 19 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                        (1 hour at most, 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 })

  // Your pdfs.build organization ID (API keys page), not your own tenant id.
  const orgId = process.env.PDFS_BUILD_ORG_ID
  const templateId = `invoice-${user.orgId}`
  const res = await fetch(
    `https://api.pdfs.build/v2/organizations/${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,
        expiresInSeconds: 3600,
      }),
    },
  )

  if (!res.ok) return new Response('Editor unavailable', { status: 502 })

  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'

async function fetchToken() {
  const res = await fetch('/api/pdfs-session', { method: 'POST' })
  if (!res.ok) throw new Error('Could not start an editor session')
  const { token } = await res.json()
  return token
}

export function ReportDesigner() {
  return (
    <div style={{ height: '100vh' }}>
      <PdfReportProvider
        fetchSessionToken={fetchToken}
        // Called again before the token runs out, so a long edit is not interrupted.
        refreshSessionToken={fetchToken}
        theme={{ primary: '#6366f1' }}
      >
        <PdfReportEditor />
      </PdfReportProvider>
    </div>
  )
}
```

`PDFS_BUILD_ORG_ID` and `PDFS_BUILD_API_KEY` both come from the API keys page of your pdfs.build workspace. The organization in the URL must be the one the key belongs to, or the call fails with `403 organization_scope_mismatch`.

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. A mounted provider keeps that one template for its whole life: to open another, mount a new provider (a React `key` that changes with the template is enough); a token for another template handed to the same provider reads as a session that has ended.

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"},
            # Optional: freeze the host-provided data contract for embedded editing.
            "schemaLocked": True,
        },
    )
    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.

### Freezing the schema

If your backend only ever sends one data shape, pass `"schemaLocked": true` next to `schema`. Users keep the full design conversation — layout, typography, color, wording — but the agent cannot add, remove, or retype a data field, and sample data cannot introduce keys the schema does not declare. Without it, an agent that invents a `discountCode` field produces a template that previews correctly and then fails on your render call, where that field is never present.

Static images stay allowed: a logo or signature the user uploads lives in the template, so you never have to send it. Sample values stay editable too, so the preview shows realistic content.

The flag belongs to the template rather than the session, so it binds the agent, the MCP tools, and the template API alike. `GET /v2/organizations/:organizationId/templates/:externalId` returns `schemaLocked` so you can confirm what you provisioned. It is set at create time only.

### 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, no control characters. See "Tenant and user identifiers" below. |
| externalUserId | string | Required, 1–200 chars, no control characters. See "Tenant and user identifiers" below. |
| expiresInSeconds | integer | Token lifetime, 60–3600. Defaults to 900 (15 minutes). |
| tenantMonthlyLimitCents | integer | Optional. Sets this tenant's monthly AI spend limit, replacing any it has. See "Default tenant spend cap". |
| tenantLimitAction | string | Optional, with tenantMonthlyLimitCents only. `block` (the default) stops the tenant's AI turns at the limit; `alert` only emails you. |

```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": "9eabee26-7d56-4d67-96e0-9387c5a462c0",
  "token": "emb_...",
  "templateId": "550e8400-e29b-41d4-a716-446655440000",
  "expiresAt": "2026-07-15T12:15:00.000Z",
  "tenantLimit": { "monthlyLimitCents": 750, "action": "block", "source": "default" }
}
```

`tenantLimit` is the spend cap the tenant has after this mint. `source` is `explicit` (this request set it), `default` (this request seeded it from the plan: see "Default tenant spend cap") or `existing` (it was there already). It is `null` when nothing caps the tenant: the plan seeds no default, or the cap was removed.

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 Platform 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, expiresInSeconds outside 60–3600, a tenantMonthlyLimitCents that is not a positive integer, or a tenantLimitAction without one. |
| 404 | template_not_found | Template not found in the API key's organization. |

### What the token cannot do

The token belongs to your end user, not to you. It edits its template's design and sample data and can rename it, but it cannot:

- change the template's `externalId`, the address your backend publishes, renders and creates sessions by (`403 embedded_scope`);
- send blocks (`designerDocument`) in a save, so it can neither detach a visual template to code editing nor turn a code template into blocks (`403 embedded_scope`). The assistant still edits a visual template's blocks;
- create a public share link (`403 embedded_share_not_allowed`). A render from the editor comes back to the browser, and no PDF or link is stored;
- publish, or promote a version;
- reach anything else: a route outside the editor's list answers `403`;
- manage your organization's AI policy or billing. The editor is not sent the policy, offers a choice of model only when you let members choose one (Settings → AI models), and when your credits run out offers no way to buy credits or turn on pay as you go: those are for your admins. Billing details are not shown to end users. No gate an embedded token is sent carries your plan name, credit figures or pay-as-you-go figures, whatever the request asks for: the SDK asks for the neutral shape (`credit-gate-neutral`), and any other request gets the older shape with those values empty. A tenant's limit is sent with its amounts at `0`, the server's message does not mention upgrading, and the usage sent after each turn leaves out your credit balance. The lists of your organization's images and fonts, and a template's versions, leave out your organization's id, where a file is stored and which of your members added it.

Change the external id and publish from your backend with your API key (`PATCH` and `POST …/publish` on `/v2/organizations/:organizationId/templates/:externalId`), and render the PDFs you hand your users there through the v2 API. Converting to code, share links and promoting a version are done in the pdfs.build app. A publish that answers `409 publish_conflict` found the template changed while it was being checked and published nothing: send it again, which checks the current version.

Two things every session of your organization or template shares, which you may want to know before you choose how to lay out your templates:

- **The organization's assets.** A token can list and download your organization's fonts and images, and the assistant can copy one of those images into the template it is editing. Every tenant's users can see the whole library by name, so keep nothing there that one tenant should not see of another.
- **The template's chat.** A template has one chat history, and everyone who opens it can read its messages and the images in them (see "Tenant and user identifiers").

### Concurrent edits

`PUT /api/templates/:id` takes an optional `expectedUpdatedAt`: the template's `updatedAt` as the caller read it (ISO 8601). If the row has moved since, nothing is written and the reply is `409` with `{ "error": "template_conflict", "message": "...", "updatedAt": "..." }`. A value that is not a date is `400 invalid_expected_updated_at`. Without the field a save goes in whatever has happened. The SDK sends it on every save, and sends the same write without it when the user chooses to keep their copy. A visual template's `expectedRevision` still applies, answered with `409 design_conflict`. `updatedAt` moves when what an editor holds changes (the code, the schema, the sample data, the page settings, the blocks or the name), and only if the write really changes them. So your backend can publish, unpublish, promote a version and `PATCH` the `externalId` or `scopePath` while somebody is editing, and their next save goes in. A generated title moves nothing. A `name` sent with a publish does move it. A save naming an older version still goes in when its body is the whole document and the row holds exactly that already, and the reply gives the row's `updatedAt`.

### Limits

What a session can send and store is bounded. Over any of these the request changes nothing:

- **Request bodies**: 149 MB for a save, a checkpoint or a render's data, 229 MB for a chat message (20 images of 8 MB each), 1 MB for generating a title (`413 payload_too_large`).
- **Images in sample data**: 5 MB each once decoded (`400 image_too_large`) and 50 new ones per save (`400 too_many_images`). A picture the template already holds counts for neither, so a template that has more than that still saves.
- **Image keys**: sample data may name images stored for its own template, and any key the template already holds (a copy keeps its source's). Anything else is `400 foreign_image_key`.
- **Checkpoints**: 200 per template, the oldest removed when another is made. A label is at most 200 characters, and the code, the schema and the sample data at most 2 MB each, not counting images, which are stored apart (`400 invalid_checkpoint`, `413 payload_too_large`). An id that exists is `409 checkpoint_exists`.
- **Pictures in memory**: past what the server can hold of them at once, overall, per organization or per `externalTenantId`, a read still answers `200`, with storage keys where the pictures were and `imagesPartial: true`, as does a chat turn's `data_update`. The SDK reads the template once more after a moment. A client of your own loads again.
- **Compiles**: the compiler is shared fairly per organization and per embedded session. A session compiling once with two more waiting, or an organization holding its whole share, gets `429 compile_share_exceeded` with `Retry-After`; other sessions and customers go on compiling. A compiler that is full for everybody is a `503`, also with `Retry-After`. During a turn the assistant is told; the compile check at the end of a turn tries three times, 1 and 3 seconds apart, before the turn ends with that message and the document saved unchecked.

### Rate limits

Every request a token makes is counted against its session and against its tenant (`externalTenantId`), so minting more sessions for a tenant buys no more allowance. Per minute:

| Requests | Per session | Per tenant |
| --- | --- | --- |
| Chat messages | 10 | 50 |
| Renders | 20 | 100 |
| Saves | 60 | 300 |
| Checkpoints | 30 | 150 |
| Title generation | 5 | 25 |
| Everything else (reads) | 240 | 1,200 |

The whole minute's limit can be used at once, and it comes back steadily over the minute. Past it the reply is `429` with a `Retry-After` header and `{ "error": "rate_limited", "message": "...", "retryAfterSeconds": 6 }`. A refused request counts against neither limit. Use one `externalTenantId` per customer: all of a tenant's users share its limits. These limits apply to the editor's token, not to your API key.

A token that does not authenticate (mistyped, expired or revoked) is counted against the address it came from, 60 a minute. Past that, every `emb_` token from that address gets the same 429 for the second or so until the next one comes back, without being looked up.

A higher limit is available per organization on request. We set it, for any row above and for the tenant's multiple, and it takes effect within 30 seconds. It is counted the same way, per API process.

Your organization's owners and admins can see how far each tenant goes. `GET /api/usage/embedded/tenants` gives each tenant its `requests`, `throttled` (both `{ total, byClass }`, by kind: `chat`, `render`, `save`, `checkpoint`, `title`, `read`), `compileRefusals`, `activeSessions` and `lastSeenAt`, and lists a tenant that made requests and never used the assistant as well. `GET /api/usage/embedded/throttles?days=N&tenant=<id>` gives the same numbers day by day: at most 90 days and 200 tenants, the most refused first and then the busiest. A day is a UTC day, today included. A compile refused inside a chat turn is the turn's own to retry or report and is not counted as a refusal, being no `429`.

### Links in a document

A link may go to an `http:`, `https:`, `mailto:` or `tel:` address. The rule is held in three places, because each sees what the others cannot:

- **In the code, when it is written.** A write that adds a link to any other scheme changes nothing and is `400 unsafe_link_target`, with a `message` naming the link: a save, a checkpoint, and the API's `POST /v1/templates` and `POST /v2/organizations/:organizationId/templates`. The assistant's tools refuse it too, and tell the assistant why. Only what a write adds is refused, so a template stored with an `sms:` link before the rule goes on saving, and a copy of a stored template adds nothing.
- **On the compiled pages, at a gate.** A text scan cannot see a link called under another name (`#let go = link`, `(link)("…")`), or an address built in the code or read from the data (`#link("sms:" + phone)`). `POST …/publish` compiles with the sample data and answers `400 unsafe_link_target` for such a link. At the end of a turn, one the turn brought gets up to two repair attempts, which are billed like any other model step, and if it is still there the user is told; the turn still saves. One the document had before the turn is pointed out and costs nothing. A link the code writes out in full is exempt here, as the scan has already answered for it, so a stored `#link("sms:…")` goes on publishing.
- **In the browser.** The SDK's preview takes every other address off the page, and keeps the four schemes as links that open in a new tab. This is what holds for an address that only arrives with a render's data.

A scheme is read as the browser will read it, character references included (`javascript&colon;x`). A host and a port (`example.com:8080/x`, `localhost:3000`) is an address with no scheme: nothing refuses it, and the preview does not draw it as a link, as it does not draw `www.example.com`. A render is never refused for a link: a PDF your API call renders carries the address your data gave it.

A Word import (in the app or over MCP; an embedded session cannot reach it) keeps the text of a hyperlink to any other scheme and drops its address, and says which went: `droppedLinks` in the response, and in the tool's result over MCP. It refuses only what it could not take down, such as prose that reads like a call (`link("sms:9")`).

### Checkpoints

A checkpoint is a saved copy of the document that the editor's History tab can go back to. The SDK makes and reads them for you, through routes the token may call:

- `POST /api/templates/:id/checkpoints` with `{ id, label, typstCode, schema, sampleData }`. `id` is a UUID the client generates. The answer is `400 invalid_checkpoint` for a body that does not parse, `400 unsafe_link_target` and `400 foreign_image_key` as for a save, `413 payload_too_large` past the limits above, and `409 checkpoint_exists` for an id in use.
- `GET /api/templates/:id/checkpoints?light=1` lists them, oldest first, with their id, label and date and no code, data or blocks.
- `GET /api/templates/:id/checkpoints/:checkpointId` has one whole, its pictures as `data:` URLs, or a `404`.
- `GET /api/templates/:id/checkpoints` without `light` sends every picture of every checkpoint, which is what an SDK from before the light list reads. Do not use it in new code: at the cap it nears the largest string a browser can parse.

### 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`** records which of your end-users sent each AI turn, so usage can be drilled down to a person. It does not split the conversation: a template has one chat history, and everyone who opens it sees the same thread. Give each tenant its own template (the one-template-per-tenant pattern above) and a tenant's users share history only with each other.
- **`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.

Checkpoints and generation logs keep the same two ids and the session's id, so a checkpoint or a render from an embedded editor says who made it after the session itself has been cleaned up, a week after it expires or is revoked. The token's own reads do not return them.

## 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()
```

## Render what your users designed

The render API serves published templates, and `"version": "draft"` renders the draft your users are editing. A template that is not published answers `403 draft_not_renderable`, except a `"version": "draft"` render on a plan with no published-template limit by a key whose creator owns the template or is an owner or admin of the organization. Users in the embedded editor cannot publish, so publish from your backend when the design is ready for the renders that leave out `version`:

```python
requests.post(
    f"https://api.pdfs.build/v2/organizations/{org_id}/templates/{external_id}/publish",
    headers=HEADERS,
).raise_for_status()
```

Publishing freezes the current design as a version, and renders keep using that version while the user goes on editing the draft. Publish again to ship their later edits; with nothing new since, the call changes nothing, so it is safe to call whenever the user saves.

## 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. Passing another one rotates it. |
| fetchSessionToken | Async token source, resolved once on mount. Prefer this — it keeps the token out of your server-rendered HTML. |
| refreshSessionToken | Async source of a new token for the same template. Called a minute before the token runs out, and when a request is refused anyway. Set this so long edits are not interrupted. |
| onUnauthorized | Called once per expiry, when the token was refused and `refreshSessionToken` is not set or failed. |
| renderSessionExpired | Full override for the expired-session banner. Return `null` to show none. |
| 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 "Manage billing" action. Hidden when unset. |
| updatePayAsYouGo | Host-app auth only: not offered to an embedded session. Enables activating pay-as-you-go from the credit gate. |
| startCreditCheckout | Host-app auth only: not offered to an embedded session. Starts a credit-pack checkout from the credit gate. |
| renderCreditGate | Full override for the credit-exhausted panel. In an embedded session `blocker` is the neutral payload: check `blocker.neutral` before reading `planName` or the figures. |
| renderSaveState | Full override for the save-state indicator. Return `null` to show none. |
| 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, save state, PDF export. Publish and share are not drawn for an embedded session. |
| EditorChat | AI chat thread and composer. Omit it to disable AI editing entirely. |
| EditorPreview | Tabbed live preview, data/schema editors, and checkpoint history. |
| SessionExpiredBanner | The expired-session banner, for a layout without `PdfReportEditor`. |
| SaveStateIndicator | The save-state indicator, for a layout without the toolbar. |

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, failedMessage, resendFailedMessage |
| usePreview() | artifact, isCompiling, error, recompile |
| useEditorChat() | messages, waitingForAnswer, pendingReplyKind, gateMessage, blocker |
| useTemplateData() | schema, sampleData, sampleDataJson, setSchema, setSampleData, setSampleDataJson |
| useCheckpoints() | checkpoints, revert. Checkpoints come from the light list (`partial: true`), and `revert(id)` fetches the one it restores. |
| useUsage() | lastUsage |
| useSaveState() | status, error, lastSavedAt, conflict, busy, retry, reload, keepMine |
| useEmbeddedSession() | expiresAt, expired, refreshing, refresh, canReconnect |

```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. On the Platform plan pay-as-you-go starts switched on, capped by the monthly and per-request spend limits you set under Settings → AI models; an owner can switch it off.

**Default tenant spend cap.** On the Platform and custom plans, a session minted for a tenant with no spend limit gives it one: a tenth of the plan's monthly AI budget ($7.50 on Platform), with action `block`. Past it, that tenant's AI turns stop until the period resets, while your other tenants carry on, so a leaked token can spend about that much (the limit is soft at the edges, below). To set the limit yourself, pass `tenantMonthlyLimitCents` and `tenantLimitAction` when creating the session, which replaces whatever the tenant has, or change it under your organization's embedded usage settings. The default is only ever written where nothing is there: it does not overwrite a limit you set, and it does not come back after you remove one.

Spend so far this period counts, so a tenant already past a tenth of the budget has its AI turns stopped by the first mint that gives it the cap. A tenant is whatever you pass as `externalTenantId`: one id for all your customers is one cap and one set of rate limits between them. The limit is checked when a turn starts, so turns running together, or one long turn, can end somewhat above it. It covers AI spend only: renders and storage are organization-wide.

Image generation, web search and title generation are metered like chat: each draws on the same credits and pay-as-you-go overage, at the same markup, is attributed to the same tenant and user, and counts toward that tenant's spend limit.

When credits run out, the editor renders a credit gate instead of accepting new AI turns. Your end user is not shown your billing: by default the gate says the assistant is unavailable and when the allowance resets, with no plan name, credit figures or pay-as-you-go figures, and those figures are not sent to the browser either. The editor names `credit-gate-neutral` on each message and on `GET /api/chat/access`, and the `402` is then `{ "code": "ai_credits_exhausted", "neutral": true, "message": "...", "periodEnd": "..." }`, with `periodEnd` left out when there is no allowance to reset. You choose what else it does:

| Prop | Effect |
| --- | --- |
| billingUrl | Adds a "Manage billing" link to your own page. It is the panel's only action. |
| renderCreditGate | Replaces the panel entirely with your own component. In an embedded session it is handed the payload above, so one that reads `blocker.planName` or the figures must check `blocker.neutral` first. |

A tenant past its spend limit gets a panel of its own, with no action: it says the limit has been reached and when access resumes, without the amount. That `402` has `monthlyLimitCents` and `spentCents` at `0`, so `blocker` from `useEditorChat` does not carry the tenant's real figures. They are under Settings → Tenants.

## Session expiry

Tokens last 15 minutes by default and never more than an hour, so a long editing session will outlive its token. Mint them for the 3600-second maximum and give the provider a way to get the next one:

```tsx
<PdfReportProvider
  fetchSessionToken={fetchToken}
  refreshSessionToken={fetchToken}
>
```

`refreshSessionToken` is called a minute before the token runs out. It is also called when a request is refused anyway, and that request then goes again with the new token, so nobody sees an interruption. It must return a token for the same template, and it counts as failed if it rejects or takes more than 15 seconds. A tab that is not showing waits until it is shown before renewing on a timer, so one left open in the background does not keep minting sessions.

If you would rather hold the token yourself, pass the new one as the `sessionToken` prop. The next request carries it, and a change refused by the old one is saved again.

When a token is refused and no new one comes, editing can go on but nothing can be saved. The editor shows a banner saying the session has expired, with a **Reconnect** button that calls `refreshSessionToken` again, or `onUnauthorized` if there is none. `onUnauthorized` is called once per expiry, not once per request, and refused saves wait for the next token instead of being retried on a timer. Replace the banner with `renderSessionExpired`, or read the state yourself:

```tsx
const { expiresAt, expired, refreshing, refresh, canReconnect } = useEmbeddedSession()
```

After a new token, whatever the old one could not do goes again once: unsaved edits, a rename, a checkpoint. A chat message is not sent by itself, as the user may have gone on: the chat offers `Send again` once the session is back.

Remounting the provider with a new `key` also starts a new session, but it discards anything not yet saved, so keep it for when you want to start over.

## Save state

The editor saves on its own, two seconds after the last edit, and `EditorToolbar` says how the last save went: `Saving…`, `Saved`, or `Not saved: <the server's message>` with a Retry button. A failed save goes again by itself after a 5xx, a 408, a 425, a 429 or no answer at all, up to three times. A refusal that would only be repeated (`schema_locked`, too large, a conflict) is not sent again by itself.

If another editor, a second tab or your backend has written since the editor loaded, the save is refused with a 409. The toolbar says `This template changed since you opened it.` and offers `Keep mine`, which saves this copy over theirs, and `Discard mine and reload`. Nothing more is saved until one is chosen, and what is on screen stays. If `Keep mine` itself gets no answer, both stay on offer and Retry sends it again.

A chat message waits for the save it is owed, because a turn edits the server's copy and hands back a replacement for what the editor holds. Edits still inside the debounce go first. If that save fails the message is not sent: the chat says why and offers `Send again`. A `429` from the chat is said the same way, with how long to wait.

Replace the indicator with `renderSaveState`, draw it elsewhere with `<SaveStateIndicator />`, or read the state yourself:

```tsx
const { status, error, lastSavedAt, conflict, busy, retry, reload, keepMine } = useSaveState()
```

## Bundler setup

| Environment | What to do |
| --- | --- |
| Vite | Add `@pdfsbuild/core` (only that one) to `optimizeDeps.exclude` — the document compiler loads a web worker by URL and pre-bundling breaks it. Excluding `@pdfsbuild/react` as well stops Vite pre-bundling its CommonJS dependencies and the page fails to load. |
| 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 | `connect-src` needs your `apiUrl` host (`backend.pdfs.build`), `cdn.pdfs.build` (compiler WASM), `fonts.gstatic.com` and `cdn.jsdelivr.net` (fonts), `packages.typst.org` (Typst packages) and `blob:`; `script-src` needs `'wasm-unsafe-eval'`. |

## 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 Platform plan. |
| 404 template_not_found 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 | `@pdfsbuild/core` was pre-bundled. Add it to `optimizeDeps.exclude`. |
| Blank page: "does not provide an export named 'default'" | `@pdfsbuild/react` is in `optimizeDeps.exclude`. Exclude only `@pdfsbuild/core`. |
| Preview says "Preview unavailable" | The compiler WASM could not be downloaded, usually a Content-Security-Policy that blocks `cdn.pdfs.build`. See CSP above. |
| Styles look wrong or unstyled | `@pdfsbuild/react/styles.css` is not imported, or a CSS layer ordering conflict is overriding it. |
| 429 rate_limited | A session or its tenant is past its request limit. Wait for `Retry-After`. One `externalTenantId` for all your customers shares one allowance. |
| 429 compile_share_exceeded | A session or your organization holds its whole share of the compiler. Wait for `Retry-After`. |
| Tenant's AI stops with "AI usage limit reached" | The tenant reached its monthly limit, the default included. Raise it per mint or under Settings → Tenants. |
| 400 unsafe_link_target on publish | A rendered link goes to a scheme other than http, https, mailto or tel, with an address built in code or read from sample data. See "Links in a document". |
| Toolbar says "Not saved" | The server refused the save. The message says why; see "Save state". |

Still stuck? Email hello@pdfs.build or see https://pdfs.build/docs.md for the rest of the platform.

## FAQ

**Is the embedded document editor white-label?** Yes. No pdfs.build branding, no pdfs.build accounts, no redirects; theming covers 20+ design tokens plus CSS variable overrides.

**How does authentication work?** Your backend exchanges its API key for a short-lived session token scoped to a single template (an hour at most) and hands only that token to the browser. The API key never reaches the client.

**Do my users need pdfs.build accounts?** No. Sessions are created by your backend for your own authenticated users.

**Which frameworks does the SDK support?** React 19 via `@pdfsbuild/react`. Other stacks can mount it in a small React island; there is no native Vue or Angular SDK today.

**Can my users design documents with AI?** Yes: AI chat editing, live PDF preview, schema and sample-data editors, and version checkpoints.

**How is usage billed across my tenants?** Sessions carry your `externalTenantId` and every AI turn is attributed to it, so you can see and cap AI spend per customer. PDF renders count against your organization, not a tenant. The editor ships with the Platform plan.

**Can I render the documents my users design?** Yes, through the same render API once the template is published: POST your JSON, get the PDF back.
