---
title: "Rotate a credential's key"
method: POST
path: "/credentials/{credentialId}/rotate"
tags: ["Credentials"]
---

# Rotate a credential's key

`POST /credentials/{credentialId}/rotate`

## Request

- Base URL: `https://inkseal.net/api/v1`
- URL: `https://inkseal.net/api/v1/credentials/{credentialId}/rotate`
- Auth: HTTP bearer

Issue a second secret for an existing credential, without downtime.

The credential's identity does not move: same `id`, same `scopes`, same audit history, same `agentIdentity`. Only the key material changes, so nothing downstream sees a new principal.

**The new raw key is returned exactly once, here.** As with creation, only its hash is stored and there is no way to read it back. The format is unchanged: `isk_` plus 40 base62 characters.

**Grace window.** The outgoing secret keeps authenticating for 24 hours from the moment of rotation, and `previousSecretExpiresAt` is the exact deadline, read back from storage rather than recomputed, so it is the timestamp to schedule against. Deploy the new key inside that window. After the deadline the old key is dead.

Rotating again inside the window kills the secret that was already on its way out, so a credential never has more than the current secret and one predecessor alive at a time. Rotating twice in quick succession therefore cuts the first window short.

Rotation refuses a credential that has been revoked or has already expired, with 409 `credential_state`. Revocation, unlike rotation, has no grace period at all.

Accepts an `Idempotency-Key`; a retry with the same key replays the original response, including the one-time key, instead of burning a second rotation.

## Headers

- `Idempotency-Key` string

## Response `200`

The credential, the only copy of its new raw key, and when the outgoing key stops working.

- object
  - `data` CredentialRotated, required: A service credential: an identity, a grant, and the keys issued for it. Raw key material never appears here.
    - `id` string, required: The credential's public identifier, stable across rotations.
    - `name` string, required: A human label, 1 to 255 characters.
    - `agentIdentity` string, nullable, required: Who or what acts under this credential. Recorded on every audit row the credential writes, so an action can be traced back to the agent rather than to the human who issued the key.
    - `scopes` CredentialScope[], required: The grant, deduplicated and ordered by the scope vocabulary. Never empty.
    - `isLegacy` boolean, required: True for a credential grandfathered by the scoping migration, which holds the full scope set. Writing an explicit grant clears it.
    - `isActive` boolean, required: False once revoked. A revoked credential still appears in listings so the audit trail stays legible.
    - `createdAt` string, date-time, required
    - `expiresAt` string, date-time, nullable, required: When the credential itself stops authenticating, if an expiry was set.
    - `revokedAt` string, date-time, nullable, required: When the credential was revoked.
    - `lastUsedAt` string, date-time, nullable, required: When any of this credential's keys last authenticated a request.
    - `secrets` CredentialSecretSummary[], required: The keys issued for this credential, newest first. Ordinarily one; two while a rotation grace window is open.
      - `prefix` string, required: The first 8 characters of the key: `isk_` plus the first four characters of its body.
      - `lastFour` string, required: The last four characters of the key.
      - `createdAt` string, date-time, required
      - `expiresAt` string, date-time, nullable, required: Set only while a rotated-out secret serves its 24-hour grace window. Null for the current secret.
      - `revokedAt` string, date-time, nullable, required: When this key stopped working, either because the credential was revoked or because a second rotation cut its grace window short.
      - `lastUsedAt` string, date-time, nullable, required: When this key last authenticated a request. Null if it never has.
    - `limits` CredentialLimits, required: The spend ceilings on a credential. Null on either means no ceiling. They are enforced in the transaction that claims an envelope for send, so concurrent sends cannot cross one, and a refusal answers 429 `credential_limit_exceeded` having sent nothing.
      - `maxCreditsPerDay` integer, nullable, required: Credits this credential may spend in one UTC day. Counted in credits rather than sends, so a bigger envelope draws more of it down, and counted only on a metered workspace.
      - `maxSendsPerHour` integer, nullable, required: Envelopes this credential may send in one UTC hour, whether or not the workspace is metered.
    - `pausedAt` string, date-time, nullable, required: Set while the credential is paused. A pause is reversible and leaves the issued keys intact, unlike a revocation: a paused key answers 401 `unauthorized` and starts working again the moment the pause is lifted.
    - `key` string, required: **The new raw key, returned once and never again.** Same format as at creation: `isk_` plus 40 base62 characters. The example below is redacted and will not authenticate.
    - `previousSecretExpiresAt` string, date-time, nullable, required: When the outgoing key stops authenticating: 24 hours after this rotation. Read back from storage rather than recomputed, so it is the deadline to schedule the redeploy against. Null when there was no predecessor to date out.

## Other responses

- `400`: The request is malformed or the resource is in the wrong state for a synchronous check. Codes: `validation_error`, `invalid_body`, `invalid_parameter`, `invalid_idempotency_key`, `invalid_event_id`, `invalid_file`, `invalid_url`, `invalid_status`, `key_mismatch`, `no_signers`, `not_completed`, `envelope_exists`, `document_not_ready`, `upload_failed`.
- `401`: The credential is missing, malformed, unknown, expired, revoked, or past its rotation grace window. Code: `unauthorized`. Every authentication failure is deliberately indistinguishable.
- `403`: The credential is valid but may not do this. Codes: `insufficient_scope` (the route's scopes are not all held; `details` names `requiredScopes` and `missingScopes`), `forbidden` (the capability check on the resolved resource failed), `scope_escalation` (a grant wider than the caller's own was requested).
- `404`: The resource does not exist, was deleted, or belongs to another workspace. Code: `not_found`. The three cases are answered identically on purpose, so a 404 never confirms that an identifier exists elsewhere.
- `409`: The resource's state forbids this transition, or another request owns the work. Codes: `invalid_state`, `invalid_status`, `credential_state`, `limit_exceeded`, `envelope_missing`, `not_retryable`, `owner_unavailable`, `workspace_unavailable`, `detection_not_completed`, `document_in_use`, `version_conflict`, `document_processing`, `document_processing_failed`, `normalized_pdf_unavailable`, `pages_not_rendered`, `document_not_ready`, `idempotency_in_progress` (which also carries `Retry-After`), `payment_replayed` (a USDC payment signature presented twice), `payment_pending` (a USDC settlement whose outcome is not yet known).
- `422`: The request parsed but its content is not acceptable. Codes: `validation_error` (envelope configuration, with an `issues` array in `details`), `invalid_envelope`, `envelope_not_ready`, `document_page_limit`, `idempotency_key_reuse`.
- `429`: A limit was reached. Codes: `rate_limited` (the credential's per-bucket HTTP limit, or the per-workspace detection throttle), `workspace_limit_exceeded` (a workspace content or send limit; `details.limit` names it), `daily_budget_exceeded` (the workspace's daily detection budget). The four rate-limit headers are sent on this response and on no successful one, so a client cannot read its remaining budget without being refused. Retrying after `Retry-After` is always safe: a rate-limit refusal happens before the handler runs.
- `503`: A dependency the request needs is unreachable and nothing was changed. Codes: `queue_unavailable` (a background job could not be enqueued; retry the call), `billing_unavailable` (Stripe would not open a checkout session; nothing was charged, retry the call), `service_unavailable` (the rate-limit store is down, so the request was refused before running).

---

[API](https://skmtc.dev/inkseal/apis/inkseal-api.md) · [All operations](https://skmtc.dev/inkseal/apis/inkseal-api/llms.txt) · [OpenAPI document](https://skmtc.dev/inkseal/apis/inkseal-api/revisions/c440c981d578?raw)
