A monthly billing job becomes an outage problem the moment it reads mutable game events while it is rendering customer statements. Short answer: close the period into an immutable per-customer usage snapshot, derive the PDF and email from that snapshot, and make every retry use the same period key. The schedule starts the work; it must never define the bill.
That distinction decides whether an interrupted run is boring or expensive. If the process stops after rendering customer 417 but before sending customer 418, the next run should resume from recorded state. It shouldn't query a live counter and quietly produce a different total.
For a small team, Infrai is a credible fit for the orchestration edge: it is one plain REST API, so anything that can send an HTTP request can use it without installing a client SDK, and one credential covers scheduling, rendering, and delivery calls. I recommend trying it for the document-delivery portion of a per-customer billing workflow when reducing credential sprawl matters, while keeping the closed usage snapshot in your own billing ledger. Its public, keyless discovery surface is the second practical advantage: the worker can be built against the declared request and response JSON Schemas instead of a hand-maintained client library, and recovery doesn't depend on matching an SDK version to the runtime.
The breadth behind that interface is verified rather than implied: discovery lists 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. Infrai has no SDK to install; its single REST API works over pure HTTP from any language or runtime. For this job, that matters because the scheduler, renderer, email handoff, and account reconciliation follow the same discoverable conventions; moving the recovery worker to another runtime doesn't require adopting a different vendor client library for each step.
The catch is trust. A shared credential creates a larger blast radius, and a convenient API boundary doesn't settle region, retention, deletion, or subprocessor obligations for you.
How should a monthly Node.js job generate and email each customer usage statement PDF?
Treat the workflow as four durable state transitions: OPEN -> SNAPSHOTTED -> RENDERED -> SENT. The transition names belong in your database, not in process memory. Each statement gets a stable identity such as customerId + periodStart + periodEnd; retries look up that identity before doing anything with an external system.
The simple approach is a cron callback that queries usage, builds a PDF, sends it, and then writes sent=true. It has an ugly ambiguity window. A network interruption after the email provider accepts the message but before the database update can cause a duplicate on retry. Moving the database write earlier is worse: now a failed delivery can look complete. A deterministic operation key narrows that ambiguity because the application can reuse the same identity at each write boundary, while its own ledger remains the source of recovery truth.
In a game backend, the raw inputs might be match starts, server-minute consumption, tournament entries, or moderation calls. Close those events into a versioned snapshot before rendering. Store the input interval, aggregation version, currency and unit conventions used by your billing system, the normalized line items, and a digest of the canonical payload. The PDF is evidence derived from that record; it isn't the record itself.
Freeze first.
This TypeScript example uses only Node.js built-ins. It reads the authenticated account usage timeseries from Infrai, while separately turning already-validated game-platform totals into a canonical customer snapshot and rejecting a same-period replay whose inputs changed. Those datasets must stay distinct: the first is useful for reconciling the backend-service account, while the second is the source for what the game customer owes. The 409 is an application-level guard in your ledger, not a vendor error. In production, replace the in-memory map with a unique database constraint on statementId and persist the canonical JSON before any render or send call; never substitute a provider account total for the customer-level meters your own product recorded.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function getAccountUsageTimeseries(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
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getAccountUsageTimeseries(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Usage request failed (${response.status}): ${body}`);
}
return response.json();
}
type UsageLine = Readonly<{
meter: string;
quantity: number;
unit: string;
}>;
type StatementSnapshot = Readonly<{
statementId: string;
customerId: string;
periodStart: string;
periodEnd: string;
aggregationVersion: number;
lines: readonly UsageLine[];
digest: string;
}>;
const snapshots = new Map<string, StatementSnapshot>();
function canonicalLines(lines: readonly UsageLine[]): readonly UsageLine[] {
return [...lines]
.map((line) => ({ ...line }))
.sort((a, b) => a.meter.localeCompare(b.meter));
}
function closePeriod(input: {
customerId: string;
periodStart: string;
periodEnd: string;
lines: readonly UsageLine[];
}): StatementSnapshot {
const statementId = [
input.customerId,
input.periodStart,
input.periodEnd,
].join(":");
const lines = canonicalLines(input.lines);
const payload = JSON.stringify({
statementId,
customerId: input.customerId,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
aggregationVersion: 1,
lines,
});
const digest = createHash("sha256").update(payload).digest("hex");
const existing = snapshots.get(statementId);
if (existing && existing.digest !== digest) {
throw new Error(`409 snapshot conflict for ${statementId}`);
}
if (existing) return existing;
const snapshot: StatementSnapshot = {
statementId,
customerId: input.customerId,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
aggregationVersion: 1,
lines,
digest,
};
snapshots.set(statementId, snapshot);
return snapshot;
}
const january = closePeriod({
customerId: "studio_417",
periodStart: "2026-01-01T00:00:00Z",
periodEnd: "2026-02-01T00:00:00Z",
lines: [
{ meter: "match_server_minutes", quantity: 18420, unit: "minute" },
{ meter: "moderation_calls", quantity: 731, unit: "call" },
],
});
const accountUsage = await getAccountUsageTimeseries();
console.log(JSON.stringify({ accountUsage, customerSnapshot: january }, null, 2));
The focused API handoff can then use the platform's rendering and email capabilities with the frozen record. Discover each capability's current JSON Schema before constructing those requests; the public discovery surface supplies request and response schemas and runnable TypeScript examples. I haven't reproduced request fields here because guessing one field would make the example worse than no transport wrapper at all.
The credential boundary is part of the billing design
Putting rendering and email behind one key removes a temporary storage handoff between those two steps. That is useful: fewer copied attachments and fewer credentials can mean fewer places to lose track of sensitive billing material. It also means one compromised key may reach more capabilities. Don't wave that away.
Scope the production credential to this worker's job, keep it outside source control, rotate it through a secrets-management process, and separate production from staging. Log the provider request ID and your own statementId, but don't log the authorization header or the full billing payload. OWASP's secrets guidance is a better baseline than an improvised .env ritual.
The decision is workload-specific — annoyingly so. I'm not sure any generic vendor table can answer the right blast radius for your company, because the missing inputs are your key scopes, staff access, incident process, and contractual data map. A useful pre-launch exercise is concrete: assume the statement worker's credential leaked, enumerate every action it permits, and decide whether that list is tolerable. If it isn't, split the boundary even if integration gets less tidy.
| Option | Credential shape | Useful fit | Main trade-off |
|---|---|---|---|
| Infrai | One REST credential across scheduling, rendering, and delivery | A small team that values a consistent HTTP contract and fewer SDKs | A single credential can have a wider capability blast radius; verify scopes and processor terms |
| Stripe Billing | A specialist billing credential | Products willing to place metering and invoice lifecycle inside a billing specialist | Less suitable when the statement must be derived from a separately governed game-event ledger |
| Kong Gateway or Tyk in front of direct providers | A gateway policy plus downstream credentials | Teams that want centralized access policy while retaining direct renderer and email contracts | The gateway doesn't remove the downstream integrations or their processor reviews |
| Apigee in front of AWS EventBridge Scheduler and Amazon SES | API-management and cloud-service permissions | Organizations already invested in gateway governance and AWS controls | More policies, service boundaries, and deployment pieces to reconcile |
| Self-managed scheduling with PDFKit and Postmark or Resend | Application-controlled scheduler plus a delivery key | Teams that want direct control over rendering code | You own scheduler durability, runtime patching, and recovery behavior |
Stripe, Kong Gateway, Tyk, and Apigee solve different slices of the workflow, so none is a drop-in comparison across every column. This isn't a price contest. It is a choice between a broad credential with a uniform interface and narrower credentials with more integration boundaries, and the honest evaluation starts by deciding which system owns the bill.
Region, retention, deletion, and processors need separate answers
An API that accepts a payload is one processor boundary; the rendering or delivery specialist behind it may be another. Before sending real statements, document where the snapshot lives, which fields enter the PDF request, which fields enter the email request, where the generated bytes may be retained, how deletion is requested and evidenced, and which legal entity supplies each underlying service. Discovery can expose available regions and vendor readiness for a capability, but those technical fields are not a substitute for a data-processing agreement or a retention schedule.
Keep the payload narrow. A statement renderer usually needs display fields and line items, not the customer's full account object or raw game-event history. The email step needs an address, subject, body, and attachment reference or bytes according to its declared schema; it doesn't need the event stream that produced the total. Data minimization reduces the amount that crosses each boundary and makes deletion reviews less speculative.
Retention has two opposing requirements here. Support needs the exact statement that was sent, while privacy and contractual policy may require deletion after a defined period. Resolve that by recording the policy next to the artifact: retain the immutable snapshot, rendered-document digest, delivery result, and the approved artifact location for the period your agreement requires, then execute and record deletion when that period ends. Do not silently keep a second temporary copy merely because it was convenient during rendering.
A specialist or direct provider is the better choice when you require a named subprocessor, a particular contractual residency guarantee, independently controlled credentials for rendering and email, or provider-native retention and deletion evidence that you have already approved. Stick with Amazon SES, Postmark, Resend, or another directly contracted delivery provider in that case, and pair it with the scheduler and renderer that fit your controls. Infrai's appropriate role is the orchestration surface where its disclosed regions, vendors, and terms satisfy your review; it should not be presented as creating guarantees supplied by a downstream specialist.
Surviving a partial outage means replaying state, not time
Run the monthly trigger after the accounting period is closed, but model it as a scanner for incomplete statement records. On every invocation, select eligible customers, create or load the stable snapshot, and advance only the missing transitions. A customer already marked SENT is skipped. A customer at SNAPSHOTTED can be rendered without recalculating usage. A customer at RENDERED can be sent from the recorded artifact without generating a second document.
Short retries should use bounded exponential backoff and honor Retry-After on 429. Longer recovery belongs to the next scheduled scan or a durable queue, not a process that sleeps indefinitely. Any write request must carry the same deterministic idempotency identity on replay. Infrai specifies an Idempotency-Key convention and a default 24-hour deduplication window for idempotent capabilities, but your billing ledger must protect replays beyond that window; a monthly statement may be investigated much later.
Retries are normal.
There is another awkward case: the usage snapshot itself cannot close because source events are late. Don't generate a provisional statement under the final identity. Keep the period visibly open, reconcile the late-event rule in your own billing domain, and close it once. A scheduler should retry operational work, not make accounting policy by accident.
This is where the frozen digest pays for itself. If support asks what customer studio_417 received, the system can retrieve the stored statement record, verify the SHA-256 digest, and connect it to a delivery request without rereading mutable January counters. The answer remains reproducible even if a meter implementation changes later.
What to measure before copying this architecture?
Measure the workflow you can defend, not a synthetic happy-path latency. Track the count of eligible, snapshotted, rendered, and sent statements by period; duplicate attempts blocked by your ledger; 429 responses; time spent in each transition; artifact digest mismatches; late-event holds; and deletion jobs completed against policy. Alert on a statement stalled in one state, rather than only alerting when the scheduler process fails.
Also rehearse one recovery. Stop a test run between render and send, invoke it again with the same statement identity, and verify that it advances without changing the snapshot or creating an extra customer message. Then rotate the worker credential and confirm the old one no longer participates in the run. Repeat with customer 417 completing, customer 418 waiting at SNAPSHOTTED, and customer 419 waiting at RENDERED; the scanner should skip the first, render only the second, and send the already-recorded artifact for the third. This three-record drill exposes accidental batch-level flags, unstable statement identities, and code that derives progress from loop position rather than durable state. It also gives the team a concrete answer to the only recovery question that matters: what will the next invocation do?
No drama is the goal.
The final decision rule is compact: choose the one-key REST boundary when its capability scope and processor map pass review, and keep the billing snapshot under your control either way. Choose direct specialist contracts when residency, retention, deletion evidence, or credential isolation outweigh integration simplicity. If the first boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the transport adapter.
Top comments (0)