Skip to content

API clients

API clients identify third-party applications 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.

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. 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. Mismatches log warnings rather than rejecting the request, and rate limiting applies either way. 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 key
const headers = { Authorization: `Bearer ${TOKEN}` };
GET /admin/api-clients

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

ParamTypeRequiredDescription
parent_idstringnoOnly 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();

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
}
]
POST /admin/api-clients

Requires 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 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();
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")
client_typestringno"confidential" (default) issues a client_secret; "public" issues none. Anything else is 400
allowed_originsstring[]noBrowser origins allowed for this client
rate_limit_capacityintegernoPer-client token bucket capacity. Falls back to DEFAULT_RATE_LIMIT_CAPACITY if unset. Applies only when both rate-limit fields are given
rate_limit_refill_ratenumbernoTokens 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 /admin/api-clients/{id}

Requires api-clients:view. Returns the same shape as the list endpoint, or 404 Not Found.

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,
}),
});
FieldTypeDescription
namestringNew display name
client_uristringNew home URL
redirect_urisstring[]Replace the allowed redirect URIs
scopesstringReplace the OAuth scopes
allowed_originsstring[] | nullReplace the allowed browser origins, or clear them with null
rate_limit_capacityinteger | nullNew bucket capacity, or clear it with null
rate_limit_refill_ratenumber | nullNew refill rate, or clear it with null
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 /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,
});

Response: 204 No Content

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.

GET /admin/api-clients/{id}/secrets

Requires 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();

Response: 200 OK

{
"secrets": [
{
"id": "original-7c9e6679-7425-40de-944b-e07fc1f0d4c7",
"label": "Original",
"created_at": "2026-05-01T00:00:00+00:00",
"last_used_at": "2026-10-30T09:12:00+00:00",
"revoked_at": null
}
]
}
FieldTypeDescription
idstringThe secret’s id, used to revoke it
labelstringIts label
created_atstringWhen it was created
last_used_atstring | nullWhen a request last presented it, to the minute. null if never
revoked_atstring | nullWhen it was revoked. null while it still works
POST /admin/api-clients/{id}/secrets

Requires api-clients:edit. The new secret works at once, alongside the client’s others.

FieldTypeRequiredDescription
labelstringyesTrimmed, 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();

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.

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/original-7c9e6679-7425-40de-944b-e07fc1f0d4c7", {
method: "DELETE",
headers,
});
const data = await response.json();

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.

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.

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

Requires 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();

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 /admin/api-clients/{id}/auth-key

Requires 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"
}
GET /admin/api-clients/{id}/auth-keys

Requires 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();
{
"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"
}
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.

{
"confidential": false,
"reason": "...",
"checked_at": "2026-08-29T10:14:02+00:00"
}
POST /admin/api-clients/{id}/auth-key/rotate

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

FieldTypeRequiredDescription
immediatebooleannoMake 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.

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

StatusMeaning
400The target is the current key. Rotate with { "immediate": true } first, then revoke the retiring key.
404No 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();
{ "kid": "8b41...", "sessions_destroyed": 3 }
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, because removing the keyset is the point.

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();
{ "revoked": 2, "sessions_destroyed": 15 }
  1. POST .../auth-key/rotate with { "immediate": true }. 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.

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.