OAuth API

Third-Party API Clients

Third-party applications can manage their own API clients via the dev.happyview.* XRPC endpoints. A third-party client is always tied to exactly one parent — the admin-created top-level API client whose DPoP session made the request. Only one level of nesting is allowed; third-party clients cannot create further children. Each third-party client gets its own rate limit bucket with instance default settings.

All endpoints use DPoP authentication. See the admin API client docs for managing clients through the admin API, and the API Clients guide for how API clients work.

Authentication

All requests require three headers:

HeaderValue
AuthorizationDPoP <access_token>
DPoPA DPoP proof JWT (method matches the HTTP method, htu is scheme + host + path, no query string)
X-Client-KeyThe parent client's client_key

The access token must belong to a valid DPoP session for the parent client.

List clients

GET /xrpc/dev.happyview.listApiClients

Returns 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

GET /xrpc/dev.happyview.getApiClient?id=<client_id>
ParameterTypeRequiredDescription
idstringyesThe 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

POST /xrpc/dev.happyview.createApiClient
const CLIENT_KEY = "hvc_parent_key"; // parent API client key
const ACCESS_TOKEN = "eyJhbG..."; // DPoP access token
const 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();
FieldTypeRequiredDescription
namestringyesDisplay name for the client
clientIdUrlstringyesUnique OAuth client ID URL
clientUristringyesThe client's homepage URL
redirectUrisstring[]yesOAuth redirect URIs
scopesstringnoSpace-separated OAuth scopes (default "atproto")
clientTypestringno"confidential" or "public" (default "confidential")
allowedOriginsstring[]noCORS 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

POST /xrpc/dev.happyview.deleteApiClient
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",
    }),
  },
);
FieldTypeRequiredDescription
idstringyesThe 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.

Errors

StatusErrorCause
400Invalid client_typeclient_type is not "confidential" or "public"
400invalid request bodyMissing required fields or malformed JSON
401requires DPoP authenticationAuthorization header is missing or doesn't use the DPoP scheme
401requires an API client keyX-Client-Key header is absent
401token_expiredThe access token has expired
401Invalid clientX-Client-Key doesn't match a known client
401child clients cannot manage API clientsThe calling client is itself a third-party (child) client
403Child clients cannot create API clientsThe calling client is itself a third-party (child) client
404API client not foundNo client with that ID owned by the authenticated user
409client_id_url already registeredAnother client already uses that clientIdUrl

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.