Skip to content

LiquidPDF

Trade price list with EAN barcodes

A product feed goes in unsorted. groupBy makes a section per category, sortBy orders each by name, min and max give the price range in the heading, and every row carries its EAN-13 barcode. A category never breaks across a page.

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

Rendered result

PDF · 2 pages · 272 KB ↓ download

What this example shows

  • products | groupBy: 'category' makes a section per category in the order the feed names them, and sortBy: 'name' sorts each section.
  • min: 'price' and max: 'price' put each category’s price range into its heading: desk lamps run from £49.50 to £118.00.
  • Every row carries its EAN-13 code as a barcode, barcode: type: 'ean13', with the digits in their usual groups. The sample codes start with 20, a prefix GS1 keeps for numbers that are only used inside one company.
  • multiply: product.pack gives the price per pack beside the unit price: ten bulbs at £3.20 make £32.00.
  • A category has break-inside: avoid, and the footer repeats the validity date from the data on every page.

Helpers used

Template folder

templates/price-list/template.htmlLiquid
<header>
  <div>
    <p class="brand">{{ company.name }}</p>
    <h1>{{ list.title }}</h1>
    <p class="muted">{{ list.season }} · {{ products | size }} products</p>
  </div>
  <p class="contact">{{ company.street }}<br>{{ company.city }}<br>{{ company.email }}</p>
</header>

{% assign categories = products | groupBy: 'category' %}
{% for category in categories %}
{% assign lowest = category.items | min: 'price' %}
{% assign highest = category.items | max: 'price' %}
{% assign sorted = category.items | sortBy: 'name' %}
<section class="category">
  <h2>{{ category.key }} <span>{{ category.items | length }} products · {{ lowest | money }} to {{ highest | money }}</span></h2>
  <table>
    <thead><tr><th>Item</th><th>EAN</th><th class="num">Pack</th><th class="num">Unit</th><th class="num">Per pack</th></tr></thead>
    <tbody>
      {% for product in sorted %}
      <tr>
        <td><strong>{{ product.name }}</strong><br><span class="muted">{{ product.sku }}</span></td>
        <td><img class="ean" src="{{ product.ean | barcode: type: 'ean13', height: 7 }}" alt="EAN {{ product.ean }}"></td>
        <td class="num">{{ product.pack }}</td>
        <td class="num">{{ product.price | money }}</td>
        <td class="num">{{ product.price | multiply: product.pack | money }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% endfor %}

<ul class="terms">
  {% for term in list.terms %}
  <li>{{ term }}</li>
  {% endfor %}
