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.

ConsumerMinimum graceWhy
Rolling web deploy1 hourrestart picks up the env var
Cron and batch workers26 hoursmust survive one full daily cycle
Third-party integration72+ hoursa human has to act
Anything in a shipped binarydon’tthe 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.

References

Browse more account developer guides