HandlebarsPDF
German certificate of attendance (Teilnahmebescheinigung)
A certificate that lists what was taught: the course modules with their teaching units, the total as a number and spelled out in German by numToWords, and a QR code to verify the certificate number. Full-bleed A4 with zero margins, rendered for a whole course as one batch.
Free plan, no card. You get your own copy in the editor, published and ready for the API.
Rendered result
What this example shows
{{#each course.modules}}lists what was taught;sum course.modules "units"adds the units to 40.numToWords (sum …) "de"spells the total out as “vierzig”, which certificates of this kind usually do.{{qrcode certificate.verify_url size=84}}encodes the verification link next to the certificate number.- Zero margins and a box exactly
297mmhigh let the coloured bar run to the paper’s edge;margin-top: autopins the signature to the bottom. - Both dates share one range:
d. MMMMfor the first day,d. MMMM yyyyfor the last.
Helpers used
Template folder
<main class="sheet">
<p class="eyebrow">Teilnahmebescheinigung</p>
<h1>{{participant.name}}</h1>
<p class="lead">
hat vom {{date course.from "d. MMMM"}} bis {{date course.until "d. MMMM yyyy"}} an der Weiterbildung
<strong>{{course.title}}</strong> teilgenommen.
</p>
<table>
<thead><tr><th>Inhalte</th><th class="num">Unterrichtseinheiten</th></tr></thead>
<tbody>
{{#each course.modules}}
<tr><td>{{title}}</td><td class="num">{{number units 0}}</td></tr>
{{/each}}
</tbody>
<tfoot>
<tr><td>Gesamt ({{numToWords (sum course.modules "units") "de"}} Unterrichtseinheiten zu je 45 Minuten)</td><td class="num">{{number (sum course.modules "units") 0}}</td></tr>
</tfoot>
</table>
<footer>
<div class="signature"><span></span>{{course.instructor}}<br><small>Kursleitung, {{provider.name}}</small></div>
<figure>
<img src="{{qrcode certificate.verify_url size=84}}" alt="Prüfcode">
<figcaption>Nr. {{certificate.id}}<br>{{provider.city}}, {{date certificate.issued "d. MMMM yyyy"}}</figcaption>
</figure>
</footer>
</main>body { margin: 0; font-family: 'Inter', sans-serif; color: #0f172a; }
.sheet {
height: 297mm;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 32mm 26mm 24mm;
border-top: 12mm solid #312e81;
}
.eyebrow { margin: 0; font-weight: 600; letter-spacing: 0.22em; text-transform: uppercase; color: #4f46e5; }
h1 { margin: 10mm 0 4mm; font-size: 32pt; }
.lead { margin: 0 0 12mm; font-size: 13pt; line-height: 1.55; color: #334155; }
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
th { padding: 2mm 0; border-bottom: 1.5px solid #0f172a; text-align: left; font-size: 8pt; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
td { padding: 2.6mm 0; border-bottom: 1px solid #e2e8f0; }
tfoot td { border-top: 1.5px solid #0f172a; border-bottom: 0; font-weight: 600; }
.num { text-align: right !important; }
footer { margin-top: auto; display: flex; justify-content: space-between; align-items: flex-end; }
.signature { width: 72mm; font-size: 10pt; }
.signature span { display: block; margin-bottom: 2mm; border-top: 1px solid #0f172a; }
small, figcaption { font-size: 8pt; color: #64748b; }
figure { margin: 0; display: flex; align-items: flex-end; gap: 3mm; text-align: right; flex-direction: row-reverse; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">{
"pdf": {
"metadata": {
"title": "Teilnahmebescheinigung {{participant.name}}"
}
},
"paper": {
"format": "A4"
},
"margin": {
"top": "0",
"right": "0",
"bottom": "0",
"left": "0"
},
"locale": "de-DE",
"timezone": "Europe/Berlin"
}{
"provider": {
"name": "Fennlor Akademie",
"city": "Musterstadt"
},
"participant": {
"name": "Max Mustermann"
},
"course": {
"title": "Dokumentenautomatisierung mit HTML und CSS",
"from": "2026-09-07",
"until": "2026-09-11",
"instructor": "Erika Mustermann",
"modules": [
{
"title": "Print-CSS: Seitenformate, Ränder, Umbrüche",
"units": 8
},
{
"title": "Vorlagensprachen: Jinja2, Liquid, Handlebars",
"units": 8
},
{
"title": "Rechnungen, GiroCode und E-Rechnung",
"units": 12
},
{
"title": "Tests, Versionen und Release Channels",
"units": 6
},
{
"title": "Praxisprojekt mit Abschlusspräsentation",
"units": 6
}
]
},
"certificate": {
"id": "TB-2026-0193",
"issued": "2026-09-11",
"verify_url": "https://akademie.example/pruefen/TB-2026-0193"
}
}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.
jq '{template: "teilnahmebescheinigung", data: ., filename: "bescheinigung-max-mustermann.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: 'teilnahmebescheinigung',
data,
filename: 'bescheinigung-max-mustermann.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="teilnahmebescheinigung",
data=data,
filename="bescheinigung-max-mustermann.pdf",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'teilnahmebescheinigung',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'bescheinigung-max-mustermann.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": "teilnahmebescheinigung",
"data": json.RawMessage(data),
"filename": "bescheinigung-max-mustermann.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 = "teilnahmebescheinigung",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "bescheinigung-max-mustermann.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": "teilnahmebescheinigung",
"filename": "bescheinigung-max-mustermann.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: "teilnahmebescheinigung",
data: JSON.parse(File.read("data.json")),
filename: "bescheinigung-max-mustermann.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"]More ways to call it
// everyone who attended, as one job with a zip at the end
const job = await formfeed.renders.batch({
template: 'teilnahmebescheinigung',
zip: true,
items: participants.map((participant) => ({
data: { provider, course, participant, certificate: certificateFor(participant) },
filename: `bescheinigung-${participant.name}.pdf`,
})),
});Start rendering today
The free plan includes 100 units a month and free test renders. Change plans when you need more.
