Zum Inhalt springen

Anwendungsfall

Report-PDFs erzeugen

Formfeed ist eine API, die aus den Zahlen eines Berichts das fertige PDF macht. Die Vorlage hält das Layout, die Diagramme und die Summen; Ihr Code sendet die Zeilen als JSON. Eine Tabelle läuft über so viele Seiten, wie sie braucht, und wiederholt dabei ihre Kopfzeile, jede Seite trägt ihre Nummer, und ein Bericht, der seine Zeit braucht, wird im Hintergrund gerendert und per Webhook gemeldet.

Kostenloser Plan mit 100 Einheiten im Monat, keine Kreditkarte

Diagramme aus den Daten

chart zeichnet ein Chart.js-Diagramm aus den Beschriftungen und Werten der Anfrage: Balken, Linie, Kreis, Ring, Netz. Gezeichnet wird ohne Animation; das PDF zeigt also das Diagramm, das die Vorschau gezeigt hat, und Ihr Code erzeugt kein Bild.

Tabellen über viele Seiten

Eine Zeile wandert als Ganzes auf die nächste Seite, und die Kopfzeile wiederholt sich auf jeder Seite. break-inside: avoid hält eine Gruppe von Zeilen zusammen, und pageBreak() beginnt ein Kapitel auf einer neuen Seite.

Summen in der Vorlage

sum, avg, groupBy und sortBy arbeiten auf den Zeilen, die Sie senden. Der Bericht bildet seine Summen selbst, sortiert seine Regionen und gruppiert seine Positionen; die Anfrage enthält Rohzeilen.

Seitenzahlen und Lesezeichen

Kopf- und Fußzeile wiederholen sich auf jeder Seite mit <span class="pageNumber"> von <span class="totalPages"> und dem Titel des Dokuments, und pdf.outline macht aus den Überschriften die Lesezeichen des PDFs.

Lange Berichte im Hintergrund

Mit "mode": "async" wird die Anfrage sofort beantwortet und der Bericht im Hintergrund gerendert. Ein Webhook bringt den Download-Link, wenn die Datei da ist, oder Ihr Code fragt danach.

Seiten, bevor Sie etwas senden

Der Editor setzt den Bericht beim Schreiben in Seiten, gefüllt mit Ihren Beispieldaten, und ein echtes Rendering zeigt das PDF so, wie die API es liefert. Mehrere Datensätze zeigen, wie das Layout mit drei Zeilen hält und mit dreihundert.

Einen PDF-Bericht aus JSON erzeugen

Ein Bericht ist die Vorlage plus die Zahlen eines Zeitraums. Speichern Sie die Daten als data.json und senden Sie sie mit dem Namen der Vorlage; die Antwort enthält den Link zum PDF. Die Vorlage zeichnet das Diagramm und rechnet den durchschnittlichen Auftragswert und die Summen selbst aus. TypeScript und Python nutzen die SDKs, jede andere Sprache schickt dasselbe JSON mit ihrer Standardbibliothek. Ein Schlüssel, der mit ff_test_ beginnt, rendert, ohne dass gezählt wird.

data.jsonJSON
{
  "report": {
    "title": "Revenue report",
    "period": "Q3 2026",
    "date": "2026-10-02"
  },
  "months": [
    {
      "month": "July",
      "orders": 312,
      "revenue": 48200
    },
    {
      "month": "August",
      "orders": 287,
      "revenue": 44950
    },
    {
      "month": "September",
      "orders": 356,
      "revenue": 57310
    }
  ]
}
render.shShell
jq '{template: "revenue-report", data: .}' data.json \
  | curl https://api-eu.formfeed.dev/v1/renders \
      -H "Authorization: Bearer ff_test_..." \
      -H "Content-Type: application/json" \
      --data-binary @-
render.tsTypeScript
import { Formfeed } from '@formfeed/sdk';
import data from './data.json' with { type: 'json' };

const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });

const render = await formfeed.renders.create({
  template: 'revenue-report',
  data,
});
console.log(render.status, render.download_url);
render.pyPython
import json
import os

from formfeed import Formfeed

formfeed = Formfeed(api_key=os.environ["FORMFEED_API_KEY"])

with open("data.json", encoding="utf-8") as file:
    data = json.load(file)

render = formfeed.renders.create(
    template="revenue-report",
    data=data,
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'revenue-report',
    'data' => json_decode(file_get_contents('data.json'), true),
];

