---
title: "Replace a key with its twin (same kind, mode, scopes, label). The old key keeps working until `replaced.expires_at` so a deploy can swap values without a gap. Needs the `keys` scope even to rotate itself — rotate a scoped key with a full key or from the dashboard. A key is rotated once: rotating it again is a 409 `key_already_rotated`, and during its grace the old key cannot mint, rotate, revoke or read deliveries (403 `key_rotated`). A test key cannot rotate a live key (403 `mode_mismatch`)."
method: POST
path: "/keys/rotate"
---

# Replace a key with its twin (same kind, mode, scopes, label). The old key keeps working until \`replaced.expires_at\` so a deploy can swap values without a gap. Needs the \`keys\` scope even to rotate itself — rotate a scoped key with a full key or from the dashboard. A key is rotated once: rotating it again is a 409 \`key_already_rotated\`, and during its grace the old key cannot mint, rotate, revoke or read deliveries (403 \`key_rotated\`). A test key cannot rotate a live key (403 \`mode_mismatch\`).

`POST /keys/rotate`

## Request

- Base URL: `https://widget.reelquoteai.com/api/v1`
- URL: `https://widget.reelquoteai.com/api/v1/keys/rotate`
- Auth: HTTP bearer

## Request body

- object
  - `id` string: Which key. Default: the key making this call.
  - `grace_hours` integer: 0 revokes the old key immediately.

## Response `200`

The replacement, shown once, and when the old one stops

- object
  - `key` ApiKey
    - `id` string, required: Key id (ULID) — the handle for rotate and revoke
    - `kind` 'publishable' | 'secret', required: pk_ is browser-safe and drives the funnel; sk_ is server-only.
    - `mode` 'live' | 'test', required: test keys create sandbox jobs: fixed inventory at /submit, no billing, no email, webhooks say livemode:false, and they see only test jobs.
    - `prefix` string, required: The first 12 characters, for recognising it in a list
    - `label` string, nullable: Free text you gave it
    - `scopes` string[], nullable, required: null = everything its kind allows
    - `status` 'active' | 'revoked', required
    - `created_at` string, date-time, required
    - `last_used_at` string, date-time, nullable
    - `expires_at` string, date-time, nullable: Set by rotation: the key works until this instant
    - `revoked_at` string, date-time, nullable
    - `rotated_to` string, nullable: The id of the key that replaced it
    - `secret` string: ONLY in the response that minted or rotated it: the raw key. Store it now; it is never shown again.
  - `replaced` object
    - `id` string
    - `prefix` string
    - `expires_at` string, date-time

## Other responses

- `400`: `invalid_request` — A field is missing, malformed or not allowed in this state. `message` says which. `secret_key_in_url` — A secret key was sent as `?k=`. Secret keys go in the Authorization header only.
- `401`: `unauthorized` — No key, an unknown key, or a revoked key.
- `403`: `secret_key_required` — The endpoint is server-only; a publishable key cannot reach it. `insufficient_scope` — The key does not carry the scope this call needs (named in `message`). Mint one that does from the keys page or POST /keys. `key_rotated` — The calling key has been rotated and is in its grace period: it still serves jobs and quotes, but key management and the delivery log belong to its replacement. `mode_mismatch` — A test key tried to manage a live key. Live keys manage both modes; test keys manage test keys only.
- `404`: `not_found` — No such job for this key, or no such endpoint.
- `409`: `last_publishable_key` — The only live publishable key cannot be revoked — the widget stops without one. Rotate it instead. `key_already_rotated` — That key was already rotated. A key is rotated once; its replacement is the one to use and to rotate next. `webhook_paused` — That webhook endpoint is paused; turn it back on before replaying a delivery to it. `video_in_flight` — A video is already being fetched for this job (POST /jobs/{id}/video). Poll GET /jobs/{id}. `video_already_supplied` — The job already has a video and has moved past upload. Start a new job for a different walkthrough. `no_rate_card` — The mover has no active rate card, so nothing can be priced yet. `ballpark_not_bookable` — Only a firm quote from a filmed walkthrough can be booked. `already_requested` — The job is already booked or awaiting the mover, so the move date can no longer change here. Contact the mover. `idempotency_in_progress` — A request with the same Idempotency-Key is still being processed. Obey `Retry-After` (1 s) and retry with the same key.
- `429`: `rate_limited` — Per-key limit (60/min publishable, 300/min secret), the mover's daily ballpark or video-analysis cap, or a visitor's daily catalog-search cap. Obey `Retry-After`. Carries `Retry-After` in seconds.

---

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