DEV Community

nathanielbrooks0360
nathanielbrooks0360

Posted on

Small SaaS Prepaid API Balance: Auto-Recharge or Manual Top-Ups in 4 Audit Gates

An education SaaS outage rarely starts with a dramatic server failure. It starts when a prepaid API balance crosses an undocumented line, a background worker keeps accepting jobs, and the first useful alert arrives after students have already lost a lesson. In a small SaaS, the same operator may own the API balance, the auto-recharge rule, and the manual top-up approval, so the audit trail has to survive a handoff at 09:00 and an incident at 09:17. The balance view must show pending reservations and the daily ceiling, not just the provider's last settled number; otherwise an apparently healthy balance can authorize a burst that the account cannot actually fund. That is the trap.

Short answer: use automatic recharge for continuity, but put it behind a hard trigger threshold, a per-day ceiling, an idempotency key, and a manual freeze path; manual top-ups alone are appropriate only when an operator can watch the balance during every demand spike.

The important design question is not which payment button you choose. It is whether an auditor can reconstruct who authorized each credit, why the system spent it, and which control stopped further spend.

The alert page is the end of the story

Picture the on-call page at 09:17. A tutoring workflow has started its morning batch, the provider balance is below the configured floor, and requests are returning a business-level “insufficient credit” response. The API servers are healthy. CPU is boring. The queue is not. Retries multiply the same paid request while a support engineer asks whether someone can add funds.

That page should have fired earlier, when the projected balance crossed the floor, not when the provider rejected work. The useful signal is a sequence: current balance, burn rate, pending authorization, recharge attempts, and a clear stop reason. A single “balance low” gauge cannot distinguish a normal class-hour burst from a stuck retry loop.

I start with a ledger, even for a small SaaS. Each debit and credit gets an immutable event ID, tenant or course scope, actor (human or service), reason, amount, and UTC timestamp. The ledger is not the provider statement; it is the evidence that lets the platform team reconcile the provider statement with application intent.

The alert then reads from two projections: available balance and reserved balance. Reserving credit before dispatch prevents ten workers from all seeing the same healthy balance and spending it. Releasing a reservation must be idempotent, because a timeout can leave the caller unsure whether the provider accepted the request.

One short rule helps: stop accepting new paid work when available - reserved <= floor. Existing work can drain under a bounded grace period. That distinction is what keeps a protection mechanism from becoming a surprise outage amplifier.

What should an auto-recharge policy record for auditability?

The trigger should be a policy object, not a magic number hidden in a worker. At minimum, record the threshold, recharge amount, daily ceiling, currency, approval mode, and the policy revision that made the decision. Store the evaluated balance and burn-rate sample alongside the decision. If the threshold changes, the old revision remains readable.

Here is the decision path in Go. It is deliberately provider-neutral: the payment adapter is a boundary, while the audit event is part of the platform contract.

package balance

import "time"

type Policy struct {
    FloorCents       int64
    RechargeCents    int64
    DailyCeilingCents int64
    Revision         string
}

type Snapshot struct {
    AvailableCents int64
    ReservedCents  int64
    ChargedToday   int64
    ObservedAt     time.Time
}

type Decision struct {
    Action      string
    Reason      string
    PolicyRev   string
    ObservedAt  time.Time
}

func Decide(p Policy, s Snapshot) Decision {
    remaining := s.AvailableCents - s.ReservedCents
    if remaining > p.FloorCents {
        return Decision{"hold", "above_floor", p.Revision, s.ObservedAt}
    }
    if s.ChargedToday+p.RechargeCents > p.DailyCeilingCents {
        return Decision{"freeze", "daily_ceiling", p.Revision, s.ObservedAt}
    }
    return Decision{"request_recharge", "at_or_below_floor", p.Revision, s.ObservedAt}
}
Enter fullscreen mode Exit fullscreen mode

The adapter that executes request_recharge must accept an idempotency key derived from the policy revision and a stable balance-window ID. A retry of the decision can then return the original authorization rather than creating a second charge. Keep authorization and capture separate if the payment system supports it; the ledger should show both transitions.

Secrets deserve the same audit trail as money. Keep payment credentials in a secrets manager, restrict which service identity can read them, rotate them on a schedule, and log access without logging the secret value. OWASP's secrets guidance is a useful baseline here, especially its separation of secret handling from application configuration.

Auto-recharge versus manual top-ups: which control fits a small SaaS?

