DEV Community

IrvinCole5861
IrvinCole5861

Posted on

Set Hard Spend Cap API Required Fields for Tenant Credentials

To set a hard spend cap through an API, first quantify the bill as metered operations multiplied by their unit cost, then constrain how many operations one tenant credential can authorize in a period. For a B2B SaaS account platform that issues and revokes one scoped key per tenant, the dominant term is maximum authorized units per period x unit cost, plus any operations admitted concurrently at the boundary. An alert cannot constrain that term. An atomic counter checked on the request path can.

TL;DR: create a versioned spend policy whose required state identifies the tenant and credential, fixes the currency or metered unit, defines an unambiguous half-open period, sets a hard limit, and declares enforcement behavior. Treat alert thresholds as notifications, never as the cap. Read the policy back after creation, compare normalized values, and save both the command and observed state in an append-only audit trail. The least complex defensible design is one scoped credential, one authoritative policy version, and one atomic reservation per billable operation.

The dominant storage cost is rarely the current counter; it is the retained decision history. Suppose a tenant produces 10 million billable requests in a 30-day period. Keeping one 300-byte enforcement record per request is roughly 3 GB before indexes and replication, whereas keeping daily aggregates is only 30 rows per credential. Those are illustrative dimensions, not a benchmark. The material change is to retain compact, immutable policy changes and reservation summaries while expiring request-level detail under a documented retention schedule. You deliberately give up indefinite per-request reconstruction; when a dispute arrives after expiry, you can prove policy versions and aggregate movement, but not replay every authorization decision.

How should an API set required fields for a hard spend cap?

A hard cap is a state machine, not a number. The write contract needs enough information to answer who is constrained, what is counted, when counting resets, what happens at exhaustion, and which revision won a concurrent update. For an illustrative vendor-neutral contract, require tenant_id, credential_id, unit, hard_limit, period_start, period_end, enforcement_mode, and version. A request identifier belongs in the command envelope so retries are idempotent.

Use half-open time intervals: the period includes period_start and excludes period_end. Store instants in UTC and reject an end that is not later than its start. If the business says "monthly," resolve that calendar rule into explicit boundaries before enforcement; otherwise daylight-saving changes, locale defaults, and month length leak into the hot path. Represent a limit as an integer in the smallest metered unit, avoiding binary floating-point ambiguity.

Alerts are subordinate policy. A threshold of 800,000 against a 1,000,000-unit ceiling means "notify at 80%"; it must not imply rejection. Model each alert with a threshold, channel-independent event type, and deduplication key. Keep rejection controlled solely by hard_limit and enforcement_mode. This separation prevents failed notification delivery from weakening the financial control.

type SpendPolicy struct {
    TenantID       string    `json:"tenant_id"`
    CredentialID   string    `json:"credential_id"`
    Unit           string    `json:"unit"`
    HardLimit      int64     `json:"hard_limit"`
    PeriodStart    time.Time `json:"period_start"`
    PeriodEnd      time.Time `json:"period_end"`
    Enforcement    string    `json:"enforcement_mode"`
    AlertThreshold []int64   `json:"alert_thresholds"`
    Version        int64     `json:"version"`
}

type PutPolicyCommand struct {
    RequestID string      `json:"request_id"`
    Policy    SpendPolicy `json:"policy"`
}
Enter fullscreen mode Exit fullscreen mode

Validation should be dull and strict: identifiers are nonempty; the limit is positive; every alert is greater than zero and lower than the limit; thresholds are unique; the period is valid; the unit cannot change within an active period; and enforcement mode comes from a closed set.

Reject ambiguity early.

Atomic reservation defines the blast radius

Checking usage and incrementing it in separate operations admits excess spend under concurrency. Ten workers can all observe 990 units used, each authorize 2 units, and collectively cross a 1,000-unit ceiling. The invariant belongs in one atomic storage operation: reserve requested units only when used + requested <= hard_limit. A failed reservation rejects the billable action before side effects begin. This choice has a cost: the authoritative counter is now on the latency-critical request path, and its availability bounds billable work. An asynchronous counter scales writes more freely but cannot promise a hard ceiling because several accepted operations may remain invisible at decision time. For workloads where temporary excess is acceptable and availability matters more than a strict financial boundary, asynchronous metering with alerts may be the better design. It is not suitable when the contractual promise is that no new operation will be admitted beyond the cap.

Distributed execution makes retries unavoidable, so the practical interface uses idempotency. The reservation key can combine tenant, credential, period, and a stable operation identifier. Replaying one operation returns its original reservation result instead of charging twice. Record the policy version beside the decision, making later reconciliation possible even after an administrator changes the ceiling.

Keep credential scope narrow. A tenant key that can invoke every workload gives one disclosure a large blast radius even with a monetary cap; a key restricted to the intended service, action, and tenant bounds what it can do before revocation propagates. OWASP recommends lifecycle controls around secret creation, rotation, revocation, expiration, and auditing. Spend enforcement complements those controls but does not replace them.

