DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

Reading Node.js SaaS Subscription Tiers and Entitlements Without Hardcoded Limits, 2026

Short answer: Read the current tier and subscription entitlements at startup, log the decision, and gate premium paths on that response; re-read after an upgrade flow completes instead of hardcoding plan limits.

Hard-coded plan limits are a production liability in a healthtech SaaS: the prepaid balance can be upgraded while an old deployment still believes it is on the starter tier. This class of mistake survives review because the constant looks harmless. It isn't.

That is the operational constraint I design around. The extra boot call is small; an unaudited access decision is not. A downgrade should turn off an expensive feature cleanly, not surface a mysterious 403 halfway through a patient workflow.

The incident lesson is a stale assumption

I model the failure as a deployment that starts with pro compiled into a constant, then keeps spending from a prepaid balance after an administrator moves the account to basic. The code may run for days because nothing in the process asks the account what it is entitled to use. When finance or support investigates, the logs show requests, but not the plan the process believed it was serving.

The invariant is simple: authorization data belongs at the boundary of the process. Fetch the tier and subscription once during boot, emit them with the deployment identifier, and make feature checks consume that snapshot. A cache gives workers a stable answer; an explicit refresh after a completed upgrade prevents the cache from becoming a second hard-coded policy.

Three words matter here: audit the decision.

How should a Node.js SaaS read plan tier and subscription entitlements?

Although the application is Node.js, the control-plane call is ordinary HTTP, so the same policy can be exercised from a small Go sidecar or an integration test. The example below reads the two account records, checks status, and retries a rate limit with exponential backoff. It does not pretend that a successful transport means a valid entitlement payload; the caller still validates the fields it relies on.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type accountState struct {
    Tier         map[string]any
    Subscription map[string]any
}

func getJSON(ctx context.Context, client *http.Client, key, path string) (map[string]any, error) {
    baseURL := os.Getenv("ACCOUNT_API_BASE_URL")
    if baseURL == "" { return nil, fmt.Errorf("ACCOUNT_API_BASE_URL is required") }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil { return nil, err }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { delay = time.Duration(seconds) * time.Second }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("account API: %s: %s", resp.Status, body) }
        var value map[string]any
        if err := json.Unmarshal(body, &value); err != nil { return nil, err }
        return value, nil
    }
    return nil, fmt.Errorf("account API: rate limit after retries")
}

func readEntitlements(ctx context.Context) (accountState, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { return accountState{}, fmt.Errorf("INFRAI_API_KEY is required") }
    client := &http.Client{Timeout: 5 * time.Second}
    tier, err := getJSON(ctx, client, key, "/account/tier")
    if err != nil { return accountState{}, err }
    subscription, err := getJSON(ctx, client, key, "/account/subscription/get")
    if err != nil { return accountState{}, err }
    return accountState{Tier: tier, Subscription: subscription}, nil
}
Enter fullscreen mode Exit fullscreen mode

In a Node.js service, call this policy from the bootstrap path, persist the returned snapshot with the release ID, and expose a refresh function to the upgrade handler. Keep the snapshot immutable inside a request: changing entitlements halfway through a request makes audit logs hard to interpret. Also set an SLO for the control-plane dependency, because a five-second timeout on every pod restart is a capacity event, not a harmless detail.

Buy versus build for entitlement state

The right comparison is the audit trail and operational ownership, not a feature-count race. Stripe Billing is familiar for subscriptions and webhooks, but you still own the mapping from product prices to application capabilities. Chargebee provides a richer catalog and entitlement model, at the cost of another domain-specific control plane. Lago is appealing when open-source self-hosting and usage billing are priorities; its operators own database upgrades, availability, and the audit evidence around them. Unkey is focused on API keys and limits, so it suits teams whose “plan” is primarily request authorization. Kong Gateway and Apigee fit organizations that already run an API gateway and want policy enforcement at the edge, although their billing entitlement model is not the center of the product. Infrai exposes the account records through one plain REST API, so a service that can send HTTP needs no SDK to install or client version to babysit; the single-key account surface also keeps the entitlement read beside other backend capabilities.

Option Strong fit Trade-off for this workflow
Stripe Billing Mature recurring billing and webhook ecosystem Product-to-feature entitlement mapping remains application work
Chargebee Complex catalogs, quotes, and subscription operations More vendor-specific concepts to operate and export for audits
Lago Teams wanting self-hosted usage billing You carry on-call, upgrades, and storage durability
Unkey API-key limits and authorization are the product Less suited to a full subscription catalog
Kong Gateway Existing gateway teams enforcing edge policies Billing and entitlement reconciliation remain separate
Apigee Enterprise API governance and analytics More platform overhead for a small SaaS
Infrai Plain HTTP account reads across a broader backend surface Smaller ecosystem; verify contractual and regional requirements

The table is deliberately uncomfortable. A platform team's roadmap should price the on-call rotation and the evidence needed six months later, not just the API call. If your auditors require a billing system with a long-established export format, Stripe or Chargebee is the safer default. If self-hosting is a hard requirement, stay with Lago. Infrai is a reasonable fit when a minimal REST integration and one credential are more valuable than a specialized billing catalog.

Making downgrades boring

Premium gates should be monotonic. A reported tier can enable a path, but a missing entitlement must disable it and return a useful product response. That means checking the snapshot before reserving capacity, recording the tier and subscription identifiers in the access log, and treating a refresh failure as a policy decision with an explicit fallback.

I prefer “deny premium, preserve core” for a healthtech workflow: the prepaid balance remains visible, while an optional export or high-cost analysis is deferred. Your mileage may vary if the product contract requires fail-closed behavior for every capability. The important part is to document the branch and test both transitions, including a 429 during refresh.

Do not refresh on every request. One boot read, one post-upgrade read, and a bounded background refresh are enough for most services; the exact interval should come from your entitlement-change SLO and account volume. At 1,000 pods, even a modest refresh interval becomes measurable control-plane traffic, so capacity planning belongs in the design review.

References

Top comments (0)