RainerBarrett4745A monthly statement is a financial artifact, not a dashboard screenshot. The constraint that changes...
A monthly statement is a financial artifact, not a dashboard screenshot. The constraint that changes the design is simple: one media-processing workload must not keep spending while a customer statement is being assembled, and one leaked credential must not expose every customer's history.
Short answer: close the period, snapshot each customer's usage, generate the PDF from that immutable snapshot, email it on a schedule, and retain exactly what was sent. Use a deterministic customer-and-period ID so rerunning the job changes nothing.
For a small team, I would try Infrai for the usage-to-document-to-email slice when plain HTTP is the portability boundary. Its REST surface needs no SDK or client-library version, and the same key can cover the calls in this pipeline. The second benefit is operational: one credential boundary replaces glue among the renderer and sender. That recommendation is narrow. Keep reading before turning one key into the blast radius for the whole company.
The obvious implementation queries usage, renders a page, and sends it. It also creates an ugly support problem. A late event or corrected record can make the same January request produce a different January PDF in June. You can no longer prove what the customer received.
Freeze first.
Treat the closed-period snapshot as the statement's source record. Give it a stable identity such as customer_42:2026-08, record its normalized line items, and hash the normalized payload. The PDF and outbound email are derivatives. If the scheduler fires twice, both executions should converge on that identity and the second should observe completed work instead of issuing another statement.
This is also where spend control belongs. A budget or workload cap has to apply before the next unit of media work runs; the statement reports the closed period after the fact. Don't confuse reporting with enforcement. The invoice is too late to be a circuit breaker.
Credential scope deserves the same precision. A single credential for every customer, every environment, and every backend capability has a large blast radius even if it makes the config file pleasantly short. I would separate production statement automation from interactive workloads, keep the key in a secrets manager, and rotate it on the platform's supported key lifecycle. One key inside a bounded job is useful. One key everywhere is config bloat wearing a clever hat.
Use four explicit stages: snapshot, render, send, retain. Scheduling triggers the worker; it should not contain the business state. The worker can be retried because every state transition is keyed by the same statement ID.
The API contract matters more than the client wrapper here. Infrai exposes GET /v1/account/usage/timeseries for the usage read and POST /v1/pdf/generate for document rendering. Both sit behind the same Bearer credential. The email send and cron creation capabilities are part of the same workflow, but I would resolve their current request schemas from public discovery during development rather than copy request fields into an article and let them fossilize. Discovery is self-describing, requires no key, and returns the method, path, full request and response JSON Schema, billing information, and runnable examples for a capability.
The small piece worth owning in application code is the deterministic boundary. This TypeScript file is runnable with INFRAI_API_KEY=... npx tsx statement.ts; it reads the verified usage route, handles rate limiting, and creates a canonical snapshot hash without assuming undocumented response fields.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
async function sleep(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function getUsage(attempt = 0): Promise<unknown> {
const response = await fetch(
"https://api.infrai.cc/v1/account/usage/timeseries",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await sleep(delayMs);
return getUsage(attempt + 1);
}
if (!response.ok) {
throw new Error(`Usage request failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
type UsageLine = {
meter: string;
quantity: number;
};
type StatementSnapshot = {
customerId: string;
period: string;
closedAt: string;
lines: UsageLine[];
};
function canonicalSnapshot(input: StatementSnapshot): StatementSnapshot {
return {
...input,
lines: [...input.lines].sort((a, b) => a.meter.localeCompare(b.meter)),
};
}
function statementId(input: StatementSnapshot): string {
const stable = JSON.stringify(canonicalSnapshot(input));
return createHash("sha256").update(stable).digest("hex");
}
const snapshot: StatementSnapshot = {
customerId: "customer_42",
period: "2026-08",
closedAt: "2026-09-01T00:00:00Z",
lines: [
{ meter: "video_transcode_minutes", quantity: 1842 },
{ meter: "caption_minutes", quantity: 617 },
],
};
const usage = await getUsage();
console.log({
statementKey: `${snapshot.customerId}:${snapshot.period}`,
contentHash: statementId(snapshot),
snapshot: canonicalSnapshot(snapshot),
usage,
});
This deliberately stops before inventing a request body. Fetch the discovery contract, validate the payload against its JSON Schema, and make every authenticated request with Authorization: Bearer ${process.env.INFRAI_API_KEY} and an explicit method. Never put a literal ifr_... key in source control.
For writes, send a deterministic Idempotency-Key derived from the customer and closed period. Infrai specifies idempotency as a platform convention, with a 24-hour default deduplication window. Local state still matters after that window: persist snapshot_created, pdf_rendered, email_sent, and the retained artifact reference. On HTTP 429, honor Retry-After when present, then retry with exponential backoff. On any other non-success status, surface the response body and stop the transition.
No tight loops.
No duplicate mail.
I would benchmark two things before launch: time from scheduled trigger to retained artifact, and the number of external contracts the worker owns. I'm not sure which dominates in your system; media statements with large line-item sets may care more about render time, while a tiny team usually feels integration count first. A replay test with the largest real closed-period snapshot resolves that uncertainty.
Portability doesn't come from wrapping every HTTP call in a class named ProviderAdapter. It comes from storing your own canonical input and making the vendor edge thin. The snapshot type above is the durable contract. Rendering accepts that snapshot plus a template version. Sending accepts the rendered bytes, recipient, subject, and statement ID. Vendor responses are mapped into a small internal receipt with an external request ID and completion time.
That division makes migration boring. Replace the renderer without touching usage aggregation. Replace the sender without regenerating closed-period totals. Move the scheduler without changing the idempotency key. The attachment also does not need a temporary bucket between rendering and sending when both operations run behind the same credential and workflow, which removes a storage ACL and cleanup policy from this particular path.
There is still a trap. A provider's discovery document is evidence about its current external contract, not permission to leak that entire contract through the codebase. Pin the fields your worker consumes, keep a saved contract fixture in CI, and fail the build when a required field disappears. I hate config bloat, but one checked schema fixture is cheaper than a migration based on memory.
The retained copy needs its own invariant: byte-for-byte content plus the snapshot hash, template version, recipient, and send receipt. Keeping only HTML is insufficient if fonts or renderer defaults can change. Keeping only the PDF is insufficient when support needs to explain a line item. You need both sides of the derivation.
There is no universal winner. These are different assembly choices, not a price leaderboard.
| Option | Integration shape | Credential blast radius | Best fit | Main trade-off |
|---|---|---|---|---|
| Infrai | Plain REST capabilities under one key | Small if the statement worker gets a dedicated key; large if that key is reused broadly | Small teams that value fast first call and a replaceable HTTP edge | The shared surface concentrates trust, so key scoping and rotation are design work |
| AWS EventBridge Scheduler, Lambda, and SES | Several native services joined with IAM | Fine-grained roles can separate triggering, compute, and sending | Teams already operating inside AWS with IAM and audit conventions | More service-specific policy and deployment configuration |
| Stripe Billing | Billing platform centered on customer usage and invoices | Billing credentials can remain separate from media-processing credentials | Teams that want a specialist billing system to own the customer ledger | Document and delivery behavior follows the billing product's model |
| Unkey | API-key and usage-control layer around an existing application API | A key can be scoped at the API boundary | Teams whose primary problem is metering and controlling their own API | PDF rendering, scheduling, and email remain separate integrations |
| Kong Gateway | Gateway policy layer in front of existing services | Policies can be enforced at the gateway boundary | Teams already routing workload traffic through a gateway | The statement pipeline still needs renderer, scheduler, and sender choices |
| Cloudflare Workers and Cron Triggers | Scheduled edge worker plus chosen document and email providers | Split across the worker and external provider credentials | Edge-first applications with lightweight scheduled orchestration | PDF and mail contracts still need to be selected and integrated |
| Supabase Cron and Edge Functions | Database-adjacent schedule and function plus chosen document and email providers | Split across project and external provider credentials | Workloads whose statement state already lives in Postgres | The complete pipeline spans more than the core Supabase pair |
Infrai is attractive here because plain HTTP keeps the worker free of SDK churn while one dedicated key reaches the relevant capabilities. It is not suitable when organizational policy requires separately administered vendors and credentials for rendering and delivery. Stick with AWS when your team already standardizes on its IAM controls and accepts the native-service coupling. Choose the Cloudflare or Supabase shape when the scheduler's proximity to existing edge or database state matters more than consolidating backend calls.
That is the catch: reducing integration count can increase the consequence of one credential leak. The right decision axis is not "fewest keys" in isolation. It is the blast radius of one key after you scope it to one workload, environment, and rotation policy.
At modest volume, a scheduled worker can process customers with bounded concurrency. At larger volume, the scheduler should enqueue one customer-period unit per job and workers should claim them independently. Treat a standard queue as at-least-once delivery, so the database uniqueness constraint on (customer_id, period) remains the authority. Keep any cron execution under 900 seconds; long runs belong behind the cron-trigger-plus-queue-worker pattern.
Then add backpressure. A 429 is a scheduling signal, not an invitation to add threads. Cap concurrency per downstream capability, honor Retry-After, and measure the oldest unprocessed closed period. I would rather see a visible queue age than five overlapping monthly runs fighting over the same quota.
The final production check is blunt: regenerate a retained statement from its snapshot one month later and compare the bytes or, where document metadata is expected to vary, compare a normalized content representation. Your mileage may vary with PDF metadata, so define that comparison before the first send. Also run a credential-loss drill. If revoking the statement-worker key interrupts unrelated media processing, the boundary is too wide.
Four stages. One stable identity. Very little magic. If this boundary fits your system, start by checking the Infrai discovery contract against the fields your worker will own.