Skip to content

HandlebarsPDF

Confidential payslip with a password

Two post-processing steps in one request: a red CONFIDENTIAL mark on every page, then AES-256 encryption with the employee’s password, allowing printing but not copying or editing. The password never reaches the render log. The download opens with the password formfeed. One unit for the page plus half a unit per step.

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

Rendered result

Rendered result: Confidential payslip with a password
PDF · 1 page · 110 KB ↓ downloadOpens with the password formfeed

What this example shows

  • Two post-processing steps in one request, applied in a fixed order: the watermark first, the password last, because an encrypted PDF cannot be changed any more.
  • password.user opens the document, and permissions: ["print"] allows printing while copying and editing stay locked. The example file opens with formfeed; in production it is the employee’s own password.
  • The password never reaches the render log.
  • Handlebars nests helpers in brackets: {{money (sum earnings "amount")}} adds the earnings and formats the result.
  • With a live key, one unit for the PDF (up to five pages) plus half a unit per post-processing step: two units for this request. Post-processing is part of the Starter plan and above.

Helpers used

Template folder

templates/payslip/template.htmlHandlebars
<header>
  <div>
    <p class="brand">{{company.name}}</p>
    <p class="muted">{{company.street}}, {{company.city}}</p>
  </div>
  <div class="period">
    <h1>Payslip</h1>
    <p class="muted">{{date period.month "MMMM yyyy"}} · paid on {{date period.paid_on "d MMM yyyy"}}</p>
  </div>
</header>

<section class="employee">
  <strong>{{employee.name}}</strong>, {{employee.role}}
  <span class="muted">· No. {{employee.number}} · Tax ID {{employee.tax_id}}</span>
</section>

