Zum Inhalt springen

Guide · Java

PDF asynchron rendern und den Webhook prüfen mit Java

Ein langer Bericht sollte keine HTTP-Anfrage offen halten. Im Async-Modus antwortet die API sofort mit einem eingereihten Rendering und ruft Ihren Endpunkt auf, wenn das PDF fertig ist. Dieser Guide enthält beide Hälften in Java: die Anfrage und einen Empfänger, der nur Zustellungen annimmt, die Formfeed wirklich signiert hat.

Kostenloser Plan mit 100 Einheiten im Monat, keine Kreditkarte

Was Sie brauchen

  • Einen API-Key. Legen Sie ihn in der App unter API-Schlüssel an und exportieren Sie ihn als FORMFEED_API_KEY. Zum Ausprobieren ist ein Test-Key (ff_test_…) richtig. Siehe Quickstart.
  • Java. Sie brauchen Java 17 oder neuer; java.net.http.HttpClient und Textblöcke gehören zum JDK.
  • Die Vorlage. revenue-report aus dem Berichtsbeispiel und dessen data.json neben dem Programm.
  • Ein Secret. 16 bis 200 zufällige Zeichen in FORMFEED_WEBHOOK_SECRET, beiden Programmen bekannt. Es signiert die Zustellungen dieses Renderings, Retries eingeschlossen.
  • Eine öffentliche URL. Ersetzen Sie https://example.com/hooks/formfeed durch Ihren Endpunkt. Während der Entwicklung leitet formfeed listen Zustellungen ohne Tunnel an localhost weiter.

Das Programm

Die Anfrage nennt die Vorlage, schaltet auf mode: "async" und sagt, wohin gemeldet wird. Die Antwort kommt sofort, mit dem Status queued.

Render.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;

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

  public static void main(String[] args) throws Exception {
    String data = Files.readString(Path.of("data.json"));
    String secret = System.getenv("FORMFEED_WEBHOOK_SECRET");

    // the JDK has no JSON library: the body is a text block, and a project would use Jackson or Gson
    String body = """
        {
          "template": "revenue-report",
          "data": %s,
          "mode": "async",
          "webhook_url": "https://example.com/hooks/formfeed",
          "webhook_secret": %s
        }
        """.formatted(data, quote(secret));

    HttpResponse<String> response = send(HttpRequest.newBuilder(URI.create(ENDPOINT)).POST(HttpRequest.BodyPublishers.ofString(body)));

    // every error is a problem document (RFC 9457) with a title and a detail
    if (response.statusCode() >= 400) {
      System.err.println(response.body());
      System.exit(1);
    }
    System.out.println(response.body()); // queued: the webhook reports the rest
  }

  /** One call to the API with the key. */
  static HttpResponse<String> send(HttpRequest.Builder request) throws Exception {
    return CLIENT.send(request
        .header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
        .header("Content-Type", "application/json")
        .build(), HttpResponse.BodyHandlers.ofString());
  }

  /** A JSON string literal. */
  static String quote(String text) {
    StringBuilder out = new StringBuilder("\"");
    for (char c : text.toCharArray()) {
      if (c == '"' || c == '\\') out.append('\\').append(c);
      else if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
      else out.append(c);
    }
    return out.append('"').toString();
  }
}

Ausführen

Es gibt die ID des Renderings und queued aus. Wenige Sekunden später erhält Ihr Endpunkt render.completed mit dem vollständigen Rendering samt download_url, oder render.failed mit dem Grund.

Shell
export FORMFEED_API_KEY=ff_test_...
export FORMFEED_WEBHOOK_SECRET=$(openssl rand -hex 24)
java Render.java

Was an Java besonders ist

  • Keine JSON-Bibliothek im JDK. Um ohne Abhängigkeiten auszukommen, ist der Body ein Textblock, und die Werte, die das Programm aus JSON braucht (id und download_url des Renderings, eine Rechnungsnummer), liest ein regulärer Ausdruck. In einem Projekt bauen Sie den Body und lesen die Antwort mit Jackson oder Gson.
  • Start aus einer Datei. java Render.java kompiliert und startet in einem Schritt; zum Ausprobieren braucht es kein Build-Tool.
  • Direkt auf die Platte. BodyHandlers.ofFile streamt das PDF in die Datei, ohne es im Speicher zu halten.

