XRPC API
XRPC is the HTTP-based RPC protocol used by the atproto. HappyView dynamically registers XRPC endpoints based on your uploaded lexicons: query lexicons become GET /xrpc/{nsid} routes, procedure lexicons become POST /xrpc/{nsid} routes.
If a query or procedure lexicon has a Lua script attached, the script handles the request. Otherwise, HappyView uses built-in default behavior (described below).
Auth
- Queries (
GET /xrpc/{method}): unauthenticated - Procedures (
POST /xrpc/{method}): require DPoP authentication (Authorization: DPoP+DPoPproof header +X-Client-Key) - getProfile: requires auth
- uploadBlob: requires auth
Fixed endpoints
These endpoints are always available regardless of which lexicons are loaded.
Health check
GET /healthconst response = await fetch("http://127.0.0.1:3000/health");
const text = await response.text(); // "ok"Response: 200 OK with body ok
Get profile
GET /xrpc/app.bsky.actor.getProfileReturns the authenticated user's profile, resolved from their PDS via PLC directory lookup.
const CLIENT_KEY = "hvc_..."; // your API client key
const TOKEN = "..."; // your access token
interface ProfileResponse {
did: string;
handle: string;
displayName: string;
description: string;
avatarURL: string;
}
const response = await fetch(
"http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile",
{
headers: {
"X-Client-Key": CLIENT_KEY,
Authorization: `Bearer ${TOKEN}`,
},
},
);
const profile: ProfileResponse = await response.json();Response: 200 OK
{
"did": "did:plc:abc123",
"handle": "user.bsky.social",
"displayName": "User Name",
"description": "Bio text",
"avatarURL": "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:abc123&cid=bafyabc"
}Upload blob
POST /xrpc/com.atproto.repo.uploadBlobProxies a blob upload to the authenticated user's PDS. Maximum size: 50MB.
import { readFile } from "node:fs/promises";
const imageData = await readFile("image.png");
const response = await fetch(
"http://127.0.0.1:3000/xrpc/com.atproto.repo.uploadBlob",
{
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "image/png",
},
body: imageData,
},
);Response: proxied from the user's PDS.
Dynamic query endpoints
Query endpoints are generated from lexicons with type: "query". Without a Lua script, they support two built-in modes depending on whether a uri parameter is provided.
Single record
GET /xrpc/{method}?uri={at-uri}const CLIENT_KEY = "hvc_..."; // your API client key
const params = new URLSearchParams({
uri: "at://did:plc:abc/xyz.statusphere.status/abc123",
});
interface RecordResponse {
record: {
uri: string;
$type: string;
status: string;
createdAt: string;
};
}
const response = await fetch(
`http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?${params}`,
{ headers: { "X-Client-Key": CLIENT_KEY } },
);
const data: RecordResponse = await response.json();Response: 200 OK
{
"record": {
"uri": "at://did:plc:abc/xyz.statusphere.status/abc123",
"$type": "xyz.statusphere.status",
"status": "\ud83d\ude0a",
"createdAt": "2025-01-01T12:00:00Z"
}
}Media blobs are automatically enriched with a url field pointing to the user's PDS.
List records
GET /xrpc/{method}?limit=20&cursor=<opaque>&did=optional| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Max records to return (max 100) |
cursor | string | --- | Opaque pagination cursor from a previous response |
did | string | --- | Filter records by DID |
const params = new URLSearchParams({ limit: "10", did: "did:plc:abc" });
interface ListResponse {
records: Array<{
uri: string;
status: string;
createdAt: string;
}>;
cursor?: string;
}
const response = await fetch(
`http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?${params}`,
{ headers: { "X-Client-Key": CLIENT_KEY } },
);
const data: ListResponse = await response.json();Response: 200 OK
{
"records": [
{
"uri": "at://did:plc:abc/xyz.statusphere.status/abc123",
"status": "\ud83d\ude0a",
"createdAt": "2025-01-01T12:00:00Z"
}
],
"cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..."
}The cursor field is an opaque string present only when more records exist. Pass it back as-is to fetch the next page.
Dynamic procedure endpoints
Procedure endpoints are generated from lexicons with type: "procedure". Without a Lua script, HappyView auto-detects create vs update based on whether the request body contains a uri field.
Create a record
POST /xrpc/{method}When the body does not contain a uri field, a new record is created.
const CLIENT_KEY = "hvc_..."; // your API client key
const ACCESS_TOKEN = "..."; // DPoP access token
const DPOP_PROOF = "..."; // DPoP proof JWT
const response = await fetch(
"http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus",
{
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
Authorization: `DPoP ${ACCESS_TOKEN}`,
DPoP: DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "\ud83d\ude0a",
createdAt: "2025-01-01T12:00:00Z",
}),
},
);HappyView proxies this to the user's PDS as com.atproto.repo.createRecord, then indexes the created record locally.
Update a record
When the body contains a uri field, the existing record is updated.
const response = await fetch(
"http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus",
{
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
Authorization: `DPoP ${ACCESS_TOKEN}`,
DPoP: DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
uri: "at://did:plc:abc/xyz.statusphere.status/abc123",
status: "\ud83c\udf1f",
createdAt: "2025-01-01T13:00:00Z",
}),
},
);HappyView proxies this to the user's PDS as com.atproto.repo.putRecord, then upserts the record locally.
Response for both: proxied from the user's PDS.
XRPC proxy
When a request targets an NSID that has no locally registered lexicon, HappyView resolves the NSID's authority via DNS and forwards the request. Admins can restrict which NSIDs are proxied — see XRPC Proxy settings.
Errors
All error responses return JSON with an error field:
{
"error": "description of what went wrong"
}| Status | Meaning | Common causes |
|---|---|---|
400 Bad Request | Invalid input | Missing required fields, malformed JSON, invalid AT URI |
401 Unauthorized | Authentication failed | Missing or invalid client identification or DPoP authentication |
404 Not Found | Method or record not found | XRPC method has no matching lexicon, or the requested record doesn't exist |
500 Internal Server Error | Server-side failure | Lua script error, database error, or upstream PDS failure |
Lua script errors
When a Lua script fails, the response is 500 with one of:
{"error": "script execution failed"}: syntax error, runtime error, or missinghandle()function{"error": "script exceeded execution time limit"}: the script hit the 1,000,000 instruction limit
The full error details are logged server-side but not exposed to the client. See Lua Scripting - Debugging for how to diagnose script issues.
PDS errors
When a procedure proxies a write to the user's PDS and the PDS returns an error, HappyView forwards the PDS response status code and body directly to the client.
Next steps
- Lua Scripting: Override the default query and procedure behavior with custom logic
- Lexicons: Understand how lexicons generate these endpoints
- Admin API: Manage lexicons and monitor your instance