Skip to content
Runtime

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 }}"]
}
]

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:

ProviderSetNotes
Cloudflare Browser RenderingPDF_CF_ACCOUNT_ID, PDF_CF_API_TOKENOver 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.
GotenbergPDF_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.

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.

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.

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.

Terminal window
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.

FieldNotes
bodyHtmlRequired on create. A whole document.
headerHtml / footerHtmlRunning header/footer, drawn on every page. Chromium’s pageNumber / totalPages spans work.
pageOptionsformat (A4 default), landscape, margin, printBackground.
filenameSuggested 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.

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>
VariableValue
theme.modelight or dark
theme.accentThe accent colour
theme.accentInkText that reads on the accent — dark on a pale accent, white on a dark one
theme.bg / theme.cardPage and panel background
theme.text / theme.muted / theme.faintBody, secondary and least prominent text
theme.borderBorders and dividers
theme.fontA CSS font-family value
theme.fontsHrefStylesheet 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.

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.

Terminal window
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.pdf

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

Renders and puts the result in storage. The outcome lands on {{ $last }} as { key, filename, size, renderer }.

Field
templateKey | htmlExactly one. Neither renders nothing; both would let the inline body silently beat the stored template.
varsExtra values on top of data / $user / $last.
filenameOverrides 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.

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.

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.

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
RESTGET/PUT/DELETE /api/admin/documents/templates[/:key], POST /api/admin/documents/render (returns the PDF)
SDKclient.documents.list / save / delete / render — render resolves to Uint8Array
GraphQLdocumentTemplates, saveDocumentTemplate, deleteDocumentTemplate, renderDocument (base64, since GraphQL has no byte type)
MCPdocuments.templates_list / _save / _delete, documents.render
CLIbacklex documents <list|save|delete|render>
AdminDocument 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.