HandlebarsPDF
Course certificate in Handlebars
A landscape certificate with a verification QR code, rendered one at a time as PNG or for a whole course as a batch that ends in a zip. Handlebars takes options as hash arguments: {{qrcode verify_url size=96}}.
Free plan, no card. You get your own copy in the editor, published and ready for the API.
Rendered result
What this example shows
- A4 landscape with zero margins: the frame is plain CSS on a box exactly as tall as the page (
210mm), so it prints to the edge. {{qrcode verify_url size=96}}turns the verification link into a QR code;{{upper id}}prints the certificate number asCERT-8841.head.htmlloads Inter and Playfair Display from Google Fonts; the render-worker fetches them once and serves them from its cache afterwards.output: "png"renders the same template as an image for sharing, andsettings.imagegives the image the page’s size,1123×794pixels: an image is otherwise taken from a window of 1200 × 630, which would cut off the signature and the QR code. Withoutoutputthe answer is the PDF shown here.- A whole course goes through
renders.batchwithzip: true: one job, one archive at the end.
Helpers used
Template folder
<main class="certificate">
<p class="eyebrow">Certificate of completion</p>
<h1>{{name}}</h1>
<p class="lead">
completed <strong>{{course}}</strong> ({{hours}} hours)
on {{date completed_on "d MMMM yyyy"}}.
</p>
<footer>
<div class="signature">
<span class="line"></span>
{{instructor}}, instructor
</div>
<figure class="verify">
<img src="{{qrcode verify_url size=96}}" alt="Verification code">
<figcaption>{{upper id}}</figcaption>
</figure>
</footer>
</main>.certificate {
height: 210mm;
padding: 22mm 28mm;
box-sizing: border-box;
display: flex;
flex-direction: column;
border: 6mm solid #312e81;
outline: 1px solid #a5b4fc;
outline-offset: -10mm;
background: linear-gradient(135deg, #ffffff 60%, #eef2ff);
font-family: 'Inter', sans-serif;
color: #0f172a;
}
.eyebrow { margin: 0; letter-spacing: 0.25em; text-transform: uppercase; color: #4f46e5; font-weight: 600; }
h1 { margin: 12mm 0 4mm; font-family: 'Playfair Display', serif; font-size: 48pt; }
.lead { max-width: 190mm; font-size: 16pt; line-height: 1.5; color: #334155; }
footer { margin-top: auto; display: flex; justify-content: space-between; align-items: end; }
.signature { font-size: 11pt; color: #334155; }
.signature .line { display: block; width: 70mm; border-top: 1px solid #0f172a; margin-bottom: 2mm; }
.verify { margin: 0; text-align: center; font-size: 8pt; color: #64748b; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Playfair+Display:wght@700&display=swap">{
"pdf": {
"metadata": {
"title": "Certificate · {{name}}"
}
},
"paper": {
"format": "A4",
"landscape": true
},
"margin": {
"top": "0",
"right": "0",
"bottom": "0",
"left": "0"
},
"locale": "en-GB"
}{
"name": "Jane Doe",
"course": "Analytical Engines 101",
"completed_on": "2026-09-10",
"hours": 16,
"instructor": "John Doe",
"id": "cert-8841",
"verify_url": "https://academy.example/verify/cert-8841"
}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: "certificate", data: ., output: "png", settings: {image: {width: 1123, height: 794}}}' 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: 'certificate',
data,
output: 'png',
settings: {
image: { width: 1123, height: 794 },
},
});
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="certificate",
data=data,
output="png",
settings={
"image": {"width": 1123, "height": 794},
},
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'certificate',
'data' => json_decode(file_get_contents('data.json'), true),
'output' => 'png',
'settings' => [
'image' => ['width' => 1123, 'height' => 794],
],
];
$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": "certificate",
"data": json.RawMessage(data),
"output": "png",
"settings": map[string]any{
"image": map[string]any{"width": 1123, "height": 794},
},
})
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 = "certificate",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
output = "png",
settings = new {
image = new { width = 1123, height = 794 },
},
};
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": "certificate",
"output": "png",
"settings": {"image":{"width":1123,"height":794}},
"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: "certificate",
data: JSON.parse(File.read("data.json")),
output: "png",
settings: {
image: { width: 1123, height: 794 },
},
}
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
// The whole course at once: one job, one zip at the end
const job = await formfeed.renders.batch({
template: 'certificate',
zip: true,
items: attendees.map((attendee) => ({ data: attendee })),
});
const done = await formfeed.jobs.waitFor(job.id);
console.log(`${done.succeeded}/${done.total} rendered`, done.zip_url);Start rendering today
The free plan includes 100 units a month and free test renders. Change plans when you need more.
