API Clients
API clients identify your application to a HappyView instance. Every XRPC request — even unauthenticated queries — must include a client key. This guide walks through creating a client, choosing between public and confidential types, and authenticating users.
For the admin CRUD endpoints, see the API reference. For the JavaScript SDK, see the SDK docs.
Concepts
An API client represents your application, not individual users. Create one client for your app and use the same client key everywhere. Users authenticate separately via OAuth — the client key identifies who built the app, not who is using it.
Each client has:
- An
hvc_-prefixed client key — included in every request to identify your app - An
hvs_-prefixed client secret — used by server-side apps to prove ownership (confidential clients only) - Rate limits — a token bucket that controls how many requests your app can make
- Scopes — which lexicons your app is allowed to access
Public vs. confidential clients
Choose based on where your code runs:
| Confidential | Public | |
|---|---|---|
| Use when | Server-side apps, CLI tools, bots | Browser apps, mobile apps |
| Authentication | X-Client-Key + X-Client-Secret headers | X-Client-Key + Origin header + PKCE |
| Can keep a secret? | Yes | No |
| Origin validation | No | Yes — Origin must match allowed_origins |
| PKCE required? | No | Yes (S256) |
If your app has a backend that can securely store the client secret, use a confidential client even if the frontend is a browser app. The backend can proxy OAuth operations.
Creating a client
From the dashboard
Go to Settings > API Clients > New client and fill in:
- Client type —
confidential(default) orpublic - Name — a human-readable label (e.g. "My atproto Client")
- Client ID URL — URL to your published OAuth client metadata document
- Client URI — your app's root domain (e.g. https://example.com)
- Redirect URIs — where the PDS should redirect after authorization
- Allowed origins — (public clients only) which
Originheaders to accept - Scopes —
atprotois always included; add custom scopes if your instance uses them
Save the client secret immediately. It is only shown once and is hashed before storage.
From the API
curl -X POST http://localhost:3000/admin/api-clients \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My atproto Client",
"client_id_url": "https://example.com/client-metadata.json",
"client_uri": "https://example.com",
"redirect_uris": ["https://example.com/oauth/callback"],
"client_type": "public",
"allowed_origins": ["https://example.com"]
}'
See the API reference for all fields.
Using your client key
Every XRPC request must include the client key. HappyView looks for it in this order:
X-Client-Keyrequest header (preferred)client_keyquery parameter
Unauthenticated queries
For public queries that don't need a user identity:
curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \
-H 'X-Client-Key: hvc_a1b2c3...'
Server-side callers should also include the secret (since there's no origin to authenticate):
curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \
-H 'X-Client-Key: hvc_a1b2c3...' \
-H 'X-Client-Secret: hvs_d4e5f6...'
Authenticated requests (user identity)
Procedures — and queries whose scripts need to know who the caller is — require a user's OAuth session. This uses DPoP authentication, where each request includes a cryptographic proof that the caller holds the right key.
curl -X POST 'https://happyview.example.com/xrpc/com.example.createPost' \
-H 'X-Client-Key: hvc_...' \
-H 'Authorization: DPoP <access_token>' \
-H 'DPoP: <proof_jwt>' \
-H 'Content-Type: application/json' \
-d '{"text": "Hello world"}'
Authenticating users
Using the JavaScript SDK
The SDK handles the entire DPoP flow. A complete browser example:
import { HappyViewBrowserClient } from "@happyview/oauth-client-browser";
const client = new HappyViewBrowserClient({
instanceUrl: "https://happyview.example.com",
clientKey: "hvc_your_client_key",
});
// Login — redirects to the user's PDS
await client.login("alice.bsky.social");
On your callback page:
const session = await client.callback();
// Make authenticated requests
const response = await session.fetchHandler(
"/xrpc/com.example.getStuff?limit=10",
{ method: "GET" },
);
On subsequent page loads, restore the session from localStorage:
const session = await client.restore();
if (session) {
// User is still logged in
}
For server-side Node.js apps, use the core @happyview/oauth-client package with a confidential client. For type-safe XRPC calls, pair either client with @happyview/lex-agent.
Manual DPoP flow
If you're not using JavaScript, or want to understand the protocol, the DPoP flow has four phases.
Phase 1: Provision a DPoP key
Ask HappyView for an ES256 keypair that will be shared between your app and the instance.
Confidential client:
POST /oauth/dpop-keys
X-Client-Key: hvc_...
X-Client-Secret: hvs_...
Content-Type: application/json
{}
Public client:
POST /oauth/dpop-keys
X-Client-Key: hvc_...
Origin: https://example.com
Content-Type: application/json
{"pkce_challenge": "<base64url-encoded S256 challenge>"}
Response:
{
"provision_id": "hvp_...",
"dpop_key": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"d": "..."
}
}
The dpop_key is the full private JWK. Store it securely — you'll use it to sign DPoP proofs.
Phase 2: OAuth with the user's PDS
Run a standard atproto OAuth flow with the user's PDS authorization server, using the provisioned DPoP key as your keypair. HappyView is not involved in this step.
- Resolve the user's handle to a DID
- Resolve the DID document to find the PDS URL
- Fetch the PDS's OAuth authorization server metadata
- Redirect the user to the PDS authorization endpoint
- Exchange the authorization code for tokens (using DPoP proofs signed with the provisioned key)
Phase 3: Register the session
After the OAuth callback, register the token set with HappyView so it can proxy requests on behalf of the user.
Confidential client:
POST /oauth/sessions
X-Client-Key: hvc_...
X-Client-Secret: hvs_...
Content-Type: application/json
{
"provision_id": "hvp_...",
"did": "did:plc:user123",
"access_token": "...",
"refresh_token": "...",
"expires_at": "2026-04-17T00:00:00Z",
"scopes": "atproto transition:generic",
"pds_url": "https://bsky.social",
"issuer": "https://bsky.social"
}
Public client — omit the secret, include the PKCE verifier:
POST /oauth/sessions
X-Client-Key: hvc_...
Content-Type: application/json
{
"provision_id": "hvp_...",
"pkce_verifier": "...",
"did": "did:plc:user123",
"access_token": "...",
"refresh_token": "...",
"expires_at": "2026-04-17T00:00:00Z",
"scopes": "atproto transition:generic",
"pds_url": "https://bsky.social",
"issuer": "https://bsky.social"
}
Phase 4: Make authenticated XRPC requests
With a registered session, sign each request with a DPoP proof:
curl -X POST 'https://happyview.example.com/xrpc/com.example.createPost' \
-H 'X-Client-Key: hvc_...' \
-H 'Authorization: DPoP <access_token>' \
-H 'DPoP: <proof_jwt>' \
-H 'Content-Type: application/json' \
-d '{"text": "Hello world"}'
HappyView validates the proof, looks up the stored session, and proxies writes to the user's PDS using the shared DPoP key.
Logout
Confidential:
DELETE /oauth/sessions/did:plc:user123
X-Client-Key: hvc_...
X-Client-Secret: hvs_...
Public (must prove key possession):
DELETE /oauth/sessions/did:plc:user123
X-Client-Key: hvc_...
Authorization: DPoP <access_token>
DPoP: <proof_jwt>
DPoP proof format
If you're implementing the flow without the SDK, a DPoP proof JWT looks like this:
Header:
{
"alg": "ES256",
"typ": "dpop+jwt",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
Payload:
{
"htm": "POST",
"htu": "https://happyview.example.com/xrpc/com.example.createPost",
"iat": 1745452800,
"ath": "<base64url SHA-256 of the access token>",
"jti": "<unique identifier>"
}
Validation rules:
htmmust match the HTTP method (case-insensitive)htumust match the request URL (scheme + host + path, no query string)iatmust be within 5 minutes of the server's clockathmust be the base64url-encoded SHA-256 hash of the access token- The JWK thumbprint (RFC 7638, SHA-256) must match the key used during provisioning
- The signature must verify against the embedded public JWK
Scopes
By default, a client's scopes are just atproto. You can add custom scopes when creating or updating the client.
HappyView supports an include: directive that expands permission sets defined in lexicons. For example, if your instance has a lexicon com.example.authBasic with a permissions array in its definition, you can set the client's scopes to:
atproto include:com.example.authBasic
This expands to include all RPC methods and repository actions defined in that permission set.
Rate limiting
Each API client has its own token bucket for rate limiting:
- Capacity — maximum tokens in the bucket
- Refill rate — tokens added per second
If not set on the client, the instance defaults apply (DEFAULT_RATE_LIMIT_CAPACITY and DEFAULT_RATE_LIMIT_REFILL_RATE).
Rate limit state is returned in response headers:
| Header | Description |
|---|---|
RateLimit-Limit | Bucket capacity |
RateLimit-Remaining | Tokens remaining |
RateLimit-Reset | Unix timestamp when the bucket will be full |
Retry-After | Seconds to wait (only on 429 responses) |
Adjust per-client rate limits via the dashboard or the admin API.
Security notes
- Client secrets are SHA-256 hashed before storage — HappyView never stores the plaintext.
- DPoP private keys and OAuth tokens are encrypted at rest with AES-256-GCM using the
TOKEN_ENCRYPTION_KEYenvironment variable. - Re-authenticating the same user with the same client upserts the session. The old DPoP key is cleaned up automatically.
- Multiple clients can have active sessions for the same user — sessions are isolated per client.
Next steps
- Authentication — full protocol details and security model
- JavaScript SDK — get started with the SDK
- Admin API — API Clients — CRUD endpoints
- Permissions — control who can manage API clients