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
pnpm add @broadpaper/server

Or run the image and install nothing:

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

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 a fonts entry's bytes, 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 fonts option, 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: 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_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_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_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.

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 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:
bash
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:

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