Document generation
Render a row into a PDF from a stored HTML template — contracts, quotes, invoices, agreements — and email it, all from a flow.
Fourteen of the twenty-six schema templates carry documents: contracts,
agreements, quotes, invoices, offers. backlex could hold the data for
one and never produce the artefact somebody signs or pays. Document
generation closes that: a stored HTML template plus a row becomes a PDF.
The usual shape is two flow ops:
[ { "type": "document.render", "templateKey": "invoice", "filename": "invoice-{{ data.no }}" }, { "type": "email", "to": "{{ data.email }}", "subject": "Invoice {{ data.no }}", "text": "Your invoice is attached.", "attach": ["{{ $last.key }}"] }]Configuring a renderer
Section titled “Configuring a renderer”On managed cloud, one is already there. A provisioned project renders through the platform’s own browser, with no configuration and no credentials of yours — see Managed cloud below. The rest of this section is about self-hosting.
Self-hosted, there is no renderer out of the box, on purpose. An unconfigured deployment refuses to render and says which variables to set.
That is a deliberate choice against the obvious alternative — bundling a
pure-JS PDF library. The PDF standard-14 fonts are WinAnsi, which has no ş,
ğ, ı or İ. A fallback that silently drops a customer’s name from a
contract is worse than one that is honestly absent. Embedding a Unicode font
would fix the glyphs and still leave tables, page breaks and running headers to
hand-roll, which is a browser’s job.
So both backends drive a real browser:
| Provider | Set | Notes |
|---|---|---|
| Cloudflare Browser Rendering | PDF_CF_ACCOUNT_ID, PDF_CF_API_TOKEN | Over the REST API, not the Worker binding — so it works from all four runtimes, not just Workers. The token needs the Browser Rendering — Edit scope. |
| Gotenberg | PDF_GOTENBERG_URL (+ optional PDF_GOTENBERG_USER / PDF_GOTENBERG_PASS) | Chromium behind HTTP, Apache-2.0, one container. The answer for a deployment that will not send its contracts to a third party. |
PDF_PROVIDER pins one (cf-browser or gotenberg). A pinned provider whose
credentials are missing yields no renderer, rather than quietly falling
through to the other one — an operator who named a provider wants that provider,
and a silent substitution is how a contract renders somewhere they did not
intend.
Managed cloud
Section titled “Managed cloud”A provisioned cloud project needs no renderer configuration. Its Worker
bindings are written by the provisioner, so it has no environment to put
PDF_CF_API_TOKEN in — an instruction to set one would be advice it cannot act
on. Instead it renders through the control plane, on the same signed channel as
managed AI and managed email: the HTML goes to the platform,
the platform’s credentials never come to the tenant.
That direction is the point. A Cloudflare token sitting in a tenant binding is one sandbox escape away from being someone else’s, and it would be the platform’s token.
It is a floor, not an override — a cloud project that sets PDF_GOTENBERG_URL
or its own PDF_CF_* keeps exactly that, and PDF_PROVIDER=cloud asks for the
gateway explicitly. Renders are throttled per project, and a document larger
than 2 MB of HTML is refused before a browser is spent on it.
This is what e-signature runs on too. Freezing a document for signing is a
render, so before the gateway existed POST /api/admin/signatures failed on a
managed tenant with the same “No PDF renderer is configured” message as
/documents/render — from an API that never mentions PDF.
Row data is interpolated as HTML
Section titled “Row data is interpolated as HTML”A template’s body is HTML and {{ … }} values are substituted into it
unescaped, exactly like an email template’s body. On most of the schema
templates a row is filled in by an end user — a form submission, a customer
portal — so treat row values as untrusted markup when you write a template.
Two things reduce what that can do:
- JavaScript is off in the Cloudflare renderer. Nothing needs it to lay out an invoice, and leaving it on would let a value in a row run code inside the renderer.
- Gotenberg runs on your network, Cloudflare’s does not. That is the
material difference between the two backends: a hostile
<img src="…">in a row is fetched by whichever browser renders it. Give the Gotenberg container no route to anything internal you would not expose anyway.
Templates
Section titled “Templates”A template is a complete HTML document, not a fragment. backlex does not wrap it: a contract sets its own fonts, page size and print styles, and a wrapper would fight that.
curl -X PUT "$APP_URL/api/admin/documents/templates/invoice" \ -H 'content-type: application/json' \ -d '{ "name": "Invoice", "bodyHtml": "<html><head><meta charset=\"utf-8\"><style>@page{size:A4}</style></head><body><h1>Fatura {{ data.no }}</h1><p>{{ data.customer }}</p></body></html>", "footerHtml": "<span style=\"font-size:9px\">Sayfa <span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></span>", "pageOptions": { "format": "A4", "margin": "20mm" }, "filename": "fatura-{{ data.no }}" }'Everything is interpolated with the same {{ … }} engine as email templates —
body, running header, running footer and filename alike.
| Field | Notes |
|---|---|
bodyHtml | Required on create. A whole document. |
headerHtml / footerHtml | Running header/footer, drawn on every page. Chromium’s pageNumber / totalPages spans work. |
pageOptions | format (A4 default), landscape, margin, printBackground. |
filename | Suggested output name, templated. .pdf is appended if missing. |
appearance | { theme, accent, font } — see Appearance. Null clears it. |
Backgrounds print by default. Every browser’s print path turns them off,
which is why an invoice with a coloured header renders as a white rectangle;
someone who wrote a background into a template meant it. Set
printBackground: false to opt out.
Appearance
Section titled “Appearance”A template can carry the same theme, accent and font a
public form has: theme is light or dark, accent a
#rrggbb colour, font one of sans, lexend, mono, system.
An appearance does two things.
It wraps the body. A body that is a FRAGMENT — every built-in template is one — is rendered inside a document built from the theme: page background, a card, the text colour, the font, and the accent on links. So picking dark, or a new accent, changes the mail and the PDF without the template mentioning the theme anywhere. The email shell is table-based with inline styles (what mail clients agree on); the PDF shell is CSS and loads the webfont.
Two escapes: a body that already starts with <!doctype/<html> is never
wrapped — an author who wrote a document owns it — and appearance.shell: false
turns the wrap off for one template, for a bare fragment on the wire. The
Appearance tab shows that as a Wrap the body in this theme switch.
It also reaches the body as {{ theme.* }} values, filled in on every
render, so a template can put a colour exactly where it wants one:
<body style="background: {{ theme.bg }}; color: {{ theme.text }}; font-family: {{ theme.font }}"> <h1 style="color: {{ theme.accent }}">Fatura {{ data.no }}</h1> <a style="background: {{ theme.accent }}; color: {{ theme.accentInk }}">Öde</a></body>| Variable | Value |
|---|---|
theme.mode | light or dark |
theme.accent | The accent colour |
theme.accentInk | Text that reads on the accent — dark on a pale accent, white on a dark one |
theme.bg / theme.card | Page and panel background |
theme.text / theme.muted / theme.faint | Body, secondary and least prominent text |
theme.border | Borders and dividers |
theme.font | A CSS font-family value |
theme.fontsHref | Stylesheet URL for the web fonts, for a <link> in the head |
A template with no appearance still gets every value — the light palette, the
default accent and font — so a template written against {{ theme.accent }} never
renders an empty color:. The palettes are the forms’ own
(@backlex/core/appearance), so the same choice means the same hex everywhere.
A caller that passes its own theme variable keeps it: the render’s vars win.
An appearance that is not one of those values is refused on every surface —
accent: "red" is a 422 on REST and a VALIDATION error on GraphQL — rather
than being dropped, because a value that reaches a style="" attribute must be
one the renderer knows.
Email templates take the same
appearance and the same theme.* values.
Workspace overrides
Section titled “Workspace overrides”Templates resolve exactly like email_templates: a workspace row overrides an
instance-wide default with the same key, and the list shows one row per key
rather than both. Editing an inherited default from inside a workspace creates
the override — it never changes what other workspaces render. Deleting removes
only the workspace’s own row; an inherited default returns a 404 rather than
silently doing nothing.
A row that shadows a default carries overridesDefault: true, and deleting it
is a reset: the response’s data is the shared default the key resolves to
again (or null when there was none), on every surface.
Rendering
Section titled “Rendering”curl -X POST "$APP_URL/api/admin/documents/render" \ -H 'content-type: application/json' \ -d '{"templateKey":"invoice","vars":{"data":{"no":"2026-114","customer":"Ayşe Yılmaz"}}}' \ -o invoice.pdfReturns the PDF bytes. html may be sent instead of templateKey for a
one-off — exactly one of the two, never both.
A render past 20 MB is refused rather than stored: a generated contract is tens of kilobytes, so anything near that is a runaway template (an unbounded loop over a relation), not a long document.
The document.render flow op
Section titled “The document.render flow op”Renders and puts the result in storage. The outcome lands on {{ $last }} as
{ key, filename, size, renderer }.
| Field | |
|---|---|
templateKey | html | Exactly one. Neither renders nothing; both would let the inline body silently beat the stored template. |
vars | Extra values on top of data / $user / $last. |
filename | Overrides the template’s, templated. |
writeBack | { collection, id, field } — stores the key on a row, so the document is reachable from the record it describes. |
The storage key is random, not derived from the filename. Two invoices both
called invoice.pdf would otherwise overwrite each other — and a filename comes
from row data, so deriving the object path from it would let whoever filled in
the row choose where the object lands.
Attaching it to an email
Section titled “Attaching it to an email”The email op’s attach takes storage keys, templated:
{ "type": "email", "to": "{{ data.email }}", "subject": "…", "text": "…", "attach": ["{{ $last.key }}"] }Two limits, and both are enforcement rather than convention:
- Keys only, never a URL. A URL would turn the mail path into a fetcher that posts whatever it was pointed at to an address the same flow chose — request forgery with the email as the exfiltration channel.
- Only this workspace’s own generated documents. Storage is one namespace across every tenant, so the prefix alone would let a flow in one workspace mail out another’s contract given its key — and a key can travel in through the row a flow reads. The check is scoped to the running workspace.
Five files per message. See Flows for the calendar-invite sibling,
ics.
Getting it signed
Section titled “Getting it signed”A document somebody has to sign goes out through
E-signature instead — document.sign freezes this same
interpolated HTML, mints a public link per signer, and re-renders the whole
thing with the signatures and a certificate once they are all in.
Surfaces
Section titled “Surfaces”Admin-only throughout — a template is interpolated and handed to a browser, so authoring one is the same trust level as authoring a flow, not a content-editor permission. Every surface funnels through one service, so the workspace-override rule and the no-renderer refusal hold identically on all of them.
| Surface | |
|---|---|
| REST | GET/PUT/DELETE /api/admin/documents/templates[/:key], POST /api/admin/documents/render (returns the PDF) |
| SDK | client.documents.list / save / delete / render — render resolves to Uint8Array |
| GraphQL | documentTemplates, saveDocumentTemplate, deleteDocumentTemplate, renderDocument (base64, since GraphQL has no byte type) |
| MCP | documents.templates_list / _save / _delete, documents.render |
| CLI | backlex documents <list|save|delete|render> |
| Admin | Document templates under Settings — the email-template editor’s twin: search, shared / customized badges, duplicate, delete and reset-to-default, an unsaved-changes guard, a preview at the real sheet size, and Render PDF. The editor is four tabs — Content (name, key, body), Page (sheet, orientation, filename, running header and footer), Appearance, Variables — and the Variables tab carries a dot when the sample data leaves one empty, because the warning is behind a click. Email templates have the same strip without Page. |
documents.render over MCP returns the metadata and a byte count, not the
bytes: base64 in a tool result fills an agent’s context window for no benefit.
Use the flow op or the SDK when the file has to go somewhere.
The admin preview is an approximation — page breaks, running headers and
margins exist only in the renderer, so Render PDF is what tells you whether a
template actually works. It renders the draft as it stands, unsaved edits
included: POST /render takes html with its own headerHtml, footerHtml
and appearance, and an appearance sent with a templateKey overrides the
template’s own for that render.