$curl = curl_init('https://api-eu.formfeed.dev/v1/renders');
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('FORMFEED_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($body),
]);
$render = json_decode(curl_exec($curl), true);

echo $render['status'], ' ', $render['download_url'], PHP_EOL;
main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	data, err := os.ReadFile("data.json")
	if err != nil {
		panic(err)
	}
	body, _ := json.Marshal(map[string]any{
		"template": "revenue-report",
		"data":     json.RawMessage(data),
	})

	request, _ := http.NewRequest("POST", "https://api-eu.formfeed.dev/v1/renders", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+os.Getenv("FORMFEED_API_KEY"))
	request.Header.Set("Content-Type", "application/json")

	response, err := http.DefaultClient.Do(request)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()

	var render struct {
		Status      string `json:"status"`
		DownloadURL string `json:"download_url"`
	}
	json.NewDecoder(response.Body).Decode(&render)
	fmt.Println(render.Status, render.DownloadURL)
}
Program.csC#
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var body = new
{
    template = "revenue-report",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
};

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FORMFEED_API_KEY"));

var response = await http.PostAsJsonAsync("https://api-eu.formfeed.dev/v1/renders", body);
var render = await response.Content.ReadFromJsonAsync<JsonElement>();

Console.WriteLine($"{render.GetProperty("status")} {render.GetProperty("download_url")}");
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 {
  public static void main(String[] args) throws Exception {
    String data = Files.readString(Path.of("data.json"));
    String body = """
        {
          "template": "revenue-report",
          "data": %s
        }
        """.formatted(data);

    HttpRequest request = HttpRequest.newBuilder(URI.create("https://api-eu.formfeed.dev/v1/renders"))
        .header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.body()); // status, download_url, page_count, units …
  }
}
render.rbRuby
require "json"
require "net/http"

body = {
  template: "revenue-report",
  data: JSON.parse(File.read("data.json")),
}

response = Net::HTTP.post(
  URI("https://api-eu.formfeed.dev/v1/renders"),
  JSON.generate(body),
  "Authorization" => "Bearer #{ENV.fetch("FORMFEED_API_KEY")}",
  "Content-Type" => "application/json"
)
render = JSON.parse(response.body)

puts render["status"], render["download_url"]

Vollständige Anfrage- und Antwortformate in der Doku. Vollständiges Vorlagenbeispiel →

Das kommt heraus

Ein echtes Rendering der Beispielvorlage für diesen Anwendungsfall, direkt aus dem Render-Worker. Ein Klick öffnet die Datei.

PDF · 2 Seiten · 34 KB ↓ herunterladen

Diese Vorlage verwenden

Kostenloser Plan, keine Kreditkarte. Sie erhalten eine eigene Kopie im Editor, veröffentlicht und bereit für die API.

Diagramme, die aus den Daten entstehen

Ein Diagramm in einem Bericht ist meist ein Bild, das jemand woanders erzeugt und eingefügt hat. Hier gehört es zur Vorlage: chart nimmt Typ, Beschriftungen und Werte, und der Renderer zeichnet es mit Chart.js, während er die Seite aufbaut. pluck holt eine Spalte aus den Zeilen; das Diagramm und die Tabelle darunter lesen also dieselben Daten und können sich nicht widersprechen.

template.htmlJinja2
{{ chart({
  type: 'bar',
  data: {
    labels: months | pluck('month'),
    datasets: [{ label: 'Revenue', data: months | pluck('revenue'), backgroundColor: '#4f46e5' }]
  },
  width: 640,
  height: 280,
  alt: 'Bar chart of the revenue by month; the table below has the figures'
}) }}

Der Helfer nimmt einfache labels und values, mehrere series oder wie hier ein vollständiges Chart.js-Objekt data, bei Bedarf mit options daneben. alt sagt in Worten, was das Diagramm zeigt; das liest ein Screenreader anstelle des Bildes. Eine eigene Diagramm-Bibliothek funktioniert ebenfalls: JavaScript läuft in der Seite, und waitFor.selector hält den Druck an, bis Ihr Skript sein letztes Element eingefügt hat.

Tabellen, die über Seiten laufen

Eine Tabelle mit vierhundert Zeilen braucht keinen Code von Ihnen, um gut umzubrechen. Eine Zeile wird nie auf zwei Seiten verteilt, es sei denn, sie ist höher als eine Seite, und ein thead wird am Kopf jeder Seite, die die Tabelle erreicht, erneut gedruckt. Geben Sie einer Gruppe von Zeilen ein eigenes tbody mit break-inside: avoid, und die Gruppe bleibt zusammen.