<table>
  <thead><tr><th>Earnings</th><th class="num">Amount</th></tr></thead>
  <tbody>
    {{#each earnings}}
    <tr><td>{{label}}</td><td class="num">{{money amount}}</td></tr>
    {{/each}}
  </tbody>
  <tfoot><tr><td>Gross pay</td><td class="num">{{money (sum earnings "amount")}}</td></tr></tfoot>
</table>

<table>
  <thead><tr><th>Deductions</th><th class="num">Amount</th></tr></thead>
  <tbody>
    {{#each deductions}}
    <tr><td>{{label}}</td><td class="num">{{money amount}}</td></tr>
    {{/each}}
  </tbody>
  <tfoot><tr><td>Total deductions</td><td class="num">{{money (sum deductions "amount")}}</td></tr></tfoot>
</table>

<p class="net">Net pay <strong>{{money net}}</strong></p>
templates/payslip/style.cssCSS
body { font-family: 'Inter', sans-serif; font-size: 10pt; color: #1e293b; }
header { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 6mm; border-bottom: 1px solid #e2e8f0; }
.brand { margin: 0; font-size: 13pt; font-weight: 700; color: #312e81; }
.muted { margin: 1mm 0 0; color: #64748b; }
.period { text-align: right; }
h1 { margin: 0; font-size: 20pt; }
.employee { margin: 8mm 0 2mm; }
table { width: 100%; margin-top: 6mm; border-collapse: collapse; }
th { padding: 2mm 1.5mm; border-bottom: 1.5px solid #1e293b; text-align: left; font-size: 8pt; text-transform: uppercase; color: #64748b; }
td { padding: 2mm 1.5mm; border-bottom: 1px solid #e2e8f0; }
tfoot td { border-top: 1px solid #1e293b; border-bottom: 0; font-weight: 600; }
.num { text-align: right !important; white-space: nowrap; }
.net { display: flex; justify-content: space-between; margin-top: 10mm; padding: 4mm; border-radius: 2mm; background: #eef2ff; font-size: 13pt; }
templates/payslip/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/payslip/settings.jsonJSON
{
  "pdf": {
    "metadata": {
      "title": "Payslip"
    }
  },
  "paper": {
    "format": "A4"
  },
  "margin": {
    "top": "20mm",
    "right": "18mm",
    "bottom": "20mm",
    "left": "18mm"
  },
  "locale": "en-GB",
  "currency": "EUR"
}
templates/payslip/data/default.jsonJSON
{
  "company": {
    "name": "Fennlor Studio GmbH",
    "street": "Musterstraße 1",
    "city": "12345 Musterstadt"
  },
  "employee": {
    "name": "Max Mustermann",
    "number": "E-0417",
    "role": "Senior engineer",
    "tax_id": "00 000 000 000"
  },
  "period": {
    "month": "2026-09-01",
    "paid_on": "2026-09-30"
  },
  "earnings": [
    {
      "label": "Base salary",
      "amount": 5800
    },
    {
      "label": "On-call allowance",
      "amount": 320
    }
  ],
  "deductions": [
    {
      "label": "Income tax",
      "amount": 1236.5
    },
    {
      "label": "Solidarity surcharge",
      "amount": 0
    },
    {
      "label": "Pension insurance",
      "amount": 569.16
    },
    {
      "label": "Health insurance",
      "amount": 520.2
    },
    {
      "label": "Unemployment insurance",
      "amount": 79.56
    },
    {
      "label": "Long-term care insurance",
      "amount": 110.16
    }
  ],
  "net": 3604.42
}

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. Post-processing needs the Starter plan or above: the Free plan answers 403, with a test key as well. Renders with a key that starts with ff_test_ are not metered.

render.shShell
jq '{template: "payslip", data: ., filename: "payslip-E-0417-2026-09.pdf", post: {watermark: {text: "CONFIDENTIAL", color: "#b91c1c", opacity: 0.12}, password: {user: "formfeed", permissions: ["print"]}}}' data.json \
  | curl https://api-eu.formfeed.dev/v1/renders \
      -H "Authorization: Bearer ff_live_..." \
      -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: 'payslip',
  data,
  filename: 'payslip-E-0417-2026-09.pdf',
  post: {
    watermark: { text: 'CONFIDENTIAL', color: '#b91c1c', opacity: 0.12 },
    password: { user: 'formfeed', permissions: ['print'] },
  },
});
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="payslip",
    data=data,
    filename="payslip-E-0417-2026-09.pdf",
    post={
        "watermark": {"text": "CONFIDENTIAL", "color": "#b91c1c", "opacity": 0.12},
        "password": {"user": "formfeed", "permissions": ["print"]},
    },
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'payslip',
    'data' => json_decode(file_get_contents('data.json'), true),
    'filename' => 'payslip-E-0417-2026-09.pdf',
    'post' => [
        'watermark' => ['text' => 'CONFIDENTIAL', 'color' => '#b91c1c', 'opacity' => 0.12],
        'password' => ['user' => 'formfeed', 'permissions' => ['print']],
    ],
];

$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": "payslip",
		"data":     json.RawMessage(data),
		"filename": "payslip-E-0417-2026-09.pdf",
		"post": map[string]any{
			"watermark": map[string]any{"text": "CONFIDENTIAL", "color": "#b91c1c", "opacity": 0.12},
			"password":  map[string]any{"user": "formfeed", "permissions": []any{"print"}},
		},
	})

	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 = "payslip",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
    filename = "payslip-E-0417-2026-09.pdf",
    post = new {
        watermark = new { text = "CONFIDENTIAL", color = "#b91c1c", opacity = 0.12 },
        password = new { user = "formfeed", permissions = new[] { "print" } },
    },
};

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": "payslip",
          "filename": "payslip-E-0417-2026-09.pdf",
          "post": {"watermark":{"text":"CONFIDENTIAL","color":"#b91c1c","opacity":0.12},"password":{"user":"formfeed","permissions":["print"]}},
          "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: "payslip",
  data: JSON.parse(File.read("data.json")),
  filename: "payslip-E-0417-2026-09.pdf",
  post: {
    watermark: { text: "CONFIDENTIAL", color: "#b91c1c", opacity: 0.12 },
    password: { user: "formfeed", permissions: ["print"] },
  },
}

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.