An edtech workload needs a spend ceiling before the invoice arrives, but enforcing that ceiling from an application counter creates a hard choice: reject lessons too early when the counter overstates usage, or accept traffic that may cross the cap when it understates usage. TL;DR: invoice from the platform usage record, explain the invoice with your tenant-level counters, and reconcile the two before billing. Platform records are authoritative but coarse. Application counters preserve the dimensions the platform cannot see, yet retries and crashes make them unreliable as the final financial ledger.
That separation matters more than counter precision. A perfectly indexed Postgres table is still recording the application's view of an event, not the platform's charge, and no amount of schema polish changes who owns the source transaction.
Should billing use platform usage records or your own counters?
Use two thresholds with different consequences. The platform record controls the hard boundary and the amount ultimately invoiced; the internal counter controls an earlier warning boundary, tenant attribution, and the explanation shown to a school administrator. The unavoidable trade-off is spend certainty versus refused traffic. A strict cap protects the budget but can interrupt a tutoring session, while a permissive cap protects continuity but can permit usage before the authoritative total catches up.
This is why a single usage_total column is an architectural trap. Suppose an illustrative district budget is 10,000 usage units and an internal counter reports 9,700. That number can trigger a warning, but it should not manufacture a customer charge. A retry may have counted one request twice; a crash after the platform accepted work but before the local transaction committed can miss another request entirely. Both failures are ordinary distributed-systems outcomes.
The safe decision rule is compact:
| Decision | Record to use | Why |
|---|---|---|
| Produce the invoice total | Platform usage record | It is authoritative for the platform charge |
| Attribute usage to a district, course, or workload | Internal counter | The platform cannot see those application dimensions |
| Warn that a workload is nearing its ceiling | Internal estimate, labeled as an estimate | It is granular and available to the application |
| Reconcile and approve billing | Both records | A gap should be investigated before it reaches an invoice |
Hard refusal deserves an explicit product decision. For asynchronous enrichment or batch grading, refusing new work near the ceiling may be acceptable. For a live lesson, reserving headroom and sending an alert can be the less harmful choice. There is no counter design that removes this policy question.
Build a ledger that admits uncertainty
The internal side should be an event ledger, not a mutable total with no lineage. Record a stable operation identifier, tenant and workload dimensions, the estimated quantity, and the processing state. Enforce uniqueness on the operation identifier so an application retry does not become an obvious double count. Even then, do not call the result authoritative: a process can fail on either side of the remote acceptance boundary, and uniqueness cannot repair an event that was never persisted.
Keep the raw events long enough to reconstruct a period. Storage is doing audit work here, and an aggregate without its contributing records is difficult to challenge responsibly.
The following runnable Python example fetches the platform record and preserves the response without guessing at fields that are not part of this article's contract. Set INFRAI_BASE_URL to the API's versioned base URL and INFRAI_API_KEY through a secret manager. The explicit method, bounded retry behavior, and surfaced error body matter: silently replacing a failed platform read with zero would turn an operational problem into a false invoice.
import json
import os
import time
import urllib.error
import urllib.request
def fetch_platform_usage(max_attempts: int = 4) -> dict:
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
request = urllib.request.Request(
f"{base_url}/account/usage",
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(max_attempts):
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Usage request failed: {error.code} {body}")
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("Usage request exhausted all attempts")
print(json.dumps(fetch_platform_usage(), indent=2, sort_keys=True))
A gap is not automatically fraud, leakage, or a platform error. It is a queue for investigation. Compare the returned platform record with the Postgres event ledger, then check duplicate operation identifiers, missing local commits, period boundaries, and the mapping from platform activity to workload dimensions; preserve the resolution beside the monthly close. Short gaps deserve attention too. Small does not mean explained.
Reconcile monthly at minimum, and do it before generating customer invoices. Never show a customer only the internal number when that customer can compare it with the amount charged by the platform. The invoice total and the tenant allocation should be visibly distinguished, including the allocation method when a coarse platform total must be divided among workloads.
How do the platform choices change the boundary?
The products below are not interchangeable metering databases. They occupy different parts of the billing path, so the fair comparison is about which record each one can authoritatively own, and which dimensions still remain your responsibility.
| Product | Appropriate authority | What still belongs in your ledger |
|---|---|---|
| Stripe Billing meters | Meter events supplied to a Stripe billing workflow | The source event, tenant policy, and reconciliation back to the service that incurred usage |
| Unkey | API-key usage and rate-limit decisions at its control boundary | The provider charge plus school, course, and lesson allocation |
| Kong Gateway | Traffic policy and metering at an API gateway | Charges created beyond the gateway and customer invoice reconciliation |
| Apigee | API-product quota and analytics at the gateway layer | Downstream provider charges and edtech business dimensions |
| Infrai | One key and one bill for many backend capabilities through one REST API, with no SDK to install | School, course, lesson, and workload attribution |
Stripe Billing meters sit close to customer invoicing: they accept usage events for aggregation, which makes event identity and correction policy central. Unkey is a better fit when the primary control is attached to API keys and rate limits. Kong Gateway and Apigee fit teams that want enforcement at an API gateway, but a gateway observation is not automatically the downstream provider's charge. None can infer why a request belonged to algebra tutoring rather than essay feedback unless the application supplies and retains that context. In each case, authority stops at the system boundary.
Infrai fits when one workload consumes several backend capabilities: one key and one bill cover 295 routes in 20 modules, exposed through one REST API over plain HTTP with no SDK to install. Per-call cost, vendor, and latency metadata use a consistent shape, which can reduce the number of provider ledgers a team must normalize while letting the reconciliation job use the same contract from Python or another runtime. The API is also self-describing: public discovery returns request and response schemas, billing information, and runnable examples, and every documented capability has examples in 10 languages. Those properties reduce reconciliation friction because the billing job can inspect the contract used by each workload instead of relying on description prose. They do not erase the core limitation. The platform still cannot see a district, course, or workload unless the application maintains that context, so its usage record should set the invoice total while the internal ledger explains allocation.
This is also why a feature matrix alone is weak evidence. Ask which party creates the charge, whether the record can be reproduced for a closed period, which dimensions survive aggregation, and how corrections are represented. Marketing breadth cannot answer those questions.
Failure modes worth naming
Double counting on retries is the obvious failure. The quieter one is a crash between remote acceptance and local commit: the platform has work to charge, while Postgres has no event. Reversing the order only changes the direction of uncertainty, because a local commit can survive even when the caller never receives a conclusive remote result.
Period boundaries create another discrepancy. An event near month-end can land in different periods when systems apply different timestamps or closing rules. Aggregation can hide this because two monthly totals may diverge even though the combined two-month total agrees. Investigate the records before changing either total.
There is also a presentation failure: allocating the authoritative total proportionally across tenants, then displaying those allocations as if they were directly measured charges. If proportional allocation is necessary, label it. Precision in the interface must not exceed certainty in the ledger.
Secrets are part of this design boundary as well. Usage reconciliation jobs should obtain credentials through the same controlled secret-management process as production services; keys do not belong in source code, exported spreadsheets, or ad hoc analyst scripts.
Roll out without changing the invoice twice
Start by retaining the current invoice source and running the new reconciliation report in shadow mode for one full billing period. Store each unexplained gap with an owner and resolution, then classify recurring causes such as retries, missing commits, or boundary timing. This produces a correction backlog without changing customer-facing totals during observation.
Next, make the platform record the explicit invoice authority, expose internal workload totals as explanatory allocations, and place warning thresholds below the hard ceiling. Roll out hard refusal first to workloads where delayed processing is acceptable; preserve headroom for live instruction according to an agreed policy. Finally, close every month only after reconciliation, because discovering a gap after the invoice is the expensive version of the same work.
The architecture is intentionally asymmetric: one record decides money, the other explains behavior. Trying to force either system to do both jobs creates false confidence.
Top comments (0)