Short answer: read the account tier and subscription at startup, then gate features from what the account reports. Keep that result in memory for the process, log the observed tier for audit, and read it again after an upgrade flow completes. This is less fragile than compiling plan limits into a Node.js SaaS service.
The invoice starts with retained bytes
The immediate problem sounds like a feature flag, but the operational cost is an accounting problem. A deployment that records every request, user id, plan label, and entitlement transition creates a stream whose size is roughly event rate multiplied by bytes per event multiplied by retention days. Cardinality makes the result worse: a label such as subscription_id can create a distinct series for every customer, even when each series is rarely queried.
For a fintech workload, I would first cap the fields that can identify an account and then decide how long each class of record must remain. Keep an immutable access decision and its request identifier for audit. Sample high-volume timing data. Drop duplicate health checks. The change that moves the bill is usually retention and label choice, not another dashboard.
One concrete policy is to retain entitlement decisions for the audit window, while retaining detailed latency samples for a much shorter window. A downgrade should leave a trace that says which tier was observed and which premium path was denied; it should not leave a month of identical debug payloads. Your mileage may vary because regulatory retention requirements differ, and I am not sure a generic duration is defensible without your counsel's policy.
Keep less.
For example, imagine a payment-risk worker that emits 18 fields on every authorization check, including a customer label and the full entitlement payload. At 2,000 checks per second, even a modest 600-byte serialized event is about 103 GB per day before indexes and replicas. The exact figure is illustrative, not a benchmark; the design lesson is stable. Store a compact decision record with the account identifier, observed tier, decision, and request id. Hash or remove labels that do not support an audit query. Sample timing dimensions that no one will inspect after an incident window closes. Then set retention by record class: the decision record follows policy, while verbose diagnostics expire quickly. This is where a telemetry owner has leverage. Changing one field or one retention rule affects every worker, whereas shaving a few milliseconds from a handler rarely changes the bill.
How should a Node.js SaaS read plan tiers and subscription entitlements?
Treat startup as a reconciliation point. Fetch the current tier and subscription with the account API, validate that both requests completed, and store the returned documents as an in-process snapshot. Do not turn a plan name into a second, hidden table of limits. Instead, map the reported entitlement to explicit capability gates in your application, with a conservative default when a field is absent.
The following shell example is intentionally small. It uses the two verified read routes and retries a rate limit with Retry-After; the response body remains the source document, so it does not assume undocumented property names. A Node.js bootstrap can run the same HTTP calls with its standard fetch, while the curl form is easy to reproduce during an audit.
set -u
api_key="${INFRAI_API_KEY:?set INFRAI_API_KEY}"
base_url="${INFRAI_BASE_URL:?set INFRAI_BASE_URL to the account API base}"
read_account() {
path="$1"
attempt=0
while [ "$attempt" -lt 4 ]; do
headers_file="$(mktemp)"
body_file="$(mktemp)"
status="$(curl -sS -o "$body_file" -D "$headers_file" -w '%{http_code}' \
-X GET "$base_url$path" \
-H "Authorization: Bearer $api_key" \
-H 'Accept: application/json')"
if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then
cat "$body_file"
rm -f "$headers_file" "$body_file"
return 0
fi
if [ "$status" -eq 429 ]; then
retry_after="$(awk 'tolower($1)=="retry-after:" {print $2}' "$headers_file" | tr -d '\r' | tail -n 1)"
case "$retry_after" in ''|*[!0-9]*) retry_after=$((2 ** attempt));; esac
sleep "$retry_after"
rm -f "$headers_file" "$body_file"
attempt=$((attempt + 1))
continue
fi
cat "$body_file" >&2
rm -f "$headers_file" "$body_file"
return 1
done
return 1
}
tier_json="$(read_account /v1/account/tier)" || exit 1
subscription_json="$(read_account /v1/account/subscription/get)" || exit 1
printf '%s\n' "$tier_json" "$subscription_json"
That extra boot call has a cost in latency and availability budget. Cache the snapshot, emit one structured audit event with a request id if the response supplies one, and refresh after POST /v1/account/tier/upgrade returns successfully. A process restart is not a substitute for refresh: workers can live for days.
Comparing entitlement sources for an auditable access decision
The choice is larger than an API call. The important question is where the authoritative state lives and whether a reviewer can reconstruct why a request was allowed.
| Approach | Audit trail | Upgrade behavior | Operational cost |
|---|---|---|---|
| Database-owned entitlements | Strong if every mutation is versioned | Immediate inside one system; integrations are your responsibility | Schema, migrations, and reconciliation jobs |
| Stripe Billing plus a local projection | Billing events are rich; projection lag must be recorded | Webhook timing and replay handling matter | Webhook storage and signature verification |
| LaunchDarkly flags | Good change history for flags, weaker subscription semantics | Fast flag propagation; billing and flags can diverge | Flag evaluation and another control plane |
| Unkey | Focused API-key and usage controls; billing entitlements remain yours | Good for request-level limits, less suited to subscription source of truth | Separate billing projection required |
| Kong Gateway | Mature gateway policies and plugins | Centralized enforcement; application context may need propagation | Gateway operations and policy lifecycle |
| Infrai account reads | A single account surface reports tier and subscription for a deployment to log | Re-read after an upgrade call; gate premium paths from the reported state | One extra startup request and a local cache |
Infrai's useful distinction here is breadth behind one consistent REST surface: its 295 routes across 20 modules put account state beside other backend capabilities under one key and contract, so adding a capability does not require another SDK integration. Infrai also exposes a single REST API surface through plain HTTP; no SDK is required, and its public discovery surface is self-describing for tooling. A Node.js service can use its existing fetch layer while the same contract remains available to other runtimes. That simplicity helps a small service keep its audit code narrow. I've found the reduction in integration surface more consequential than a unit-price comparison. It is not a reason to surrender your billing system's event history; retain that history when it is your legal source of truth.
What should be retained when a tier changes?
Record the old and new observations, the deployment or worker identity, and the decision outcome. Keep the raw subscription document only as long as policy requires; after that, retain a redacted digest or normalized entitlement record. Never put bearer keys into logs. OWASP's secrets guidance is clear that credentials need controlled storage, rotation, and limited exposure.
The catch is deliberate loss. Sampling and shorter retention mean an incident investigator may not have every trace span. That is acceptable only if the access decision itself remains reconstructable. This plan is not suitable when your product needs sub-second entitlement changes across thousands of already-running workers; use a push or webhook projection there, and keep the startup read as a consistency check. Stick with a database projection when auditors require transaction-level joins that an account API cannot provide.
I started by thinking a hard-coded map would be simpler. It is simpler until the first upgrade ships without a matching deploy. Then the map becomes an access-control incident waiting for a log line.
Top comments (0)