Skip to content

LiquidPDF

Till receipt on an 80 mm roll

A receipt for thermal printers whose page is exactly as long as the receipt: the template writes its own @page size from the number of items and VAT rates. Prices include VAT, and the VAT analysis per rate is worked out in the template with where and sum.

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

Rendered result

Rendered result: Till receipt on an 80 mm roll
PDF · 1 page · 27 KB ↓ download

What this example shows

  • The paper is 80 mm wide and as long as the receipt: the template writes @page { size: 80mm {{ length }}mm; } from the number of items and VAT rates, and preferCssPageSize: true lets that rule decide. Four items and two rates give 160 mm on one page.
  • Every row has a fixed height, and an item name too long for its line ends in an ellipsis instead of wrapping, which is what makes the length computable.
  • Prices include VAT, so the analysis works backwards: where: 'vat', rate.code | sum: 'total' collects the gross amount of a rate, and times: rate.rate | divided_by: divisor takes the VAT out of it, 3.13 of 18.80 at 20%.
  • barcode: type: 'code128' prints the receipt number for the returns desk, qrcode links to the digital copy, and dateAdd: 30, 'days' gives the last day for returns.
  • Times are shown in the template’s time zone Europe/London: 07:41:00Z in the data prints as 08:41.

Helpers used

Template folder

templates/receipt/template.htmlLiquid
{% comment %} The roll is as long as the receipt: fixed blocks plus 5 mm per item and 4.5 mm per VAT rate. {% endcomment %}
{% assign items = receipt.lines | size %}
{% assign rates = vat_rates | size | times: 4.5 %}
{% assign length = items | times: 5 | plus: 131 | plus: rates %}
<style>@page { size: 80mm {{ length }}mm; }</style>

<header class="shop">
  <h1>{{ shop.name }}</h1>
  <p>{{ shop.street }}, {{ shop.city }}</p>
  <p>VAT {{ shop.vat_id }}</p>
</header>

<dl class="meta">
  <div><dt>Date</dt><dd>{{ receipt.issued | date: 'd MMM yyyy, HH:mm' }}</dd></div>
  <div><dt>Receipt</dt><dd>{{ receipt.number }}</dd></div>
  <div><dt>Till</dt><dd>{{ receipt.till }} · {{ receipt.served_by }}</dd></div>
</dl>

<ul class="items">
  {% for line in receipt.lines %}
  <li>
    <span class="name">{{ line.qty }} × {{ line.name }}{% if line.qty > 1 %} @ {{ line.price | number: 2 }}{% endif %}</span>
    <span>{{ line.total | number: 2 }}</span>
    <span class="code">{{ line.vat }}</span>
  </li>
  {% endfor %}
</ul>

<div class="totals">
  <p class="grand"><span>Total</span><span>{{ receipt.lines | sum: 'total' | money }}</span></p>
  <p><span>{{ receipt.payment.method }} {{ receipt.payment.card }}</span><span>{{ receipt.payment.amount | money }}</span></p>
</div>

<table class="vat">
  <tr><th>VAT rate</th><th>Net</th><th>VAT</th><th>Gross</th></tr>
  {% for rate in vat_rates %}
  {% assign gross = receipt.lines | where: 'vat', rate.code | sum: 'total' %}
  {% assign divisor = rate.rate | plus: 100 %}
  {% assign vat = gross | times: rate.rate | divided_by: divisor %}
  <tr>
    <td>{{ rate.code }} {{ rate.rate }}%</td>
    <td>{{ gross | minus: vat | number: 2 }}</td>
    <td>{{ vat | number: 2 }}</td>
    <td>{{ gross | number: 2 }}</td>
  </tr>
  {% endfor %}
</table>

<div class="codes">
  <img class="barcode" src="{{ receipt.number | barcode: type: 'code128', height: 9 }}" alt="Receipt number">
  <div class="returns">
    <img src="{{ receipt.url | qrcode: size: 80 }}" alt="Digital receipt">
    <p>Returns and exchanges until {{ receipt.issued | dateAdd: 30, 'days' | date: 'd MMMM yyyy' }} with this receipt. Scan the code for a digital copy.</p>
  </div>