Der Empfänger

Er lauscht auf Port 8080, liest den rohen Body, prüft Signatur und Alter des Zeitstempels und sieht sich erst dann das JSON an. Eine Zustellung, die die Prüfung nicht besteht, erhält 401.

Hook.javaJava
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Hook {
  static final byte[] SECRET = System.getenv("FORMFEED_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8);

  /** Checks "Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of t.body>". */
  static boolean valid(String header, byte[] body) throws Exception {
    Map<String, String> parts = new HashMap<>();
    for (String part : (header == null ? "" : header).split(",")) {
      String[] pair = part.trim().split("=", 2);
      if (pair.length == 2) parts.put(pair[0], pair[1]);
    }
    long timestamp;
    byte[] given;
    try {
      timestamp = Long.parseLong(parts.getOrDefault("t", ""));
      given = HexFormat.of().parseHex(parts.getOrDefault("v1", ""));
    } catch (IllegalArgumentException e) {
      return false;
    }
    if (Math.abs(System.currentTimeMillis() / 1000 - timestamp) > 300) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
    mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    return MessageDigest.isEqual(mac.doFinal(body), given); // constant time
  }

  public static void main(String[] args) throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
    server.createContext("/hooks/formfeed", exchange -> {
      // the signature covers the bytes on the wire: read the raw body, never re-serialised JSON
      byte[] body = exchange.getRequestBody().readAllBytes();
      boolean ok;
      try {
        ok = valid(exchange.getRequestHeaders().getFirst("Webhook-Signature"), body);
      } catch (Exception e) {
        ok = false;
      }
      if (ok) System.out.println(new String(body, StandardCharsets.UTF_8)); // hand it to your JSON library
      exchange.sendResponseHeaders(ok ? 204 : 401, -1);
      exchange.close();
    });
    server.start();
  }
}

Empfänger starten

Shell
FORMFEED_WEBHOOK_SECRET=… java Hook.java

Prüfen in Java

  • MessageDigest.isEqual. Es vergleicht die Byte-Arrays in konstanter Zeit; Arrays.equals tut das nicht.
  • In Spring. Nehmen Sie den Body als @RequestBody byte[] (oder String) und prüfen Sie ihn, bevor Ihr ObjectMapper ihn auf ein Objekt abbildet.

Was ankommt

Ein POST mit Webhook-Signature: t=<Unix-Sekunden>,v1=<hex>; das Hex ist HMAC-SHA256 über t, einen Punkt und den rohen Body, mit Ihrem Secret als Schlüssel, genau so, wie Sie es haben, als UTF-8-Bytes: das webhook_secret, das Sie gesendet haben, oder die ganze whsec_…-Zeichenkette eines registrierten Endpunkts samt Präfix. Jeder Versuch wird mit eigenem t neu signiert. Webhook-Id bleibt über Retries gleich und ist damit Ihr Schlüssel zum Deduplizieren. data ist das Rendering, wie GET /renders/{id} es liefert:

POST /hooks/formfeedJSON
{
  "id": "evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a",
  "type": "render.completed",
  "created_at": "2026-09-18T10:42:00Z",
  "workspace_id": "3a6f…",
  "data": {
    "id": "rnd_7da09ab6cd9946b798c187c2a9350852",
    "status": "succeeded",
    "download_url": "https://cdn.formfeed.dev/o/eu/…/document.pdf?exp=…&sig=…",
    "page_count": 2,
    "units": 1,
    …
  }
}

