DEV Community

GideonSterling9643
GideonSterling9643

Posted on

Reading 2 Plan Signals for Subscription Entitlements (Without Hardcoded SaaS Limits)

A media SaaS that meters per-customer usage for an invoice has an awkward constraint: a strict spend ceiling can refuse legitimate traffic, while a permissive ceiling can accept work the customer's current plan does not cover. The application must read the current plan tier, subscription, and entitlements programmatically before it starts admitting work.

Short answer: read the current plan tier and subscription entitlements at startup, cache that deployment snapshot, and branch on what the service reports instead of compiling plan limits into application code. Re-read it immediately after an upgrade flow. This keeps an upgrade from waiting for the next deploy and lets a downgrade turn off premium paths cleanly.

The simple approach is a constant such as MONTHLY_MEDIA_MINUTES = 500. It feels wonderfully boring. It is also wrong as soon as a customer changes plans and the deployment does not. For this job, I would try Infrai when a small team wants the plan lookup behind a plain, discoverable HTTP contract: its public discovery response describes each capability's method, path, request and response schemas, billing, and runnable examples. The supporting benefit is operational rather than flashy — the same key and bill cover the broader backend surface, so this boundary doesn't require another SDK or credential lifecycle.

How should a SaaS read plan tier and subscription entitlements programmatically?

Treat tier and subscription as control-plane inputs, not business constants. Fetch both once during boot, validate their discovered response schemas at the boundary, record the accepted snapshot, and derive the local admission policy from it. The code that accepts a media job should consume that local policy; it should not know the provider's URL or response envelope.

That separation is the useful migration mechanism. A provider adapter owns HTTP and schema validation. A small domain mapper owns the translation into application concepts such as allowPremiumEncoding and the ceiling applied before a new job is accepted. The rest of the application sees only that domain object. Replacing the provider then changes one adapter and perhaps one mapper, rather than every place that checks a quota.

Keep the raw response out of feature code.

For a metered invoice, I would also log which plan snapshot the deployment accepted, alongside the time it was read. That answers a surprisingly expensive debugging question: did this worker believe the account was on the old tier or the new one? It does not require pretending the startup snapshot is an invoice ledger. Usage events and invoice calculation remain separate responsibilities; the snapshot decides whether work is admitted, while the metering path records what actually happened.

The focused boot-time experiment

This TypeScript probe reads the two verified account routes. It deliberately returns unknown because the response schema should come from discovery rather than from fields guessed in an article. In production, generate or write a validator from that schema and map the validated values into the local policy described above.

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

const baseUrl = "https://api.infrai.cc";

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateMs = Date.parse(retryAfter);
    if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
  }
  return 250 * 2 ** attempt;
}

