Render service
@broadpaper/server turns either PDF backend into an HTTP service. It exists so that the machine holding your data does not have to be a JavaScript machine: a .NET, Java, Python, Go or PHP application can send a template and a data set and get PDF bytes back.
It is the same renderer the browser uses. BroadPaper still owns pagination, the blocks are still pure functions, and the bytes a service produces for a given input are the bytes the browser produces for it.
docker run -p 4780:4780 -e BROADPAPER_TOKEN=$TOKEN broadpaper/server
curl -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
--data @request.json http://localhost:4780/render --output report.pdfIn your own Node host
The service is a plain request handler, so it drops into an existing server rather than demanding one of its own. createRenderHandler returns a function that answers true if it handled the request and false if the path was not one of its own — so anything else on that port carries on working.
import { createServer } from "node:http";
import { createRenderHandler } from "@broadpaper/server";
const render = createRenderHandler({
token: process.env.BROADPAPER_TOKEN,
basePath: "/api/pdf",
fonts: [{ family: "Inter", src: "./fonts/Inter-Regular.ttf", weight: 400 }],
concurrency: 4,
allowedHosts: ["assets.example.com"]
});
createServer(async (req, res) => {
if (await render(req, res)) return;
res.writeHead(404).end();
}).listen(3000);Pass your own custom blocks in blocks: [...] — the same BlockDefinition objects the designer is given — so the service can render the documents your users built with them.
createRenderServer(options) is the same thing with a node:http server already wrapped around it, and it is what the container entry point calls.
Endpoints
| Method | Path | What it does |
|---|---|---|
POST | /render | One document. Returns application/pdf, or JSON with format: "json". |
POST | /render/batch | One template, many data sets, one round trip. Always JSON. |
GET | /health | Backends available, queue depth, version, protocol revision. |
Everything is mounted under basePath when you set one.
POST /render
{
"template": { "schemaVersion": 1, "...": "the saved template" },
"data": { "client": { "fullName": "Eleanor Whitfield" } },
"dataSources": [{ "id": "client", "label": "Client", "schema": {} }],
"theme": { "colors": { "primary": "#1f3a5f" } },
"now": "2026-08-01T00:00:00.000Z",
"locale": "en-GB",
"currency": "GBP",
"metadata": { "title": "Investment review", "lang": "en-GB" },
"backend": "forme",
"tagged": true,
"pdfUa": false,
"fonts": [{ "family": "Inter", "bytes": "<base64 TTF>", "weight": 400 }],
"network": { "allowedHosts": ["assets.example.com"] }
}Only template is required. Older schema versions are migrated on the way in, so a template saved by last year's designer still renders. A template that does not validate is rejected with 400 and a message naming the fields — not a stack trace from three layers down.
The response carries what you would otherwise have to parse the body to learn:
x-broadpaper-pages: 3
x-broadpaper-warnings: 0
x-broadpaper-backend: forme
x-broadpaper-duration-ms: 285Warnings are things the renderer could not do exactly as asked — a missing font, a CSS property with no equivalent. They are worth logging and are not failures. When there are any, each is spelled out in x-broadpaper-warning-1, -2 and so on; format: "json" returns them as an array alongside pdfBase64.
POST /render/batch
The same template against many data sets, which is the shape of most real work — a statement run, a monthly factsheet for every portfolio. One parse of the template, one queue slot, one connection.
{
"template": { "...": "" },
"items": [
{ "id": "eleanor", "data": { "client": { "fullName": "Eleanor Whitfield" } } },
{ "id": "daniel", "data": { "client": { "fullName": "Daniel Okafor" } } }
]
}Each result is keyed by the id you sent and succeeds or fails on its own: one client with unusable data does not lose you the other four hundred.
{
"results": [
{ "id": "eleanor", "ok": true, "pdfBase64": "…", "pages": 4, "warnings": [] },
{ "id": "daniel", "ok": false, "error": "…", "code": "RENDER_FAILED" }
],
"backend": "forme",
"durationMs": 812
}Configuration
Every option is an environment variable, because that is what a container orchestrator can set. A host that imports createRenderHandler passes the same things in code.
| Variable | Default | What it controls |
|---|---|---|
PORT | 4780 | Listen port. |
HOST | 0.0.0.0 | Listen address. |
BROADPAPER_TOKEN | — | Shared-secret bearer token. Without one the endpoint is open to anyone who can reach it, and the service says so at startup. |
BROADPAPER_BASE_PATH | "" | Mount point, e.g. /api/pdf. |
BROADPAPER_CONCURRENCY | 4 | Renders at once. Beyond it, requests queue. |
BROADPAPER_QUEUE_TIMEOUT_MS | 30000 | How long a request may wait for a slot before 503 BUSY. |
BROADPAPER_TIMEOUT_MS | 60000 | Per-render timeout. |
BROADPAPER_MAX_PAGES | 500 | Refuses documents longer than this. |
BROADPAPER_MAX_BODY_BYTES | 26214400 | Request body limit. |
BROADPAPER_ALLOWED_HOSTS | — | Comma-separated hosts remote assets may be fetched from. |
BROADPAPER_CORS | * | Origin allowed to call this from a browser, or off. |
Errors
Failures are JSON with a stable code, so a caller can branch on the kind of failure rather than on a message.
| Code | Status | Meaning |
|---|---|---|
BAD_REQUEST | 400 | The body or the template is not usable. The message names what. |
UNAUTHORISED | 401 | Missing or wrong token. Checked before the body is read. |
PAYLOAD_TOO_LARGE | 413 | Body over the limit. |
TOO_MANY_PAGES | 422 | The document is longer than maxPages. |
BACKEND_UNAVAILABLE | 400 | A backend was asked for that this service does not have. |
TIMEOUT | 504 | The render took longer than timeoutMs. |
BUSY | 503 | The queue was full for longer than queueTimeoutMs. Retry. |
RENDER_FAILED | 500 | Everything else. |
BUSY and TIMEOUT are the two worth retrying; the rest will fail again the same way.
Which backend
The browserless backend (forme) is the default and needs nothing but the process it runs in — about 7 MB of WebAssembly, no browser, no native libraries. The Chromium backend is available only if you give the service one:
The backend is injected rather than imported, so a service that only wants the browserless engine never pulls Playwright into its image:
import { createRenderHandler } from "@broadpaper/server";
import { renderPdf, type RenderPdfOptions } from "@broadpaper/pdf";
createRenderHandler({
allowedBackends: ["forme", "chromium"],
defaultBackend: "forme",
chromium: {
render: (opts) => renderPdf(opts as RenderPdfOptions)
}
});Then a caller may ask for "backend": "chromium" per request. What each one guarantees sets out the difference; in short, both are handed pages BroadPaper has already decided, and they differ only in how text is shaped.
Security
The token is checked before the body is read, so an unauthenticated caller cannot make the service buy a 25 MB allocation. allowedHosts governs where remote images may be fetched from and a request may only narrow that list, never widen it. Everything else in Security — no eval, no HTML, private-network blocking — applies here unchanged, because it is the same renderer.
Run it on your own network. It handles your customers' data, and it has no reason to be reachable from the internet.
Deterministic output
Given the same template, data, theme, fonts and now, the service produces the same bytes. That is what makes a rendered PDF safe to hash, cache or compare in a test. now is the one you have to set yourself: leave it out and a report with a "generated at" stamp differs every time, correctly.
A .NET Core host
dotnet/BroadPaper.Client is a .NET 8 client for this protocol. See .NET client.