XRPC API
XRPC is the HTTP-based RPC protocol atproto uses. HappyView 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>, aDPoPproof header andX-Client-Key. - Service auth JWTs:
Authorization: Bearer <service_auth_jwt>, for inter-service calls. The token’saudmust name this instance and a service entry allowed to call the method. A token whoselxmnames a different method than the one called is rejected. - Space credentials:
Authorization: Atproto-Space <credential>with anAtproto-Space-Audienceheader and an HTTP Message Signature, on space routes only. See Space credentials. - Cookie-based session auth: the dashboard’s signed session cookie. It counts only on a request from the instance’s own pages with no
Authorizationheader. - Anonymous: no auth headers, so
ctx.caller_didis absent in 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, 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. See API client identification.
Fixed endpoints
Section titled “Fixed endpoints”These endpoints are always available regardless of which lexicons are loaded.
| Endpoints | Documented in |
|---|---|
GET /health, GET /xrpc/app.bsky.actor.getProfile, POST /xrpc/com.atproto.repo.uploadBlob | This page |
dev.happyview.listApiClients, getApiClient, createApiClient, deleteApiClient | Third-party API clients |
dev.happyview.delegation.* | Delegation |
com.atproto.space.*, com.atproto.simplespace.* | Spaces, while spaces are turned on |
Delegation
Section titled “Delegation”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.
| Method | Type | Parameters or body | Success |
|---|---|---|---|
dev.happyview.delegation.linkAccount | procedure | did | 201 with { did } |
dev.happyview.delegation.unlinkAccount | procedure | did | 200 with {} |
dev.happyview.delegation.addDelegate | procedure | accountDid, userDid, role | 201 with {} |
dev.happyview.delegation.removeDelegate | procedure | accountDid, userDid | 200 with {} |
dev.happyview.delegation.listAccounts | query | none | 200 with { accounts } |
dev.happyview.delegation.getAccount | query | did | 200 with { did, role, linkedBy, createdAt } |
dev.happyview.delegation.listDelegates | query | accountDid | 200 with { delegates } |
Health check
Section titled “Health check”GET /healthconst response = await fetch("http://127.0.0.1:3000/health");const text = await response.text(); // "ok"const response = await fetch("http://127.0.0.1:3000/health");const text = await response.text(); // "ok"let client = reqwest::Client::new();
let response = client .get("http://127.0.0.1:3000/health") .send() .await?;
let text = response.text().await?; // "ok"resp, err := http.Get("http://127.0.0.1:3000/health")curl http://127.0.0.1:3000/healthResponse: 200 OK with body ok
Get profile
Section titled “Get profile”GET /xrpc/app.bsky.actor.getProfileReturns the authenticated user’s profile, read from their PDS. A service auth JWT isn’t accepted here.
const CLIENT_KEY = "hvc_..."; // your API client keyconst ACCESS_TOKEN = "..."; // DPoP access tokenconst DPOP_PROOF = "..."; // DPoP proof JWT
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: `DPoP ${ACCESS_TOKEN}`, DPoP: DPOP_PROOF, }, },);
const profile: ProfileResponse = await response.json();const CLIENT_KEY = "hvc_..."; // your API client keyconst ACCESS_TOKEN = "..."; // DPoP access tokenconst DPOP_PROOF = "..."; // DPoP proof JWT
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 = await response.json();let client_key = "hvc_..."; // your API client keylet access_token = "..."; // DPoP access tokenlet dpop_proof = "..."; // DPoP proof JWT
let response = client .get("http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .send() .await?;
let profile: serde_json::Value = response.json().await?;clientKey := "hvc_..." // your API client keyaccessToken := "..." // DPoP access tokendpopProof := "..." // DPoP proof JWT
req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile", nil)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)
resp, err := http.DefaultClient.Do(req)curl http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile \ -H "X-Client-Key: $CLIENT_KEY" \ -H "Authorization: DPoP $ACCESS_TOKEN" \ -H "DPoP: $DPOP_PROOF"Response: 200 OK. displayName, description and avatarURL are left out when the profile doesn’t have them.
{ "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
Section titled “Upload blob”POST /xrpc/com.atproto.repo.uploadBlobProxies 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, },);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, },);let image_data = std::fs::read("image.png")?;
let response = client .post("http://127.0.0.1:3000/xrpc/com.atproto.repo.uploadBlob") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .header("Content-Type", "image/png") .body(image_data) .send() .await?;imageData, _ := os.ReadFile("image.png")
req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(imageData))req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)req.Header.Set("Content-Type", "image/png")
resp, err := http.DefaultClient.Do(req)curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.repo.uploadBlob \ -H "X-Client-Key: $CLIENT_KEY" \ -H "Authorization: DPoP $ACCESS_TOKEN" \ -H "DPoP: $DPOP_PROOF" \ -H "Content-Type: image/png" \ --data-binary @image.pngResponse: proxied from the user’s PDS.
Dynamic query endpoints
Section titled “Dynamic query endpoints”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.
Single record
Section titled “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();const CLIENT_KEY = "hvc_..."; // your API client key
const params = new URLSearchParams({ uri: "at://did:plc:abc/xyz.statusphere.status/abc123",});
const response = await fetch( `http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?${params}`, { headers: { "X-Client-Key": CLIENT_KEY } },);
const data = await response.json();let client_key = "hvc_..."; // your API client key
let response = client .get("http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses") .query(&[("uri", "at://did:plc:abc/xyz.statusphere.status/abc123")]) .header("X-Client-Key", client_key) .send() .await?;
let data: serde_json::Value = response.json().await?;clientKey := "hvc_..." // your API client key
req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fxyz.statusphere.status%2Fabc123", nil)req.Header.Set("X-Client-Key", clientKey)
resp, err := http.DefaultClient.Do(req)curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fxyz.statusphere.status%2Fabc123" \ -H "X-Client-Key: $CLIENT_KEY"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.
List records
Section titled “List records”GET /xrpc/{method}?limit=20&cursor=<opaque>&did=optional| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Max records to return. A higher value is cut to 100. |
cursor | string | none | Opaque pagination cursor from a previous response |
did | string | none | Filter 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();const params = new URLSearchParams({ limit: "10", did: "did:plc:abc" });
const response = await fetch( `http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?${params}`, { headers: { "X-Client-Key": CLIENT_KEY } },);
const data = await response.json();let response = client .get("http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses") .query(&[("limit", "10"), ("did", "did:plc:abc")]) .header("X-Client-Key", client_key) .send() .await?;
let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=10&did=did:plc:abc", nil)req.Header.Set("X-Client-Key", clientKey)
resp, err := http.DefaultClient.Do(req)curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=10&did=did:plc:abc" \ -H "X-Client-Key: $CLIENT_KEY"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.
Dynamic procedure endpoints
Section titled “Dynamic procedure endpoints”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.
Create a record
Section titled “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 keyconst ACCESS_TOKEN = "..."; // DPoP access tokenconst 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", }), },);const CLIENT_KEY = "hvc_..."; // your API client keyconst ACCESS_TOKEN = "..."; // DPoP access tokenconst 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", }), },);let client_key = "hvc_..."; // your API client keylet access_token = "..."; // DPoP access tokenlet dpop_proof = "..."; // DPoP proof JWT
let response = client .post("http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .json(&serde_json::json!({ "status": "\ud83d\ude0a", "createdAt": "2025-01-01T12:00:00Z" })) .send() .await?;clientKey := "hvc_..." // your API client keyaccessToken := "..." // DPoP access tokendpopProof := "..." // DPoP proof JWT
body := bytes.NewBufferString(`{"status": "\ud83d\ude0a", "createdAt": "2025-01-01T12:00:00Z"}`)
req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus", body)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \ -H "X-Client-Key: $CLIENT_KEY" \ -H "Authorization: DPoP $ACCESS_TOKEN" \ -H "DPoP: $DPOP_PROOF" \ -H "Content-Type: application/json" \ -d '{ "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
Section titled “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", }), },);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", }), },);let response = client .post("http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", dpop_proof) .json(&serde_json::json!({ "uri": "at://did:plc:abc/xyz.statusphere.status/abc123", "status": "\ud83c\udf1f", "createdAt": "2025-01-01T13:00:00Z" })) .send() .await?;body := bytes.NewBufferString(`{ "uri": "at://did:plc:abc/xyz.statusphere.status/abc123", "status": "\ud83c\udf1f", "createdAt": "2025-01-01T13:00:00Z"}`)
req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus", body)req.Header.Set("X-Client-Key", clientKey)req.Header.Set("Authorization", "DPoP "+accessToken)req.Header.Set("DPoP", dpopProof)req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \ -H "X-Client-Key: $CLIENT_KEY" \ -H "Authorization: DPoP $ACCESS_TOKEN" \ -H "DPoP: $DPOP_PROOF" \ -H "Content-Type: application/json" \ -d '{ "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.
Request validation
Section titled “Request validation”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.
XRPC proxy
Section titled “XRPC proxy”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.
Errors
Section titled “Errors”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, an error the method’s lexicon declares |
401 Unauthorized | Authentication failed | Missing client identification, or invalid DPoP or service authentication |
403 Forbidden | Refused | The proxy policy doesn’t allow the NSID |
404 Not Found | Method or record not found | The requested record doesn’t exist, or the feature is turned off |
408 Request Timeout | A script ran out of time | See Script errors |
429 Too Many Requests | Rate limited | The API client’s budget is spent |
500 Internal Server Error | Server-side failure | Script error, database error, or upstream PDS failure |
503 Service Unavailable | Storage unavailable | The storage that holds the collection is down |
Script errors
Section titled “Script errors”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 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.
PDS errors
Section titled “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
Section titled “Next steps”© HappyProto

