Skip to content

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.

bash
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.pdf

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

ts
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

MethodPathWhat it does
POST/renderOne document. Returns application/pdf, or JSON with format: "json".
POST/render/batchOne template, many data sets, one round trip. Always JSON.
GET/healthBackends available, queue depth, version, protocol revision.

Everything is mounted under basePath when you set one.

POST /render

json
{
  "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: 285

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

json
{
  "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.

json
{
  "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.

VariableDefaultWhat it controls
PORT4780Listen port.
HOST0.0.0.0Listen address.
BROADPAPER_TOKENShared-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_CONCURRENCY4Renders at once. Beyond it, requests queue.
BROADPAPER_QUEUE_TIMEOUT_MS30000How long a request may wait for a slot before 503 BUSY.
BROADPAPER_TIMEOUT_MS60000Per-render timeout.
BROADPAPER_MAX_PAGES500Refuses documents longer than this.
BROADPAPER_MAX_BODY_BYTES26214400Request body limit.
BROADPAPER_ALLOWED_HOSTSComma-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.

CodeStatusMeaning
BAD_REQUEST400The body or the template is not usable. The message names what.
UNAUTHORISED401Missing or wrong token. Checked before the body is read.
PAYLOAD_TOO_LARGE413Body over the limit.
TOO_MANY_PAGES422The document is longer than maxPages.
BACKEND_UNAVAILABLE400A backend was asked for that this service does not have.
TIMEOUT504The render took longer than timeoutMs.
BUSY503The queue was full for longer than queueTimeoutMs. Retry.
RENDER_FAILED500Everything 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:

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