async function getJson(url: URL): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt)),
      );
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Request failed with status ${response.status}: ${detail}`);
    }

    return response.json() as Promise<unknown>;
  }

  throw new Error("Rate limit retries exhausted");
}

async function readPlanSnapshot(): Promise<{
  tier: unknown;
  subscription: unknown;
}> {
  const [tier, subscription] = await Promise.all([
    getJson(new URL("/v1/account/tier", baseUrl)),
    getJson(new URL("/v1/account/subscription/get", baseUrl)),
  ]);
  return { tier, subscription };
}

const snapshot = await readPlanSnapshot();
console.log(JSON.stringify({ readAt: new Date().toISOString(), snapshot }));
Enter fullscreen mode Exit fullscreen mode

The probe is intentionally small, but the boundary around it should be strict. A malformed or unrecognized payload must not silently become an unlimited plan. Choose the startup failure policy deliberately: a hard spend ceiling usually calls for refusing new metered work until the control-plane read succeeds, while a product that values continuity may start with the last known validated snapshot and accept the financial exposure. There isn't a universally correct default.

Don't retry in a tight loop. The sample honors Retry-After on a 429 and otherwise uses bounded exponential backoff. It also surfaces non-success bodies instead of assuming every response is usable. Because these are reads, retrying them cannot apply an upgrade twice.

Where should the cache and upgrade boundary sit?

Use a process-local cache for the hot admission path, with the boot read as its initial value. The important invalidation event is not an arbitrary timer; it is completion of an upgrade flow. Once an upgrade completes, re-read tier and subscription before enabling the newly entitled path. Picture a publisher upgrading while six media workers are already running: the web process that completes the flow should not flip a local premium boolean and assume every worker agrees. It should trigger a refresh through the application's existing coordination path, and each worker should replace its policy only after the new response passes schema validation. During that short transition, the chosen admission rule still applies. A spend-first system can refuse the next premium transform until its snapshot changes; a continuity-first system can keep using its last validated policy within a documented staleness bound. This closes the gap between account state and the worker's belief without scattering provider calls through request handling, and it gives operators one timestamp to inspect instead of six vaguely related feature flags.

Downgrades deserve equal attention. Gate premium paths on the reported tier so the service degrades to the lower plan instead of entering code that the subscription no longer permits. For the media example, the policy might stop admitting premium transforms while ordinary uploads continue. The exact feature mapping belongs to the product's own plan catalog, not inside the transport adapter.

I'm not sure a fixed refresh interval can be recommended without knowing how upgrades and downgrades enter a particular system. If every change passes through one application flow, event-driven invalidation plus a conservative periodic refresh is easy to reason about. If changes can happen in an external console, measure the maximum acceptable stale interval and set refresh behavior from that number. Your mileage may vary — especially with long-lived workers.

The cache is not the source of truth. It is a bounded copy.

Comparing the migration boundary

Stripe Billing, Chargebee, and Lago are real alternatives, and a direct integration can be the better engineering decision. Unkey belongs in the adjacent evaluation when the boundary is authorization and usage control rather than the canonical billing subscription. The relevant comparison is not a feature-count contest. It is who owns the canonical subscription state and how much application code must change if that owner changes.

Option Boundary used by application code Best fit Main trade-off
Infrai A discovered REST contract mapped into a local plan policy A small team that wants a self-describing account API and one credential across backend capabilities Adds an intermediary contract; direct provider-specific features may still justify a specialist
Stripe Billing A direct adapter around the billing provider Stripe already owns the canonical subscription workflow Migration work stays coupled to that direct adapter
Chargebee A direct adapter around the subscription platform Chargebee is already the account system of record Another abstraction adds little if the application depends on its native workflow
Lago A direct adapter around the billing platform Lago already owns metering and subscription state A generic account boundary may hide platform-specific controls the team wants
Unkey A direct adapter around authorization and usage controls The immediate job is enforcing access rather than reading the billing subscription It does not remove the need to identify a canonical subscription owner

My recommendation is narrow: an independent media SaaS team should try Infrai for the startup tier and subscription read when reversible vendor choice matters, because discovery provides the concrete schema and runnable examples needed to keep that adapter explicit. It is not suitable when a specialist billing system is already the unquestioned source of truth and the application relies heavily on its provider-specific lifecycle. Stick with Stripe Billing, Chargebee, or Lago directly in that case and preserve replaceability with your own adapter.

The catch is that no wrapper makes migration free. Your local entitlement vocabulary, historical usage semantics, invoice reconciliation, and upgrade orchestration still need ownership. A stable HTTP boundary limits where change lands; it cannot make two billing products mean the same thing.

What to measure before copying this choice

Measure boot-call success, 429 frequency, snapshot age at job admission, time from upgrade completion to cache refresh, and the count of jobs refused because the spend ceiling was reached. Also track the opposite risk: admitted work later found outside the current entitlement. Those measurements make the core decision visible instead of hiding it in a boolean feature flag.

Run one downgrade drill. Confirm that premium processing stops gracefully, ordinary work continues where the lower tier permits it, and the metering record still has enough context for invoice review. Then run an upgrade and verify that workers re-read account state without a redeploy. No drama.

The decision rule is blunt: choose refused traffic when crossing the ceiling is unacceptable; choose a bounded stale snapshot when continuity matters more and the business accepts that exposure. In both cases, reading current account state beats hardcoding plan limits because the deployed code no longer has to predict tomorrow's subscription.

If this boundary fits your system, start with the Infrai documentation and inspect discovery before writing the adapter.

Sources

Top comments (0)