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#

  1. Send the template and the data as separate fields, as shown below. Don't merge them.
  2. Check the status code before treating the body as a PDF. Errors are application/problem+json (error table).
  3. Retry only 503 (honor Retry-After) and, once, 504/500. Never retry 400/413/422 unchanged.
  4. Set a client timeout a little above the server's RD_RENDER_TIMEOUT_MS (default 60 s).
  5. Log X-Request-Id next to your own order or shipment ID, and watch X-Render-Warnings (non-zero means something printed empty).
  6. 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 with body.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})`);