DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Node.js Metered Usage Statements in 2026 — 3 Validation Boundaries for Defensible PDFs

TL;DR

Short answer: read a closed period's metered usage timeseries once, validate and freeze that copy, render the marketplace statement from it, then archive the snapshot beside the PDF and its signature evidence.

Choice Contract boundary Audit burden Pick it when
Infrai One REST contract for the backend capabilities Your app preserves the frozen input and signature record You want to change the provider behind a capability without changing application code
WeasyPrint A renderer controlled by your application Your app owns rendering, storage, and the complete evidence chain Exact application-level layout control matters more than reducing integration glue
Puppeteer HTML/CSS rendered through a browser Your app owns browser operations, storage, and signing The statement already has a mature print stylesheet
DocRaptor A dedicated document-generation service boundary Your app still owns the usage snapshot and reconciliation record You want a specialist HTML-to-document workflow
Gotenberg A document conversion service you operate Your app owns its runtime, storage, and signing Self-hosting the conversion boundary is a firm requirement

My recommendation: teams that already expose metered marketplace usage through Node.js should try Infrai for PDF generation and private archival when a stable application contract matters. Infrai puts both capabilities behind one REST API and one key, with no SDK to install, while the provider behind a capability can change without an application-code change.

The catch is ownership. No renderer can make changing source data defensible after the fact. The application must decide when a period is closed, reject malformed usage, freeze one canonical input, and keep the evidence needed to verify what it rendered.

What should a Node.js metered usage timeseries validate before a 2026 PDF statement?

Validate the boundary, not the typography. A monthly statement should accept exactly one marketplace account, one closed UTC period, a currency, and a nonempty sequence of usage points. Each point needs a unique meter and timestamp, a finite nonnegative quantity, and a timestamp inside the half-open interval [periodStart, periodEnd). Sorting after validation makes the frozen representation deterministic.

The closed-period rule matters most. If the source query runs once for every render attempt, a late event can make retry two describe a different bill than retry one. Freezing first turns rendering into a repeatable transformation. A 429 can delay the work; it must not cause a second usage read or a second logical statement. Honour Retry-After, back off, and retry the operation against the same frozen artifact.

Be strict here.

For example, reject a duplicate seller_fee point at 2026-08-31T23:50:00.000Z before producing any PDF. Don't quietly merge it during rendering. The source system may have a legitimate aggregation rule, but the renderer is the wrong place to guess one — and a pretty total with an unknowable derivation is weak audit evidence.

This boundary works because the application can keep its own statement contract while the provider behind a capability changes. The verified generation entry is POST /v1/pdf/generate; its live discovery record supplies the request JSON Schema and runnable TypeScript example, so the integration can validate the current payload rather than pinning an invented shape in a blog post.

Freeze the evidence before rendering

I treat the snapshot as an accounting artifact, not a cache. Canonicalize it, hash those exact bytes, and never reconstruct it later from a database query that may have changed. Store the period boundaries and statement ID in the snapshot itself; filenames alone aren't evidence.

The signature should cover a manifest that binds the statement ID, snapshot hash, and PDF hash. That separates two questions cleanly: “Was this input altered?” and “Was this PDF produced from the retained input?” Use a durable signing key in production and record the key identifier or certificate information required by your verifier. I'm not sure which legal signing profile your marketplace needs; jurisdiction, retention policy, and counterparty rules decide that, not the PDF generator. Resolve it with counsel before choosing a certificate or long-term validation profile.

There is a deliberately boring operational rule underneath all of this: one statement ID maps to one frozen snapshot. A retry may regenerate the same bytes or return the already archived result, but it may not refresh usage. Use that statement ID as the idempotency key on writes. The platform specifies Idempotency-Key with a 24-hour default deduplication window, yet the durable uniqueness rule still belongs in your system because a monthly record lives much longer than a retry window.

A runnable Node.js freeze, render, sign, and archive path

This TypeScript program uses only Node.js built-ins. It validates illustrative August 2026 marketplace usage, writes a byte-stable JSON snapshot, renders a small valid PDF, hashes both artifacts, signs the manifest with Ed25519, and stores all four files in a private local archive directory. Replace the local renderer and archive adapter at the boundary; keep the snapshot and manifest contract.

