Jinja2PDF
Deutsche Rechnung mit GiroCode
Positionen, Netto, Umsatzsteuer und Brutto in der Vorlage berechnet, Datumsangaben im deutschen Format mit Fälligkeit nach 14 Tagen, ein SEPA-QR-Code für Banking-Apps und eine Fußzeile mit Seitenzahlen. Ihr Code sendet die Fakten; die Vorlage verantwortet das Layout.
Kostenloser Plan, keine Kreditkarte. Sie erhalten eine eigene Kopie im Editor, veröffentlicht und bereit für die API.
Gerendertes Ergebnis
Was dieses Beispiel zeigt
- Die Anfrage enthält weder Summen noch Steuer:
sum('total')addiert die Positionen, Umsatzsteuer und Brutto ergeben sich in der Vorlage ausinvoice.vat_rate. epcQrzeichnet den GiroCode (EPC-QR-Code) aus Name, IBAN, BIC, Betrag und Verwendungszweck; die Banking-App füllt die Überweisung nach dem Scan aus. Für einen einzelnen Code erzeugt der GiroCode-Generator denselben Code im Browser.dateAdd(14, 'days')setzt die Fälligkeit; mit der Localede-DEgebendate('dd.MM.yyyy')undmoney24.09.2026und3.760,40 €aus.- Die Fußzeile ist ein Chromium-Footer-Template mit
pageNumberundtotalPages: „Seite 1 von 1“ stimmt auch dann, wenn die Positionen über mehrere Seiten laufen. filenamenennt den Downloadrechnung-2026-0042.pdf. Der Name gilt so, wie er gesendet wird: Ein Vorlagenausdruck darin wird nicht ausgefüllt, seine Klammern werden zu_. Ein Programm, das viele Rechnungen rendert, setzt den Namen selbst aus der jeweiligen Nummer zusammen.
Verwendete Helfer
Vorlagenordner
<p class="sender">{{ company.name }} · {{ company.street }} · {{ company.city }}</p>
<address class="recipient">
<strong>{{ customer.name }}</strong><br>
{{ customer.street }}<br>
{{ customer.city }}
</address>
<h1>Rechnung {{ invoice.number }}</h1>
<p class="meta">
Datum {{ invoice.date | date('dd.MM.yyyy') }},
fällig am {{ invoice.date | dateAdd(14, 'days') | date('dd.MM.yyyy') }}
</p>
<table class="lines">
<thead>
<tr><th>Leistung</th><th class="num">Menge</th><th class="num">Preis</th><th class="num">Summe</th></tr>
</thead>
<tbody>
{% for line in invoice.lines %}
<tr>
<td>{{ line.description }}</td>
<td class="num">{{ line.qty | number(0) }}</td>
<td class="num">{{ line.price | money }}</td>
<td class="num">{{ line.total | money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% set net = invoice.lines | sum('total') %}
{% set vat = net * invoice.vat_rate / 100 %}
<table class="totals">
<tr><td>Netto</td><td class="num">{{ net | money }}</td></tr>
<tr><td>USt. {{ invoice.vat_rate }} %</td><td class="num">{{ vat | money }}</td></tr>
<tr class="grand"><td>Gesamt</td><td class="num">{{ (net + vat) | money }}</td></tr>
</table>
<figure class="pay">
<img src="{{ epcQr({ name: company.name, iban: company.iban, bic: company.bic, amount: net + vat, text: 'Rechnung ' ~ invoice.number }, { size: 110 }) }}" alt="GiroCode">
<figcaption>Mit der Banking-App scannen und bezahlen</figcaption>
</figure>body { font-family: 'Inter', sans-serif; font-size: 9.5pt; color: #1e293b; }
.sender {
display: inline-block;
margin: 10mm 0 3mm;
padding-bottom: 1mm;
border-bottom: 1px solid #cbd5e1;
font-size: 7pt;
color: #64748b;
}
.recipient { font-style: normal; line-height: 1.5; }
h1 { margin: 18mm 0 1mm; font-size: 18pt; color: #312e81; }
.meta { margin: 0 0 8mm; color: #475569; }
table { width: 100%; border-collapse: collapse; }
.lines th {
padding: 2mm 1.5mm;
border-bottom: 1.5px solid #1e293b;
text-align: left;
font-size: 7.5pt;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
.lines td { padding: 2.5mm 1.5mm; border-bottom: 1px solid #e2e8f0; }
.num { text-align: right !important; white-space: nowrap; }
.totals { width: 72mm; margin: 5mm 0 0 auto; }
.totals td { padding: 1.3mm 1.5mm; }
.totals .grand td { border-top: 1.5px solid #1e293b; font-weight: 700; font-size: 11pt; }
.pay { display: flex; align-items: center; gap: 4mm; margin: 14mm 0 0; }
.pay figcaption { max-width: 45mm; font-size: 8pt; color: #475569; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"><div style="box-sizing:border-box;width:100%;display:flex;justify-content:space-between;padding:0 18mm;font-size:8px;color:#555">
<span>{{ company.name }} · USt-IdNr. {{ company.vat_id }} · IBAN {{ company.iban }}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>{
"pdf": {
"metadata": {
"title": "Rechnung {{ invoice.number }}"
}
},
"paper": {
"format": "A4"
},
"margin": {
"top": "20mm",
"right": "18mm",
"bottom": "22mm",
"left": "18mm"
},
"locale": "de-DE",
"timezone": "Europe/Berlin",
"currency": "EUR",
"footer": {
"height": "12mm"
}
}{
"company": {
"name": "Fennlor Studio GmbH",
"street": "Musterstraße 1",
"city": "12345 Musterstadt",
"iban": "DE36000000000000000000",
"bic": "XXXXDEXXXXX",
"vat_id": "DE000000000"
},
"customer": {
"name": "Olvarest GmbH",
"street": "Beispielweg 2",
"city": "54321 Beispielstadt"
},
"invoice": {
"number": "2026-0042",
"date": "2026-09-10",
"vat_rate": 19,
"lines": [
{
"description": "Consulting",
"qty": 8,
"price": 120,
"total": 960
},
{
"description": "Implementation",
"qty": 20,
"price": 110,
"total": 2200
}
]
}
}Über die API rendern
Speichern Sie die Beispieldaten als data.json und senden Sie sie mit dem Namen der Vorlage. TypeScript und Python nutzen die SDKs; jede andere Sprache schickt dasselbe JSON mit ihrer Standardbibliothek an die REST-API. Renderings mit einem Schlüssel, der mit ff_test_ beginnt, werden nicht gezählt; im Free-Tarif tragen sie ein kleines Wasserzeichen.
jq '{template: "invoice-de", data: ., filename: "rechnung-2026-0042.pdf"}' data.json \
| curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer ff_test_..." \
-H "Content-Type: application/json" \
--data-binary @-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: 'invoice-de',
data,
filename: 'rechnung-2026-0042.pdf',
});
console.log(render.status, render.download_url);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="invoice-de",
data=data,
filename="rechnung-2026-0042.pdf",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'invoice-de',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'rechnung-2026-0042.pdf',
];
$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;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": "invoice-de",
"data": json.RawMessage(data),
"filename": "rechnung-2026-0042.pdf",
})
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)
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var body = new
{
template = "invoice-de",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "rechnung-2026-0042.pdf",
};
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")}");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": "invoice-de",
"filename": "rechnung-2026-0042.pdf",
"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 …
}
}require "json"
require "net/http"
body = {
template: "invoice-de",
data: JSON.parse(File.read("data.json")),
filename: "rechnung-2026-0042.pdf",
}
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"]Aus dem Blog
- Die Vorschau, die lügt, und die, die es nicht tut →
Eine paginierte Vorschau auf Basis von Paged.js bringt neun Karten auf eine Seite, wo Chromiums PDF sechs bringt. Warum, die eine Regel, die wir geändert haben, damit die Vorschau umbricht, wo das PDF umbricht — und der Fall, den keine Vorschau zeigen kann, weil Chromium das ganze PDF verkleinert.
- GiroCode auf der Rechnung: der Standard und die Fallstricke →
Was in einem GiroCode (EPC-QR-Code) steht, Feld für Feld nach EPC069-12, welche Grenzen gelten — und warum so viele erzeugte Codes an einer Prüfziffer, einem Komma oder einem Umlaut scheitern.
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.