</ul>
templates/price-list/style.cssCSS
body { font-family: 'Inter', sans-serif; font-size: 9pt; color: #1e293b; }
header { display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: 6mm; border-bottom: 2px solid #0f766e; }
.brand { margin: 0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #0f766e; }
h1 { margin: 1mm 0; font-size: 22pt; color: #0f172a; }
.muted { margin: 0; color: #64748b; }
.contact { margin: 0; text-align: right; font-size: 8pt; line-height: 1.5; color: #475569; }
.category { margin-top: 8mm; break-inside: avoid; }
h2 { margin: 0 0 2mm; font-size: 12pt; color: #0f172a; }
h2 span { margin-left: 2mm; font-size: 8.5pt; font-weight: 400; color: #64748b; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th { padding: 1.5mm; border-bottom: 1.5px solid #0f172a; text-align: left; font-size: 7pt; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
th:nth-child(1) { width: 40%; }
th:nth-child(2) { width: 27%; }
th:nth-child(3) { width: 9%; }
td { padding: 1.6mm 1.5mm; border-bottom: 1px solid #e2e8f0; vertical-align: middle; }
.num { text-align: right !important; white-space: nowrap; }
.ean { display: block; height: 10mm; }
.terms { margin: 8mm 0 0; padding-left: 4mm; font-size: 8pt; line-height: 1.6; color: #475569; }
templates/price-list/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/price-list/footer.htmlLiquid
<div style="width:100%;display:flex;justify-content:space-between;font-size:7.5px;color:#64748b">
  <span>Trade prices in GBP excluding VAT, valid until {{ list.valid_until | date: 'd MMMM yyyy' }}</span>
  <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>
templates/price-list/settings.jsonJSON
{
  "pdf": {
    "metadata": {
      "title": "{{ list.title }}"
    }
  },
  "paper": {
    "format": "A4"
  },
  "margin": {
    "top": "18mm",
    "right": "16mm",
    "bottom": "20mm",
    "left": "16mm"
  },
  "locale": "en-GB",
  "timezone": "Europe/London",
  "currency": "GBP",
  "footer": {
    "height": "12mm"
  }
}
templates/price-list/data/default.jsonJSON
{
  "company": {
    "name": "Fennlor Studio Ltd",
    "street": "1 Example Street",
    "city": "Exampletown",
    "email": "trade@fennlor.example"
  },
  "list": {
    "title": "Trade price list",
    "season": "Autumn/Winter 2026",
    "valid_until": "2027-03-31",
    "terms": [
      "Minimum order £250; free delivery within the UK from £750.",
      "Lamps ship in packs of two, shades in packs of four and bulbs in packs of ten.",
      "Prices exclude VAT and may change with thirty days’ notice."
    ]
  },
  "products": [
    {
      "sku": "FF-1030",
      "name": "Task lamp Line, black",
      "category": "Desk lamps",
      "ean": "2000000010304",
      "price": 92,
      "pack": 2
    },
    {
      "sku": "FF-1001",
      "name": "Desk lamp Arc, black",
      "category": "Desk lamps",
      "ean": "2000000010014",
      "price": 64,
      "pack": 2
    },
    {
      "sku": "FF-1020",
      "name": "Reading lamp Fold, brass",
      "category": "Desk lamps",
      "ean": "2000000010205",
      "price": 118,
      "pack": 2
    },
    {
      "sku": "FF-1002",
      "name": "Desk lamp Arc, white",
      "category": "Desk lamps",
      "ean": "2000000010021",
      "price": 64,
      "pack": 2
    },
    {
      "sku": "FF-1010",
      "name": "Clamp lamp Studio, graphite",
      "category": "Desk lamps",
      "ean": "2000000010106",
      "price": 49.5,
      "pack": 2
    },
    {
      "sku": "FF-2010",
      "name": "Pendant Cone, smoked glass",
      "category": "Pendant lights",
      "ean": "2000000020105",
      "price": 138,
      "pack": 1
    },
    {
      "sku": "FF-2001",
      "name": "Pendant Dome 30 cm, sand",
      "category": "Pendant lights",
      "ean": "2000000020013",
      "price": 76,
      "pack": 1
    },
    {
      "sku": "FF-2020",
      "name": "Cluster pendant, three lights",
      "category": "Pendant lights",
      "ean": "2000000020204",
      "price": 229,
      "pack": 1
    },
    {
      "sku": "FF-2002",
      "name": "Pendant Dome 45 cm, sand",
      "category": "Pendant lights",
      "ean": "2000000020020",
      "price": 104,
      "pack": 1
    },
    {
      "sku": "FF-3002",
      "name": "Linen shade 35 cm, natural",
      "category": "Shades",
      "ean": "2000000030029",
      "price": 16.8,
      "pack": 4
    },
    {
      "sku": "FF-3020",
      "name": "Rattan shade 30 cm",
      "category": "Shades",
      "ean": "2000000030203",
      "price": 21.5,
      "pack": 4
    },
    {
      "sku": "FF-3001",
      "name": "Linen shade 25 cm, natural",
      "category": "Shades",
      "ean": "2000000030012",
      "price": 12.4,
      "pack": 4
    },
    {
      "sku": "FF-3010",
      "name": "Paper shade 40 cm, white",
      "category": "Shades",
      "ean": "2000000030104",
      "price": 9.9,
      "pack": 4
    },
    {
      "sku": "FF-4010",
      "name": "LED filament G95, 470 lm",
      "category": "Bulbs",
      "ean": "2000000040103",
      "price": 5.6,
      "pack": 10
    },
    {
      "sku": "FF-4001",
      "name": "LED bulb E27, 806 lm, warm white",
      "category": "Bulbs",
      "ean": "2000000040011",
      "price": 3.2,
      "pack": 10
    },
    {
      "sku": "FF-4030",
      "name": "LED spot GU10, 345 lm",
      "category": "Bulbs",
      "ean": "2000000040301",
      "price": 2.4,
      "pack": 10
    },
    {
      "sku": "FF-4002",
      "name": "LED bulb E27, 1521 lm, warm white",
      "category": "Bulbs",
      "ean": "2000000040028",
      "price": 4.9,
      "pack": 10
    },
    {
      "sku": "FF-4020",
      "name": "LED candle E14, 470 lm",
      "category": "Bulbs",
      "ean": "2000000040202",
      "price": 2.9,
      "pack": 10
    }
  ]
}

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