···11+# API Clients
22+33+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.
44+55+For the admin CRUD endpoints, see the [API reference](../reference/admin/api-clients.md). For the JavaScript SDK, see the [SDK docs](../sdk/overview.md).
66+77+## Concepts
88+99+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_.
1010+1111+Each client has:
1212+1313+- An `hvc_`-prefixed **client key** — included in every request to identify your app
1414+- An `hvs_`-prefixed **client secret** — used by server-side apps to prove ownership (confidential clients only)
1515+- **Rate limits** — a token bucket that controls how many requests your app can make
1616+- **Scopes** — which lexicons your app is allowed to access
1717+1818+## Public vs. confidential clients
1919+2020+Choose based on where your code runs:
2121+2222+| | Confidential | Public |
2323+| ---------------------- | ------------------------------------------ | ------------------------------------------- |
2424+| **Use when** | Server-side apps, CLI tools, bots | Browser apps, mobile apps |
2525+| **Authentication** | `X-Client-Key` + `X-Client-Secret` headers | `X-Client-Key` + `Origin` header + PKCE |
2626+| **Can keep a secret?** | Yes | No |
2727+| **Origin validation** | No | Yes — `Origin` must match `allowed_origins` |
2828+| **PKCE required?** | No | Yes (S256) |
2929+3030+:::tip
3131+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.
3232+:::
3333+3434+## Creating a client
3535+3636+### From the dashboard
3737+3838+Go to **Settings > API Clients > New client** and fill in:
3939+4040+- **Client type** — `confidential` (default) or `public`
4141+- **Name** — a human-readable label (e.g. "My atproto Client")
4242+- **Client ID URL** — URL to your published [OAuth client metadata](https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html) document
4343+- **Client URI** — your app's root domain (e.g. https://example.com)
4444+- **Redirect URIs** — where the PDS should redirect after authorization
4545+- **Allowed origins** — (public clients only) which `Origin` headers to accept
4646+- **Scopes** — `atproto` is always included; add custom scopes if your instance uses them
4747+4848+**Save the client secret immediately.** It is only shown once and is hashed before storage.
4949+5050+### From the API
5151+5252+```sh
5353+curl -X POST http://localhost:3000/admin/api-clients \
5454+ -H "Authorization: Bearer $TOKEN" \
5555+ -H "Content-Type: application/json" \
5656+ -d '{
5757+ "name": "My atproto Client",
5858+ "client_id_url": "https://example.com/client-metadata.json",
5959+ "client_uri": "https://example.com",
6060+ "redirect_uris": ["https://example.com/oauth/callback"],
6161+ "client_type": "public",
6262+ "allowed_origins": ["https://example.com"]
6363+ }'
6464+```
6565+6666+See the [API reference](../reference/admin/api-clients.md#create-an-api-client) for all fields.
6767+6868+## Using your client key
6969+7070+Every XRPC request must include the client key. HappyView looks for it in this order:
7171+7272+1. `X-Client-Key` request header (preferred)
7373+2. `client_key` query parameter
7474+7575+### Unauthenticated queries
7676+7777+For public queries that don't need a user identity:
7878+7979+```sh
8080+curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \
8181+ -H 'X-Client-Key: hvc_a1b2c3...'
8282+```
8383+8484+Server-side callers should also include the secret (since there's no origin to authenticate):
8585+8686+```sh
8787+curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \
8888+ -H 'X-Client-Key: hvc_a1b2c3...' \
8989+ -H 'X-Client-Secret: hvs_d4e5f6...'
9090+```
9191+9292+### Authenticated requests (user identity)
9393+9494+Procedures — and queries whose scripts need to know who the caller is — require a user's OAuth session. This uses [DPoP authentication](../getting-started/authentication.md#dpop-key-provisioning-for-third-party-apps), where each request includes a cryptographic proof that the caller holds the right key.
9595+9696+```sh
9797+curl -X POST 'https://happyview.example.com/xrpc/com.example.createPost' \
9898+ -H 'X-Client-Key: hvc_...' \
9999+ -H 'Authorization: DPoP <access_token>' \
100100+ -H 'DPoP: <proof_jwt>' \
101101+ -H 'Content-Type: application/json' \
102102+ -d '{"text": "Hello world"}'
103103+```
104104+105105+## Authenticating users
106106+107107+### Using the JavaScript SDK
108108+109109+The SDK handles the entire DPoP flow. A complete browser example:
110110+111111+```typescript
112112+import { HappyViewBrowserClient } from "@happyview/oauth-client-browser";
113113+114114+const client = new HappyViewBrowserClient({
115115+ instanceUrl: "https://happyview.example.com",
116116+ clientKey: "hvc_your_client_key",
117117+});
118118+119119+// Login — redirects to the user's PDS
120120+await client.login("alice.bsky.social");
121121+```
122122+123123+On your callback page:
124124+125125+```typescript
126126+const session = await client.callback();
127127+128128+// Make authenticated requests
129129+const response = await session.fetchHandler(
130130+ "/xrpc/com.example.getStuff?limit=10",
131131+ { method: "GET" },
132132+);
133133+```
134134+135135+On subsequent page loads, restore the session from localStorage:
136136+137137+```typescript
138138+const session = await client.restore();
139139+if (session) {
140140+ // User is still logged in
141141+}
142142+```
143143+144144+For server-side Node.js apps, use the core [`@happyview/oauth-client`](../sdk/oauth-client.md) package with a confidential client. For type-safe XRPC calls, pair either client with [`@happyview/lex-agent`](../sdk/lex-agent.md).
145145+146146+### Manual DPoP flow
147147+148148+If you're not using JavaScript, or want to understand the protocol, the DPoP flow has four phases.
149149+150150+#### Phase 1: Provision a DPoP key
151151+152152+Ask HappyView for an ES256 keypair that will be shared between your app and the instance.
153153+154154+**Confidential client:**
155155+156156+```http
157157+POST /oauth/dpop-keys
158158+X-Client-Key: hvc_...
159159+X-Client-Secret: hvs_...
160160+Content-Type: application/json
161161+162162+{}
163163+```
164164+165165+**Public client:**
166166+167167+```http
168168+POST /oauth/dpop-keys
169169+X-Client-Key: hvc_...
170170+Origin: https://example.com
171171+Content-Type: application/json
172172+173173+{"pkce_challenge": "<base64url-encoded S256 challenge>"}
174174+```
175175+176176+**Response:**
177177+178178+```json
179179+{
180180+ "provision_id": "hvp_...",
181181+ "dpop_key": {
182182+ "kty": "EC",
183183+ "crv": "P-256",
184184+ "x": "...",
185185+ "y": "...",
186186+ "d": "..."
187187+ }
188188+}
189189+```
190190+191191+The `dpop_key` is the full private JWK. Store it securely — you'll use it to sign DPoP proofs.
192192+193193+#### Phase 2: OAuth with the user's PDS
194194+195195+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.
196196+197197+1. Resolve the user's handle to a DID
198198+2. Resolve the DID document to find the PDS URL
199199+3. Fetch the PDS's OAuth authorization server metadata
200200+4. Redirect the user to the PDS authorization endpoint
201201+5. Exchange the authorization code for tokens (using DPoP proofs signed with the provisioned key)
202202+203203+#### Phase 3: Register the session
204204+205205+After the OAuth callback, register the token set with HappyView so it can proxy requests on behalf of the user.
206206+207207+**Confidential client:**
208208+209209+```http
210210+POST /oauth/sessions
211211+X-Client-Key: hvc_...
212212+X-Client-Secret: hvs_...
213213+Content-Type: application/json
214214+215215+{
216216+ "provision_id": "hvp_...",
217217+ "did": "did:plc:user123",
218218+ "access_token": "...",
219219+ "refresh_token": "...",
220220+ "expires_at": "2026-04-17T00:00:00Z",
221221+ "scopes": "atproto transition:generic",
222222+ "pds_url": "https://bsky.social",
223223+ "issuer": "https://bsky.social"
224224+}
225225+```
226226+227227+**Public client** — omit the secret, include the PKCE verifier:
228228+229229+```http
230230+POST /oauth/sessions
231231+X-Client-Key: hvc_...
232232+Content-Type: application/json
233233+234234+{
235235+ "provision_id": "hvp_...",
236236+ "pkce_verifier": "...",
237237+ "did": "did:plc:user123",
238238+ "access_token": "...",
239239+ "refresh_token": "...",
240240+ "expires_at": "2026-04-17T00:00:00Z",
241241+ "scopes": "atproto transition:generic",
242242+ "pds_url": "https://bsky.social",
243243+ "issuer": "https://bsky.social"
244244+}
245245+```
246246+247247+#### Phase 4: Make authenticated XRPC requests
248248+249249+With a registered session, sign each request with a DPoP proof:
250250+251251+```sh
252252+curl -X POST 'https://happyview.example.com/xrpc/com.example.createPost' \
253253+ -H 'X-Client-Key: hvc_...' \
254254+ -H 'Authorization: DPoP <access_token>' \
255255+ -H 'DPoP: <proof_jwt>' \
256256+ -H 'Content-Type: application/json' \
257257+ -d '{"text": "Hello world"}'
258258+```
259259+260260+HappyView validates the proof, looks up the stored session, and proxies writes to the user's PDS using the shared DPoP key.
261261+262262+#### Logout
263263+264264+**Confidential:**
265265+266266+```http
267267+DELETE /oauth/sessions/did:plc:user123
268268+X-Client-Key: hvc_...
269269+X-Client-Secret: hvs_...
270270+```
271271+272272+**Public** (must prove key possession):
273273+274274+```http
275275+DELETE /oauth/sessions/did:plc:user123
276276+X-Client-Key: hvc_...
277277+Authorization: DPoP <access_token>
278278+DPoP: <proof_jwt>
279279+```
280280+281281+### DPoP proof format
282282+283283+If you're implementing the flow without the SDK, a DPoP proof JWT looks like this:
284284+285285+**Header:**
286286+287287+```json
288288+{
289289+ "alg": "ES256",
290290+ "typ": "dpop+jwt",
291291+ "jwk": {
292292+ "kty": "EC",
293293+ "crv": "P-256",
294294+ "x": "...",
295295+ "y": "..."
296296+ }
297297+}
298298+```
299299+300300+**Payload:**
301301+302302+```json
303303+{
304304+ "htm": "POST",
305305+ "htu": "https://happyview.example.com/xrpc/com.example.createPost",
306306+ "iat": 1745452800,
307307+ "ath": "<base64url SHA-256 of the access token>",
308308+ "jti": "<unique identifier>"
309309+}
310310+```
311311+312312+Validation rules:
313313+314314+- `htm` must match the HTTP method (case-insensitive)
315315+- `htu` must match the request URL (scheme + host + path, no query string)
316316+- `iat` must be within 5 minutes of the server's clock
317317+- `ath` must be the base64url-encoded SHA-256 hash of the access token
318318+- The JWK thumbprint (RFC 7638, SHA-256) must match the key used during provisioning
319319+- The signature must verify against the embedded public JWK
320320+321321+## Scopes
322322+323323+By default, a client's scopes are just `atproto`. You can add custom scopes when creating or updating the client.
324324+325325+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:
326326+327327+```
328328+atproto include:com.example.authBasic
329329+```
330330+331331+This expands to include all RPC methods and repository actions defined in that permission set.
332332+333333+## Rate limiting
334334+335335+Each API client has its own token bucket for rate limiting:
336336+337337+- **Capacity** — maximum tokens in the bucket
338338+- **Refill rate** — tokens added per second
339339+340340+If not set on the client, the instance defaults apply (`DEFAULT_RATE_LIMIT_CAPACITY` and `DEFAULT_RATE_LIMIT_REFILL_RATE`).
341341+342342+Rate limit state is returned in response headers:
343343+344344+| Header | Description |
345345+| --------------------- | ------------------------------------------- |
346346+| `RateLimit-Limit` | Bucket capacity |
347347+| `RateLimit-Remaining` | Tokens remaining |
348348+| `RateLimit-Reset` | Unix timestamp when the bucket will be full |
349349+| `Retry-After` | Seconds to wait (only on `429` responses) |
350350+351351+Adjust per-client rate limits via the dashboard or the [admin API](../reference/admin/api-clients.md#update-an-api-client).
352352+353353+## Security notes
354354+355355+- Client secrets are SHA-256 hashed before storage — HappyView never stores the plaintext.
356356+- DPoP private keys and OAuth tokens are encrypted at rest with AES-256-GCM using the `TOKEN_ENCRYPTION_KEY` environment variable.
357357+- Re-authenticating the same user with the same client upserts the session. The old DPoP key is cleaned up automatically.
358358+- Multiple clients can have active sessions for the same user — sessions are isolated per client.
359359+360360+## Next steps
361361+362362+- [Authentication](../getting-started/authentication.md) — full protocol details and security model
363363+- [JavaScript SDK](../sdk/overview.md) — get started with the SDK
364364+- [Admin API — API Clients](../reference/admin/api-clients.md) — CRUD endpoints
365365+- [Permissions](permissions.md) — control who can manage API clients