Guides

    Rotating and revoking Versely API keys

    The raw key is shown once. Rotate by minting a replacement, swapping the running job, then soft-revoking the old key so in-flight work still finishes.

    Versely Team7 min read

    The raw Versely API key is returned in one 201 response and then gone. It is never stored. List endpoints return a 12-character key_prefix, a name, scopes, and timestamps. There is no "show me the key again" route, and there is no PATCH to change scopes or the per-key rate limit. Rotation is therefore a planned swap: mint a replacement while the old key still works, move the job, then soft-revoke. Treat it as an emergency and you will revoke the credential the running worker is still using.

    Keys authenticate the REST surface documented on the developer page. The CLI can mint the first long-lived credential on a machine. After that, every rotation should go through POST / GET / DELETE on /api/v1/auth/api-keys so you control the name, the scopes, and the moment of revoke.

    What the server actually stores

    A key is vsk_ plus 40 hex characters (160 bits). On create, the server SHA-256 hashes that string, stores the hash, and stores key_prefix as the first 12 characters (vsk_ plus the first eight hex chars). The 201 body is the only time api_key.key is present, alongside warning: "Save this key now. It will not be shown again."

    GET /api/v1/auth/api-keys returns, for each row: id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, expires_at, created_at, revoked_at. Use name and key_prefix to identify which row is live. Use id for the delete. last_used_at is written asynchronously after a successful auth, so it can lag a moment behind the first request. Do not treat a still-null timestamp 50 milliseconds after deploy as proof the new key is unused.

    Revoke is a soft delete: DELETE /api/v1/auth/api-keys/:keyId sets revoked_at and returns { success: true, revoked: { id, key_prefix, name } }. The next request that presents the old raw value fails with 401 API key has been revoked. There is no drain interval. If the row is already revoked, or the id is not yours, the delete returns 404 API key not found or already revoked.

    expires_at is the scheduled version of the same idea. A key with an expiry in the past fails with 401 API key has expired without anyone calling delete. Put an expiry on a contractor key and a CI key you expect to replace each quarter. Do not put an expiry on a production worker unless the rotation is already on the calendar.

    Key management sits behind authenticateUser and the auth rate limiter (30 requests per 15 minutes per IP). A session JWT is the right caller for mint and revoke. A valid API key can also authenticate those routes, because /auth/api-keys is not owned by a catalog scope. Do not use the job key you are about to revoke as the only credential that can finish the rotation.

    The swap, in order

    Do this while the old key is still valid. The whole point is that the running job never sees a 401.

    1. Inventory. GET /api/v1/auth/api-keys with a session token. Record id, name, key_prefix, scopes, rate_limit_rpm, and expires_at for the live key. If you cannot tell which row it is, the name was wrong at mint time. Fix the name on the next key.

    2. Mint the replacement. POST /api/v1/auth/api-keys with the same scopes and the same rate_limit_rpm, a new name (nightly-hooks-2026-08 is better than nightly-hooks-new), and an expires_at if the job has a planned end. Capture api_key.key from the 201 immediately. If that value leaves the response unstored, you are already in a rotation: mint again and revoke the unused row.

    3. Load, do not cut over yet. Write the new raw key into the secret store the job will read. Leave the old secret in place. Dual-hold is the entire safety margin.

    4. Roll the job. Restart or redeploy so new work presents the new key. In-flight HTTP requests that already left with the old Authorization header will finish or fail on their own; you are not draining a connection pool on Versely's side. What you are draining is your process.

    5. Confirm use. List keys again. The new row's last_used_at should populate after the first authenticated call. Poll GET /api/v1/status/:requestId for a job the new key submitted if you want a live check that generate-and-poll still works. Status is authorized as the user, not as a specific key, so a request id created under the old key is still readable with the new one.

    6. Revoke the old row. DELETE /api/v1/auth/api-keys/:oldKeyId. From this moment, the old raw value is a 401. Keep the new secret where the job can read it.

    7. Watch the first 401s. A worker you missed will log API key has been revoked. That is the signal to find the leftover process, not to recreate the old key (you cannot). Point it at the new secret.

    If you revoke in step 2, every in-flight call from the old worker starts failing immediately. Generations already accepted keep their request_id; you can still poll them with the new key. What you lose is the worker's ability to submit the next job until you finish the swap. For a multi-step automation or a workflow run you may need to resume, that gap is the outage.

    Changing scopes or rate_limit_rpm is the same procedure. There is no update route. Mint a key with the new grant, swap, revoke.

    Losing the raw value

    If the 201 body was not saved, the key is unusable to you and still valid on the server until you revoke it. List by created_at and key_prefix, mint a replacement, put the new raw value in the secret store, then delete the forgotten row. Do not leave an unused valid key sitting because "nobody has the string." Anyone who intercepted the 201 still does.

    If a key leaked, skip dual-hold only in this sense: mint the replacement and cut the job over as fast as you can, then revoke the leaked row without waiting for a quiet last_used_at on the old prefix. A leaked key that is still valid is the incident. A few 401s on a worker you control are not.

    Scheduled expiry is the way to force this procedure onto a calendar so it is not an incident. A CI key with expires_at 90 days out, named with the quarter, is a rotation you can put in the runbook. The skills pack is a reasonable place to keep that runbook next to the generate and workflow procedures, because the people who will rotate the key are the people who already wired the job.

    FAQ

    Can I recover a raw key from key_prefix?

    No. The prefix is an identifier for humans and for the list endpoint. The server stores a SHA-256 hash of the full string. If you do not have the 201 body (or the secret store you copied it into), mint a new key.

    What happens to a generation that is still running when I revoke?

    The submit already happened. Poll GET /api/v1/status/:requestId with any valid credential for that user, including the replacement key. The old key will 401 on the next request it makes, including a status poll, so the process that is waiting on the result needs the new key before you delete.

    Can I revoke from the job key itself?

    Key management authenticates with authenticateUser, which accepts a session JWT or a vsk_ key. Using the key you are destroying as the only caller is how a failed delete leaves you with no credential that can retry. Mint and revoke from a session, or from a dedicated management key you are not rotating today.

    Why did my create/revoke script start getting 429s?

    /api/v1/auth/api-keys uses the auth rate limiter: 30 requests per 15 minutes per IP. Rotation is a handful of calls. A test harness that mints and deletes in a tight loop will hit that cap. Back off, and do not put key CRUD on the same timer as generate.

    Picking REST rather than MCP or the CLI for the job that holds the key is covered in CLI, MCP, or API. Rotation is the operational half of that choice: if the credential is long-lived, you need a swap procedure before the first leak, not after.