LiquidPDF
Gift voucher with Tailwind, as PDF and PNG
A voucher in the DIN long format, styled with Tailwind classes that are compiled at render time, arbitrary values such as h-[99mm] included. The expiry date is three years after the issue date through dateAdd, the message appears only when there is one, and the same template renders as a PNG for the mail.
Free plan, no card. You get your own copy in the editor, published and ready for the API.
Rendered result
What this example shows
tailwind: truecompiles the classes the markup uses at render time, arbitrary values such ash-[99mm]andtext-[40pt]included; there is no stylesheet.- The page is
210mm×99mm(DIN long) with zero margins, so the gradient runs to the edge. voucher.issued | dateAdd: 3, "years" | date: "dd.MM.yyyy"prints the expiry date18.09.2029.{% if voucher.message %}leaves the message line out when there is none, anddefault: "Sie"addresses a voucher without a recipient.output: "png"withsettings.image.clipSelector: "main"renders the same template as an image of just the voucher for the mail;outputalone would take a 1200 × 630 window with the voucher in its corner. Without either the answer is the PDF for printing.
Helpers used
Template folder
<main class="flex h-[99mm] w-[210mm] bg-gradient-to-br from-indigo-950 via-indigo-800 to-sky-600 text-white">
<section class="flex flex-1 flex-col justify-between p-[11mm]">
<p class="text-[8pt] font-semibold uppercase tracking-[0.25em] text-sky-200">Gutschein · {{ shop.name }}</p>
<div>
<p class="text-[40pt] font-bold leading-none">{{ voucher.amount | money }}</p>
<p class="mt-[3mm] text-[11pt] text-sky-100">für {{ voucher.recipient | default: "Sie" }}</p>
{% if voucher.message %}<p class="mt-[2mm] max-w-[110mm] text-[9.5pt] italic text-sky-100">„{{ voucher.message }}“</p>{% endif %}
</div>
<p class="text-[8pt] text-sky-200">
Ausgestellt am {{ voucher.issued | date: "dd.MM.yyyy" }} · einlösbar bis {{ voucher.issued | dateAdd: 3, "years" | date: "dd.MM.yyyy" }} · {{ shop.url }}
</p>
</section>
<aside class="flex w-[58mm] flex-col items-center justify-center gap-[3mm] bg-white text-slate-900">
<img class="h-[34mm] w-[34mm]" src="{{ voucher.redeem_url | qrcode: size: 120 }}" alt="Einlöse-Code">
<p class="font-mono text-[11pt] font-semibold tracking-widest">{{ voucher.code }}</p>
<p class="text-[7.5pt] text-slate-500">Code im Warenkorb eingeben</p>
</aside>
</main>{
"pdf": {
"metadata": {
"title": "Gutschein"
}
},
"paper": {
"width": "210mm",
"height": "99mm"
},
"margin": {
"top": "0",
"right": "0",
"bottom": "0",
"left": "0"
},
"tailwind": true,
"locale": "de-DE",
"timezone": "Europe/Berlin",
"currency": "EUR"
}{
"shop": {
"name": "Fennlor Studio",
"url": "shop.fennlor.example"
},
"voucher": {
"code": "FF-7K2M-9QXA",
"amount": 50,
"recipient": "Erika",
"message": "Alles Gute zum Geburtstag!",
"issued": "2026-09-18",
"redeem_url": "https://shop.fennlor.example/einloesen/FF-7K2M-9QXA"
}
}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: "gutschein", data: .}' 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: 'gutschein',
data,
});
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="gutschein",
data=data,
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'gutschein',
'data' => json_decode(file_get_contents('data.json'), true),
];
$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": "gutschein",
"data": json.RawMessage(data),
})
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 = "gutschein",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
};
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": "gutschein",
"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: "gutschein",
data: JSON.parse(File.read("data.json")),
}
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.