Retries, und was Ihr Endpunkt leisten muss

  • Schnell antworten. Eine Zustellung gelingt mit einem 2xx innerhalb von 10 Sekunden. Speichern Sie das Event und erledigen Sie die Arbeit danach.
  • Retries. Alles andere, auch eine Weiterleitung, wird nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 12 Stunden wiederholt: insgesamt sechs Versuche über etwa 14 Stunden und 40 Minuten. Dasselbe Event kann also zweimal ankommen: Deduplizieren Sie über Webhook-Id.
  • Ohne Secret. Eine webhook_url ohne webhook_secret wird unsigniert zugestellt. Für signierte Zustellungen aller Renderings registrieren Sie einmal einen Endpunkt in der App oder über POST /webhooks; er erhält ein eigenes Secret.
  • Lokal ausprobieren. formfeed listen --forward-to http://localhost:8080/hooks/formfeed leitet die Events Ihrer Test-Renderings an Ihren Rechner weiter, signiert mit dem eigenen Secret der Session statt mit webhook_secret: Starten Sie den Empfänger mit diesem, das formfeed listen --print-secret ausgibt (vor der Session aufrufen, denn es beendet eine laufende). formfeed trigger render.completed --template revenue-report erzeugt ein echtes Test-Rendering, dessen Event Sie ankommen sehen. Der Schlüssel braucht den Scope webhook:listen. Siehe Webhooks lokal entwickeln.

Wenn die API nein sagt

  • Eine Form für jeden Fehler. Fehler sind Problem-Dokumente (RFC 9457) mit type, title, status, detail, einem stabilen code und der request_id, die Sie dem Support nennen; die type-URL führt zu einer Seite über diesen Fehler. Die Liste steht in der Fehlerreferenz.
  • Die üblichen. 401 unauthorized bei fehlendem oder widerrufenem Schlüssel, 404 template_not_found bei falschem Vorlagennamen, 422 template_runtime_error, wenn die Vorlage an den Daten scheitert, 429 rate_limited über den Anfragen pro Sekunde des Tarifs, 402 quota_exceeded, wenn die Einheiten des Free-Tarifs verbraucht sind (ein bezahlter Tarif rechnet weitere Einheiten ab, bis zu seiner Obergrenze).
  • Sicher wiederholen. Senden Sie beim POST einen Idempotency-Key-Header und wiederholen Sie die Anfrage mit demselben Schlüssel, wenn Sie nicht wissen, ob der erste Versuch ankam: 24 Stunden lang liefert der Schlüssel die erste Antwort, statt erneut zu rendern und zu berechnen. Gespeichert wird die Antwort, sobald der erste Versuch beantwortet ist; wiederholen Sie also nicht, solange er noch laufen kann. Auch Probleme unter 500 werden gespeichert, eine korrigierte Anfrage braucht deshalb einen neuen Schlüssel.

Die Aufgabe um dieses Programm

Dieser Guide ist ein Programm. Was die Aufgabe darum herum braucht, von der Vorlage bis zu Limits und Preis, steht auf der Seite Report-PDFs erzeugen.

Weitere Guides für Java

FAQ

Kann ich pollen, statt einen Webhook zu empfangen?
Ja: GET /renders/{id}, bis der Status succeeded oder failed ist; die SDKs haben dafür waitFor und wait_for. Ein Webhook spart die Anfragen und meldet sich früher. Siehe Async und Batch.
Warum muss der rohe Body verwendet werden?
Die Signatur läuft über die Bytes auf der Leitung. JSON zu parsen und neu zu serialisieren kann Schlüssel umsortieren, Zahlenformate ändern oder Zeichen anders maskieren, und der HMAC dieses Textes passt dann nicht mehr.
Wozu dient die Fünf-Minuten-Toleranz?
Sie verhindert, dass eine abgefangene Zustellung später erneut eingespielt wird. Sie bedeutet auch, dass die Uhr Ihres Servers ungefähr stimmen muss.

Asynchron rendern mit Webhook in anderen Sprachen

Heute mit dem Rendern beginnen

Der kostenlose Plan enthält 100 Einheiten im Monat und kostenlose Testrenderings. Wechseln Sie den Plan, wenn Sie mehr brauchen.