Skip to content

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 script attached, the script handles the request. Otherwise, HappyView uses built-in default behavior (described below).

XRPC routes accept several authentication methods:

  • DPoP auth — Authorization: DPoP <token> + DPoP proof header + X-Client-Key
  • Service auth JWTs — Authorization: Bearer <service_auth_jwt> (inter-service calls). A token whose lxm names a different method than the one called is rejected.
  • Space credentials — Authorization: Atproto-Space <credential> with an Atproto-Space-Audience header and an HTTP Message Signature (space routes only). See Space credentials.
  • Cookie-based session auth — signed session cookies (used by the dashboard, falls back when no Authorization header is present)
  • Anonymous — no auth headers (identity is nil in Lua scripts)

Bearer API keys (hv_*) are rejected on XRPC routes — they are only accepted on the admin API.

Default auth behavior:

  • Queries (GET /xrpc/{method}): unauthenticated by default (identity available if provided)
  • Procedures (POST /xrpc/{method}): require authentication (DPoP, session cookie, or service auth)
  • getProfile: requires auth
  • uploadBlob: requires auth

These endpoints are always available regardless of which lexicons are loaded.

GET /health
const response = await fetch("http://127.0.0.1:3000/health");
const text = await response.text(); // "ok"

Response: 200 OK with body ok

GET /xrpc/app.bsky.actor.getProfile

Returns 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"
}
POST /xrpc/com.atproto.repo.uploadBlob

Proxies 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.

Query endpoints are generated from lexicons with type: "query". Without a script, they support two built-in modes depending on whether a uri parameter is provided.

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.

GET /xrpc/{method}?limit=20&cursor=<opaque>&did=optional
ParamTypeDefaultDescription
limitinteger20Max records to return (max 100)
cursorstring---Opaque pagination cursor from a previous response
didstring---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.

Procedure endpoints are generated from lexicons with type: "procedure". Without a script, HappyView auto-detects create vs update based on whether the request body contains a uri field.

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.

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.

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.

All error responses return JSON with an error field:

{
"error": "description of what went wrong"
}
StatusMeaningCommon causes
400 Bad RequestInvalid inputMissing required fields, malformed JSON, invalid AT URI
401 UnauthorizedAuthentication failedMissing or invalid client identification or DPoP authentication
404 Not FoundMethod or record not foundXRPC method has no matching lexicon, or the requested record doesn’t exist
500 Internal Server ErrorServer-side failureLua script error, database error, or upstream PDS failure

When a Lua script fails, the response is a script_error body:

{ "error": "script_error", "errorType": "runtime", "message": "attempt to index a nil value (local 't')", "method": "xyz.statusphere.getStatus", "line": 3, "file": "src/lib/format.lua" }

errorType is syntax, runtime or missing_handle with status 500, or timeout with status 408 when the script hit the instruction limit or the wall clock, both set in System > General. line is the script line that raised, or null. file is the project file line is in, relative to the project root: the handler itself, or a local module it imported. It is null for a script saved through the dashboard, which has no file.

The same error is logged server-side with the script source and the caller’s input. See Errors for how to diagnose script issues.

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.

  • Scripts: 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