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.
Two operations, with very 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 keytoken := "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 users that revoke forced to re-authenticate.
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)curl -H "$AUTH" http://127.0.0.1:3000/admin/oauth/instance-key{ "keys": [ { "kid": "f0c3a1e2-...", "status": "current", "created_at": "2026-08-29T10:14:02Z", "activates_at": null, "session_count": 128 }, { "kid": "8b41d0aa-...", "status": "retiring", "created_at": "2026-02-11T08:02:55Z", "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. 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)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+00:00", "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, and those users must log in again. Use this when a key has leaked, not to tidy up old keys; a retiring key with no remaining sessions is revoked automatically.
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?;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)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 fresh key 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 users step 3 will sign out.DELETE /admin/oauth/instance-key/{kid}on the leaked key.
Doing this in the other order is refused: the current key cannot be revoked, because that would leave the instance with no key to authenticate with.
© HappyProto

