Skip to content

Guide · Java

Generate PDFs from a CSV in Java

A list of people and a template make a stack of documents. Instead of one request per row, a batch queues up to 1,000 renders as one job, and one zip comes back. This Java program reads a CSV, maps the rows and waits for the job.

Free plan with 100 units a month, no credit card

What you need

  • An API key. Create one in the app under API keys and export it as FORMFEED_API_KEY. A test key (ff_test_…) is right for trying things out. See the quick start.
  • Java. You need Java 17 or newer; java.net.http.HttpClient and text blocks are part of the JDK.
  • The template. certificate from the certificate example, whose fields are exactly the columns of the CSV.
  • The CSV. attendees.csv next to the program, with a header row. It is shown below.
  • Enough units. Batches are part of every plan, and each item counts as at least one unit. On the Free plan a live batch is refused when the month has fewer units left than the batch has items; a test key is not counted. The limits page has the units per plan.

The program

The program reads the CSV, turns every row into an item, queues them as one job with zip: true, waits until the job is done and saves the archive.

Batch.javaJava
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.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Batch {
  static final String BASE = "https://api-eu.formfeed.dev/v1";
  static final HttpClient CLIENT = HttpClient.newHttpClient();

  static String api(String method, String path, String body) throws Exception {
    HttpRequest.BodyPublisher publisher =
        body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body);
    HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
        .header("Content-Type", "application/json")
        .method(method, publisher)
        .build();
    HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
    if (response.statusCode() >= 400) {
      System.err.println(response.body());
      System.exit(1);
    }
    return response.body();
  }

  /** The value of a top-level JSON field; a project would use Jackson or Gson instead. */
  static String field(String json, String name) {
    Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(?:\"([^\"]*)\"|([^,}\\s]+))").matcher(json);
    return m.find() ? (m.group(1) != null ? m.group(1) : m.group(2)) : "";
  }

  public static void main(String[] args) throws Exception {
    // the JDK has no CSV reader; this file has no quoted fields, so a split does. Otherwise: Commons CSV.
    List<String> lines = Files.readAllLines(Path.of("attendees.csv"));
    List<String> items = new ArrayList<>();
    for (String line : lines.subList(1, lines.size())) {
      String[] v = line.split(",");
      // every field of a CSV is text: the template expects hours as a number
      items.add("""
          {"data": {"name": "%s", "course": "%s", "completed_on": "%s", "hours": %d}}
          """.formatted(v[0], v[1], v[2], Integer.parseInt(v[3])).strip());
    }

    String queued = api("POST", "/renders/batch",
        "{\"template\": \"certificate\", \"zip\": true, \"items\": [" + String.join(",", items) + "]}");
    String id = field(queued, "id");
    System.out.println(id + " " + field(queued, "total") + " queued");

    // the job is always asynchronous: ask for it until it is no longer queued or processing
    String job = queued;
    while (field(job, "status").equals("queued") || field(job, "status").equals("processing")) {
      Thread.sleep(2000);
      job = api("GET", "/jobs/" + id, null);
    }
    System.out.println(field(job, "succeeded") + "/" + field(job, "total") + " rendered, " + field(job, "failed") + " failed");

    String zip = field(job, "zip_url");
    if (!zip.isEmpty() && !zip.equals("null")) {
      CLIENT.send(HttpRequest.newBuilder(URI.create(zip)).build(),
          HttpResponse.BodyHandlers.ofFile(Path.of("certificates.zip")));
    }
  }
}

Run it

It prints the job id and how many renders were queued, then 4/4 rendered, 0 failed, and certificates.zip lies next to the program with one PDF per row, 0001-document.pdf to 0004-document.pdf.

Shell
export FORMFEED_API_KEY=ff_test_...
java Batch.java

What is particular about Java

  • No JSON library in the JDK. To stay free of dependencies the body is a text block, and the values the program needs from JSON (the render’s id and download_url, an invoice number) are read with a regular expression. In a project, build the body and read the answer with Jackson or Gson.
  • Single-file launch. java Render.java compiles and runs in one step, so trying it needs no build tool.
  • Straight to disk. BodyHandlers.ofFile streams the PDF into the file without holding it in memory.

The CSV

One header row, one line per document. The columns are the fields the template expects, so no mapping table is needed:

attendees.csvText
name,course,completed_on,hours
Erika Mustermann,Analytical Engines 101,2026-09-10,16
Max Mustermann,Analytical Engines 101,2026-09-10,16
Jane Doe,Print CSS for developers,2026-09-11,8
John Doe,Print CSS for developers,2026-09-11,8

What a batch does differently

  • Always asynchronous. The answer comes back at once with the status queued. The renders run in the background, as many at a time as your plan allows.
  • One template, many items. A top-level template applies to every item that does not name its own, so an item is usually just its data.
  • Partly failed is a state. A job in which some items failed ends as partially_failed (failed when all did). succeeded and failed count them, and every item is a render of its own in items, with its own error.
  • The zip is optional. Every item keeps its own download URL in items, which is what you want when the documents go to different people. zip: true adds one archive of the succeeded ones, each named 0001- and its item’s filename (document.pdf without one), in row order.
  • Or a webhook. Instead of waiting, give the batch a webhook_url and let job.completed (or job.failed, when every item failed) tell you. For thousands of documents that is the better shape: no process waiting for minutes.

When the API says no

  • One shape for every error. Errors are problem documents (RFC 9457) with type, title, status, detail, a stable code and the request_id to quote to support; the type URL leads to a page about that error. The list is in the error reference.
  • The usual ones. 401 unauthorized for a missing or revoked key, 404 template_not_found for a wrong template name, 422 template_runtime_error when the template fails on the data, 429 rate_limited above the plan’s requests per second, 402 quota_exceeded when the Free plan’s units are used up (a paid plan bills extra units up to its hard cap).
  • Retry safely. Send an Idempotency-Key header with the POST, and repeat the request with the same key when you do not know whether the first attempt arrived: for 24 hours the key returns the first answer instead of rendering and billing again. The answer is stored once the first attempt has been answered, so do not retry while it may still run; and problems below 500 are stored too, so a corrected request needs a new key.

The job around this program

This guide is one program. What the job needs around it, from the template to the limits and the price, is on the page Certificate generator API.

More Java guides

FAQ

How many rows can one job have?
Up to 1,000 items per batch. Split a longer list into several jobs; there is no limit on how many jobs you run, and each counts once against the rate limit. See async and batch.
What happens when one row is wrong?
When the template fails on a row’s data, that item fails and the others still render: the job ends as partially_failed and the failed item carries the error, so you can fix the row and render it again on its own. An item the API refuses outright (a request field it does not accept, or data that misses the template’s schema under validate_data) refuses the whole batch before anything is queued, and the problem names the item’s position.
Does a failed item cost units?
No. Only documents that were produced are counted.
Can I send a spreadsheet instead of writing code?
Yes: the app takes a CSV or Excel file, maps the columns to the template’s fields and runs the batch for you. The code here is for the same thing inside your own system.

Batch from a CSV in other languages

Start rendering today

The free plan includes 100 units a month and free test renders. Change plans when you need more.