Here's the page you don't want: a tenant's scoped API key starts refusing requests at 03:07 on a Sunday, the support queue behind it stops draining, and the on-call engineer opens a dashboard where every service is green. Use the provisioning path to retire that entire class of page — set a default payment method as an explicit step in automated API account provisioning, so auto-recharge has something to charge before the balance runs low rather than during an incident.
Auto-recharge without a default method is a configuration that silently does nothing until it matters.
For a customer-support platform that issues one scoped key per tenant, the axis that decides this design isn't unit cost, it's auditability of access: which key was issued to which tenant, which policy version authorized spend on that account, and who is allowed to change either without a second pair of eyes. A default payment method on file is a standing authorization to spend, so it belongs on the same audit trail as the key itself, created by the same worker, in the same transaction, with the same record.
The page fires two layers away from the cause
The alert that actually fires is a per-tenant availability alert. Requests on one scoped key are being refused, that tenant's error budget is burning at several times its normal rate, and the service dashboards have nothing to say because the services are fine. One layer down, the account wallet sits at zero. One layer further down, auto-recharge was configured months ago — threshold, top-up amount, all of it — on an account that never had a default payment method attached, so there was nothing for the policy to charge when the threshold was crossed.
The alert is honest. It's just late.
Plan billing headroom the way you plan capacity, in days of runway rather than dollars of balance. A support tenant with a p95 daily burn and a known balance has a runway number you can compute every hour, and an alert at three days of runway gives payment operations a business day to act. That's a boring metric with a boring dashboard tile, and it's the one that would have fired on the Friday instead of paging someone on the Sunday. Most teams skip it because the vendor console already shows a balance, and a balance on a screen looks enough like monitoring to pass a review.
What should automated API account provisioning do about a default payment method?
Treat it as one ordered gate with a hard acceptance test, not as four independent dashboard toggles that someone will finish later:
- Issue the tenant-scoped key and record who requested it, against which contract, with an expiry.
- Set the default payment method, because everything after it is inert without one.
- Configure auto-recharge with the approved trigger and the approved per-day ceiling.
- Read the policy back and compare it, field by field, with the approved version before the account is allowed to serve a single request.
Step four is the one that gets dropped, and it's the only one that produces evidence. Configuration you haven't read back is configuration you're assuming, and an assumption is not something you can hand an auditor six months later when they ask who authorized a $4,000 top-up run. Doing all of it at provisioning time also keeps a finance decision out of the incident path: nobody should be choosing a spend ceiling at 03:07 with a queue backing up, because that's how ceilings get set to "whatever makes the page stop".
Revocation is the same gate in reverse. When a tenant offboards, the scoped key is revoked and the standing payment authorization gets reviewed in the same workflow — otherwise you end up with dormant accounts that can still charge a card nobody remembers adding.
Instrumenting the prerequisite instead of the symptom
The instrumentation change is small: the provisioning worker stops being a fire-and-forget script and starts being a gate that emits a verdict. Two writes and a read, then either an audit record or a refusal. The setup is deliberately dull, which is the point — you want this to be the least interesting part of onboarding a tenant.
The example below takes both request bodies from the approved policy file rather than constructing billing fields in application code, which keeps the worker honest about what finance actually signed off on. It sets an explicit method on every request, sends a stable idempotency key so a retried write can't apply twice, honours Retry-After on 429, and treats any non-2xx response as a refusal to activate.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
type client struct {
http *http.Client
base string // REST base URL for the account API, from INFRAI_BASE_URL
key string
}
func main() {
c := &client{
http: &http.Client{Timeout: 10 * time.Second},
base: mustEnv("INFRAI_BASE_URL"),
key: mustEnv("INFRAI_API_KEY"),
}
tenant := mustEnv("TENANT_ID")
payment := []byte(mustEnv("PAYMENT_METHOD_BODY_JSON"))
policy := []byte(mustEnv("AUTORECHARGE_BODY_JSON"))
if _, err := c.do("POST", "/v1/account/payment_method/set_default", payment, "prov-"+tenant+"-payment"); err != nil {
exit(err)
}
if _, err := c.do("PUT", "/v1/account/autorecharge/configure", policy, "prov-"+tenant+"-recharge"); err != nil {
exit(err)
}
live, err := c.do("GET", "/v1/account/autorecharge/get", nil, "")
if err != nil {
exit(err)
}
if err := readBack(policy, live); err != nil {
exit(err) // account stays inactive and payment operations owns it
}
fmt.Printf("tenant=%s billing prerequisite verified\n", tenant)
}
func (c *client) do(method, path string, body []byte, idem string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem) // same value on every retry
}
res, err := c.http.Do(req)
if err != nil {
return nil, err
}
payload, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
time.Sleep(backoff(res.Header.Get("Retry-After"), attempt))
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: %d %s", method, path, res.StatusCode, payload)
}
return payload, nil
}
return nil, fmt.Errorf("%s %s: rate limited after 4 attempts", method, path)
}
func backoff(retryAfter string, attempt int) time.Duration {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(math.Pow(2, float64(attempt))) * 250 * time.Millisecond
}
// readBack compares the approved policy with what the account reports.
func readBack(approved, live []byte) error {
var want map[string]any
var have any
if err := json.Unmarshal(approved, &want); err != nil {
return err
}
if err := json.Unmarshal(live, &have); err != nil {
return err
}
for field, expected := range want {
actual, ok := lookup(have, field)
if !ok {
return fmt.Errorf("read-back: %q missing from the account policy", field)
}
if fmt.Sprint(actual) != fmt.Sprint(expected) {
return fmt.Errorf("read-back: %q is %v, approved policy says %v", field, actual, expected)
}
}
return nil
}
// lookup finds a field anywhere in a decoded response, so the check doesn't
// depend on how the payload happens to be nested.
func lookup(doc any, field string) (any, bool) {
switch v := doc.(type) {
case map[string]any:
if hit, ok := v[field]; ok {
return hit, true
}
for _, child := range v {
if hit, ok := lookup(child, field); ok {
return hit, true
}
}
case []any:
for _, child := range v {
if hit, ok := lookup(child, field); ok {
return hit, true
}
}
}
return nil, false
}
func mustEnv(name string) string {
v := os.Getenv(name)
if v == "" {
exit(fmt.Errorf("%s is not set", name))
}
return v
}
func exit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Run the same read-back on a schedule, not only at provisioning. A nightly job that walks every active tenant, compares the live policy against the approved one, and opens a ticket on drift costs a few minutes of compute and catches the account somebody adjusted by hand during a busy week.
Buy, build, or bolt it onto the biller you already have
Nobody builds this from nothing. The real question is which existing system owns the payment prerequisite and which one owns the per-tenant key, because the audit trail you can defend is the one that lives in a single place.
| Option | What it gives you | Where the audit trail lives | Main limit |
|---|---|---|---|
| Stripe Billing | Payment methods, invoices, dunning, tax | Stripe events plus your own mapping to tenants | Knows customers, not your API keys — you join the two yourself |
| Unkey | Per-tenant key issue, revoke, rate limits | Key metadata and its audit log | No wallet or payment side at all |
| Kong Gateway | Consumer credentials and quota enforcement at the edge | Gateway admin API and its logs | You operate the gateway, and billing stays elsewhere |
| OpenMeter | Usage metering and aggregation feeding a biller | Metering pipeline | Measures spend, doesn't authorize it |
| Infrai account API | Scoped keys, default payment method and auto-recharge policy behind one plain REST API — no SDK to install, so any language that can send an HTTP request can run the provisioning gate | Your own provisioning records, written from the API responses | Not a billing suite: no invoicing, dunning or tax, so a finance team that needs those keeps a dedicated biller alongside |
The row that matters for a small platform team is the integration shape, not the feature grid. Stripe Billing plus Unkey is two vendors, two sets of credentials, and a mapping table you own forever; that's a fine trade when finance already lives in Stripe and you need invoices and tax handling anyway. The argument for keeping keys, balance and recharge policy together is that the same auth header and the same idempotency contract then apply to the provisioning worker as to everything else it calls, so there's one adapter to test instead of three. Infrai is worth a look at that seam, because it puts consistent conventions over 295 routes across 20 modules behind one API surface, and its discovery endpoint is public with no key required, so you can read the request and response schemas for the provisioning calls before committing to anything.
The catch is real, though. A single provider for keys and money is also a single blast radius, and if your compliance story requires the payment relationship to sit with a processor your auditors already know, that's a governance constraint, not a preference. Stick with the dedicated biller in that case and keep the account API for the key lifecycle.
Where a tight trigger costs more than it saves
Getting the auto-recharge trigger wrong in the safe direction is still wrong. Set the low-balance threshold too tight and it fires on ordinary weekday variance — a Monday ticket spike, a batch reprocess, a customer importing two years of history — and after the third false page in a fortnight somebody routes the alert to a channel nobody reads, which is a strictly worse position than having no alert at all. Set it too loose and the top-up lands after the refusals have already started, which is the failure you built this to avoid.
Pick the trigger from the burn distribution, not from a round number. Three days of p95 runway is a defensible starting point for a support tenant with steady traffic; for a tenant whose volume triples during a product launch, your mileage may vary, and the honest answer is that you need a few weeks of their data before the number means anything.
The other trade-off is the card itself. A default payment method is a standing authorization, and the way to bound it is the per-day ceiling in the recharge policy plus an alert on consecutive top-ups, not leaving the method unset and calling that a control. An account with no default method doesn't fail safe. It fails at 03:07, in front of a customer.
Top comments (0)