Skip to main content

Rotation

Rotating a key swaps both the api_key and the rotation_secret on the same key row, in one transaction. The same key_id and label stay; new hashes get persisted; the old api_key keeps working for 4 hours so you can roll your process restart without an outage window.

When to rotate

  • Scheduled: a few days before expires_at (typically wired to a cron in your own infrastructure, driven by the expires_at you saved at issue time).
  • Reactive: after a security review, after staff changes, or any time a secret might have leaked.
  • Defensive: if your monitoring sees rising 401s on a key, rotate rather than debug, then debug.

Request

POST /v1/partner/account/keys/{key_id}/rotate
X-API-Key: <current key, must hash to :key_id>
X-Rotation-Secret: <current rotation secret for the same key>
Content-Type: application/json

{
"expires_interval_days": 90
}

Body is optional. Behaviour when fields are omitted:

| Both omitted | Re-use the key's stored expires_interval_days. If the stored value is NULL (issued with "never"), the new key never expires either. | | expires_interval_days (30/90/180/365 or null) | Set lifetime from now. null explicitly means never. | | expires_at (ISO 8601 in the future) | Use that exact timestamp. The stored interval becomes NULL (future rotations will default to "never" unless overridden). | | Both fields supplied | expires_at wins. |

Self-rotate only

The caller's X-API-Key MUST hash to the same key row as :key_id in the URL. The rotate endpoint is self-rotate only: you cannot rotate a different key from the one you are authenticating with.

This is deliberate. If you want to retire one key from another, two calls:

  1. POST /partner/account/keys (from the surviving key) to mint a replacement.
  2. DELETE /partner/account/keys/{old_key_id} (from the surviving key) to revoke the old one.

Response

HTTP/1.1 200 OK
Content-Type: application/json

{
"id": "91fb8014-ce1c-4f3e-81c7-290636e41262",
"api_key": "sk_YYYYYYYYYYYYYYYYYYYYYYYYYYYY",
"rotation_secret": "rs_YYYYYYYYYYYYYYYYYYYYYYYYYYYY",
"expires_at": "2026-08-18T01:37:35.234Z",
"expires_interval_days": 90,
"rotation_due_at": null,
"old_key_grace_until": "2026-05-20T05:37:35.234Z"
}

:::note Bare response shape Unlike every other Partner API endpoint, rotate returns a bare credential object, not the usual { "success": true, "data": { ... } } envelope. This is deliberate. The response carries plaintext credentials that you must persist immediately, and the smaller payload reduces the chance of partial logging or accidental serialization through wrappers that strip the outer envelope. Treat the entire body as sensitive. :::

api_key and rotation_secret are returned in plaintext once and never shown again. The same care applies as at first issue: write to your secret store before doing anything else.

The 4-hour grace window

The previous api_key keeps authenticating for 4 hours after the rotation succeeds. During that window both keys work; new requests should use the new key, in-flight requests with the old one still succeed.

Mechanically, Nuntiq inserts a supplier_api_key_grace row holding the old key's hash + an expires_at 4 hours out. The auth lookup checks the live key row first; on a miss it falls back to the grace row.

A second rotation within the grace window REPLACES the grace row, not stacks. The first old key becomes immediately invalid; only the most recently rotated-from key keeps grace.

Reference implementation pattern

def rotate_partner_key(secrets):
"""Rotate the active partner key in our vault, with safe rollback."""
old = secrets.load("nuntiq.partner.active") # {key_id, api_key, rotation_secret, ...}

resp = requests.post(
f"https://api.apreceiving.com/api/v1/partner/account/keys/{old['key_id']}/rotate",
headers={
"X-API-Key": old["api_key"],
"X-Rotation-Secret": old["rotation_secret"],
},
timeout=10,
)
resp.raise_for_status()
new = resp.json()

# 1. Persist new credentials BEFORE flipping any switches.
secrets.write("nuntiq.partner.pending", {
"key_id": new["id"],
"api_key": new["api_key"],
"rotation_secret": new["rotation_secret"],
"expires_at": new["expires_at"],
"expires_interval_days": new["expires_interval_days"],
})

# 2. Promote to active. If your fleet rolls slowly, you have
# `old_key_grace_until` worth of headroom before old becomes invalid.
secrets.promote("nuntiq.partner.pending", to="nuntiq.partner.active")

# 3. (Optional) Keep the old creds around in cold storage for the
# grace window so you can roll back if the new ones are bad.
secrets.archive("nuntiq.partner.active.previous", old, ttl_hours=4)

The order matters: never overwrite the old key before the new one is durably stored. A crash between mint and persist would leave you locked out until the customer revokes and reissues.