Third-party API clients
Third-party apps can manage their own API clients via the dev.happyview.* XRPC endpoints. A third-party client is always tied to exactly one parent: the top-level API client whose DPoP session made the request, created through the admin API. Only one level of nesting is allowed; third-party clients cannot create further children. Each third-party client gets its own rate limit bucket (see Operational notes).
All endpoints use DPoP authentication; the dashboard’s own session cookie also works, on a request from the instance’s own pages. See the admin API client docs for managing clients through the admin API, and the API Clients guide for how API clients work.
Authentication
Section titled “Authentication”All requests require three headers:
| Header | Value |
|---|---|
Authorization | DPoP <access_token> |
DPoP | A DPoP proof JWT (method matches the HTTP method, htu is scheme + host + path, no query string) |
X-Client-Key | The parent client’s client_key |
The access token must belong to a valid DPoP session for the parent client.
List clients
Section titled “List clients”GET /xrpc/dev.happyview.listApiClientsReturns all API clients owned by the authenticated user.
Response: 200 OK
{ "clients": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "clientKey": "hvc_a1b2c3d4e5f6...", "name": "My App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential", "scopes": "atproto", "allowedOrigins": [], "isActive": true, "createdAt": "2026-04-28T12:00:00Z" } ]}Get a client
Section titled “Get a client”GET /xrpc/dev.happyview.getApiClient?id=<client_id>| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The client’s UUID |
Response: 200 OK
{ "client": { "id": "550e8400-e29b-41d4-a716-446655440000", "clientKey": "hvc_a1b2c3d4e5f6...", "name": "My App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential", "scopes": "atproto", "allowedOrigins": [], "isActive": true, "createdAt": "2026-04-28T12:00:00Z" }}Returns 404 if the client doesn’t exist or isn’t owned by the authenticated user.
Create a client
Section titled “Create a client”POST /xrpc/dev.happyview.createApiClientconst CLIENT_KEY = "hvc_parent_key"; // parent API client keyconst ACCESS_TOKEN = "eyJhbG..."; // DPoP access tokenconst DPOP_PROOF = "eyJhbG..."; // DPoP proof JWT
interface CreateClientResponse { client: { id: string; clientKey: string; name: string; clientIdUrl: string; clientUri: string; redirectUris: string[]; clientType: string; scopes: string; allowedOrigins: string[]; isActive: boolean; createdAt: string; }; clientSecret?: string;}
const response = await fetch( "https://happyview.example.com/xrpc/dev.happyview.createApiClient", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, Authorization: `DPoP ${ACCESS_TOKEN}`, DPoP: DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ name: "My Third-Party App", clientIdUrl: "https://myapp.example.com/client-metadata.json", clientUri: "https://myapp.example.com", redirectUris: ["https://myapp.example.com/callback"], clientType: "confidential", }), },);
const data: CreateClientResponse = await response.json();const CLIENT_KEY = "hvc_parent_key"; // parent API client keyconst ACCESS_TOKEN = "eyJhbG..."; // DPoP access tokenconst DPOP_PROOF = "eyJhbG..."; // DPoP proof JWT
const response = await fetch( "https://happyview.example.com/xrpc/dev.happyview.createApiClient", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, Authorization: `DPoP ${ACCESS_TOKEN}`, DPoP: DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ name: "My Third-Party App", clientIdUrl: "https://myapp.example.com/client-metadata.json", clientUri: "https://myapp.example.com", redirectUris: ["https://myapp.example.com/callback"], clientType: "confidential", }), },);
const data = await response.json();let client = reqwest::Client::new();let client_key = "hvc_parent_key"; // parent API client keylet access_token = "eyJhbG..."; // DPoP access tokenlet dpop_proof = "eyJhbG..."; // DPoP proof JWT
let response = client .post("https://happyview.example.com/xrpc/dev.happyview.createApiClient") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .json(&serde_json::json!({ "name": "My Third-Party App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential" })) .send() .await?;
let data: serde_json::Value = response.json().await?;clientKey := "hvc_parent_key" // parent API client keyaccessToken := "eyJhbG..." // DPoP access tokendpopProof := "eyJhbG..." // DPoP proof JWT
body := bytes.NewBufferString(`{ "name": "My Third-Party App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential"}`)
req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/dev.happyview.createApiClient", body)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)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 https://happyview.example.com/xrpc/dev.happyview.createApiClient \ -H "X-Client-Key: hvc_parent_key" \ -H "Authorization: DPoP eyJhbG..." \ -H "DPoP: eyJhbG..." \ -H "Content-Type: application/json" \ -d '{ "name": "My Third-Party App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential" }'| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name for the client |
clientIdUrl | string | yes | Unique OAuth client ID URL |
clientUri | string | yes | The client’s homepage URL |
redirectUris | string[] | yes | OAuth redirect URIs |
scopes | string | no | Space-separated OAuth scopes (default "atproto") |
clientType | string | no | "confidential" or "public" (default "confidential") |
allowedOrigins | string[] | no | CORS allowed origins (relevant for public clients) |
Response: 201 Created
{ "client": { "id": "550e8400-e29b-41d4-a716-446655440000", "clientKey": "hvc_a1b2c3d4e5f6...", "name": "My Third-Party App", "clientIdUrl": "https://myapp.example.com/client-metadata.json", "clientUri": "https://myapp.example.com", "redirectUris": ["https://myapp.example.com/callback"], "clientType": "confidential", "scopes": "atproto", "allowedOrigins": [], "isActive": true, "createdAt": "2026-04-28T12:00:00Z" }, "clientSecret": "hvs_f6e5d4c3b2a1..."}The clientSecret is only present for confidential clients and is only returned in this response. It is stored as a SHA-256 hash and cannot be retrieved again.
Delete a client
Section titled “Delete a client”POST /xrpc/dev.happyview.deleteApiClientconst response = await fetch( "https://happyview.example.com/xrpc/dev.happyview.deleteApiClient", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, Authorization: `DPoP ${ACCESS_TOKEN}`, DPoP: DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", }), },);const response = await fetch( "https://happyview.example.com/xrpc/dev.happyview.deleteApiClient", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, Authorization: `DPoP ${ACCESS_TOKEN}`, DPoP: DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", }), },);let response = client .post("https://happyview.example.com/xrpc/dev.happyview.deleteApiClient") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .json(&serde_json::json!({ "id": "550e8400-e29b-41d4-a716-446655440000" })) .send() .await?;clientKey := "hvc_parent_key" // parent API client keyaccessToken := "eyJhbG..." // DPoP access tokendpopProof := "eyJhbG..." // DPoP proof JWT
body := bytes.NewBufferString(`{"id": "550e8400-e29b-41d4-a716-446655440000"}`)
req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/dev.happyview.deleteApiClient", body)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)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 https://happyview.example.com/xrpc/dev.happyview.deleteApiClient \ -H "X-Client-Key: hvc_parent_key" \ -H "Authorization: DPoP eyJhbG..." \ -H "DPoP: eyJhbG..." \ -H "Content-Type: application/json" \ -d '{ "id": "550e8400-e29b-41d4-a716-446655440000" }'| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | The client’s UUID |
Response: 200 OK with {}
Returns 404 if the client doesn’t exist or isn’t owned by the authenticated user. Deleting a client cascades to all its children.
Confidential client authentication
Section titled “Confidential client authentication”These two endpoints are unrelated to the third-party XRPC methods above. They belong to any API client that wants to run its own atproto OAuth flow against a user’s PDS as a confidential client (private_key_jwt) instead of a public one. See the API Clients guide for the full flow, or generate and inspect the key from the dashboard: Access > API Clients, then the client’s AT Protocol Client Auth panel.
Get the client’s JWKS
Section titled “Get the client’s JWKS”GET /oauth/clients/{id}/jwks.jsonPublic and unauthenticated: this is the URL your app publishes as jwks_uri in its client_id_url document, so the user’s PDS (a stranger to HappyView) can fetch it during authorization. An id with no provisioned key returns an empty key set rather than a 404, so this endpoint can’t be used to enumerate which client ids exist.
const response = await fetch( "https://happyview.example.com/oauth/clients/550e8400-e29b-41d4-a716-446655440000/jwks.json",);
const jwks = await response.json();const response = await fetch( "https://happyview.example.com/oauth/clients/550e8400-e29b-41d4-a716-446655440000/jwks.json",);
const jwks = await response.json();let response = client .get("https://happyview.example.com/oauth/clients/550e8400-e29b-41d4-a716-446655440000/jwks.json") .send() .await?;
let jwks: serde_json::Value = response.json().await?;resp, err := http.Get( "https://happyview.example.com/oauth/clients/550e8400-e29b-41d4-a716-446655440000/jwks.json",)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl https://happyview.example.com/oauth/clients/550e8400-e29b-41d4-a716-446655440000/jwks.jsonResponse: 200 OK
{ "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "b3a1...", "alg": "ES256", "use": "sig" } ]}keys is empty until an authentication key has been provisioned for this client (POST /admin/api-clients/{id}/auth-key).
Sign a client assertion
Section titled “Sign a client assertion”POST /oauth/client-assertionMints a private_key_jwt assertion for the calling client, for use in an atproto OAuth flow the client is running itself against a user’s PDS. Needed twice per flow, once for the pushed authorization request (PAR) and once for the token exchange, since each assertion expires after 60 seconds and only covers one request.
Authenticate in one of three ways:
- As a confidential client, with
X-Client-Key+X-Client-Secret. - With
X-Client-Key+ an active DPoP-bound session (Authorization: DPoP <token>+ aDPoPproof covering this request). - During a public client’s first sign-in, when it has neither, with
X-Client-Key,provision_idandpkce_verifier. When the client hasallowed_origins, theOriginheader must be one of them.
const CLIENT_KEY = "hvc_...";const CLIENT_SECRET = "hvs_...";
interface ClientAssertionResponse { client_assertion: string; client_assertion_type: string; expires_in: number; kid: string;}
const response = await fetch( "https://happyview.example.com/oauth/client-assertion", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "X-Client-Secret": CLIENT_SECRET, "Content-Type": "application/json", }, body: JSON.stringify({ issuer: "https://bsky.social" }), },);
const data: ClientAssertionResponse = await response.json();const CLIENT_KEY = "hvc_...";const CLIENT_SECRET = "hvs_...";
const response = await fetch( "https://happyview.example.com/oauth/client-assertion", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "X-Client-Secret": CLIENT_SECRET, "Content-Type": "application/json", }, body: JSON.stringify({ issuer: "https://bsky.social" }), },);
const data = await response.json();let client_key = "hvc_...";let client_secret = "hvs_...";
let response = client .post("https://happyview.example.com/oauth/client-assertion") .header("X-Client-Key", client_key) .header("X-Client-Secret", client_secret) .json(&serde_json::json!({ "issuer": "https://bsky.social" })) .send() .await?;
let data: serde_json::Value = response.json().await?;clientKey := "hvc_..."clientSecret := "hvs_..."
body := bytes.NewBufferString(`{"issuer": "https://bsky.social"}`)
req, _ := http.NewRequest("POST", "https://happyview.example.com/oauth/client-assertion", body)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("X-Client-Secret", clientSecret)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 https://happyview.example.com/oauth/client-assertion \ -H "X-Client-Key: hvc_..." \ -H "X-Client-Secret: hvs_..." \ -H "Content-Type: application/json" \ -d '{"issuer": "https://bsky.social"}'| Field | Type | Required | Description |
|---|---|---|---|
issuer | string | yes | The issuer URL from the target PDS authorization server’s metadata |
kid | string | no | Sign with this specific key instead of whichever is current. Required when refreshing; see below |
provision_ | string | if pkce_verifier is sent | The provision_id from POST /oauth/dpop-keys. A provision keeps the key its first assertion used |
pkce_ | string | if provision_id is sent | The verifier for that provision’s pkce_challenge |
Response: 200 OK
{ "client_assertion": "eyJhbG...", "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", "expires_in": 60, "kid": "8b41d0aa-..."}Attach client_assertion and client_assertion_type as form parameters on the PAR request and, separately, on the token exchange request. Each needs its own call to this endpoint.
Refreshing a token: store the kid
Section titled “Refreshing a token: store the kid”kid names the key that signed the assertion. Store it alongside the session you establish, and pass it back as the kid field whenever you request an assertion to refresh that session.
An authorization server binds a session to the key that established it. After a key rotation, a refresh with an assertion signed by the newer key presents a key the server never bound the session to. A conforming server responds by destroying the session, not by refusing the one request. The user is silently signed out, typically hours later, with nothing to connect it to the rotation.
Omit kid for an initial authorization, where any currently valid key is correct.
Returns 400 (this client has no authentication key; provision one first) if no key has been provisioned yet via POST /admin/api-clients/{id}/auth-key.
Returns 400 (no usable authentication key '<kid>' ...) if the requested kid is unknown or has been revoked. A revoked key means any session established under it is already gone, so run a fresh authorization rather than a refresh.
Errors
Section titled “Errors”| Status | Error | Cause |
|---|---|---|
| 400 | client_type must be 'confidential' or 'public' | clientType is neither |
| 400, 415, 422 | a plain-text body | Malformed JSON (400), no JSON Content-Type (415), or a missing or mistyped field (422); getApiClient without id answers 400 |
| 401 | <method> requires DPoP authentication, or the session cookie on a request from this instance | No DPoP auth, and no session cookie from the instance’s own pages |
| 401 | DPoP auth requires X-Client-Key header | A DPoP request without X-Client-Key |
| 401 | unknown client | X-Client-Key doesn’t match an active client |
| 401 | createApiClient requires an API client key | A dashboard session that names no API client |
| 401 | Invalid client | createApiClient from a session whose API client no longer exists or is inactive |
| 401 | child clients cannot manage API clients | listApiClients from a third-party (child) client |
| 403 | Child clients cannot create API clients | createApiClient from a third-party (child) client |
| 404 | API client not found | getApiClient: no client with that ID owned by the authenticated user |
| 404 | api client '<id>' not found | deleteApiClient: no client with that ID owned by the authenticated user |
| 409 | client_id_url already registered | Another client already uses that clientIdUrl |
| 429 | RateLimited | The calling client’s rate-limit bucket is empty |
Operational notes
Section titled “Operational notes”Each third-party client gets its own rate limit bucket using the instance’s default capacity and refill rate (DEFAULT_RATE_LIMIT_CAPACITY / DEFAULT_RATE_LIMIT_REFILL_RATE). Deactivating or deleting a parent via the admin API cascades to all its children.
The admin API clients list (GET /admin/api-clients) returns parent_client_id and owner_did fields for each client and supports ?parent_id= filtering. The dashboard’s API Clients table shows these as “Parent Client” and “Owner” columns.
© HappyProto

