Embedded document editor API
A white-label document editor API for your SaaS: ship the AI document 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 (1 hour at most, 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 })
// 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 })
} '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.
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.
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 the API reference 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. The flag belongs to the template rather than the session, so it binds the agent, the MCP tools, and the template API alike — and GET on the template 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}. |
/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. An opaque identifier you choose — see below. |
| externalUserId | string | Required, 1–200 chars, no control characters. An opaque identifier you choose — see 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 Billing & attribution. |
| tenantLimitAction | string | Optional, with tenantMonthlyLimitCents only. block (the default) stops the tenant's AI turns at the limit; alert only emails you. |
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": "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 Billing & attribution) 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 at0, 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 is409 checkpoint_exists. - Pictures in memory: past what the server can hold of them at once, overall, per organization or per
externalTenantId, a read still answers200, with storage keys where the pictures were andimagesPartial: true, as does a chat turn'sdata_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_exceededwithRetry-After; other sessions and customers go on compiling. A compiler that is full for everybody is a503, also withRetry-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 amessagenaming the link: a save, a checkpoint, and the API'sPOST /v1/templatesandPOST /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 ansms: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 …/publishcompiles with the sample data and answers400 unsafe_link_targetfor 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: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/checkpointswith{ id, label, typstCode, schema, sampleData }.idis a UUID the client generates. The answer is400 invalid_checkpointfor a body that does not parse,400 unsafe_link_targetand400 foreign_image_keyas for a save,413 payload_too_largepast the limits above, and409 checkpoint_existsfor an id in use. -
GET /api/templates/:id/checkpoints?light=1lists them, oldest first, with their id, label and date and no code, data or blocks. -
GET /api/templates/:id/checkpoints/:checkpointIdhas one whole, its pictures asdata:URLs, or a404. -
GET /api/templates/:id/checkpointswithoutlightsends 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 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:
-
externalUserIdrecords 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. -
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.
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.
/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() 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:
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
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.
<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, 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
// 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, 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 |
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. 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:
| 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.
<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:
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:
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 the API reference for the rest of the platform.
FAQ
Is the embedded document editor white-label?
Yes. Your users see your product, your theme, and your domain: no pdfs.build branding, no pdfs.build accounts, and no redirects. Theming covers 20+ design tokens, with CSS variable overrides for anything the tokens do not reach.
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 itself never reaches the client.
Do my users need pdfs.build accounts?
No. Sessions are created by your backend for your own authenticated users, so who may edit what remains entirely your decision.
Which frameworks does the SDK support?
React 19, via @pdfsbuild/react. The editor is a client-only React component; apps on other stacks can mount it inside a small React island, but there is no native Vue or Angular SDK today.
Can my users design documents with AI?
Yes. The embedded editor is the full pdfs.build designer: AI chat editing, live PDF preview, schema and sample-data editors, and version checkpoints.
How is usage billed across my tenants?
Every session carries 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 embedded editor ships with the Platform plan.
Can I render the documents my users design?
Yes — that is the point. Once the template is published, it renders through the same PDF generation API: POST your JSON, get the PDF back.
Plan details are on the pricing page, and rendering is covered by the PDF generation API.