</div>
<p class="thanks">Thank you for visiting</p>
templates/receipt/style.cssCSS
body { margin: 0; font-family: 'IBM Plex Mono', monospace; font-size: 7.5pt; color: #111827; }
.shop { height: 22mm; box-sizing: border-box; padding-top: 3mm; border-bottom: 1px dashed #111827; text-align: center; }
.shop h1 { margin: 0 0 2mm; font-size: 12pt; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
.shop p { margin: 0; line-height: 4mm; }
.meta { height: 17mm; box-sizing: border-box; margin: 0; padding: 2mm 0; border-bottom: 1px dashed #111827; }
.meta div { display: flex; justify-content: space-between; line-height: 4.3mm; }
.meta dt, .meta dd { margin: 0; }
.items { margin: 0; padding: 2mm 0; list-style: none; border-bottom: 1px dashed #111827; }
.items li { display: flex; gap: 2mm; height: 5mm; line-height: 5mm; }
.items .name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.items .code { width: 3mm; }
.totals { height: 16mm; box-sizing: border-box; padding: 1.5mm 0; border-bottom: 1px dashed #111827; }
.totals p { display: flex; justify-content: space-between; margin: 0; line-height: 5mm; }
.totals .grand { font-size: 10.5pt; font-weight: 600; line-height: 7mm; }
.vat { width: 100%; margin: 2mm 0; border-collapse: collapse; }
.vat th, .vat td { height: 4.5mm; padding: 0; text-align: right; font-weight: 400; }
.vat th:first-child, .vat td:first-child { text-align: left; }
.vat th { color: #6b7280; }
.codes { height: 44mm; box-sizing: border-box; padding-top: 2mm; border-top: 1px dashed #111827; }
.barcode { display: block; height: 13mm; margin: 0 auto 3mm; }
.returns { display: flex; align-items: center; gap: 3mm; }
.returns img { width: 22mm; height: 22mm; }
.returns p { margin: 0; font-size: 6.5pt; line-height: 3.4mm; }
.thanks { height: 8mm; margin: 0; line-height: 8mm; text-align: center; font-weight: 600; }
templates/receipt/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&display=swap">
templates/receipt/settings.jsonJSON
{
  "pdf": {
    "metadata": {
      "title": "Receipt · {{ shop.name }}"
    }
  },
  "paper": {
    "width": "80mm",
    "height": "200mm"
  },
  "preferCssPageSize": true,
  "margin": {
    "top": "4mm",
    "right": "4mm",
    "bottom": "4mm",
    "left": "4mm"
  },
  "locale": "en-GB",
  "timezone": "Europe/London",
  "currency": "GBP"
}
templates/receipt/data/default.jsonJSON
{
  "shop": {
    "name": "Fennlor Coffee",
    "street": "1 Example Street",
    "city": "Exampletown",
    "vat_id": "GB000000000"
  },
  "receipt": {
    "number": "R-2026-004721",
    "issued": "2026-09-28T07:41:00Z",
    "till": 2,
    "served_by": "Jane",
    "url": "https://fennlor.example/r/R-2026-004721",
    "lines": [
      {
        "name": "Flat white",
        "qty": 2,
        "price": 3.4,
        "total": 6.8,
        "vat": "A"
      },
      {
        "name": "Almond croissant",
        "qty": 1,
        "price": 3.1,
        "total": 3.1,
        "vat": "Z"
      },
      {
        "name": "Coffee beans, house blend 250 g",
        "qty": 1,
        "price": 8.5,
        "total": 8.5,
        "vat": "Z"
      },
      {
        "name": "Enamel mug, sand",
        "qty": 1,
        "price": 12,
        "total": 12,
        "vat": "A"
      }
    ],
    "payment": {
      "method": "Card",
      "card": "•••• 0000",
      "amount": 30.4
    }
  },
  "vat_rates": [
    {
      "code": "A",
      "rate": 20
    },
    {
      "code": "Z",
      "rate": 0
    }
  ]
}

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