Zum Inhalt springen

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

Gerendertes Ergebnis: Deutsche Rechnung mit GiroCode
PDF · 1 Seite · 90 KB ↓ herunterladen

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 aus invoice.vat_rate.
  • epcQr zeichnet 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 Locale de-DE geben date('dd.MM.yyyy') und money 24.09.2026 und 3.760,40 € aus.
  • Die Fußzeile ist ein Chromium-Footer-Template mit pageNumber und totalPages: „Seite 1 von 1“ stimmt auch dann, wenn die Positionen über mehrere Seiten laufen.
  • filename nennt den Download rechnung-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

Mehr zu diesem Anwendungsfall: Rechnungs-PDF-API →

Vorlagenordner

templates/invoice-de/template.htmlJinja2
<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>
templates/invoice-de/style.cssCSS
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; }
templates/invoice-de/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/invoice-de/footer.htmlJinja2
<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>
templates/invoice-de/settings.jsonJSON
{
  "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"
  }
}
templates/invoice-de/data/default.jsonJSON
{
  "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.

render.shShell
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 @-
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: 'invoice-de',
  data,
  filename: 'rechnung-2026-0042.pdf',
});
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="invoice-de",
    data=data,
    filename="rechnung-2026-0042.pdf",
)
print(render.status, render.download_url)
render.phpPHP
<?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;
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": "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)
}
Program.csC#
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")}");
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": "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 …
  }
}
render.rbRuby
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.