Generating invoices from a template, idempotently

An invoice is a numbered financial record, not a rendered page. Idempotency per invoice number, the hash you keep, and the retention nobody plans for.

Invoices are the case where document generation stops being cosmetic. An invoice is a numbered financial record that a tax authority may ask about in six years, which changes three things about how you generate it: the call needs an idempotency_key, the result’s sha256 needs storing, and the file needs to outlive the platform’s retention_days. Infrai’s POST /v1/pdf/template/create and POST /v1/pdf/generate handle the rendering; the rest is discipline.

Get the idempotency right and you’ll never issue INV-4821 twice with different totals.

The template, once

curl -sS -X POST "https://api.infrai.cc/v1/pdf/template/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice-2026-v2",
    "html": "<h1>Invoice {{number}}</h1><p>{{customer_name}}<br>{{customer_address}}</p><table>{{line_rows}}</table><p class=\"total\">Total due {{total}}</p><p>Payable by {{due_date}}</p>",
    "vars_schema": {"number": "string", "customer_name": "string", "customer_address": "string",
                    "line_rows": "string", "total": "string", "due_date": "string"}
  }'
{
  "ok": true,
  "data": {
    "template_id": "tpl_9wQ1zV6pLkS3dHyB",
    "name": "invoice-2026-v2",
    "vars_schema": {"number": "string", "customer_name": "string", "customer_address": "string",
                    "line_rows": "string", "total": "string", "due_date": "string"},
    "created_at": "2026-09-21T03:45:00Z"
  }
}

Version the template name. invoice-2026-v2 rather than invoice, because an invoice you re-render next year must look like the one you issued — if the template changed underneath, you’ve produced a different document with the same number, and that’s the kind of discrepancy an auditor notices.

Store the template_id against each invoice you issue with it.

Render with an idempotency key

curl -sS -X POST "https://api.infrai.cc/v1/pdf/generate" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "tpl_9wQ1zV6pLkS3dHyB",
    "template_vars": {
      "number": "INV-4821",
      "customer_name": "Northwind Ltd",
      "customer_address": "12 Dock Road, Bristol",
      "line_rows": "<tr><td>Platform, September</td><td>$1,200.00</td></tr><tr><td>Overage</td><td>$84.00</td></tr>",
      "total": "$1,284.00",
      "due_date": "2026-10-15"
    },
    "page_size": "A4",
    "idempotency_key": "invoice-INV-4821",
    "store": true
  }'

The key is the invoice number. A retried request — a timeout, a re-queued job, someone clicking twice — returns the original document rather than rendering a second one, which matters here beyond the cost: two PDFs for one invoice number is a reconciliation problem.

The whole issuance, with the bookkeeping

import base64
import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
TEMPLATE_ID = os.environ["INVOICE_TEMPLATE_ID"]
BUCKET = os.environ.get("INVOICE_BUCKET", "invoices")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def money(cents: int, symbol: str = "$") -> str:
    """Format in one place. An invoice with '1284.0' on it looks like a bug because
    it is one, and a template cannot fix formatting it was handed."""
    return f"{symbol}{cents // 100:,}.{cents % 100:02d}"


def render_invoice(number: str, customer: dict, lines: list[dict], due_date: str) -> dict:
    rows = "".join(
        f"<tr><td>{line['label']}</td><td>{money(line['cents'])}</td></tr>" for line in lines
    )
    total = money(sum(line["cents"] for line in lines))

    resp = SESSION.post(
        f"{API}/v1/pdf/generate",
        json={
            "template_id": TEMPLATE_ID,
            "template_vars": {"number": number, "customer_name": customer["name"],
                              "customer_address": customer["address"], "line_rows": rows,
                              "total": total, "due_date": due_date},
            "page_size": "A4",
            "idempotency_key": f"invoice-{number}",
            "store": True,
        },
        timeout=120,
    )
    body = resp.json()
    if not body.get("ok"):
        raise RuntimeError(body["error"]["code"])
    return body["data"]


def archive(pdf_url: str, number: str) -> str:
    """Copy into our own bucket for statutory retention. The platform's
    retention_days is a working window, not a records policy."""
    media = requests.get(pdf_url, timeout=120)
    media.raise_for_status()
    key = f"{number[:8]}/{number}.pdf"
    SESSION.put(
        f"{API}/v1/storage/object/put/{BUCKET}/{key}",
        json={"data_base64": base64.b64encode(media.content).decode("ascii"),
              "content_type": "application/pdf", "storage_class": "durable",
              "metadata": {"invoice_number": number}},
        timeout=300,
    ).raise_for_status()
    return key


def issue(number: str, customer: dict, lines: list[dict], due_date: str) -> dict:
    rendered = render_invoice(number, customer, lines, due_date)
    key = archive(rendered["url"], number)
    # Store the hash with your own invoice row: it is what proves the archived
    # file is the document you issued.
    return {"number": number, "pdf_id": rendered["pdf_id"], "sha256": rendered["sha256"],
            "pages": rendered["page_count"], "bucket_key": key}


if __name__ == "__main__":
    print(issue("INV-4821",
                {"name": "Northwind Ltd", "address": "12 Dock Road, Bristol"},
                [{"label": "Platform, September", "cents": 120000},
                 {"label": "Overage", "cents": 8400}],
                "2026-10-15"))

Formatting money in one function, in integer cents, is worth more than it looks. Floating-point totals on invoices are a recurring source of one-cent discrepancies that someone eventually has to explain.

Four things an invoice needs that a rendered page doesn’t

RequirementHow
Never issued twiceidempotency_key = the invoice number
Provably unchangedstore the response sha256
Retained for yearscopy to your own bucket, storage_class: "durable"
Reproduciblestore the template_id used, and version template names

The third row is the one that catches teams. retention_days on the response is a working window for the platform’s copy — treating it as your archive means the invoice you need in four years has been gone for most of them.

Delivering it

POST /v1/email/send takes the document as an attachment, and GET /v1/email/get/{id} tells you whether it landed — which for an invoice is worth recording against the invoice row, because “we sent it” and “they received it” are different claims when a payment is late.

That’s the practical argument for doing this on one credential: the render, the archive, the delivery and the delivery receipt are four calls on one key, showing up as one line in GET /v1/account/usage, rather than a document vendor plus a storage vendor plus an email vendor each with their own invoice — which is a slightly absurd thing to reconcile when the subject is invoicing.

Limitations

There’s no numbering service: generating INV-4821 in sequence, without gaps and without collisions under concurrency, is your own database’s job, and it’s the part with the actual correctness risk. There’s no locale-aware formatting either — dates, currency symbols and decimal separators are strings you supply, so a template serving several countries needs that logic upstream.

And no tax calculation, obviously. Anvil and PDFMonkey both offer more template tooling with editors non-developers can use, which is worth it if your finance team wants to change invoice layout without a deploy. Generation bills per call, live in GET /v1/discovery/pdf.generate (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.

References

Browse more pdf developer guides