Skip to content

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

Rendered result: US invoice with sales tax
PDF · 1 page · 141 KB ↓ download

What this example shows

  • paper.format is Letter and the locale en-US, so money prints $11,278.46 and date('MMMM d, yyyy') prints September 28, 2026.
  • Sales tax applies only to lines with taxable: true: where('taxable', true) | sum('amount') is the $1,565.00 the 7.25% falls on, and round(2) fixes the tax at $113.46 before it is added.
  • payments is 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, and default('—') stands in for a missing PO number.

Helpers used

More on this use case: Invoice PDF API →

Template folder

templates/invoice-us/template.htmlJinja2
{% 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>
templates/invoice-us/style.cssCSS
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; }
templates/invoice-us/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/invoice-us/footer.htmlJinja2
<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>
templates/invoice-us/settings.jsonJSON
{
  "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"
  }
}
templates/invoice-us/data/default.jsonJSON
{
  "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.

render.shShell
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 @-
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: 'invoice-us',
  data,
  filename: 'invoice-INV-2026-0107.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="invoice-us",
    data=data,
    filename="invoice-INV-2026-0107.pdf",
)
print(render.status, render.download_url)
render.phpPHP
<?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;
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": "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)
}
Program.csC#
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")}");
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": "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 …
  }
}
render.rbRuby
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.