Rotating an API key with no downtime and no failed requests
The rotate endpoint issues the new secret and keeps the old one alive for a grace window. How to size that window, and how to prove the old key is idle before you cut.
Key rotation goes wrong in one predictable way: you issue a new secret, revoke the old one, and discover the deploy that picks it up takes four minutes — during which everything using the old key is refused. Infrai’s POST /v1/account/keys/rotate/{id} avoids that with a grace_hours parameter: the new secret works immediately and the old one keeps working until the grace window closes.
Rotation becomes a scheduling problem instead of a coordination problem.
Rotate with a grace window
curl -sS -X POST "https://api.infrai.cc/v1/account/keys/rotate/ifr_...4c4a" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"grace_hours": 24}'
{
"ok": true,
"data": {
"key_id": "ifr_...9f13",
"key_secret": "your_infrai_api_key_value_shown_once",
"old_key_id": "ifr_...4c4a",
"rotated": true,
"name": "tenant:northwind:prod",
"tier": "standard",
"scopes": ["ai", "storage", "email"],
"status": "active",
"created_at": "2026-09-21T02:45:00Z"
}
}
Three fields matter. key_secret is the new value and it’s shown exactly once — lose it and you rotate again. old_key_id is what you’ll be watching until the window closes. And rotated: true confirms this was a rotation rather than a fresh create, which is what you want in an audit log.
The old key stays usable for the grace period you asked for. Nothing you deploy has to be atomic.
Size the window to your slowest consumer
Not your fastest deploy — your slowest thing that holds a credential.
A web service that reads the key from an environment variable picks up a new one in a rolling restart, so an hour is plenty. A nightly batch job holding a key in its config picks it up tomorrow, so you need at least 24 hours. A mobile release with a key baked into a build needs weeks and shouldn’t have had the key in the first place. And a partner integration where someone else has to paste the secret into their own console needs however long it takes them to answer email, which is the real reason grace_hours exists at all.
| Consumer | Minimum grace | Why |
|---|---|---|
| Rolling web deploy | 1 hour | restart picks up the env var |
| Cron and batch workers | 26 hours | must survive one full daily cycle |
| Third-party integration | 72+ hours | a human has to act |
| Anything in a shipped binary | don’t | the key can’t be rotated on their schedule |
Prove the old key is idle before you cut
The grace window is a deadline, not a guarantee, so check before you revoke:
curl -sS "https://api.infrai.cc/v1/account/keys/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Each entry carries last_used_at and last_used_ip. If the old key’s last_used_at is still moving an hour before the window closes, something didn’t get the message — and the IP tells you which host to go look at.
import os
from datetime import datetime, timedelta, timezone
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def key_activity(key_id: str) -> dict | None:
resp = SESSION.get(f"{API}/v1/account/keys/list", timeout=20)
resp.raise_for_status()
for item in resp.json()["data"].get("items", []):
if item["key_id"] == key_id:
return item
return None
def safe_to_revoke(key_id: str, idle_minutes: int = 60) -> tuple[bool, str]:
"""Only cut a key that has been quiet for a while. `last_used_at` is updated
on a throttle, so treat a recent timestamp as 'still in use' and a stale one
as 'probably idle' rather than as proof either way."""
item = key_activity(key_id)
if item is None:
return True, "key not listed; already gone"
if item.get("status") != "active":
return True, f"status is {item['status']}"
last = item.get("last_used_at")
if not last:
return True, "never used"
seen = datetime.fromisoformat(last.replace("Z", "+00:00"))
quiet_for = datetime.now(timezone.utc) - seen
if quiet_for < timedelta(minutes=idle_minutes):
return False, f"used {int(quiet_for.total_seconds() // 60)} min ago from {item.get('last_used_ip')}"
return True, f"idle for {quiet_for}"
if __name__ == "__main__":
ok, why = safe_to_revoke(os.environ["OLD_KEY_ID"])
print(("safe: " if ok else "wait: ") + why)
One caveat in there, and it’s deliberate: last_used_at is written on a throttle rather than on every request, so a very recent timestamp is a reliable “in use” signal while an old one is only a probable “idle”. Treat it as evidence, not proof, and keep the grace window generous.
Then revoke, deliberately
curl -sS -X DELETE "https://api.infrai.cc/v1/account/keys/revoke/ifr_...4c4a" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Anything still presenting that secret now gets KEY_REVOKED, which is a typed, non-retryable error — so a client that retries it forever is a client you’ll find quickly in your own error stream.
If the reason for rotating was a leak rather than hygiene, don’t wait for a grace window at all. POST /v1/account/keys/suspected_compromise/{id} takes confirmed_leak and auto_rotate, and it’s the call to make when the secret is in a public repository: containment first, continuity second.
What this doesn’t do
There’s no scheduled auto-rotation — no “rotate every 90 days” policy you can set once and forget. If you want that, it’s a job of your own: POST /v1/cron/create on the same key, calling rotate on a schedule and writing the new secret wherever your services read it from. The secret-store half is the piece nobody can do for you, and it’s the limitation to plan around: a rotation endpoint without a secret manager just moves the problem.
Nor is there a per-key spend view; GET /v1/account/usage breaks down by capability, not by key.
The compensating side is that the surrounding machinery is already on this credential. The rotation reminder goes out through POST /v1/email/send, the schedule lives on POST /v1/cron/create, and a failed rotation is captured by POST /v1/errors/capture — no second vendor, one bill, and GET /v1/account/usage shows all of it in one line.
Key management routes report billing_class: free in discovery: create, rotate, list, update and revoke cost nothing per call. Your account’s live rates and remaining credit are in GET /v1/account/balance, which also returns an affordable_uses_hint per capability (verified 2026-09-21) — read that rather than a published figure, since platform rates move downward as vendor contracts improve.