Jinja2PDF
US invoice with sales tax
Letter paper, dollars and US dates from the template’s en-US locale. Sales tax applies only to the lines marked taxable, a deposit reduces the balance, the status badge and the pay-online QR code follow from what is left to pay.
Free plan, no card. You get your own copy in the editor, published and ready for the API.
Rendered result
What this example shows
paper.formatisLetterand the localeen-US, somoneyprints$11,278.46anddate('MMMM d, yyyy')printsSeptember 28, 2026.- Sales tax applies only to lines with
taxable: true:where('taxable', true) | sum('amount')is the$1,565.00the 7.25% falls on, andround(2)fixes the tax at$113.46before it is added. paymentsis a list, so a deposit and any later part-payment each get a line under the total, and the balance due is what is left:$8,278.46.- The badge and the QR code follow from the balance: “Paid” at zero, “Partially paid” after a deposit, and no pay-online code once nothing is due.
dateAdd(invoice.terms_days, 'days')turns Net 30 into the due date,October 28, 2026, anddefault('—')stands in for a missing PO number.
Helpers used
Template folder
{% set subtotal = invoice.lines | sum('amount') %}
{% set taxable = invoice.lines | where('taxable', true) | sum('amount') %}
{% set tax = (taxable * invoice.tax_rate / 100) | round(2) %}
{% set total = subtotal + tax %}
{% set paid = invoice.payments | sum('amount') %}
{% set balance = total - paid %}
<header class="top">
<div>
<p class="brand">{{ company.name }}</p>
<p class="muted">{{ company.street }}<br>{{ company.city }}</p>
</div>
<div class="title">
<h1>Invoice</h1>
<p class="muted">{{ invoice.number }}</p>
{% if balance <= 0 %}<p class="status paid">Paid</p>{% elif paid > 0 %}<p class="status partial">Partially paid</p>{% endif %}
</div>
</header>
<section class="parties">
<div>
<h2>Bill to</h2>
<p><strong>{{ customer.name }}</strong><br>Attn: {{ customer.contact }}<br>{{ customer.street }}<br>{{ customer.city }}</p>
</div>
<dl>
<div><dt>Invoice date</dt><dd>{{ invoice.date | date('MMMM d, yyyy') }}</dd></div>
<div><dt>Terms</dt><dd>Net {{ invoice.terms_days }}</dd></div>
<div><dt>Due date</dt><dd>{{ invoice.date | dateAdd(invoice.terms_days, 'days') | date('MMMM d, yyyy') }}</dd></div>
<div><dt>PO number</dt><dd>{{ invoice.po | default('—') }}</dd></div>
</dl>
</section>
<table class="lines">
<thead>
<tr><th>Description</th><th class="num">Qty</th><th class="num">Rate</th><th class="num">Amount</th></tr>
</thead>
<tbody>
{% for line in invoice.lines %}
<tr>
<td>{{ line.description }}{% if line.taxable %} <span class="taxed">T</span>{% endif %}</td>
<td class="num">{{ line.qty | number }}</td>
<td class="num">{{ line.rate | money }}</td>
<td class="num">{{ line.amount | money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="summary">
<div class="pay">
{% if balance > 0 %}
<img src="{{ qrcode(invoice.pay_url, { size: 96 }) }}" alt="Pay online">
<p><strong>Pay online</strong><br>Scan the code or open<br>{{ invoice.pay_url | replace('https://', '') }}</p>
{% endif %}
</div>
<table class="totals">
<tr><td>Subtotal</td><td class="num">{{ subtotal | money }}</td></tr>
<tr><td>Sales tax {{ invoice.tax_rate }}% on {{ taxable | money }} <span class="taxed">T</span></td><td class="num">{{ tax | money }}</td></tr>
<tr class="total"><td>Total</td><td class="num">{{ total | money }}</td></tr>
{% for payment in invoice.payments %}
<tr><td>Paid {{ payment.date | date('MMM d') }}, {{ payment.method }}</td><td class="num">−{{ payment.amount | money }}</td></tr>
{% endfor %}
<tr class="due"><td>Balance due</td><td class="num">{{ balance | money }}</td></tr>
</table>
</section>
<p class="notes">{{ invoice.notes }}</p>body { font-family: 'Inter', sans-serif; font-size: 9.5pt; color: #1e293b; }
.top { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 7mm; border-bottom: 2px solid #0f172a; }
.brand { margin: 0; font-size: 15pt; font-weight: 700; color: #0f172a; }
.muted { margin: 1mm 0 0; color: #64748b; line-height: 1.5; }
.title { text-align: right; }
h1 { margin: 0; font-size: 24pt; letter-spacing: -0.01em; }
.status { display: inline-block; margin: 2mm 0 0; padding: 1mm 2.5mm; border-radius: 1mm; font-size: 7.5pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.status.paid { background: #dcfce7; color: #166534; }
.status.partial { background: #fef3c7; color: #92400e; }
.parties { display: flex; justify-content: space-between; margin: 8mm 0; }
h2 { margin: 0 0 1.5mm; font-size: 7.5pt; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
.parties p { margin: 0; line-height: 1.5; }
dl { margin: 0; }
dl div { display: flex; justify-content: space-between; gap: 10mm; padding: 0.6mm 0; }
dt { color: #64748b; }
dd { margin: 0; font-weight: 600; }
table { border-collapse: collapse; }
.lines { width: 100%; }
.lines th { padding: 2mm 1.5mm; border-bottom: 1.5px solid #0f172a; text-align: left; font-size: 7.5pt; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
.lines td { padding: 2.4mm 1.5mm; border-bottom: 1px solid #e2e8f0; }
.num { text-align: right !important; white-space: nowrap; }
.taxed { display: inline-block; padding: 0 1mm; border: 1px solid #94a3b8; border-radius: 0.8mm; font-size: 6.5pt; color: #475569; }
.summary { display: flex; justify-content: space-between; align-items: flex-start; gap: 10mm; margin-top: 6mm; break-inside: avoid; }
.pay { display: flex; align-items: center; gap: 4mm; }
.pay img { width: 25mm; height: 25mm; }
.pay p { margin: 0; font-size: 8pt; line-height: 1.5; color: #475569; }
.totals { width: 88mm; }
.totals td { padding: 1.3mm 1.5mm; }
.totals .total td { border-top: 1.5px solid #0f172a; font-weight: 700; }
.totals .due td { border-top: 1px solid #cbd5e1; font-size: 12pt; font-weight: 700; color: #0f172a; }
.notes { margin-top: 10mm; font-size: 8pt; color: #64748b; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"><div style="width:100%;display:flex;justify-content:space-between;font-size:7.5px;color:#64748b">
<span>{{ company.name }} · EIN {{ company.ein }} · {{ company.email }}</span>
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>{
"pdf": {
"metadata": {
"title": "Invoice {{ invoice.number }}"
}
},
"paper": {
"format": "Letter"
},
"margin": {
"top": "0.6in",
"right": "0.7in",
"bottom": "0.8in",
"left": "0.7in"
},
"locale": "en-US",
"timezone": "America/New_York",
"currency": "USD",
"footer": {
"height": "0.5in"
}
}{
"company": {
"name": "Fennlor Studio LLC",
"street": "100 Example Street, Suite 200",
"city": "Exampletown, NY 00000",
"email": "billing@fennlor.example",
"ein": "00-0000000"
},
"customer": {
"name": "Olvarest Inc.",
"contact": "John Doe",
"street": "200 Example Avenue",
"city": "Exampleville, NY 00000"
},
"invoice": {
"number": "INV-2026-0107",
"date": "2026-09-28",
"terms_days": 30,
"po": "PO-55120",
"tax_rate": 7.25,
"pay_url": "https://pay.fennlor.example/i/INV-2026-0107",
"lines": [
{
"description": "Template design, fixed fee",
"qty": 1,
"rate": 4800,
"amount": 4800,
"taxable": false
},
{
"description": "Implementation, hours",
"qty": 32,
"rate": 150,
"amount": 4800,
"taxable": false
},
{
"description": "Thermal label printer",
"qty": 2,
"rate": 649,
"amount": 1298,
"taxable": true
},
{
"description": "Label rolls, 4 × 6 in, case of 12",
"qty": 3,
"rate": 89,
"amount": 267,
"taxable": true
}
],
"payments": [
{
"date": "2026-09-02",
"method": "ACH",
"amount": 3000
}
],
"notes": "Please pay by ACH or card through the link above and quote the invoice number. Thank you for your business."
}
}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: "invoice-us", data: ., filename: "invoice-INV-2026-0107.pdf"}' 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: 'invoice-us',
data,
filename: 'invoice-INV-2026-0107.pdf',
});
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="invoice-us",
data=data,
filename="invoice-INV-2026-0107.pdf",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'invoice-us',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'invoice-INV-2026-0107.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;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": "invoice-us",
"data": json.RawMessage(data),
"filename": "invoice-INV-2026-0107.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)
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var body = new
{
template = "invoice-us",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "invoice-INV-2026-0107.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")}");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": "invoice-us",
"filename": "invoice-INV-2026-0107.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 …
}
}require "json"
require "net/http"
body = {
template: "invoice-us",
data: JSON.parse(File.read("data.json")),
filename: "invoice-INV-2026-0107.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.