Zwei Dinge gehen in jedem HTML-Bericht schief, und für beide gibt es eine Lösung. Ein tfoot wiederholt sich am Fuß jeder Seite wie die Kopfzeile; eine dort notierte Gesamtsumme erscheint also auf jeder. Das Beispiel druckt sie mit der Zeile unten einmal, am Ende. Und eine Tabelle in einer Box mit inline-block wird gar nicht umbrochen: Der Seitenrand schneidet durch die Zeile, die gerade dort steht.

style.cssCSS
.figures tfoot { display: table-row-group; } /* the total once, at the end, not at the foot of every page */

Seitenumbrüche in Tabellen zeigt an gerenderten Dateien, was über Seiten hinweg hält und was nicht.

Inhaltsverzeichnis, Lesezeichen und ein getaggtes PDF

Ein langer Bericht braucht Wege hindurch. pdf.outline schreibt die Überschriften als Lesezeichen ins PDF, nach ihrer Ebene verschachtelt, und die Einträge eines Inhaltsverzeichnisses können auf ihre Kapitel verlinken. Chromium trägt die Seitenzahl eines Linkziels nicht ein; ein Inhaltsverzeichnis nennt die Kapitel also ohne Seitenzahlen, und Lesezeichen und Links übernehmen die Navigation. Das Jahresbericht-Beispiel hat das alles: ein Deckblatt mit verlinktem Inhaltsverzeichnis, Lesezeichen, Kennzahlen, ein Liniendiagramm über zwei Jahre und einen nach Regionen gruppierten Anhang.

Die Lesezeichen entstehen aus den Tags des PDFs, der Struktur, anhand derer ein Screenreader ein Dokument liest. Mit pdf.ua machen diese Tags den Bericht zu einer PDF/UA-1-Datei, die veraPDF vor der Auslieferung gegen die Regeln dieses Standards prüft, die eine Maschine prüfen kann; ein Diagramm braucht dann sein alt und eine Tabelle ihre th-Zellen. Was die Prüfung abdeckt und was nicht, steht unter barrierefreie PDFs.

Ein langer Bericht im Hintergrund

Ein Bericht mit zwei Seiten kommt in der Antwort zurück. Einer mit zweihundert sollte keine Anfrage von Ihnen offen halten: Senden Sie ihn mit "mode": "async", und die API antwortet sofort mit 202 und dem Rendering im Status queued. meta trägt eine Referenz von Ihnen bis ins Ergebnis.

POST /v1/rendersJSON
{
  "template": "revenue-report",
  "data": { … },
  "mode": "async",
  "webhook_url": "https://example.com/hooks/formfeed",
  "webhook_secret": "a-long-random-string-you-keep",
  "meta": { "report": "2026-Q3" }
}

Ist die Datei da, geht render.completed an die Webhook-Endpunkte des Workspaces und an die webhook_url der Anfrage, mit dem Rendering und seinem Download-Link; render.failed sagt, warum es keinen gibt. Das webhook_secret signiert die Zustellungen an diese Adresse; Ihr Handler kann also den Header Webhook-Signature prüfen, bevor er einem Ereignis glaubt. Ohne Webhook fragen Sie GET /v1/renders/{id} alle ein bis zwei Sekunden ab: Ein Rendering zu lesen, zählt nicht gegen das Rate-Limit.

Eine synchrone Anfrage schlägt nicht fehl, wenn ein Bericht lange braucht. Findet sie binnen 30 Sekunden keinen freien Render-Slot oder hat sie nach insgesamt 60 Sekunden keine Antwort, wird auch sie mit 202 beantwortet und im Hintergrund fertiggestellt; Code, der synchron fragt, sollte also mit einem 202 umgehen können. In den SDKs ist das renders.waitFor und renders.wait_for.

Wie lang ein Bericht sein darf

Ein PDF darf im Free-Plan 20 Seiten haben, bei Starter 200, bei Pro 500 und bei Business 1.000. Ein Bericht, der länger ausfällt, wird nicht abgeschnitten: Das Rendering schlägt mit page_limit_exceeded fehl, nennt die Zahl der Seiten, die es geworden wären, und kostet nichts. Dieselbe Grenze gilt für das Ergebnis, wenn an einen Bericht weitere PDFs angehängt werden.

