Attributing API spend to each tenant and charging it back

One key per tenant plus the usage breakdown turns cost attribution into a read instead of a reconciliation project. The pattern, the limits, and the ratios to watch.

Per-tenant cost attribution on Infrai is a read, not a pipeline. GET /v1/account/usage returns a per-capability cost breakdown for the period, GET /v1/account/usage/timeseries buckets the same data over time, and POST /v1/account/keys/create gives you the dimension to slice by. Everything your tenants consume — inference, storage, email, queues, PDF rendering — lands in one view because it’s all on one account.

That’s the structural difference from a stack of point vendors, where the same question means exporting from six dashboards and hoping the period boundaries line up.

What the usage read actually returns

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "period": "30d",
    "period_start": "2026-08-22T00:00:00+00:00",
    "period_end": "2026-09-21T02:45:30+00:00",
    "total_cost": 147.77267529,
    "total_calls": 2787232,
    "total_failed_calls": 0,
    "cache_hits": 610,
    "cache_savings": 0.0,
    "breakdown": [
      {"key": "storage.object.put", "label": "storage.object.put", "cost": 71.8075, "calls": 718075, "failed_calls": 0},
      {"key": "ai.chat", "label": "ai.chat", "cost": 58.58761455, "calls": 5872, "failed_calls": 0},
      {"key": "pdf.generate", "label": "pdf.generate", "cost": 8.745, "calls": 583, "failed_calls": 0}
    ]
  }
}

Note what’s in there beyond the total: failed_calls per capability, and cache_hits with cache_savings. Failed calls that still cost money are the line item nobody budgets for, and a cache-savings figure of zero on a workload with 610 hits tells you something about what your traffic looks like.

The dimension you have to add

The breakdown is by capability, not by tenant. Nothing in that response says which of your customers spent the $58 on inference.

So you add the dimension yourself, and the cheapest version is one key per tenant:

curl -sS -X POST "https://api.infrai.cc/v1/account/keys/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name": "tenant:northwind:prod", "scopes": ["ai", "storage", "email"]}'
{
  "ok": true,
  "data": {
    "key_id": "ifr_...4c4a",
    "key_secret": "your_infrai_api_key_value_shown_once",
    "name": "tenant:northwind:prod",
    "tier": "standard",
    "scopes": ["ai", "storage", "email"],
    "status": "active",
    "created_at": "2026-09-21T02:45:00Z"
  }
}

The key_secret is shown once. Store it wherever you keep tenant credentials, and put the tenant id in the name with a scheme you can parse — tenant:<id>:<env> is enough, and it survives someone reading the key list six months from now.

scopes is the other half. A key that can only reach the capabilities a tenant actually uses is a key whose blast radius you can describe.

Reading it back per key

curl -sS "https://api.infrai.cc/v1/account/keys/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Each item carries key_id, name, status, created_at, last_used_at and last_used_ip. That last field is quietly useful for chargeback disputes — a tenant claiming they didn’t run a job is easier to answer when the key’s last use has an address attached.

import os
from collections import defaultdict

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})


def tenant_of(key_name: str) -> str:
    """tenant:<id>:<env> -> <id>. Anything else is shared infrastructure."""
    parts = (key_name or "").split(":")
    return parts[1] if len(parts) >= 2 and parts[0] == "tenant" else "_shared"


def spend_by_capability() -> dict[str, float]:
    resp = SESSION.get(f"{API}/v1/account/usage", timeout=20)
    resp.raise_for_status()
    data = resp.json()["data"]
    return {row["key"]: row["cost"] for row in data.get("breakdown", [])}


def tenant_inventory() -> dict[str, list[str]]:
    """Which keys belong to which tenant, so a chargeback report can name them."""
    resp = SESSION.get(f"{API}/v1/account/keys/list", timeout=20)
    resp.raise_for_status()
    grouped = defaultdict(list)
    for item in resp.json()["data"].get("items", []):
        if item.get("status") == "active":
            grouped[tenant_of(item.get("name"))].append(item["key_id"])
    return dict(grouped)


if __name__ == "__main__":
    print({"capability_spend": spend_by_capability(), "tenants": tenant_inventory()})

The limitation, stated plainly

The usage breakdown doesn’t split by key. You get cost per capability for the account, and a list of keys with their names — not a cross-tab of the two. If you need exact per-tenant dollars rather than a proportional allocation, record the cost yourself at call time: every response carries a metadata.cost_usd figure, and writing that alongside your tenant id is a few lines in whatever wrapper already adds the auth header.

That’s the honest answer. The platform gives you the total and the dimensions; the join is yours.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

// Every response carries its own cost in metadata. Capturing it per call is what
// turns "cost per capability" into "cost per tenant" with no estimation.
export async function callWithCostCapture(path, body, tenantId, record) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = await res.json();
  const cost = payload.metadata?.cost_usd ?? 0;
  await record({ tenantId, path, cost, requestId: payload.metadata?.request_id });
  if (!payload.ok) throw new Error(payload.error?.code ?? "call_failed");
  return payload.data;
}

Ratios worth watching, not just totals

SignalRead it fromWhat a bad value means
Failed calls as a share of callstotal_failed_calls / total_callsyou’re paying for retries somewhere
Cost concentrationlargest breakdown row / total_costone capability is your whole bill
Cache savingscache_savingsrepeated prompts you aren’t caching
RunwayGET /v1/account/balance → runway_daystopping up reactively instead of on a schedule

runway_days is the one to put on a dashboard. It’s derived from your own daily average, so it moves before your balance does anything dramatic.

Guardrails on the same key

Attribution without a cap is a report you read after the money’s gone. PUT /v1/account/budget/set takes a hard_cap_usd and a period of daily or monthly, with an alert_threshold_usd below it — so the tenant whose integration starts looping hits a ceiling you chose. POST /v1/account/webhooks/register then pushes the threshold event to your own service rather than making you poll for it, and the alert email goes out through POST /v1/email/send on this same credential. One key, one bill, one place to answer “who spent what”.

Account reads are free: usage, balance, keys/list and budget/get all report billing_class: free in discovery, so the whole reporting layer costs nothing to run. The prices being attributed are live — GET /v1/account/balance even returns an affordable_uses_hint with the current per-capability rate and how many calls your remaining credit buys, which beats any table for freshness (verified 2026-09-21). Those rates move downward as vendor contracts improve, so read them rather than caching them into a spreadsheet.

References

Browse more account developer guides