Skip to content

Jinja2PDF

German packing list (Packliste) grouped by package

The request sends one flat list of items; groupBy turns it into a section per package with its own table and weight, and the opening line counts packages, items and kilograms. A package never splits across two pages.

Free plan, no card. You get your own copy in the editor, published and ready for the API.

Rendered result

Rendered result: German packing list (Packliste) grouped by package
PDF · 1 page · 95 KB ↓ download

What this example shows

  • shipment.items | groupBy('package') returns a list of { key, items }, so the caller sends the items as they come out of the warehouse system and the template builds the sections.
  • Each section sums its own weight with package.items | sum('weight_kg'); the opening line sums the whole list: 3 packages, 44 items, 19,6 kg.
  • number(1) prints one decimal with the German comma because the locale is de-DE.
  • .package { break-inside: avoid } keeps a package with its table on one page when the list grows.

Helpers used

Template folder

templates/packliste/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>
<table class="facts">
  <tr><td>Packliste</td><td>{{ shipment.number }}</td></tr>
  <tr><td>Datum</td><td>{{ shipment.date | date('dd.MM.yyyy') }}</td></tr>
  <tr><td>Bestellung</td><td>{{ shipment.order_number }}</td></tr>
</table>

<h1>Packliste {{ shipment.number }}</h1>
{% set packages = shipment.items | groupBy('package') %}
<p>{{ packages | length }} Packstücke, {{ shipment.items | sum('qty') | number(0) }} Artikel, {{ shipment.items | sum('weight_kg') | number(1) }} kg gesamt.</p>

{% for package in packages %}
<section class="package">
  <h2>Packstück {{ package.key }} <span class="muted">· {{ package.items | sum('weight_kg') | number(1) }} kg</span></h2>
  <table class="lines">
    <thead><tr><th>Artikelnr.</th><th>Bezeichnung</th><th class="num">Menge</th><th class="num">Gewicht</th></tr></thead>
    <tbody>
      {% for item in package.items %}
      <tr>
        <td>{{ item.sku }}</td>
        <td>{{ item.description }}</td>
        <td class="num">{{ item.qty | number(0) }}</td>
        <td class="num">{{ item.weight_kg | number(1) }} kg</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% endfor %}
templates/packliste/style.cssCSS
body { font-family: 'Inter', sans-serif; font-size: 9.5pt; line-height: 1.45; color: #1e293b; }
.sender {
  display: inline-block;
  margin: 10mm 0 3mm;
  padding-bottom: 1mm;
  border-bottom: 1px solid #cbd5e1;
  font-size: 7pt;
  color: #64748b;
}
.recipient { min-height: 27mm; font-style: normal; line-height: 1.5; }
.facts { float: right; margin-top: -27mm; font-size: 8.5pt; color: #475569; }
.facts td { padding: 0.4mm 0 0.4mm 5mm; }
.facts td:last-child { text-align: right; color: #1e293b; }
h1 { clear: both; margin: 14mm 0 4mm; font-size: 16pt; color: #312e81; }
table.lines { width: 100%; margin-top: 6mm; 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.3mm 1.5mm; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
.lines tr { break-inside: avoid; }
.num { text-align: right !important; white-space: nowrap; }
.totals { width: 78mm; margin: 5mm 0 0 auto; border-collapse: collapse; }
.totals td { padding: 1.2mm 1.5mm; }
.totals .grand td { border-top: 1.5px solid #1e293b; font-weight: 700; font-size: 11pt; }
.muted { color: #64748b; }
.package { margin-top: 8mm; break-inside: avoid; }
h2 { margin: 0; font-size: 11pt; }
.package table.lines { margin-top: 2mm; }
templates/packliste/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/packliste/footer.htmlJinja2
<div style="box-sizing:border-box;width:100%;display:flex;justify-content:space-between;gap:6mm;padding:0 18mm;font-size:7px;line-height:1.5;color:#555">
  <span>{{ company.name }} · {{ company.street }} · {{ company.city }}<br>{{ company.register }} · Geschäftsführung: {{ company.director }}</span>
  <span>USt-IdNr. {{ company.vat_id }}<br>IBAN {{ company.iban }} · BIC {{ company.bic }}</span>
  <span style="white-space:nowrap">Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
templates/packliste/settings.jsonJSON
{
  "paper": {
    "format": "A4"
  },
  "margin": {
    "top": "20mm",
    "right": "18mm",
    "bottom": "26mm",
    "left": "22mm"
  },
  "locale": "de-DE",
  "timezone": "Europe/Berlin",
  "currency": "EUR",
  "pdf": {
    "metadata": {
      "title": "Packliste {{ shipment.number }}"
    }
  },
  "footer": {
    "height": "16mm"
  }
}
templates/packliste/data/default.jsonJSON
{
  "company": {
    "name": "Fennlor Studio GmbH",
    "street": "Musterstraße 1",
    "city": "12345 Musterstadt",
    "phone": "+49 000 0000000",
    "email": "hallo@fennlor.example",
    "iban": "DE36000000000000000000",
    "bic": "XXXXDEXXXXX",
    "vat_id": "DE000000000",
    "register": "Amtsgericht Musterstadt, HRB 00000",
    "director": "Erika Mustermann"
  },
  "customer": {
    "name": "Olvarest GmbH",
    "contact": "Max Mustermann",
    "street": "Beispielweg 2",
    "city": "54321 Beispielstadt",
    "number": "K-10482"
  },
  "shipment": {
    "number": "PL-2026-0117",
    "date": "2026-09-16",
    "order_number": "B-4471",
    "items": [
      {
        "package": "1 von 3",
        "sku": "FF-1001",
        "description": "Tischleuchte, schwarz",
        "qty": 6,
        "weight_kg": 7.2
      },
      {
        "package": "1 von 3",
        "sku": "FF-3300",
        "description": "Leuchtmittel E27, warmweiß",
        "qty": 12,
        "weight_kg": 0.9
      },
      {
        "package": "2 von 3",
        "sku": "FF-1001",
        "description": "Tischleuchte, schwarz",
        "qty": 6,
        "weight_kg": 7.2
      },
      {
        "package": "2 von 3",
        "sku": "FF-3300",
        "description": "Leuchtmittel E27, warmweiß",
        "qty": 12,
        "weight_kg": 0.9
      },
      {
        "package": "3 von 3",
        "sku": "FF-2040",
        "description": "Leinenschirm, natur",
        "qty": 8,
        "weight_kg": 3.4
      }
    ]
  }
}

Render it through the API

Save the sample data as data.json and send it with the template’s name. TypeScript and Python use the SDKs; every other language posts the same JSON to the REST API with its standard library. Renders with a key that starts with ff_test_ are not metered; on the Free plan they carry a small watermark.

render.shShell
jq '{template: "packliste", data: ., filename: "packliste-PL-2026-0117.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: 'packliste',
  data,
  filename: 'packliste-PL-2026-0117.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="packliste",
    data=data,
    filename="packliste-PL-2026-0117.pdf",
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'packliste',
    'data' => json_decode(file_get_contents('data.json'), true),
    'filename' => 'packliste-PL-2026-0117.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": "packliste",
		"data":     json.RawMessage(data),
		"filename": "packliste-PL-2026-0117.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 = "packliste",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
    filename = "packliste-PL-2026-0117.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": "packliste",
          "filename": "packliste-PL-2026-0117.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: "packliste",
  data: JSON.parse(File.read("data.json")),
  filename: "packliste-PL-2026-0117.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"]

Start rendering today

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