API clients identify third-party applications that call HappyView’s XRPC endpoints. Every request — authenticated or not — needs an X-Client-Key header (or client_key query param). Requests without one get 401 Unauthorized. The client key is HappyView’s rate-limit bucket.
A single API client represents your application, not individual users. 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 and an hvs_-prefixed client secret. The secret is only returned at creation and is sha256-hashed in the database. Server-to-server callers pass the secret as X-Client-Secret. Browser callers use the Origin header, which is matched against the client’s client_uri. Mismatches currently log warnings rather than rejecting the request, but rate limiting applies either way. See Authentication — XRPC 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 keytoken := "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, 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)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 a client_secret for a confidential client. Store the secret — it won’t be shown again.
interface ApiClient { 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: ApiClient = 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 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)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 |
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 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 — only provided fields are changed. Updating either rate-limit field re-registers the client with the rate limiter using the new values.
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, }),});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)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[] | Replace the allowed browser origins. null leaves them unchanged |
rate_ | integer | New bucket capacity. null leaves the value unchanged; an override can’t currently be cleared |
rate_ | number | New refill rate. null leaves the value unchanged; an override can’t currently be cleared |
is_ | boolean | Disable (false) or re-enable (true) the client without deleting it |
Response: 204 No Content
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,});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)curl -X DELETE http://127.0.0.1:3000/admin/api-clients/7c9e6679-7425-40de-944b-e07fc1f0d4c7 -H "$AUTH"Response: 204 No Content
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 — it is a conclusion the authorization server reaches 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 — returns the existing key if one is already provisioned.
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.
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 while a key is pending returns that pending key. 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+00:00", "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 — those users must re-authenticate.
This is the response to a leaked or compromised key, not routine cleanup. Rotation alone is not containment: the rotated-out key remains published and valid, which is precisely 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 first, then revoke the retiring key. |
404 | No key with that kid belongs to this client. |
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 — removing the keyset is the point.
Returns 400 if the client already holds no key.
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 }Responding to a leaked client key
Section titled “Responding to a leaked client key”POST .../auth-key/rotatewith{ "immediate": true }— the leaked key becomesretiring, a fresh key takes over, and the client keeps working.GET .../auth-keys— confirm it is nowretiringand note itssession_count, which is how many users step 3 signs out.DELETE .../auth-key/{kid}on the leaked key.
The reverse order is refused: the current key cannot be revoked, because that would leave the client unable to authenticate at all.
If instead you want to stop delegating altogether — not replace the key, but have HappyView hold none — use DELETE .../auth-keys above, and remove the three published fields from the app’s metadata document at the same time.
© HappyProto