There is another race at policy updates. Require compare-and-swap semantics on version, return a conflict for stale writers, and never reset accumulated usage merely because a policy document changed. Reducing a ceiling below current usage should make subsequent reservations fail; it should not rewrite history. Increasing it creates a new auditable revision.

History stays put.

Read back what the server accepted

A successful write response proves little if a server normalized timestamps or lost a concurrent update. Send an idempotent command, retrieve the authoritative representation, normalize both sides under documented rules, and compare every enforcement-relevant field.

Read it back.

func PutAndVerify(ctx context.Context, c *http.Client, baseURL string, cmd PutPolicyCommand) (SpendPolicy, error) {
    body, err := json.Marshal(cmd)
    if err != nil { return SpendPolicy{}, err }

    put, err := http.NewRequestWithContext(ctx, http.MethodPut, baseURL+"/spend-policy", bytes.NewReader(body))
    if err != nil { return SpendPolicy{}, err }
    put.Header.Set("Content-Type", "application/json")
    put.Header.Set("Idempotency-Key", cmd.RequestID)
    resp, err := c.Do(put)
    if err != nil { return SpendPolicy{}, err }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return SpendPolicy{}, fmt.Errorf("policy write returned %s", resp.Status)
    }

    get, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/spend-policy", nil)
    if err != nil { return SpendPolicy{}, err }
    readBack, err := c.Do(get)
    if err != nil { return SpendPolicy{}, err }
    defer readBack.Body.Close()
    if readBack.StatusCode != http.StatusOK {
        return SpendPolicy{}, fmt.Errorf("policy read returned %s", readBack.Status)
    }

    var got SpendPolicy
    if err := json.NewDecoder(io.LimitReader(readBack.Body, 1<<20)).Decode(&got); err != nil {
        return SpendPolicy{}, err
    }
    if !sameEnforcementState(cmd.Policy, got) {
        return SpendPolicy{}, errors.New("read-back state differs from requested policy")
    }
    return got, nil
}
Enter fullscreen mode Exit fullscreen mode

In production, authenticate the client, impose deadlines, bound response bodies, and classify transport failures separately from validation and version conflicts. A timeout after the write is ambiguous; retry with the same idempotency key, then read back. Generating a fresh key turns uncertainty into a second command.

Normalize timestamps to UTC, sort alert thresholds, and compare the server-issued version against the expected transition. Do not silently coerce units or round limits. Persist the request ID, actor, credential fingerprint rather than the secret, old and new policy hashes, server version, and timestamps. Never put raw keys in logs.

Reconciliation and retention close the loop

Request-path enforcement and a ledger answer different questions. The first prevents a new reservation beyond the ceiling. The second proves that accepted reservations, finalized usage, reversals, and the tenant-facing balance agree. Reconcile from immutable events grouped by tenant, credential, and period, then compare the result with the online counter. Any difference is an operational exception with a stable identifier, not a reason to mutate history in place.

Alert delivery also needs idempotency. Emit an event when usage crosses a threshold, keyed by policy version and threshold, then let delivery retry independently. If usage moves from 79% to 83% in one reservation, the 80% event is emitted once. A later reversal should not repeatedly re-arm it unless policy explicitly says so.

Test one unit below the cap, exactly at it, one unit above it, simultaneous reservations, repeated operation IDs, stale versions, a shortened period, and credential revocation racing with authorization. Property tests can assert that accepted net reservations never exceed the applicable limit. Deployments must preserve mixed-version compatibility because old and new workers overlap.

Observability should expose remaining capacity, rejected reservation counts, reconciliation lag, duplicate command counts, and alert-delivery age without labeling metrics by raw credential ID. High-cardinality identifiers belong in trace or audit storage with access controls. Page on loss of enforcement or growing reconciliation lag; an 80% tenant threshold is a business notification, not necessarily an operator incident.

Retain the minimum evidence that supports the dispute and compliance window the organization has adopted: policy revisions, actor and request identifiers, reservation aggregates, reconciliation outcomes, and revocation records. Encrypt the audit store, restrict access, and make deletion controlled and recorded. Request-level records improve forensic reconstruction but multiply storage, privacy exposure, and access-control burden. Aggregation lowers those costs but reduces the questions an investigator can answer later. This is a real limitation, not an implementation detail: once request evidence expires, aggregate reconciliation cannot identify which individual authorization produced a disputed total. Choose the boundary with legal, security, and finance stakeholders, document it, and test expiry.

A sound tenant spend ceiling has three independently testable properties: atomic admission prevents the next operation from exceeding the configured bound, idempotent commands make retries financially harmless, and read-back plus reconciliation shows that declared policy and observed accounting converge. Alerts improve reaction time. They are not enforcement.

Further reading

Top comments (0)