Separating dev, staging and production with scoped API keys

One key per environment, scoped to the capabilities that environment actually needs, named so the list still makes sense in six months.

Running dev, staging and production off one Infrai key works right up until a test suite sends a real email to a real customer. POST /v1/account/keys/create takes a name and a scopes array, so the fix is three keys whose capabilities differ — and the one your test suite holds simply cannot reach the send route.

Scopes are the enforcement. Naming is what makes the arrangement survivable a year later.

Create the three keys

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": "app:prod", "scopes": ["ai", "storage", "email", "sms", "queue", "cron", "errors"]}'
{
  "ok": true,
  "data": {
    "key_id": "ifr_...53b9",
    "key_secret": "your_infrai_api_key_value_shown_once",
    "name": "app:prod",
    "tier": "standard",
    "scopes": ["ai", "storage", "email", "sms", "queue", "cron", "errors"],
    "status": "active",
    "created_at": "2026-09-21T02:45:00Z"
  }
}

Then a narrower one for staging, and a deliberately crippled one for local development:

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": "app:dev", "scopes": ["ai", "storage", "queue", "errors"]}'

No email, no sms. A developer who accidentally triggers the notification path gets SCOPE_INSUFFICIENT — a clear, typed refusal at the platform boundary rather than a message arriving at someone’s phone.

That’s the whole trick, and it’s better than an environment check in your code because it doesn’t depend on your code being right.

Name them so the list reads well

GET /v1/account/keys/list returns key_id, name, status, created_at, last_used_at and last_used_ip. In six months that list is your only map, and name is the only field you control.

Use a scheme with parts you can parse: app:prod, app:staging, worker:prod, tenant:northwind:prod, ci:github-actions. Then a report is a split, not a guessing game.

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 inventory() -> dict[str, list[dict]]:
    """Group live keys by environment so an audit is a read, not an archaeology
    project. Anything unparseable lands in 'unknown', which is exactly the list
    you want to shrink to zero."""
    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", []):
        name = item.get("name") or ""
        parts = name.split(":")
        env = parts[-1] if len(parts) >= 2 else "unknown"
        grouped[env].append({
            "key_id": item["key_id"],
            "name": name,
            "status": item.get("status"),
            "last_used_at": item.get("last_used_at"),
            "last_used_ip": item.get("last_used_ip"),
        })
    return dict(grouped)


if __name__ == "__main__":
    for env, keys in sorted(inventory().items()):
        print(f"{env}: {len(keys)} key(s)")
        for k in keys:
            print(f"   {k['key_id']}  {k['name']:<28} {k['status']:<8} last used {k['last_used_at']}")

What scoping buys you, honestly

RiskScoped keys help?
Test suite emailing real usersyes — the route is unreachable
Dev machine sending SMSyes
Leaked dev key used to spend on inferencepartly — it can still call ai
One environment’s spend attributed separatelyno — usage breaks down by capability
Staging writing to production datano — scopes are capabilities, not datasets

Two real limitations sit in that table. Scopes don’t isolate data: a staging key with storage can read the same buckets as production unless you separate them by bucket name and enforce that in your own code — scopes are capability boundaries, not tenancy boundaries, and treating them as the latter is the mistake to avoid. And GET /v1/account/usage breaks down by capability rather than by key, so per-environment cost needs you to capture metadata.cost_usd per call and tag it yourself.

Rotate the noisy ones on a schedule

A dev key that’s been on twenty laptops is the one to rotate often. POST /v1/account/keys/rotate/{id} with grace_hours issues the new secret while the old one keeps working:

curl -sS -X POST "https://api.infrai.cc/v1/account/keys/rotate/ifr_...53b9" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"grace_hours": 48}'

Watch last_used_at on the old key_id before you revoke, and give a shared dev key a longer window than a service key — humans update their .env more slowly than a deploy does.

To adjust what an existing key can reach without reissuing it, PATCH /v1/account/keys/update/{id} takes name and scopes. That’s the call for “staging now needs email but only email”.

CI deserves its own key

A CI key is a shared secret in a system many people can trigger, so treat it like a dev key with a smaller scope and a shorter life. Name it for the runner (ci:github-actions), scope it to what tests genuinely exercise, and put a budget ceiling underneath everything with PUT /v1/account/budget/set — a test loop is the classic way to discover you had no cap.

All of this is one credential’s worth of administration, which is the part that doesn’t transfer to a stack of separate vendors: the keys, their scopes, the spend they produce, the alert email through POST /v1/email/send and the rotation reminder on POST /v1/cron/create are the same account and the same invoice. Six vendors means six key regimes and six audits.

Every key-management route reports billing_class: free in discovery, so this whole arrangement costs nothing to operate. Your live rates and remaining credit are in GET /v1/account/balance, which returns an affordable_uses_hint per capability (verified 2026-09-21) — read it there, and expect platform rates to drift downward rather than up.

References

Browse more account developer guides