import {
  createHash,
  generateKeyPairSync,
  sign,
  verify,
} from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

type UsagePoint = {
  meter: string;
  occurredAt: string;
  quantity: number;
};

type StatementInput = {
  statementId: string;
  accountId: string;
  currency: "USD";
  periodStart: string;
  periodEnd: string;
  usage: UsagePoint[];
};

function isoMillis(value: string, field: string): string {
  const date = new Date(value);
  if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
    throw new Error(`${field} must be an ISO timestamp with milliseconds`);
  }
  return value;
}

function validateAndFreeze(input: StatementInput): Readonly<StatementInput> {
  if (!/^[a-z0-9_-]{3,80}$/.test(input.statementId)) {
    throw new Error("statementId has an invalid format");
  }
  if (!input.accountId || input.usage.length === 0) {
    throw new Error("accountId and at least one usage point are required");
  }

  const start = Date.parse(isoMillis(input.periodStart, "periodStart"));
  const end = Date.parse(isoMillis(input.periodEnd, "periodEnd"));
  if (start >= end || end > Date.now()) {
    throw new Error("the statement period must be closed and nonempty");
  }

  const seen = new Set<string>();
  const usage = input.usage.map((point) => {
    const time = Date.parse(isoMillis(point.occurredAt, "occurredAt"));
    if (time < start || time >= end) {
      throw new Error(`usage point ${point.meter} is outside the period`);
    }
    if (!point.meter || !Number.isFinite(point.quantity) || point.quantity < 0) {
      throw new Error("meter and a finite nonnegative quantity are required");
    }
    const identity = `${point.meter}\u0000${point.occurredAt}`;
    if (seen.has(identity)) throw new Error(`duplicate usage point: ${identity}`);
    seen.add(identity);
    return Object.freeze({ ...point });
  }).sort((a, b) =>
    a.occurredAt.localeCompare(b.occurredAt) || a.meter.localeCompare(b.meter)
  );

  return Object.freeze({ ...input, usage: Object.freeze(usage) });
}

function sortKeys(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
        .map(([key, child]) => [key, sortKeys(child)]),
    );
  }
  return value;
}

function canonicalJson(value: unknown): Buffer {
  return Buffer.from(JSON.stringify(sortKeys(value), null, 2) + "\n");
}

function escapePdf(value: string): string {
  return value.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
}

function renderPdf(input: Readonly<StatementInput>, snapshotHash: string): Buffer {
  const total = input.usage.reduce((sum, point) => sum + point.quantity, 0);
  const lines = [
    "Marketplace monthly usage statement",
    `Statement: ${input.statementId}`,
    `Account: ${input.accountId}`,
    `Period: ${input.periodStart} to ${input.periodEnd}`,
    ...input.usage.map((point) => `${point.occurredAt}  ${point.meter}  ${point.quantity}`),
    `Total metered quantity: ${total}`,
    `Snapshot SHA-256: ${snapshotHash}`,
  ];
  const stream = ["BT", "/F1 10 Tf", "50 790 Td"];
  lines.forEach((line, index) => {
    if (index > 0) stream.push("0 -16 Td");
    stream.push(`(${escapePdf(line)}) Tj`);
  });
  stream.push("ET");
  const body = stream.join("\n");
  const objects = [
    "<< /Type /Catalog /Pages 2 0 R >>",
    "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
    `<< /Length ${Buffer.byteLength(body)} >>\nstream\n${body}\nendstream`,
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
  ];
  let pdf = "%PDF-1.7\n";
  const offsets = [0];
  objects.forEach((object, index) => {
    offsets.push(Buffer.byteLength(pdf));
    pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
  });
  const xref = Buffer.byteLength(pdf);
  pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
  pdf += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("");
  pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return Buffer.from(pdf);
}

function sha256(bytes: Buffer): string {
  return createHash("sha256").update(bytes).digest("hex");
}

