Embedding the editor
Put the collaborative editor in your own app with an iframe.
Render DocDealer's collaborative editor inside your own product. Your users never see a DocDealer login, and your API key never reaches their browser.
How it works
Your backend trades its API key for an embed token: short-lived, scoped to one document, and safe to put in an iframe URL.
A cross-site iframe sends no cookies, so the token in the URL is the credential. That is also why you must mint it server-side: anything the browser can read, your users can read.
Mint a session
# Your backend, holding your API key.
curl -X POST https://thedocdealer.com/api/embed/sessions \
-H "Authorization: Bearer $DOCDEALER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"documentId": "<uuid>",
"displayName": "Ana Torres",
"externalUserId": "user-1042",
"parentOrigin": "https://app.example.com"
}'
# → { "token": "...", "url": "https://thedocdealer.com/embed/<uuid>?embed_token=...",
# "expiresAt": "2026-08-24T12:34:56.000Z" }Prop
Type
Then drop the returned url straight into an iframe:
<iframe src="{url}" style="width:100%;height:100%;border:0" title="Editor"></iframe>Keeping the session alive
Tokens last 15 minutes by default, and the iframe renews itself at half-life without any help from you. Each renewal re-checks your API key server-side, which is what makes revocation take effect.
It cannot always renew itself, though: a laptop that slept past the expiry, a reload of a stale
src, or a session that has run for 12 hours. Then it asks your page, because only your backend can
mint a token. Implement the other half of that exchange and sessions survive indefinitely:
// Your page. The iframe renews itself while it can; when it can't,
// it asks you — because only your backend holds the API key.
window.addEventListener('message', async event => {
if (event.origin !== 'https://thedocdealer.com') return
const msg = event.data
if (msg?.source !== 'docdealer-embed' || msg.type !== 'token-request') return
// Your own session check belongs here. Returning nothing ends the
// embedded session, which is what you want when your user signs out.
if (!currentUser) return
const res = await fetch('/api/embed-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: msg.documentId, displayName: currentUser.name }),
})
const { token } = await res.json()
iframe.contentWindow.postMessage(
{ source: 'docdealer-embed', v: 1, type: 'token', token },
'https://thedocdealer.com'
)
})Note the origin check on the way in and the explicit target origin on the way out. Both matter: without them any page could hand your iframe a token, or read yours.
Declining to answer is a feature. It is how you end an embedded session when your own user signs out.
Matching your theme
Pass theme when you mint the session and the editor paints light or dark from the first frame — no
flash of the wrong palette:
{ "documentId": "<uuid>", "displayName": "Ana Torres", "theme": "light" }If your users can toggle the theme without a reload, send it over the same bridge and the iframe follows immediately:
iframe.contentWindow.postMessage(
{ source: 'docdealer-embed', v: 1, type: 'theme', theme: 'dark' },
'https://thedocdealer.com'
)With ui.theme: "auto" (the default), the editor follows the host theme. Set
ui.theme to "light" or "dark" to force that mode for this document; it does not
change the saved application preference. The ordinary editor follows that
preference in automatic mode.
Omit theme to follow the application preference until the host supplies a theme.
Customising the editor
ui goes alongside theme on the mint request. Like theme it rides the returned URL rather than
the token, so you can change it live over the bridge and it survives a token refresh untouched.
{
"documentId": "<uuid>",
"displayName": "Ana Torres",
"theme": "light",
"ui": {
"hide": ["provenance", "status"],
"colors": {
"light": { "primary": "#1f5945", "background": "#f2f3f0" },
"dark": { "primary": "#5ea88b", "background": "#0f1512" }
},
"layout": { "baseFontSize": "14px" }
}
}hide is cosmetic, not a permission. It changes what the editor draws; it does not change
what the session may do. Hiding the export button does not revoke the export endpoint, and hiding
the status selector does not stop the status being set — an embed token can still reach both
directly. If you need a real restriction, do not ship a hidden button and call it one.
hide
| Key | Hides |
|---|---|
status | The document status selector. |
export | The export button. The export dialog is unreachable without it, but the endpoint is not. |
provenance | DocDealer's "API" / "MCP" origin badges. Most integrators want this one — the badge names the API key that created the document. |
collaborators | Presence avatars. |
title | The document title, and the AI suggestion beside it. |
titleEdit | Click-to-edit on the title, leaving it visible but read-only. Implied by title. |
generate | The "generate document" button. |
validate | The "validate" button. |
zoom | The zoom control. |
toolbar | The whole formatting toolbar. |
toolbar.history, toolbar.font, toolbar.heading, toolbar.format, toolbar.color, toolbar.lists, toolbar.align, toolbar.insert, toolbar.table | Individual toolbar groups. Hide them all and the bar goes with them. |
Anything the editor does not recognise is ignored rather than rejected, so a key from a newer release will not stop you minting a session against an older deployment — it simply has no effect.
Hide title, provenance, collaborators, status and export together and the top bar goes
with them, rather than leaving an empty strip and a border. generate and validate live in the
form panel, so they do not count toward that.
The formatting toolbar follows the same rule: hide every toolbar.* group, or toolbar outright,
and the bar is not rendered at all. Groups rather than individual buttons — each is a delimited
block of the toolbar, which covers the whole thing at a granularity worth documenting.
colors
One palette per theme. You own the theme and can change it while the editor is open, so a single palette would be wrong in whichever state it was not designed for — and a dark scheme is rarely a mechanical inversion of its light counterpart.
Supply only light or only dark to inherit the other from the template, or pass a flat palette
(no light/dark keys) as shorthand for both.
Accepted tokens: primary, primaryForeground, background, foreground, card, muted,
border. Values must be #rgb, #rrggbb, #rrggbbaa or transparent; anything else is dropped.
Three independent surface tokens are also available in each palette:
| Token | Surface | Default |
|---|---|---|
pageBackground | Tiptap document sheet | Theme card |
editorBackground | Canvas around the document | Transparent |
toolbarBackground | Editor formatting toolbar | Theme muted |
These settings change only the editor's appearance, not DOCX/PDF formatting.
Those seven are all you normally need. The editor's surfaces read a wider set — a card is
card + cardForeground, a form field's outline is input, an open select menu is popover +
popoverForeground, a hovered row is accent — so the rest are derived from the seven:
| Derived | Falls back to |
|---|---|
cardForeground, accentForeground, secondaryForeground | foreground |
popover | card |
popoverForeground | cardForeground |
accent, secondary | muted |
input | border |
ring | primary |
mutedForeground | foreground mixed 62% toward background |
Name any of them explicitly and your value wins. mutedForeground is mixed rather than borrowed
because taking foreground outright would erase the contrast step that makes secondary text read
as secondary.
theme and zoom
ui.theme accepts auto (default), light, or dark. Both views default to
fitting their pane width. Configure their initial zoom independently:
{
"ui": {
"theme": "auto",
"colors": {
"light": { "pageBackground": "#ffffff", "toolbarBackground": "#eeeeee" },
"dark": { "editorBackground": "transparent" }
},
"zoom": { "editor": 125, "word": "fit-width" }
}
}editor means Editor, and word means Render. Each zoom accepts a percentage
from 10 to 400, or fit-width. Width fits follow pane resizes. When omitted, the document
opens at 100% and shrinks only if the pane is narrower than the page. A manually chosen
percentage remains in effect for the current opening; reopening restores the
initial configuration. Zoom controls can resume fitting at any time.
layout
radius and baseFontSize, as bare px or rem lengths. They are theme-independent, so they sit
outside the palettes.
baseFontSize is worth knowing about: DocDealer's own root is a deliberately dense 12px, so
every rem inside the iframe is 0.75x what a host page with a 16px root expects. Set it to match
your app.
Changing it live
iframe.contentWindow.postMessage(
{ source: 'docdealer-embed', v: 1, type: 'ui', ui: { hide: ['export'] } },
'https://thedocdealer.com'
)The message replaces the session overrides wholesale, so send the full set each
time. ui: {} returns to the template's defaults. Cosmetic theme/ui messages
require the signed parentOrigin when present. Without one, only the direct
parent window may send them; this does not enable token delivery or renewal.
Locking a document
lock sits beside ui, not inside it, and the distinction is the point: everything in ui is
cosmetic and travels on the URL, while lock is enforced and is signed into the token.
{ "documentId": "<uuid>", "displayName": "Ana Torres", "lock": "content" }| Value | Blocks | Still works |
|---|---|---|
content | Hand-editing the prose. The formatting toolbar is hidden with it. | The form, memory, attachments, status, and AI generation |
all | Every client write — prose, form, memory, status | Server-side AI generation, so a frozen document can still be filled in by the API that froze it |
null | Nothing | — |
An unrecognised value is a 400, unlike an unrecognised hide key. Degrading an enforcement level
you did not understand into "no lock" would fail open.
content is enforced on the REST write routes and in the editor, but not at the collaborative
layer. The websocket carries the form and the prose over one connection, and refusing only the
prose means inspecting each binary update — which we do not do yet. A determined end user with a
collaboration token can still change the text under content. all is enforced end to end;
reach for it when the restriction has to hold.
Restricting downloads
A template can refuse to be handed over in a given format. Its author sets this in DocDealer, under
the template's Downloads setting, and it is stored as uiDefaults.denyExports — any of docx,
pdf and zip. Absent or empty means all three are allowed, which is what every template does
until somebody says otherwise.
Like lock, and unlike everything in ui, this is enforced:
POST /api/documents/{id}/exports answers 403 for a denied format, and the MCP export_document
tool refuses for the same reason. The editor dropping the button is the visible consequence, not the
mechanism.
Two things follow from that:
- A session cannot widen it.
uitravels on the iframe URL, which the viewer can edit, so honouring adenyExportssent there would demote the restriction to a query parameter. Only the template sets it. - The value is pinned per document, like the rest of the template's frozen surface. Loosening a template does not retroactively unlock the documents already drawn from it, and tightening one does not lock them either — a new document made from the new version gets the new answer.
The previews are unaffected: preview-pdf and preview-layout hand nothing to the reader's
machine, so a template that forbids every download is still readable on screen.
Templates supply the default
A template carries its own uiDefaults — the same object, plus lock and denyExports — set by its
author in DocDealer. Colours merge per token and per theme, layout settings
merge per field, and zoom merges per view. An omitted field inherits; an explicit
transparent is a choice. Scalar fields such as theme use the session value
when supplied, otherwise the template value. hide replaces rather than merges, so a session cannot be
prevented from un-hiding something; the cost is that adding one entry means restating the list.
denyExports is the exception: it is not overridable at all, for the reason above.
lock: null is a decision, not an absence: it unlocks a template that locked itself. You hold the
API key and could edit over REST regardless, so this grants nothing you did not already have.
With no embed session at all, DocDealer's own editor reads those defaults directly.
What an embed session can do
Everything a DocDealer user can do to that one document. The embedded editor is the same component the app itself renders — modulo whatever chrome you asked it to hide — so your users get the form, the collaborative document, attachments with OCR, section generation and export.
The boundary is the document, not the feature set — and ui.hide does not move it, it only changes
what is drawn:
- One document. Every other id is denied — including documents in the same organization, and including on the endpoints that take the id in the request body rather than the path.
- No creating or listing. It cannot create documents, list your documents, or list your templates.
- No administration. No API-key management, no admin endpoints, and it cannot mint another session.
It can spend AI credit on that document: generating sections and analysing attachments are AI calls, and they are what the editor is for. So treat an embed token like a logged-in session scoped to one document — short-lived, and worth revoking if it leaks. Disabling the API key that minted it kills every live session within one token lifetime.
One deliberate exception to the single-document rule: fields backed by an organization connector can read and invoke that connector, since those fields are unusable otherwise.
Practical notes
- Content Security Policy. Your page needs
frame-src https://thedocdealer.com. If you serve over HTTP in development, note thatupgrade-insecure-requestswill break the frame. - An invalid token renders an error, not a redirect. Redirecting a cross-site iframe to a login
page is a dead end, so a bad token returns
401and an expired one shows a recoverable state instead. - Your users appear as themselves. The
displayNameyou sign shows up on cursors and in the document's edit history.
A complete worked example — backend, page, and both halves of the bridge — lives in the repository.
It also shows the two things a real integration needs beyond a first embed: listing documents back
with GET /api/documents and reopening one in place, and consuming the organization's own MCP
connector in the other direction.
The chat editor defaults to ui.colorScheme: "neutral", a gray base palette in
light and dark mode, including primary actions, focus rings and informational
accents. Set colorScheme: "brand" to use DocDealer's palette. Explicit
colors.light/colors.dark tokens take precedence over either base palette.
Semantic success/warning/error colors and document/export content remain unchanged.
ui.layout.formDisplay selects drawer, inline or hidden in embeds; it
inherits per layout field from the template and defaults to drawer. Ordinary
editors retain their inline form. hide: ["formPanel"] hides either mode.