Generate contract PDFs from HTML templates

Generate contract PDFs from HTML templates

Generate contract PDFs from HTML templates with placeholders, unbroken clauses, signature blocks, "Page X of Y" footers and versioned filenames.

Service agreements, NDAs, statements of work and rental contracts are mostly boilerplate with a few variable fields, such as parties, dates, amounts and scope. Keep the contract as an HTML template and the legal text lives in one place. Your app fills in the details, and every PDF looks the same.

Quick answer. Fill your template, then send the HTML to the API and save the PDF:

curl -X POST https://html-pdf.com/v1/pdf \
  -H "Authorization: Bearer $HTML_PDF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<h1>Service Agreement</h1><p>Between Studio North Ltd and Acme Inc.</p>"}' \
  -o contract.pdf

1. Write the template with placeholders

This section gives you a ready contract template. Use a template engine so values are escaped automatically.

Below is a Handlebars template. inc is a small helper, registered in step 3, that numbers clauses from 1. Jinja2 in Python uses the same double-brace syntax.

<!doctype html><html><head><meta charset="utf-8"><style>
  @page { size: A4; margin: 22mm 20mm 24mm; }
  body { font: 11pt/1.55 Georgia, serif; color: #111; }
  h1 { font-size: 18pt; text-align: center; margin-bottom: 4mm; }
  h2 { font-size: 12pt; margin: 6mm 0 2mm; break-after: avoid; }
  .clause { break-inside: avoid; }
  .signatures { display: flex; gap: 16mm; margin-top: 14mm; break-inside: avoid; }
  .sig { flex: 1; }
  .line { border-bottom: 1px solid #000; height: 16mm; }
</style></head><body>
  <h1>Service Agreement</h1>
  <p>This agreement is made on {{date}} between <strong>{{provider}}</strong> ("Provider")
     and <strong>{{client}}</strong> ("Client").</p>

  {{#each clauses}}
  <section class="clause">
    <h2>{{inc @index}}. {{title}}</h2>
    <p>{{body}}</p>
  </section>
  {{/each}}

  <div class="signatures">
    <div class="sig"><div class="line"></div>{{provider}}<br>Name, title, date</div>
    <div class="sig"><div class="line"></div>{{client}}<br>Name, title, date</div>
  </div>
</body></html>

2. Keep clauses and signatures together

These three CSS rules stop headings and signature blocks from splitting across pages. A heading alone at the bottom of a page, or a signature block cut in two, makes a contract look careless.

A clause longer than a page will still break, and that's expected. See HTML to PDF page breaks for more techniques.

3. Fill and render (Node.js)

This code fills the template with data and renders the PDF with a numbered footer.

import fs from "node:fs";
import Handlebars from "handlebars";

Handlebars.registerHelper("inc", n => n + 1);
const render = Handlebars.compile(fs.readFileSync("contract.hbs", "utf8")); // escapes values by default

const data = {
  date: "10 October 2026",
  provider: "Studio North Ltd",
  client: "Acme Inc.",
  clauses: [
    { title: "Services", body: "Provider will design and build the marketing website described in Schedule A." },
    { title: "Fees", body: "Client will pay EUR 12,000 in three equal instalments." },
    { title: "Term", body: "This agreement starts on the date above and ends on delivery." },
  ],
};

const version = 3;
const filename = `contract-acme-2026-10-10-v${version}.pdf`;

const res = await fetch("https://html-pdf.com/v1/pdf", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    html: render(data),
    prefer_css_page_size: true,
    footer_html: `<div style="font-size:8px;width:100%;text-align:center;color:#666">
      ${filename} · Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
    filename,
    max_pages: 30,
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
fs.writeFileSync(filename, Buffer.from(await res.arrayBuffer()));

The footer needs a bottom margin to print, and the 24 mm @page margin provides it. Putting the filename (with its version) in the footer shows which draft someone is reading.

If you prefer pure CSS, Chromium also supports @page margin boxes such as @bottom-center { content: "Page " counter(page) " of " counter(pages); }. See headers, footers and page numbers.

4. Version your filenames

A clear naming pattern lets you find and rebuild any draft. Use something sortable like contract-<client>-<YYYY-MM-DD>-v<n>.pdf and increment the version whenever terms change.

Keep the data that produced each version (the JSON above) next to the PDF, and you can regenerate any version later. Avoid names like "final" and "final2".

5. Send it for signature

The generated PDF can go straight into your signing flow. Most e-signature tools accept a PDF upload, either in their web app or through their API.

Leave clear space in the signature block for signature fields. If your tool places fields by text anchors, include the anchor text in the template. Check your tool's documentation for size limits and field placement.

Python version

Here is the same flow in Python with Jinja2.

import os, requests
from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(loader=FileSystemLoader("."), autoescape=select_autoescape(["html"]))
html = env.get_template("contract.html").render(
    date="10 October 2026", provider="Studio North Ltd", client="Acme Inc.",
    clauses=[{"title": "Services", "body": "Website design and build."}])

r = requests.post("https://html-pdf.com/v1/pdf",
    headers={"Authorization": f"Bearer {os.environ['HTML_PDF_KEY']}"},
    json={"html": html, "prefer_css_page_size": True, "filename": "contract-acme-v1.pdf"},
    timeout=60)
r.raise_for_status()
open("contract-acme-v1.pdf", "wb").write(r.content)

In Jinja2, write clause numbers as loop.index inside a for loop instead of the Handlebars helper.

Generate contracts with html-pdf.com

html-pdf.com turns HTML or a URL into a PDF with Chromium, so the page-break rules, @page and footers above behave as described. You pay per page. You get 100 free credits once you confirm your email, then pay from $0.005 per page, and purchased credits never expire.

Your HTML is not stored. Start from the free proposal template, then get an API key.

With the MCP server, an AI agent can draft and render in one step:

"Turn this signed-off scope into a service agreement PDF between Studio North Ltd and Acme Inc. with fees of EUR 12,000 in three instalments, signature blocks for both parties, and 'Page X of Y' in the footer. Name it contract-acme-v1.pdf."

FAQ

How do I add "Page X of Y" to a contract PDF?

Pass a footer_html with <span class="pageNumber"></span> and <span class="totalPages"></span>, and leave a bottom margin so the footer has room. You can also use CSS @page margin boxes with counter(page) and counter(pages).

How do I stop a clause or signature block from splitting across pages?

Add break-inside: avoid to each clause and to the signature block, and break-after: avoid to headings. A clause longer than one page will still break.

Is there a free way to generate contract PDFs?

Yes. Every new account gets 100 free credits once, with no card required, and 1 credit is 1 PDF page. After that, packs start at $10 for 1,000 pages.

Are my contracts stored on your servers?

No. Your HTML is not stored. If you ask for a download link instead of the file, that link lasts 24 hours.

This page as markdown · text