Manage the ES256 key HappyView signs OAuth client assertions with. As a confidential OAuth client, HappyView authenticates to PDSes with private_key_jwt rather than as a public client, which earns its sessions the 2-year lifetime cap instead of 2 weeks. All endpoints require the settings:manage permission.
Every session records the kid of the key that established it and is refreshed with that key, never with whichever key happens to be current later. That is what makes rotation safe: an authorization server treats a mismatched kid as grounds to destroy the session, not merely to refuse the request.
Rotating and revoking a key have different consequences:
- Rotate mints a new key, which becomes
currentafter 75 minutes (or at once withimmediate) and marks the previous oneretiring. A retiring key stays published in the JWKS and keeps signing every session established with it, so nothing is logged out. Rotation is safe and cheap. - Revoke removes a key from the JWKS immediately and permanently breaks every session pinned to it. It is the response to a leaked or compromised key, not routine maintenance.
Because of that difference, the current key cannot be revoked: that would leave the instance unable to authenticate to any PDS at all. To contain a leaked key, rotate first with { "immediate": true } (which demotes the leaked key to retiring and mints a fresh current), then revoke the retiring key.
const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };let token = "hv_..."; // your API keyconst token = "hv_..." // your API key# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"List keys
Section titled “List keys”GET /admin/oauth/instance-keyReturns every key the instance holds, ordered pending, current, retiring, then revoked. Revoked keys are included deliberately, so an operator can confirm a revoke took effect.
session_count is the number of live sessions pinned to that key, counted across the dashboard, linked-repo, and DPoP session tables. For a revoked key the number is retained rather than zeroed: revoking does not delete session rows, so the count tells you how many sessions that revoke ended.
interface InstanceKey { kid: string; status: "pending" | "current" | "retiring" | "revoked"; created_at: string; // When a pending key becomes current; null otherwise. activates_at: string | null; session_count: number;}
const response = await fetch( "http://127.0.0.1:3000/admin/oauth/instance-key", { headers },);const data: { keys: InstanceKey[] } = await response.json();const response = await fetch( "http://127.0.0.1:3000/admin/oauth/instance-key", { headers },);const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/oauth/instance-key") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/oauth/instance-key", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -H "$AUTH" http://127.0.0.1:3000/admin/oauth/instance-key{ "keys": [ { "kid": "f0c3a1e2-...", "status": "current", "created_at": "2026-08-29T10:14:02.512+00:00", "activates_at": null, "session_count": 128 }, { "kid": "8b41d0aa-...", "status": "retiring", "created_at": "2026-02-11T08:02:55.084+00:00", "activates_at": null, "session_count": 37 } ]}Rotate the key
Section titled “Rotate the key”POST /admin/oauth/instance-key/rotateMints a new key. By default it is published as pending and becomes current after 75 minutes, when the previous key becomes retiring. Send { "immediate": true } to switch at once; authorization servers that cached the old key set may refuse the new key until their cache expires. Rotating while a key is pending returns that pending key, and with immediate makes that pending key current rather than minting another. An instance with no current key gets its new key as current at once.
Nothing is logged out: the retiring key stays in the published JWKS and keeps signing refreshes for every session established with it. New sessions use the new key once it is current.
| Field | Type | Required | Description |
|---|---|---|---|
immediate | boolean | no | Make the new key current at once. Default false |
status is pending or current. activates_at is when a pending key becomes current, and is null when status is current.
orphaned_sessions counts sessions that predate key pinning and therefore carry no recorded kid. These cannot be protected by this or any future rotation.
interface RotationResult { kid: string; status: "pending" | "current"; activates_at: string | null; orphaned_sessions: number;}
const response = await fetch( "http://127.0.0.1:3000/admin/oauth/instance-key/rotate", { method: "POST", headers },);const data: RotationResult = await response.json();const response = await fetch( "http://127.0.0.1:3000/admin/oauth/instance-key/rotate", { method: "POST", headers },);const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/oauth/instance-key/rotate") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/oauth/instance-key/rotate", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST -H "$AUTH" http://127.0.0.1:3000/admin/oauth/instance-key/rotate{ "kid": "f0c3a1e2-...", "status": "pending", "activates_at": "2026-08-29T11:29:02.512Z", "orphaned_sessions": 0}Revoke a key
Section titled “Revoke a key”DELETE /admin/oauth/instance-key/{kid}Removes the key from the published JWKS immediately and destroys every session pinned to it, so whoever held one of those sessions must log in again. Use this when a key has leaked, not to tidy up old keys: an hourly cleanup revokes every retiring key that no session is pinned to.
sessions_destroyed reports how many sessions the revoke invalidated.
Errors:
| Status | Meaning |
|---|---|
400 | The target is the current key. Rotate with { "immediate": true } first, then revoke the retiring key. |
404 | No unrevoked key with that kid belongs to this instance. |
interface RevokeResult { kid: string; sessions_destroyed: number;}
const response = await fetch( `http://127.0.0.1:3000/admin/oauth/instance-key/${kid}`, { method: "DELETE", headers },);const data: RevokeResult = await response.json();const response = await fetch( `http://127.0.0.1:3000/admin/oauth/instance-key/${kid}`, { method: "DELETE", headers },);const data = await response.json();let client = reqwest::Client::new();let response = client .delete(format!( "http://127.0.0.1:3000/admin/oauth/instance-key/{kid}" )) .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;kid := "..." // the key to revoke
url := "http://127.0.0.1:3000/admin/oauth/instance-key/" + kidreq, _ := http.NewRequest("DELETE", url, nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X DELETE -H "$AUTH" \ "http://127.0.0.1:3000/admin/oauth/instance-key/$KID"{ "kid": "8b41d0aa-...", "sessions_destroyed": 37}Responding to a leaked key
Section titled “Responding to a leaked key”POST /admin/oauth/instance-key/rotatewith{ "immediate": true }. The leaked key becomesretiring, a new key (the pending one, if a rotation was waiting) becomescurrent, and the instance keeps working throughout.GET /admin/oauth/instance-key. Confirm the leaked key is nowretiringand note itssession_count, which is how many sessions step 3 will end.DELETE /admin/oauth/instance-key/{kid}on the leaked key.
Doing this in the other order is refused, because the current key cannot be revoked.
© HappyProto

