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.

Confidential client authentication

These two endpoints are unrelated to the third-party (XRPC) CRUD flow above — they belong to any top-level 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's Settings > API Clients > "AT Protocol Client Auth" card.

Get the client's JWKS

GET /oauth/clients/{id}/jwks.json

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

Response: 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

POST /oauth/client-assertion

Mints 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), once for the token exchange — since each assertion expires after 60 seconds and only covers one request.

Authenticate as a confidential client with X-Client-Key + X-Client-Secret, or as a public client with X-Client-Key + an active DPoP-bound session (Authorization: DPoP <token> + a DPoP proof covering this request).

const CLIENT_KEY = "hvc_...";
const CLIENT_SECRET = "hvs_...";

interface ClientAssertionResponse {
  clientAssertion: string;
  clientAssertionType: string;
  expiresIn: number;
}

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();
FieldTypeRequiredDescription
issuerstringyesThe issuer URL from the target PDS authorization server's metadata
kidstringnoSign with this specific key instead of whichever is current. Required when refreshing — see below

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

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.

This matters after a key rotation. An authorization server binds a session to the key that established it. If you refresh with an assertion signed by a newer key, the server sees a key it never bound the session to — and 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

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.