The choice is operational, not ideological. Auto-recharge reduces the time between a low-balance signal and restored capacity, but it can turn a runaway retry loop into a runaway bill. Manual top-ups make every spend visible to a person, but they move recovery latency into the on-call schedule.

Control model Strength Failure mode Audit evidence to require
Auto-recharge with ceiling Fast recovery during class-hour demand Repeated triggers can consume the daily cap Policy revision, idempotency key, approval result
Manual top-up Explicit human authorization Coverage gaps and slow recovery Actor, ticket or incident ID, before/after balance
Hybrid reserve Automatic small refill, human approval for escalation More states to explain Reservation events, escalation decision, freeze reason
Hard stop Prevents uncontrolled spend Legitimate jobs are rejected Rejection reason, affected scope, operator override

For a small team, I usually choose the hybrid shape: a modest automatic refill that cannot exceed the daily ceiling, plus a manual approval route for a second refill. The catch is that this is not suitable when every request must have a human purchase order, or when finance cannot reconcile provider charges daily; stick with manual top-ups and accept the recovery delay.

Capacity planning belongs in this decision. Set the floor high enough to cover the time to detect, approve, and settle a refill, plus the largest expected burst. Do not multiply average hourly spend by a vague safety factor. Use a class schedule, queue depth, reservation TTL, and the longest observed provider settlement time. Your mileage may vary across regions and billing rails, so record the assumptions with the policy instead of pretending the number is universal.

How do you test thresholds without creating a spending incident?

Test the state machine with a fake payment adapter and a replayable ledger. A useful test set crosses the boundaries: balance one cent above the floor, exactly at the floor, a reservation that consumes the remaining credit, and a recharge that would exceed the daily ceiling. Replay the same event ID twice and assert one ledger effect. Advance the clock over UTC midnight and verify that the ceiling resets only once.

Then test the ugly path. Inject a timeout after the adapter accepts a charge but before the worker receives the response. The retry must query by idempotency key, not blindly charge again. Inject a delayed ledger write and verify that dispatch remains blocked until the reservation is durable. Send two policy revisions at once and ensure the newer revision wins without deleting the older audit record.

The alert itself needs an SLO. For example, define a target for detecting a floor crossing and another for placing a freeze after the ceiling is reached. The exact durations belong to your traffic pattern and settlement contract; the important part is measuring them separately from API latency. A green request SLO does not prove that spend protection is working.

I would also run a dry-run month. Decisions are logged as would_recharge while the existing manual process remains authoritative. Compare projected refills, false positives, and the number of times the ceiling would have prevented a real class session. This is where a threshold that looked prudent on a spreadsheet usually reveals itself as noisy.

The dry run should preserve the awkward details instead of smoothing them away: a scheduled import that reserves credit and is cancelled ten minutes later, a teacher retrying a browser request after a mobile handoff, a weekend batch that begins before the finance team is online, and two workers that observe different policy revisions while a deployment rolls through. For each case, keep the raw snapshot, the decision, the adapter response, and the eventual reconciliation result. Compare the projected balance with the settled provider statement at the end of each UTC day, then inspect every difference above a deliberately small tolerance. If the ledger says one refill and the statement says two, the investigation should start from idempotency keys and event IDs, not from a guess about which dashboard is right. That evidence also tells you whether the floor is protecting the student-facing SLO or merely paging the same operator more often. A month is long enough to include a billing boundary and a normal teaching break, but short enough to change the policy before the next enrollment cycle.

Measure it.

The operational rule I would publish

Publish the policy beside the runbook, not in a private dashboard. It should name the balance floor, reservation behavior, daily ceiling, freeze owner, escalation window, and reconciliation query. Every operator should be able to answer “why did this recharge happen?” with a ledger event and a policy revision, not a screenshot.

Do not make price the primary decision. The durable advantage of a well-designed account platform is one auditable control plane across balances, reservations, approvals, and provider adapters. If the platform cannot expose those events, a cheaper refill is irrelevant because the missing evidence becomes an incident cost.

The limitation is real: automatic recharge cannot solve an incorrect forecast, a compromised credential, or a provider settlement delay. Keep the hard stop. Keep a manual path. For a tiny SaaS with low and predictable usage, manual top-ups may remain the simpler and more accountable answer; for scheduled education bursts, bounded automation usually protects the student-facing SLO better.

References

Top comments (0)