API clients identify third-party apps that call HappyView’s XRPC endpoints. A caller names its client with an X-Client-Key header or a client_key query param. The client key is HappyView’s rate-limit bucket: an anonymous request with no key shares one anonymous bucket, and an authenticated request with no key gets 401 Unauthorized.
One API client represents your app as a whole. Create one client for your app and use the same client key across all instances. Users authenticate separately via OAuth: the client key identifies your app, not who is using it.
Each client has an hvc_-prefixed client key. A confidential client also has one or more hvs_-prefixed client secrets (Client secrets). A secret is returned only when it’s created, and only its SHA-256 hash is stored. Server-to-server callers pass a secret as X-Client-Secret. Browser callers use the Origin header, which is matched against the client’s client_uri. A request that matches neither is logged and handled as anonymous rather than rejected, and is not charged to the client’s rate limit bucket. The same holds for a key HappyView doesn’t know. A request made through the client’s own session needs neither check. See XRPC API client identification for the client-side view, and the API keys guide for how admin API keys differ from API clients.
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 API clients
Section titled “List API clients”GET /admin/api-clientsRequires api-clients:view. Returns clients ordered by created_at descending. Secrets are never returned.
| Param | Type | Required | Description |
|---|---|---|---|
parent_ | string | no | Only clients whose parent_client_id is this id (query param) |
The list includes builtin-hpy, the public client hpy signs in with (client_key hvc_hpy). HappyView writes it at every boot, and updating it, deleting it, adding or revoking a secret, or provisioning or rotating an authentication key for it answers 409 Conflict.
interface ApiClient { id: string; client_key: string; name: string; client_id_url: string; client_uri: string; redirect_uris: string[]; scopes: string; client_type: "confidential" | "public"; allowed_origins: string[] | null; rate_limit_capacity: number | null; rate_limit_refill_rate: number | null; is_active: boolean; created_by: string; created_at: string; updated_at: string; parent_client_id: string | null; owner_did: string | null;}
const response = await fetch("http://127.0.0.1:3000/admin/api-clients", { headers,});const data: ApiClient[] = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/api-clients") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/api-clients", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/api-clients -H "$AUTH"Response: 200 OK
[ { "id": "7c9e6679-7425-40de-944b-e07fc1f0d4c7", "client_key": "hvc_a1b2c3...", "name": "My Game Client", "client_id_url": "https://example.com/client-metadata.json", "client_uri": "https://example.com", "redirect_uris": ["https://example.com/callback"], "scopes": "atproto", "client_type": "confidential", "allowed_origins": null, "rate_limit_capacity": 200, "rate_limit_refill_rate": 5.0, "is_active": true, "created_by": "did:plc:...", "created_at": "2026-04-13T12:00:00Z", "updated_at": "2026-04-13T12:00:00Z", "parent_client_id": null, "owner_did": null }]Create an API client
Section titled “Create an API client”POST /admin/api-clientsRequires api-clients:create. Generates a client_key, and for a confidential client a first client_secret, labelled Original. Store the secret: it won’t be shown again.
interface CreatedApiClient { id: string; client_key: string; // Omitted for a public client. client_secret?: string; name: string; client_id_url: string; client_type: "confidential" | "public";}
const response = await fetch("http://127.0.0.1:3000/admin/api-clients", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ name: "My Game Client", client_id_url: "https://example.com/client-metadata.json", client_uri: "https://example.com", redirect_uris: ["https://example.com/callback"], scopes: "atproto", rate_limit_capacity: 200, rate_limit_refill_rate: 5.0, }),});const data: CreatedApiClient = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ name: "My Game Client", client_id_url: "https://example.com/client-metadata.json", client_uri: "https://example.com", redirect_uris: ["https://example.com/callback"], scopes: "atproto", rate_limit_capacity: 200, rate_limit_refill_rate: 5.0, }),});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/api-clients") .bearer_auth(token) .json(&serde_json::json!({ "name": "My Game Client", "client_id_url": "https://example.com/client-metadata.json", "client_uri": "https://example.com", "redirect_uris": ["https://example.com/callback"], "scopes": "atproto", "rate_limit_capacity": 200, "rate_limit_refill_rate": 5.0 })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "name": "My Game Client", "client_id_url": "https://example.com/client-metadata.json", "client_uri": "https://example.com", "redirect_uris": ["https://example.com/callback"], "scopes": "atproto", "rate_limit_capacity": 200, "rate_limit_refill_rate": 5.0}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/api-clients", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/api-clients \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "name": "My Game Client", "client_id_url": "https://example.com/client-metadata.json", "client_uri": "https://example.com", "redirect_uris": ["https://example.com/callback"], "scopes": "atproto", "rate_limit_capacity": 200, "rate_limit_refill_rate": 5.0 }'| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human-readable display name |
client_ | string | yes | URL to the client’s published OAuth client metadata document |
client_ | string | yes | The client’s home/landing URL |
redirect_ | string[] | yes | Allowed OAuth redirect URIs |
scopes | string | no | Space-separated OAuth scopes (default "atproto") |
client_ | string | no | "confidential" (default) issues a client_secret; "public" issues none. Anything else is 400 |
allowed_ | string[] | no | Browser origins allowed for this client |
rate_ | integer | no | Per-client token bucket capacity. Falls back to DEFAULT_RATE_LIMIT_CAPACITY if unset. Applies only when both rate-limit fields are given |
rate_ | number | no | Tokens added per second. Falls back to DEFAULT_RATE_LIMIT_REFILL_RATE if unset. Applies only when both rate-limit fields are given |
Returns 400 Bad Request for an unknown client_type, and 409 Conflict if client_id_url is a registered domain’s OAuth client.
Response: 201 Created. client_secret is omitted for a public client.
{ "id": "7c9e6679-7425-40de-944b-e07fc1f0d4c7", "client_key": "hvc_a1b2c3...", "client_secret": "hvs_d4e5f6...", "name": "My Game Client", "client_id_url": "https://example.com/client-metadata.json", "client_type": "confidential"}The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView.
Get an API client
Section titled “Get an API client”GET /admin/api-clients/{id}Requires api-clients:view. Returns the same shape as the list endpoint, or 404 Not Found.
Update an API client
Section titled “Update an API client”PUT /admin/api-clients/{id}Requires api-clients:edit. All fields are optional, and a field left out is unchanged. allowed_origins and the two rate-limit fields also take null, which clears them. The rate limiter uses the client’s own limits only while both fields are set; otherwise it falls back to the defaults.
await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Renamed Client", rate_limit_capacity: 500, }),});await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Renamed Client", rate_limit_capacity: 500, }),});let client = reqwest::Client::new();client .put("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7") .bearer_auth(token) .json(&serde_json::json!({ "name": "Renamed Client", "rate_limit_capacity": 500 })) .send() .await?;body := bytes.NewBufferString(`{ "name": "Renamed Client", "rate_limit_capacity": 500}`)req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X PUT http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7 \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "name": "Renamed Client", "rate_limit_capacity": 500 }'| Field | Type | Description |
|---|---|---|
name | string | New display name |
client_ | string | New home URL |
redirect_ | string[] | Replace the allowed redirect URIs |
scopes | string | Replace the OAuth scopes |
allowed_ | string[] | null | Replace the allowed browser origins, or clear them with null |
rate_ | integer | null | New bucket capacity, or clear it with null |
rate_ | number | null | New refill rate, or clear it with null |
is_ | boolean | Disable (false) or re-enable (true) the client without deleting it. Disabling a client also disables its child clients |
Response: 204 No Content, or 404 Not Found if the client doesn’t exist.
The OAuth registry is updated in place. The client_id_url is immutable: to change it, delete and recreate the client.
Delete an API client
Section titled “Delete an API client”DELETE /admin/api-clients/{id}Requires api-clients:delete. Removes the client from the OAuth registry, the rate limiter, and the client identity store. It also revokes the client’s authentication keys and deletes its child clients. Returns 404 Not Found if the client doesn’t exist.
await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", { method: "DELETE", headers,});await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", { method: "DELETE", headers,});let client = reqwest::Client::new();client .delete("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7", 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 http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7 -H "$AUTH"Response: 204 No Content
Client secrets
Section titled “Client secrets”A confidential client can hold several secrets at once, each with a label and each revocable on its own. A public client has none: it is identified by its key and its Origin.
To rotate a secret, add a new one, move your callers to it, then revoke the old one. The client, its key and its sessions are untouched throughout.
List secrets
Section titled “List secrets”GET /admin/api-clients/{id}/secretsRequires api-clients:view. Lists every secret the client has held, revoked ones included, as { "secrets": [...] }. The secrets themselves are never returned.
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets -H "$AUTH"Response: 200 OK
{ "secrets": [ { "id": "9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d", "label": "Original", "created_at": "2026-05-01T00:00:00+00:00", "last_used_at": "2026-10-30T09:12:00+00:00", "revoked_at": null } ]}| Field | Type | Description |
|---|---|---|
id | string | The secret’s id, used to revoke it |
label | string | Its label |
created_ | string | When it was created |
last_ | string | null | When a request last presented it, to the minute. null if never |
revoked_ | string | null | When it was revoked. null while it still works |
Add a secret
Section titled “Add a secret”POST /admin/api-clients/{id}/secretsRequires api-clients:edit. The new secret works at once, alongside the client’s others.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Trimmed, then 1 to 100 characters |
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ label: "staging" }),});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ label: "staging" }),});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets") .bearer_auth(token) .json(&serde_json::json!({ "label": "staging" })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "label": "staging"}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "label": "staging" }'Response: 201 Created with the fields above plus client_secret, the plaintext. This response is the only place it appears.
Returns 400 Bad Request for a public client, or a label that is empty or too long, and 409 Conflict for builtin-hpy.
Revoke a secret
Section titled “Revoke a secret”DELETE /admin/api-clients/{id}/secrets/{secret_id}Requires api-clients:edit. The secret stops working at once and the client’s others are untouched. Revoking one that is already revoked changes nothing and answers revoked: false. Returns 404 Not Found if the client has no such secret.
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets/9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d", { method: "DELETE", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets/9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d", { method: "DELETE", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .delete("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets/9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets/9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d", 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 http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/secrets/9b2d4f1a-6c3e-4a8b-b5d7-2e1f0c9a8b3d -H "$AUTH"Response: 200 OK
{ "revoked": true, "active_secrets": 1 }active_secrets is how many of the client’s secrets still work. At 0 the client can’t authenticate with a secret until you add one.
AT Protocol client authentication keys
Section titled “AT Protocol client authentication keys”A client can delegate its AT Protocol OAuth signing key to HappyView, so HappyView authenticates to users’ PDSes as a confidential client on its behalf. Confidential clients get far longer session lifetimes than public ones (the authorization server’s policy: 2 years rather than 2 weeks on a conforming PDS).
Delegating takes two steps: provision a key here, then publish the fields it returns in the client metadata document served at the client’s Client ID URL. Until both are done the client stays public, which is the default and requires no action.
Whether the client is confidential is not a flag HappyView sets. The authorization server concludes it by fetching the app’s published document. These endpoints only provision the key and report what that document currently says.
Provision the key
Section titled “Provision the key”POST /admin/api-clients/{id}/auth-keyRequires api-clients:edit. Generates an ES256 keypair for this client. Idempotent: it returns the current key if one is already provisioned.
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key", { method: "POST", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key", { method: "POST", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-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 -X POST http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key -H "$AUTH"Response: 200 OK with the key’s kid and the jwks_uri to publish.
{ "kid": "f0c3...", "jwks_uri": "https://<instance>/oauth/clients/<id>/jwks.json"}Get the current key
Section titled “Get the current key”GET /admin/api-clients/{id}/auth-keyRequires api-clients:view. Returns the current key’s kid and the jwks_uri to publish, or 404 if the client has no key.
{ "kid": "f0c3...", "jwks_uri": "https://<instance>/oauth/clients/<id>/jwks.json"}List every key
Section titled “List every key”GET /admin/api-clients/{id}/auth-keysRequires api-clients:view. Every key the client holds, ordered pending, current, retiring, then revoked, each with a live session_count and an activates_at (when a pending key becomes current, otherwise null).
Distinct from GET .../auth-key, which reports only the current key. An operator revoking a leaked key needs to see the retiring one and how many sessions revoking it will destroy. Revoked keys are listed so a revoke is visibly done.
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys -H "$AUTH"{ "keys": [ { "kid": "f0c3...", "status": "current", "created_at": "...", "activates_at": null, "session_count": 12 }, { "kid": "8b41...", "status": "retiring", "created_at": "...", "activates_at": null, "session_count": 3 } ], "jwks_uri": "https://happyview.example.com/oauth/clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/jwks.json"}Re-check the published document
Section titled “Re-check the published document”POST /admin/api-clients/{id}/auth-key/recheckRequires api-clients:edit. Re-fetches the client’s published metadata and reports whether HappyView can act as a confidential client for it, with the reason if not.
{ "confidential": false, "reason": "...", "checked_at": "2026-08-29T10:14:02+00:00"}Rotate the key
Section titled “Rotate the key”POST /admin/api-clients/{id}/auth-key/rotateRequires api-clients:edit. Mints a new key, staged the same way as the instance 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 again without immediate while a key is pending returns that pending key, and a client with no current key gets its new key as current at once. Nothing is logged out: a retiring key stays in the published JWKS and keeps signing refreshes for the sessions established with it.
| Field | Type | Required | Description |
|---|---|---|---|
immediate | boolean | no | Make the new key current at once. Default false |
{ "kid": "f0c3...", "status": "pending", "activates_at": "2026-08-29T11:29:02.000Z", "orphaned_sessions": 0}status is pending or current. activates_at is null when status is current.
Revoke a key
Section titled “Revoke a key”DELETE /admin/api-clients/{id}/auth-key/{kid}Requires api-clients:edit. Removes the key from the published JWKS immediately and strands every session pinned to it, and those users must re-authenticate.
This is the response to a leaked or compromised key, not routine cleanup. Rotation alone does not contain a leak: the rotated-out key stays published and valid, which is what keeps existing sessions working. A retiring key with no remaining sessions is revoked automatically.
| Status | Meaning |
|---|---|
400 | The target is the current key. Rotate with { "immediate": true } first, then revoke the retiring key. |
404 | No key with that kid belongs to this client. |
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key/8b41d0aa", { method: "DELETE", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key/8b41d0aa", { method: "DELETE", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .delete("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key/8b41d0aa") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key/8b41d0aa", 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 http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-key/$KID -H "$AUTH"{ "kid": "8b41...", "sessions_destroyed": 3 }Remove the key entirely
Section titled “Remove the key entirely”DELETE /admin/api-clients/{id}/auth-keysRequires api-clients:edit. Revokes every key this client holds and leaves its JWKS empty, un-delegating it completely. Unlike the per-key revoke above, this one does take the current key, because it removes the whole keyset.
Returns 400 if the client already holds no key.
const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", { method: "DELETE", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", { method: "DELETE", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .delete("http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys", 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 http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7/auth-keys -H "$AUTH"{ "revoked": 2, "sessions_destroyed": 15 }Respond to a leaked client key
Section titled “Respond to a leaked client key”- Rotate with
POST .../auth-key/rotateand{ "immediate": true }. The leaked key becomesretiring, a fresh key takes over, and the client keeps working. - Call
GET .../auth-keysand confirm the leaked key isretiring. Itssession_countis how many sessions step 3 ends. - Revoke the leaked key with
DELETE .../auth-key/{kid}.
The reverse order is refused: the current key cannot be revoked, because that would leave the client unable to authenticate at all.
To stop delegating altogether, so that HappyView holds no key, use DELETE .../auth-keys above, and remove the three published fields from the app’s metadata document at the same time.
© HappyProto

