Skip to content

XRPC API

XRPC is the HTTP-based RPC protocol atproto uses. HappyView registers XRPC endpoints from your lexicons, whether they come from a project, an upload or the network: 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 DPoP auth, service auth JWTs, space credentials, the dashboard’s session cookie, or no authentication at all. The authentication reference owns each method’s headers and the order HappyView tries them in. Admin API keys (hv_*) are refused on XRPC routes. An anonymous request leaves ctx.caller_did absent in scripts.

Default auth behavior:

  • Queries (GET /xrpc/{method}): unauthenticated by default, with the caller’s identity available if provided.
  • Procedures (POST /xrpc/{method}): require authentication.
  • getProfile and uploadBlob: require authentication.

A request with an identity also has to name its API client, with X-Client-Key or client_key. A request is charged to that client’s rate-limit bucket only when it proves it is that client; otherwise it is rate limited as anonymous. See API client identification.

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

EndpointsDocumented in
GET /health, GET /xrpc/app.bsky.actor.getProfile, POST /xrpc/com.atproto.repo.uploadBlobThis page
dev.happyview.listApiClients, getApiClient, createApiClient, deleteApiClientThird-party API clients
dev.happyview.delegation.*Delegation
com.atproto.space.*, com.atproto.simplespace.*Spaces, while spaces are turned on

dev.happyview.delegation.* are the methods that link accounts and manage who may write for them. A write under a delegated account passes delegateDid, and the script sees it as ctx.delegate_did.

MethodTypeParameters or bodySuccess
dev.happyview.delegation.linkAccountproceduredid201 with { did }
dev.happyview.delegation.unlinkAccountproceduredid200 with {}
dev.happyview.delegation.addDelegateprocedureaccountDid, userDid, role201 with {}
dev.happyview.delegation.removeDelegateprocedureaccountDid, userDid200 with {}
dev.happyview.delegation.listAccountsquerynone200 with { accounts }
dev.happyview.delegation.getAccountquerydid200 with { did, role, linkedBy, createdAt }
dev.happyview.delegation.listDelegatesqueryaccountDid200 with { delegates }
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, read from their PDS. A service auth JWT isn’t accepted here.

const CLIENT_KEY = "hvc_..."; // your API client key
const ACCESS_TOKEN = "..."; // DPoP access token
const DPOP_PROOF = "..."; // DPoP proof JWT
interface ProfileResponse {
did: string;
handle: string;
displayName?: string;
description?: string;
avatarURL?: string;
avatar?: { $link: string; mimeType: string; size: number | null };
}
const response = await fetch(
"http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile",
{
headers: {
"X-Client-Key": CLIENT_KEY,
Authorization: `DPoP ${ACCESS_TOKEN}`,
DPoP: DPOP_PROOF,
},
},
);
const profile: ProfileResponse = await response.json();

Response: 200 OK. displayName, description, avatarURL and avatar are left out when the profile doesn’t have them. avatar is the avatar’s blob reference, and avatarURL is a URL that fetches it from the user’s PDS.

{
"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",
"avatar": {
"$link": "bafyabc",
"mimeType": "image/jpeg",
"size": 52000
}
}
POST /xrpc/com.atproto.repo.uploadBlob

Proxies a blob upload to the authenticated user’s PDS. Maximum size: 50 MiB.

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: `DPoP ${ACCESS_TOKEN}`,
DPoP: DPOP_PROOF,
"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"
}
}

record is the stored record with its uri added. A URI with no indexed record answers 404.

GET /xrpc/{method}?limit=20&cursor=<opaque>&did=optional
ParamTypeDefaultDescription
limitinteger20Max records to return. A higher value is cut to 100.
cursorstringnoneOpaque pagination cursor from a previous response
didstringnoneFilter records by DID

A list needs the lexicon’s target_collection. Without it, the request answers 400.

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": "10"
}

Each record is its stored body with its uri added. The cursor field is an opaque string, present when the page was full. Pass it back as-is to fetch the next page.

Procedure endpoints are generated from lexicons with type: "procedure". Without a script, the lexicon’s action decides what happens: create, update, delete or upsert, the default. upsert creates or updates based on whether the request body contains a uri field, as below. The lexicon needs a target_collection, or the request answers 400.

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.

HappyView checks each call to a dynamic endpoint against its lexicon before a script runs. A call that breaks the lexicon gets 400 with the error InvalidRequest. Lexicon checks lists what’s checked.

A request for an NSID with no lexicon on the instance falls through to the proxy. The proxy’s mode decides whether the method may be proxied, and its routing decides where it goes. See XRPC routing for unknown methods and XRPC proxy settings.

Error responses return JSON with an error field, and many also carry a message:

{
"error": "description of what went wrong"
}
StatusMeaningCommon causes
400 Bad RequestInvalid inputMissing required fields, malformed JSON, invalid AT URI, an error the method’s lexicon declares
401 UnauthorizedAuthentication failedMissing client identification, or invalid DPoP or service authentication
403 ForbiddenRefusedThe proxy policy doesn’t allow the NSID
404 Not FoundMethod or record not foundThe requested record doesn’t exist, or the feature is turned off
408 Request TimeoutA script ran out of timeSee Script errors
429 Too Many RequestsRate limitedThe bucket the request is charged to is empty: its API client’s, once proven, otherwise the shared anonymous one
500 Internal Server ErrorServer-side failureScript error, database error, or upstream PDS failure
502 Bad GatewayUpstream failureThe XRPC proxy couldn’t resolve or reach the method’s authority
503 Service UnavailableStorage unavailableThe storage that holds the collection is down

When a 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, memory 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 script itself, or a local module it imported. It is null for a script saved through the dashboard, which has no file.

The instance logs the same error 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 caller.

  • 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