Was ein Bericht kostet

Ein PDF mit bis zu fünf Seiten ist eine Einheit, und je weitere angefangene fünf Seiten kommt eine hinzu: Ein Bericht mit 12 Seiten sind drei Einheiten, einer mit 200 Seiten 40. Der Free-Plan hat 100 Einheiten im Monat und verlangt keine Kreditkarte. Starter hat 3.000 Einheiten für 19 € im Monat. Renderings mit einem Test-Schlüssel kosten nichts, solange Sie bauen, und ein fehlgeschlagenes Rendering wird nie gezählt. Alle Pläne stehen auf der Preisseite.

Was bei Ihnen bleibt

Formfeed druckt den Bericht, den Ihre Daten beschreiben. Es fragt Ihre Datenbank nicht ab, es hat keinen Zeitplan, der einen Monatsbericht startet, und es verschickt das PDF nicht an seine Leser: Abfrage, Zeitsteuerung und Versand bleiben bei Ihnen. Sie können auch Schritte einer Automation sein. Formfeed steht im App-Verzeichnis von Zapier, wo ein Zeitplan den Zap startet und ein E-Mail-Schritt den Link verschickt, und wie es um Make, n8n und Power Automate steht, sagt die Integrationsseite. Dateien bleiben für die Aufbewahrung Ihres Plans erhalten, einen Tag im Free-Plan, 30 Tage bei Starter und 365 Tage bei Pro; speichern Sie also die Berichte, die Sie länger brauchen. Ab dem Pro-Plan geht von jeder Datei beim Rendern eine Kopie in Ihren eigenen S3- oder R2-Bucket.

Aus dem Blog

  • Seitenumbrüche in Tabellen: was Chromium 2026 beachtet →

    Einundzwanzig Tabellen-Layouts, mit Headless Chromium 153 als PDF gerendert und nachgemessen — welche Umbruchregeln Chromium einhält und mit welchen drei Fehlern eine Zeile halbiert, unter der Fußzeile versteckt oder die Summe auf jede Seite gedruckt wird.

FAQ

Kann ein PDF-Bericht Diagramme enthalten?
Ja. Der Helfer chart zeichnet Balken-, Linien-, Kreis-, Ring- und Netzdiagramme mit Chart.js aus den Daten der Anfrage, und eine eigene Diagramm-Bibliothek läuft in der Seite wie in einem Browser.
Gibt es ein Seitenlimit?
Pro Dokument, je nach Plan: 20 Seiten im Free-Plan, 200 bei Starter, 500 bei Pro und 1.000 bei Business. Je angefangene fünf Seiten zählen als eine Einheit.
Kann ein Inhaltsverzeichnis Seitenzahlen zeigen?
Nicht automatisch. Chromium, das den Bericht druckt, löst target-counter() nicht auf; das Verzeichnis kann auf die Kapitel verlinken, ihre Seiten aber nicht nennen. Lesezeichen aus pdf.outline und die Links decken die Navigation ab; das Jahresbericht-Beispiel zeigt beides.
Kann ich mehrere PDFs zu einem Bericht zusammenführen?
Ja, ab dem Starter-Plan. post.merge_after hängt frühere Renderings oder PDFs aus der Dateibibliothek beim Rendern an einen Bericht an, und POST /v1/pdf/merge fügt Renderings zusammen, die es schon gibt; beides kostet eine halbe Einheit. merge_after lässt sich nicht mit pdf.ua kombinieren, weil ein Zusammenführen die Tags der angehängten Seiten verwirft. Oft ist eine Vorlage mit allen Kapiteln einfacher.
Lassen sich Berichte zeitgesteuert erzeugen?
Nicht von Formfeed. Ihr Scheduler, Ihr Backend oder eine Automation ruft die API auf, wenn ein Bericht fällig ist; das Rendern im Hintergrund und der Webhook sind für Jobs gemacht, auf die niemand wartet.
Kann derselbe Bericht eine Word- oder PowerPoint-Datei sein?
Ab dem Starter-Plan ja: Eine Word- oder PowerPoint-Vorlage ist die Datei, die Sie schon haben, mit denselben Tags darin, und ein Rendering gibt sie gefüllt zurück, als DOCX oder PPTX zum Bearbeiten oder als PDF. Den Helfer chart gibt es dort nicht.

Weitere Anwendungsfälle

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.