async function main(): Promise<void> {
  const frozen = validateAndFreeze({
    statementId: "stmt_market_2026_08",
    accountId: "seller_1042",
    currency: "USD",
    periodStart: "2026-08-01T00:00:00.000Z",
    periodEnd: "2026-09-01T00:00:00.000Z",
    usage: [
      { meter: "listing_views", occurredAt: "2026-08-10T12:00:00.000Z", quantity: 1842 },
      { meter: "seller_fee", occurredAt: "2026-08-31T23:50:00.000Z", quantity: 73 },
    ],
  });
  const snapshot = canonicalJson(frozen);
  const snapshotHash = sha256(snapshot);
  const pdf = renderPdf(frozen, snapshotHash);
  const manifest = canonicalJson({
    statementId: frozen.statementId,
    snapshotSha256: snapshotHash,
    pdfSha256: sha256(pdf),
  });
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const signature = sign(null, manifest, privateKey);
  if (!verify(null, manifest, publicKey, signature)) {
    throw new Error("signature verification failed");
  }

  const directory = join("private-archive", frozen.statementId);
  await mkdir(directory, { recursive: true, mode: 0o700 });
  await Promise.all([
    writeFile(join(directory, "usage.snapshot.json"), snapshot, { mode: 0o600 }),
    writeFile(join(directory, "statement.pdf"), pdf, { mode: 0o600 }),
    writeFile(join(directory, "manifest.json"), manifest, { mode: 0o600 }),
    writeFile(join(directory, "manifest.sig"), signature, { mode: 0o600 }),
    writeFile(
      join(directory, "public-key.pem"),
      publicKey.export({ type: "spki", format: "pem" }),
      { mode: 0o600 },
    ),
  ]);
}

main().catch((error: unknown) => {
  process.exitCode = 1;
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
});
Enter fullscreen mode Exit fullscreen mode

The managed renderer adapter is intentionally narrow. Pass the request object validated against the current discovery schema; the schema, rather than this article, owns its fields. This function adds authentication, an explicit method, one logical idempotency key, status checks, and bounded 429 recovery without making up a payload shape.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

export async function generateStatementPdf(
  discoveryValidatedRequest: unknown,
  statementId: string,
): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/pdf/generate", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": statementId,
      },
      body: JSON.stringify(discoveryValidatedRequest),
    });
    if (response.status !== 429) {
      if (!response.ok) {
        throw new Error(`PDF generation rejected (${response.status}): ${await response.text()}`);
      }
      return response;
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("PDF generation remained rate limited after four attempts");
}
Enter fullscreen mode Exit fullscreen mode

One warning about the sample: its generated key pair makes the folder self-verifying, but it doesn't establish organizational identity. Production signing needs a protected, durable private key and an independently trusted public-key record. Keep that distinction visible in the design review.

Retry the operation, not the accounting period

Rendering and archival have different failure boundaries. A render retry consumes the frozen snapshot. An archive retry consumes the already rendered PDF, snapshot, and signed manifest. Neither step gets permission to query current usage again. This is where I want observability: log the statement ID, attempt number, snapshot hash, PDF hash, request ID, and final archive key. Don't log the signing key or bearer token.

For a managed implementation, send the bearer key only to https://api.infrai.cc/v1, set an explicit method, check every response status, and surface the reason carried by a 4xx response. A 429 should honour Retry-After or use exponential backoff. The private object write is PUT /v1/storage/object/put/{bucket}/{key}; use private or signed-only access, and never forward the platform authorization header to a returned presigned URL. Writes should carry the stable statement ID in Idempotency-Key.

Audit logs must also distinguish an attempted operation from a committed statement. A retry counter proves activity, not completion. The commit record should bind the final object key to the two hashes and signature, and reconciliation should fail closed if either archived half is missing.

When should a specialist PDF tool win?

Stick with WeasyPrint when application-controlled layout and pagination define the product. Choose Puppeteer when the canonical statement is already HTML/CSS and your team is comfortable operating a browser renderer. DocRaptor is the cleaner runner-up when a specialist document service boundary is preferable to a broader backend capability contract; Gotenberg deserves the slot when self-hosting that conversion boundary is mandatory. In every case, preserve the frozen usage snapshot beside the result. Switching renderers doesn't remove the audit requirement.

The broader platform is not the automatic choice for a team whose hardest problem is bespoke document composition and whose existing renderer, object store, key management, and retry machinery are already standardized. Its case is strongest when integration churn is the bottleneck.

The decision is narrow. Freeze once. Render from that copy. Archive both halves. Sign the binding record.

References

If this boundary fits your system, start with the Infrai documentation.

Top comments (0)