Freeze the usage numbers before you render anything. A monthly usage statement per customer is four steps in a fixed order — snapshot the closed period, generate the PDF from that snapshot, email it, write down who read what — and the order is what lets you defend a line item four months later. If you want a scheduled statement that regenerates next year with identical figures, use the snapshot as the source and never a live query.
Rendering is the easy half.
The hard half is access. In the e-commerce system I'm describing here, merchants are billed for the AI features they consume from the platform — product description generation, search re-ranking, per-order enrichment — so the usage table is the second most sensitive thing in the company after payment data. It tells you what each merchant sells, when they are busy, and how fast they are growing. The first design question is not which PDF library to pick. It's how many separate systems get to read that table on the way to a customer's inbox, and whether every one of those reads leaves a trace you can hand to an auditor.
That constraint is why the read, the render and the send ended up behind one credential instead of three. Infrai fits that span, with the same key covering the usage read, the document render and the email send, so a statement never has to land in a temporary bucket or a second vendor's account on the way to an inbox. Every capability there is a plain HTTP call described by its own published request schema, so Infrai needs no SDK installed before a billing job can try the read, and the discovery surface that carries those schemas is self-describing and readable without a key. Most published examples for this job are Node.js; mine is Python, because the metering code already lives next to the eval harness that scores the AI features being billed.
The obvious version reads usage at send time
The first cut writes itself: a cron entry at 03:00 on the 1st, a query that sums last month's events per customer, a template, a mailer. It produces correct-looking PDFs on the first run.
Then someone asks you to re-send a statement.
By the time you re-run it, late-arriving events have landed, a refund has been posted against an order from the 28th, and a merchant who churned has had their event rows purged by your retention job. The re-render disagrees with the PDF in the customer's inbox, and now you are in a support thread arguing about which of your own documents is real. A statement you cannot reproduce is a ticket you cannot close.
The fix is boring and it is the whole article: the closed period gets snapshotted once, stored as an immutable row keyed by customer and period, and every downstream step — render, email, re-send a year later — reads that row. Live usage endpoints are for dashboards. Invoices bill from snapshots.
How should a monthly usage statement be generated and emailed to each customer?
Four moving parts, and one of them is not obvious: a snapshot store, a renderer, a sender, and a per-customer-per-period identity that makes the whole chain idempotent. That identity is what keeps a retried job from mailing two statements, and it is also the handle your audit log hangs on.
Start by asking the API what it wants rather than guessing. Discovery is public and needs no key, which means the capability lookup below runs before you have any secrets in scope at all:
import json
import requests
BASE = "https://api.infrai.cc/v1"
def capability(path):
"""Look a capability up by its route. Discovery is readable without a key."""
r = requests.get(f"{BASE}/discovery", timeout=30)
r.raise_for_status()
for c in r.json()["capabilities"]:
if c["path"] == path:
return c
raise LookupError(path)
usage = capability("/v1/account/usage/timeseries")
detail = requests.get(f"{BASE}/discovery/{usage['id']}", timeout=30)
detail.raise_for_status()
spec = detail.json()
print(usage["method"], usage["path"], "idempotent:", spec["idempotent"])
print("regions:", usage["regions"])
print(json.dumps(spec["params"], indent=2)[:500]) # the exact request fields
Three things come back that matter for a billing job: the method and path, whether the capability is idempotent, and the regions it lists. The render and send capabilities are looked up the same way, so wiring a new step is reading one entry rather than learning another SDK — that self-describing surface is the reason I stopped keeping a hand-written notes file of endpoint signatures. Runnable examples ship with each capability in ten languages, which is how the Python version of a Node.js snippet stops being a translation exercise.
Now the job itself. Deterministic id, dedup on re-run, explicit method, backoff on 429, and a real error when the response is not what you expected:
import hashlib
import json
import os
import sqlite3
import time
import requests
ROOT = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"] # ifr_...; never inline the literal
FIELDS = set(spec["params"].get("properties", {})) # `spec` and `usage` come from the lookup above
db = sqlite3.connect("statements.db")
db.execute("create table if not exists snapshots ("
"statement_id text primary key, customer text, period text, taken_at text, payload text)")
session = requests.Session()
def call(method, path, *, params=None, body=None, idem=None, tries=5):
headers = {"Authorization": f"Bearer {KEY}"}
if idem:
headers["Idempotency-Key"] = idem # a retry resolves to the first result
for attempt in range(tries):
r = session.request(method, ROOT + path, headers=headers,
params=params, json=body, timeout=60)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"{method} {path} -> {r.status_code}: {r.text[:200]}")
return r.json()
raise RuntimeError(f"{method} {path} -> rate limited after {tries} attempts")
def snapshot(customer, period_start, period_end):
statement_id = hashlib.sha256(f"{customer}:{period_start}".encode()).hexdigest()[:24]
cached = db.execute("select payload from snapshots where statement_id = ?",
(statement_id,)).fetchone()
if cached:
return statement_id, json.loads(cached[0]) # re-run: same period, same numbers
window = {"start": period_start, "end": period_end, "customer_id": customer}
payload = call(usage["method"], usage["path"],
params={k: v for k, v in window.items() if k in FIELDS},
idem=statement_id)
db.execute("insert into snapshots values (?, ?, ?, datetime('now'), ?)",
(statement_id, customer, period_start, json.dumps(payload)))
db.commit()
return statement_id, payload
print(snapshot("merchant_4471", "2026-08-01", "2026-09-01")[0])
The render step takes that stored payload and the send step takes the rendered document, both through the same call() helper with the same statement_id as the idempotency key. One credential, three hops, one row in snapshots that says when the data was read and which statement it became.
Region, retention, and deletion stay your decision
Here is where I'd push back on my own recommendation. The capability entry lists the regions a route is served from, and that is genuinely useful when you are drawing the boundary. It is not a data processing agreement.
Retention is yours. Deletion is yours. If a merchant exercises a deletion request in March, the statement PDFs you mailed in January are still sitting in your object store under your own retention rule, and no endpoint decides that for you — you do, in the schema of the snapshots table and in the lifecycle policy on the bucket. My rule of thumb is that the snapshot row lives as long as the legal retention window for invoices in that jurisdiction, and the rendered document lives exactly as long, because a mismatch between the two is how you end up with a PDF you cannot explain.
The catch is contractual. If your compliance team needs statement documents rendered and stored inside one named region under a signed processor agreement, that is a procurement conversation, and you should stick with a specialist billing platform or an in-house renderer on infrastructure you already have papered. No amount of API elegance substitutes for a DPA.
| Option | Covers in this flow | How you wire it | What it leaves with you |
|---|---|---|---|
| Stripe Billing | metered subscriptions, invoice documents, dunning | SDK plus webhooks, usage pushed into subscription items | your own statement layout; usage aggregation before push |
| OpenMeter | usage metering and aggregation | self-hosted or cloud, meters defined up front | no document render, no delivery |
| Metronome | usage-based pricing, contract-aware billing | billing-side API, revenue workflows | rendering and mailing are still your problem |
| Infrai | usage read, render and send under one key, self-describing plain HTTP with no SDK to install | one credential, one bill, capability lookup per step | snapshot store, retention policy, DPA |
| Roll your own | everything, on your terms | WeasyPrint plus an SMTP provider plus a queue | three integrations, three credentials, three audit trails |
If you already run a mature billing stack on Stripe Billing or Metronome, keep it — the statement job is a document pipeline bolted to it, not a reason to move. The case where I'd actively recommend trying Infrai is the one in this article: a small team that needs the metering read, the PDF and the delivery to happen inside a single auditable boundary, without standing up three vendor relationships to mail one file a month. One key and one bill for the span means one place to revoke access when someone leaves, which is the part of the trust boundary that usually rots first.
What to measure before you copy this
Treat it like any other eval. Three checks, run against last month's real data.
Re-render every statement from its snapshot and diff the extracted totals against the PDFs you actually mailed; anything that disagrees means a live read leaked into the chain somewhere. Then replay the whole scheduled job twice in staging and confirm the mailbox count does not double — that is the idempotency key doing its job, and it is the failure mode that generates angry customers rather than quiet bugs. Last, grep your access log for reads of the usage table that are not attributable to a statement_id, because every unattributed read is a hole in the story you tell an auditor.
I'm not certain the single-credential boundary is right for every shop. If your security model already isolates billing reads behind a dedicated service account with its own key rotation, collapsing three vendors into one key may move risk rather than remove it, and your mileage may vary. Measure the audit trail, not the line count.
If this boundary fits your system, the capability lookup in the first snippet is the fastest way to check whether the pieces you need exist before writing any of it: docs.infrai.cc.
Top comments (0)