Render API
Every sample reads the service address from RD_URL (default http://localhost:8080), sends bol.template.json (a template saved from the designer) with
bol.data.json (one shipment), and writes bol.pdf. All of them are run against a live server by the sample checker,
except the C# sample.
What goes into data, and how it binds to the template's placeholders, is described in Preparing the data.
Client rules of thumb#
- Send the template and the data as separate fields, as shown below. Don't merge them.
- Check the status code before treating the body as a PDF. Errors are
application/problem+json(error table). - Retry only
503(honorRetry-After) and, once,504/500. Never retry400/413/422unchanged. - Set a client timeout a little above the server's
RD_RENDER_TIMEOUT_MS(default 60 s). - Log
X-Request-Idnext to your own order or shipment ID, and watchX-Render-Warnings(non-zero means something printed empty). - Reuse one HTTP client (connection pooling). Requests are independent, so render in parallel up to the server's capacity.
curl#
RD_URL=${RD_URL:-http://localhost:8080}
# Build the request body from the two files, then render.
printf '{"template": %s, "data": %s, "options": {"filename": "BOL2026102001"}}' \
"$(cat bol.template.json)" "$(cat bol.data.json)" > request.json
curl -sS --fail-with-body -X POST "$RD_URL/api/v1/render" \
-H 'Content-Type: application/json' \
--data-binary @request.json \
-o bol.pdf -D headers.txt
grep -i -E '^x-(page-count|render-warnings|license-status)' headers.txt
Validate a template, for example in a CI job for your template repository:
RD_URL=${RD_URL:-http://localhost:8080}
printf '{"template": %s}' "$(cat bol.template.json)" > validate.json
curl -sS -X POST "$RD_URL/api/v1/validate" -H 'Content-Type: application/json' --data-binary @validate.json
Node.js (18+, built-in fetch)#
import { readFile, writeFile } from 'node:fs/promises';
const RD_URL = process.env.RD_URL ?? 'http://localhost:8080';
/** Renders a template with data. Retries 503 (server busy) using Retry-After. Throws with the problem details otherwise. */
export async function renderPdf(template, data, options = {}, attempts = 4) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${RD_URL}/api/v1/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, data, options }),
signal: AbortSignal.timeout(70_000),
});
if (res.ok) {
return {
pdf: Buffer.from(await res.arrayBuffer()),
pages: Number(res.headers.get('x-page-count')),
warnings: Number(res.headers.get('x-render-warnings')),
license: res.headers.get('x-license-status'),
requestId: res.headers.get('x-request-id'),
};
}
const problem = await res.json().catch(() => ({ title: res.statusText }));
if (res.status === 503 && attempt < attempts) {
const wait = Number(res.headers.get('retry-after') ?? 1) * 1000;
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw Object.assign(new Error(`${res.status} ${problem.title}: ${problem.detail ?? ''}`), { status: res.status, problem });
}
}
const template = JSON.parse(await readFile('bol.template.json', 'utf8'));
const data = JSON.parse(await readFile('bol.data.json', 'utf8'));
const result = await renderPdf(template, data, { filename: data.bolNumber });
await writeFile('bol.pdf', result.pdf);
console.log(`bol.pdf: ${result.pages} pages, ${result.warnings} warnings, license ${result.license}, request ${result.requestId}`);
Streaming the PDF to a browser download#
Your web back end calls the render service and streams the PDF straight to the user's browser, without buffering it in memory:
import { createServer } from 'node:http';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const RD_URL = process.env.RD_URL ?? 'http://localhost:8080';
createServer(async (req, res) => {
const match = /^\/shipments\/([\w-]+)\/bol\.pdf$/.exec(req.url ?? '');
if (!match) {
res.writeHead(404).end();
return;
}
const [template, data] = await Promise.all([loadPublishedTemplate('BILL_OF_LADING'), loadShipmentData(match[1])]);
const upstream = await fetch(`${RD_URL}/api/v1/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, data, options: { filename: `BOL-${match[1]}`, disposition: 'attachment' } }),
});
if (!upstream.ok) {
res.writeHead(502, { 'Content-Type': 'text/plain' }).end(`Could not render the BOL (${upstream.status})`);
return;
}
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': upstream.headers.get('content-disposition'),
});
await pipeline(Readable.fromWeb(upstream.body), res);
}).listen(3000);
// Your data access:
async function loadPublishedTemplate(docType) { /* SELECT template_json FROM report_template … */ }
async function loadShipmentData(shipmentId) { /* build the shipment JSON */ }
Batch: one PDF with a copy per record#
If the template's One copy per (recordsPath) is set, for example to shipments, send the list:
import { readFile, writeFile } from 'node:fs/promises';
const RD_URL = process.env.RD_URL ?? 'http://localhost:8080';
const template = JSON.parse(await readFile('bol.template.json', 'utf8'));
template.recordsPath = 'shipments'; // normally set by the author in the designer
const batch = JSON.parse(await readFile('bol-batch.data.json', 'utf8')); // { "shipments": [ {...}, {...}, {...} ] }
const res = await fetch(`${RD_URL}/api/v1/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, data: batch }),
});
if (!res.ok) {
throw new Error(`${res.status}: ${(await res.json()).detail}`);
}
await writeFile('bols.pdf', Buffer.from(await res.arrayBuffer()));
console.log(`bols.pdf: ${batch.shipments.length} BOLs, ${res.headers.get('x-page-count')} pages`);
Python (requests)#
import json
import os
import time
import requests
RD_URL = os.environ.get("RD_URL", "http://localhost:8080")
session = requests.Session()
def render_pdf(template: dict, data: dict, options: dict | None = None, attempts: int = 4) -> requests.Response:
"""POST /api/v1/render; retries 503 using Retry-After, raises with the problem details otherwise."""
for attempt in range(1, attempts + 1):
res = session.post(f"{RD_URL}/api/v1/render", json={"template": template, "data": data, "options": options or {}}, timeout=70)
if res.ok:
return res
if res.status_code == 503 and attempt < attempts:
time.sleep(float(res.headers.get("Retry-After", "1")))
continue
problem = res.json() if res.headers.get("Content-Type", "").startswith("application/problem+json") else {}
raise RuntimeError(f"{res.status_code} {problem.get('title', res.reason)}: {problem.get('detail', '')} {problem.get('issues', '')}")
raise AssertionError("unreachable")
with open("bol.template.json", encoding="utf-8") as f:
template = json.load(f)
with open("bol.data.json", encoding="utf-8") as f:
data = json.load(f)
res = render_pdf(template, data, {"filename": data["bolNumber"]})
with open("bol.pdf", "wb") as f:
f.write(res.content)
print(f"bol.pdf: {res.headers['X-Page-Count']} pages, {res.headers['X-Render-Warnings']} warnings, license {res.headers['X-License-Status']}")
Java 17+ (java.net.http + Jackson)#
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class RenderBol {
private static final ObjectMapper JSON = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private static final String RD_URL = System.getenv().getOrDefault("RD_URL", "http://localhost:8080");
/** Renders template + data; retries 503 using Retry-After; throws with the problem details otherwise. */
static byte[] renderPdf(JsonNode template, JsonNode data, ObjectNode options) throws Exception {
ObjectNode body = JSON.createObjectNode();
body.set("template", template);
body.set("data", data);
body.set("options", options);
HttpRequest request = HttpRequest.newBuilder(URI.create(RD_URL + "/api/v1/render"))
.timeout(Duration.ofSeconds(70))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)))
.build();
for (int attempt = 1; ; attempt++) {
HttpResponse<byte[]> response = HTTP.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
System.out.printf("pages=%s warnings=%s license=%s%n",
response.headers().firstValue("X-Page-Count").orElse("?"),
response.headers().firstValue("X-Render-Warnings").orElse("?"),
response.headers().firstValue("X-License-Status").orElse("?"));
return response.body();
}
if (response.statusCode() == 503 && attempt < 4) {
long seconds = response.headers().firstValueAsLong("Retry-After").orElse(1);
Thread.sleep(seconds * 1000);
continue;
}
JsonNode problem = JSON.readTree(response.body());
throw new IllegalStateException(response.statusCode() + " " + problem.path("title").asText() + ": " + problem.path("detail").asText());
}
}
public static void main(String[] args) throws Exception {
JsonNode template = JSON.readTree(Path.of("bol.template.json").toFile());
JsonNode data = JSON.readTree(Path.of("bol.data.json").toFile());
ObjectNode options = JSON.createObjectNode().put("filename", data.path("bolNumber").asText());
Files.write(Path.of("bol.pdf"), renderPdf(template, data, options));
System.out.println("wrote bol.pdf");
}
}
Tip: Store templates as JSON strings in your database. Use
JSON.readTree(templateJson), or embed the string directly withbody.set("template", JSON.readTree(row.getString("template_json"))). Never rebuild the template JSON with string concatenation.
Java with Spring (RestClient, Spring Framework 6.1+ / Boot 3.2+)#
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;
public class SpringRenderClient {
private final RestClient client;
public SpringRenderClient(String baseUrl) {
this.client = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.build();
}
public byte[] render(JsonNode template, JsonNode data, String filename) {
ResponseEntity<byte[]> response = client.post()
.uri("/api/v1/render")
.body(Map.of("template", template, "data", data, "options", Map.of("filename", filename)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, res) -> {
throw new IllegalStateException("Render failed: " + res.getStatusCode() + " " + new String(res.getBody().readAllBytes()));
})
.toEntity(byte[].class);
System.out.println("pages=" + response.getHeaders().getFirst("X-Page-Count") + " license=" + response.getHeaders().getFirst("X-License-Status"));
return response.getBody();
}
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
SpringRenderClient renderer = new SpringRenderClient(System.getenv().getOrDefault("RD_URL", "http://localhost:8080"));
JsonNode template = json.readTree(Path.of("bol.template.json").toFile());
JsonNode data = json.readTree(Path.of("bol.data.json").toFile());
Files.write(Path.of("bol.pdf"), renderer.render(template, data, data.path("bolNumber").asText()));
System.out.println("wrote bol.pdf");
}
}
In a Spring Boot application, make SpringRenderClient a @Component, inject RestClient.Builder, and put the base URL in application.yml. Add a retry for 503s,
for example with Spring Retry's @Retryable(retryFor = HttpServerErrorException.ServiceUnavailable.class) when you use retrieve() without the custom
onStatus handler.
C# (.NET 8, HttpClient)#
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
var rdUrl = Environment.GetEnvironmentVariable("RD_URL") ?? "http://localhost:8080";
using var http = new HttpClient { BaseAddress = new Uri(rdUrl), Timeout = TimeSpan.FromSeconds(70) };
var template = JsonNode.Parse(await File.ReadAllTextAsync("bol.template.json"));
var data = JsonNode.Parse(await File.ReadAllTextAsync("bol.data.json"));
var body = new JsonObject { ["template"] = template, ["data"] = data, ["options"] = new JsonObject { ["filename"] = data!["bolNumber"]!.GetValue<string>() } };
for (var attempt = 1; ; attempt++)
{
using var response = await http.PostAsJsonAsync("/api/v1/render", body);
if (response.IsSuccessStatusCode)
{
await File.WriteAllBytesAsync("bol.pdf", await response.Content.ReadAsByteArrayAsync());
Console.WriteLine($"pages={response.Headers.GetValues("X-Page-Count").First()} license={response.Headers.GetValues("X-License-Status").First()}");
break;
}
if (response.StatusCode == HttpStatusCode.ServiceUnavailable && attempt < 4)
{
await Task.Delay(response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
continue;
}
var problem = await response.Content.ReadFromJsonAsync<JsonObject>();
throw new InvalidOperationException($"{(int)response.StatusCode} {problem?["title"]}: {problem?["detail"]}");
}
This C# sample is not verified automatically, because no .NET SDK is available in the verification environment.
Testing templates against warnings#
During development, request ?format=json to see the warning texts and fail your test when there are any:
import { readFile } from 'node:fs/promises';
const RD_URL = process.env.RD_URL ?? 'http://localhost:8080';
const res = await fetch(`${RD_URL}/api/v1/render?format=json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: JSON.parse(await readFile('bol.template.json', 'utf8')),
data: JSON.parse(await readFile('bol.data.json', 'utf8')),
}),
});
const { pageCount, warnings, license } = await res.json();
if (warnings.length) {
throw new Error(`Template has warnings:\n${warnings.map((w) => `${w.elementId ?? '-'}: ${w.message}`).join('\n')}`);
}
console.log(`OK: ${pageCount} pages, no warnings (license ${license})`);