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.
pnpm add @broadpaper/serverOr run the image and install nothing:
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"] }
}Fonts
The fonts in theme.fonts are embedded, exactly as the designer declared them; a request does not have to repeat them. Because a request is untrusted input, the service reads its fonts — the theme's and the request's own fonts — under a narrower rule than the SDK does:
- a
data:URI, or base64 in afontsentry'sbytes, always; - a web address only on a host in
allowedHosts, and a redirect is not followed; - a file path never. Paths belong in the service's own
fontsoption, which embeds them in every render.
A face the service cannot read is named in warnings and drawn in a standard face.
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 service speaks plain HTTP, and TLS is yours
The service listens with Node's http module. There is no certificate option and no https mode, deliberately: terminating TLS is what an ingress, a load balancer or a sidecar already does in every environment this runs in, and doing it here as well would mean owning certificate rotation for no benefit.
That has a consequence worth being blunt about. BROADPAPER_TOKEN is a bearer token, and a bearer token in clear text is the token itself. Anything that can read the request can replay it. So:
- Run it on your own network. It handles your customers' data and has no reason to be reachable from the internet.
- If a request crosses anything you do not control — a different VPC, a different cluster, the internet — put TLS in front of it and point callers at the
https://address. The .NET client and the JavaScript client both take a base URL, so this is configuration rather than code:
BROADPAPER_SERVICE_URL=https://render.internal.example.com- Loopback or a private network segment is a reasonable place to leave it plain. "It is inside the firewall" is not, on its own, a reason to send a long-lived secret in the clear across a shared network.
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.
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 client for this protocol, targeting .NET 8 and .NET 10. See .NET client.
Licensing
A render service renders under its own licence, set once from BROADPAPER_LICENSE in its environment. It is deliberately not part of the render request: what a service produces is the operator's decision, not its callers'.
GET /health reports it, so you can tell whether a deployment is producing clean or watermarked documents without generating one to find out:
{ "ok": true, "backends": ["forme"], "license": { "state": "active", "plan": "saas", "validUntil": "2027-09-07T00:00:00.000Z" } }The container logs the same thing at start-up. An evaluation key reports "plan": "evaluation", and once it ends it reports "state": "expired" straight away, because an evaluation key has no grace period.
To evaluate without the mark, give the service a free 60-day key from broadpaper.com/eval, in the same variable. It needs a 1.0.0 or later image. Production needs a paid licence. See Licensing.