Admin API

API Clients

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 key
const headers = { Authorization: `Bearer ${TOKEN}` };

List API clients

GET /admin/api-clients

Requires api-clients:view. Returns clients ordered by created_at descending. Secrets are never returned.

interface ApiClient {
  id: string;
  client_key: string;
  name: string;
  client_id_url: string;
  client_uri: string;
  redirect_uris: string[];
  scopes: string;
  rate_limit_capacity: number;
  rate_limit_refill_rate: number;
  is_active: boolean;
  created_by: string;
  created_at: string;
  updated_at: string;
}

const response = await fetch("http://127.0.0.1:3000/admin/api-clients", {
  headers,
});
const data: ApiClient[] = await response.json();

Response: 200 OK

[
  {
    "id": "01J9...",
    "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",
    "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

POST /admin/api-clients

Requires api-clients:create. Generates a client_key and client_secret. Store the secret — it won't be shown again.

interface ApiClient {
  id: string;
  client_key: string;
  client_secret: string;
  name: string;
  client_id_url: string;
}

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();
FieldTypeRequiredDescription
namestringyesHuman-readable display name
client_id_urlstringyesURL to the client's published OAuth client metadata document
client_uristringyesThe client's home/landing URL
redirect_urisstring[]yesAllowed OAuth redirect URIs
scopesstringnoSpace-separated OAuth scopes (default "atproto")
rate_limit_capacityintegernoPer-client token bucket capacity. Falls back to DEFAULT_RATE_LIMIT_CAPACITY if unset
rate_limit_refill_ratenumbernoTokens added per second. Falls back to DEFAULT_RATE_LIMIT_REFILL_RATE if unset

Response: 201 Created

{
  "id": "01J9...",
  "client_key": "hvc_a1b2c3...",
  "client_secret": "hvs_d4e5f6...",
  "name": "My Game Client",
  "client_id_url": "https://example.com/client-metadata.json"
}

The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView.

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

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/01J9...", {
  method: "PUT",
  headers: {
    ...headers,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Renamed Client",
    rate_limit_capacity: 500,
  }),
});
FieldTypeDescription
namestringNew display name
client_uristringNew home URL
redirect_urisstring[]Replace the allowed redirect URIs
scopesstringReplace the OAuth scopes
rate_limit_capacityintegerNew bucket capacity. Pass null to clear the override
rate_limit_refill_ratenumberNew refill rate. Pass null to clear the override
is_activebooleanDisable (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

DELETE /admin/api-clients/{id}

Requires api-clients:delete. Removes the client from the OAuth registry, the rate limiter, and the client identity store.

await fetch("http://127.0.0.1:3000/admin/api-clients/01J9...", {
  method: "DELETE",
  headers,
});

Response: 204 No Content

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

POST /admin/api-clients/{id}/auth-key

Requires 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/01J9.../auth-key -H "$AUTH"

Response: 200 OK with the key's kid and the jwks_uri to publish.

List every key

GET /admin/api-clients/{id}/auth-keys

Requires api-clients:view. Every key the client holds, ordered current, then retiring, then revoked, each with a live session_count.

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/01J9.../auth-keys -H "$AUTH"
{
  "keys": [
    { "kid": "f0c3...", "status": "current",  "created_at": "...", "session_count": 12 },
    { "kid": "8b41...", "status": "retiring", "created_at": "...", "session_count": 3 }
  ],
  "jwks_uri": "https://happyview.example.com/oauth/clients/01J9.../jwks.json"
}

Re-check the published document

POST /admin/api-clients/{id}/auth-key/recheck

Requires 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.

Rotate the key

POST /admin/api-clients/{id}/auth-key/rotate

Requires api-clients:edit. Mints a new current key and demotes the previous one to retiring. Nothing is logged out: a retiring key stays in the published JWKS and keeps signing refreshes for the sessions established with it.

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 swept automatically.

StatusMeaning
400The target is the current key. Rotate first, then revoke the retiring key.
404No key with that kid belongs to this client.
curl -X DELETE http://127.0.0.1:3000/admin/api-clients/01J9.../auth-key/$KID -H "$AUTH"
{ "kid": "8b41...", "sessions_destroyed": 3 }

Remove the key entirely

DELETE /admin/api-clients/{id}/auth-keys

Requires 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/01J9.../auth-keys -H "$AUTH"
{ "revoked": 2, "sessions_destroyed": 15 }

Responding to a leaked client key

  1. POST .../auth-key/rotate — the leaked key becomes retiring, a fresh key takes over, and the client keeps working.
  2. GET .../auth-keys — confirm it is now retiring and note its session_count, which is how many users step 3 signs out.
  3. 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.