Manage the service entries published in the instance’s DID document. Each entry declares an atproto service type (e.g. AtprotoLabeler, BskyFeedGenerator). Its access mode decides which XRPC methods a caller using service auth addressed to that entry may call. All endpoints require the settings:manage permission.
const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };let token = "hv_..."; // your API keyconst token = "hv_..." // your API key# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"List service entries
Section titled “List service entries”GET /admin/service-entriesinterface ServiceEntry { id: number; fragment_id: string; service_type: string; access_mode: "all" | "specific"; created_at: string; updated_at: string;}
const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { headers,});const data: ServiceEntry[] = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/service-entries") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/service-entries", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/service-entries -H "$AUTH"Response: 200 OK
[ { "id": 1, "fragment_id": "#happyview", "service_type": "AtprotoLabeler", "access_mode": "all", "created_at": "2026-05-09T12:00:00.000000+00:00", "updated_at": "2026-05-09T12:00:00.000000+00:00" }]Create a service entry
Section titled “Create a service entry”POST /admin/service-entriesconst response = await fetch("http://127.0.0.1:3000/admin/service-entries", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ fragment_id: "#feed", service_type: "BskyFeedGenerator", }),});const data: ServiceEntry = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ fragment_id: "#feed", service_type: "BskyFeedGenerator", }),});const data = await response.json();let response = client .post("http://127.0.0.1:3000/admin/service-entries") .bearer_auth(token) .json(&serde_json::json!({ "fragment_id": "#feed", "service_type": "BskyFeedGenerator" })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{"fragment_id": "#feed", "service_type": "BskyFeedGenerator"}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/service-entries \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "fragment_id": "#feed", "service_type": "BskyFeedGenerator" }'| Field | Type | Required | Description |
|---|---|---|---|
fragment_ | string | yes | DID document fragment (e.g. #feed, #happyview). Must be unique |
service_ | string | yes | atproto service type (e.g. BskyFeedGenerator) |
The entry is created with access_mode set to all. The endpoint URL is derived from PUBLIC_URL. A duplicate fragment_id returns 409 Conflict.
Response: 201 Created, with the created entry
{ "id": 2, "fragment_id": "#feed", "service_type": "BskyFeedGenerator", "access_mode": "all", "created_at": "2026-05-09T12:00:00.000000+00:00", "updated_at": "2026-05-09T12:00:00.000000+00:00"}Update a service entry
Section titled “Update a service entry”PUT /admin/service-entries/{id}await fetch("http://127.0.0.1:3000/admin/service-entries/1", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ service_type: "AtprotoLabeler", access_mode: "specific", }),});await fetch("http://127.0.0.1:3000/admin/service-entries/1", { method: "PUT", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ service_type: "AtprotoLabeler", access_mode: "specific", }),});client .put("http://127.0.0.1:3000/admin/service-entries/1") .bearer_auth(token) .json(&serde_json::json!({ "service_type": "AtprotoLabeler", "access_mode": "specific" })) .send() .await?;body := bytes.NewBufferString(`{"service_type": "AtprotoLabeler", "access_mode": "specific"}`)req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/service-entries/1", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X PUT http://127.0.0.1:3000/admin/service-entries/1 \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "service_type": "AtprotoLabeler", "access_mode": "specific" }'| Field | Type | Required | Description |
|---|---|---|---|
fragment_ | string | no | Updated DID document fragment |
service_ | string | no | Updated service type |
access_ | string | no | all, or specific (only the XRPCs bound to this entry). Any other value is 400 Bad Request |
All fields are optional. Only provided fields are updated. Returns 404 Not Found if the entry doesn’t exist.
Response: 204 No Content
Delete a service entry
Section titled “Delete a service entry”DELETE /admin/service-entries/{id}await fetch("http://127.0.0.1:3000/admin/service-entries/1", { method: "DELETE", headers,});await fetch("http://127.0.0.1:3000/admin/service-entries/1", { method: "DELETE", headers,});client .delete("http://127.0.0.1:3000/admin/service-entries/1") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/service-entries/1", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X DELETE http://127.0.0.1:3000/admin/service-entries/1 \ -H "$AUTH"Returns 404 Not Found if the entry doesn’t exist.
Response: 204 No Content
List XRPCs for a service entry
Section titled “List XRPCs for a service entry”GET /admin/service-entries/{id}/xrpcsReturns the lexicon IDs (NSIDs) bound to this service entry.
const response = await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { headers,});const data: string[] = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { headers,});const data = await response.json();let response = client .get("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") .bearer_auth(token) .send() .await?;let data: Vec<String> = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/service-entries/1/xrpcs -H "$AUTH"Response: 200 OK
["app.bsky.feed.getFeedSkeleton", "app.bsky.feed.describeFeedGenerator"]Add XRPCs to a service entry
Section titled “Add XRPCs to a service entry”POST /admin/service-entries/{id}/xrpcsawait fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], }),});await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], }),});client .post("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") .bearer_auth(token) .json(&serde_json::json!({ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] })) .send() .await?;body := bytes.NewBufferString(`{"lexicon_ids": ["app.bsky.feed.getFeedSkeleton"]}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/service-entries/1/xrpcs \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] }'| Field | Type | Required | Description |
|---|---|---|---|
lexicon_ | string[] | yes | Lexicon NSIDs to bind to this entry |
Response: 204 No Content
Remove XRPCs from a service entry
Section titled “Remove XRPCs from a service entry”DELETE /admin/service-entries/{id}/xrpcsawait fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { method: "DELETE", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], }),});await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { method: "DELETE", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], }),});client .delete("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") .bearer_auth(token) .json(&serde_json::json!({ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] })) .send() .await?;body := bytes.NewBufferString(`{"lexicon_ids": ["app.bsky.feed.getFeedSkeleton"]}`)req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X DELETE http://127.0.0.1:3000/admin/service-entries/1/xrpcs \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] }'| Field | Type | Required | Description |
|---|---|---|---|
lexicon_ | string[] | yes | Lexicon NSIDs to unbind from this entry |
Response: 204 No Content
List services for a lexicon
Section titled “List services for a lexicon”GET /admin/lexicons/{id}/servicesReturns service entries that grant access to the specified lexicon.
const response = await fetch("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", { headers,});const data: ServiceEntry[] = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", { headers,});const data = await response.json();let response = client .get("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services -H "$AUTH"Response: 200 OK with an array of ServiceEntry objects.
Sync to PLC directory
Section titled “Sync to PLC directory”These endpoints publish your service entries to the PLC directory so they appear in the DID document. The sync method depends on the service identity mode.
Direct sync (did:plc mode)
Section titled “Direct sync (did:plc mode)”POST /admin/service-entries/sync-plcSigns and submits a PLC update operation using the stored rotation key. Only available when the identity mode is did_plc.
await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc", { method: "POST", headers,});await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc", { method: "POST", headers,});let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/service-entries/sync-plc") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries/sync-plc", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc -H "$AUTH"The operation keeps the DID’s existing rotation keys, handles and services, and adds the instance’s verification methods. Each service entry is written with PUBLIC_URL as its endpoint, and a service pointing at PUBLIC_URL that no longer has an entry is removed.
Returns 400 Bad Request if no identity is configured, the identity mode is not did_plc, or no DID is registered yet. Returns 500 Internal Server Error if TOKEN_ENCRYPTION_KEY is not set.
Response: 204 No Content
Request PLC token (attach_account mode)
Section titled “Request PLC token (attach_account mode)”POST /admin/service-entries/sync-plc/requestRequests a PLC operation signature token via the attached account’s PDS. This sends an email confirmation code to the account holder. Only available when the identity mode is attach_account.
await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc/request", { method: "POST", headers,});await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc/request", { method: "POST", headers,});let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/service-entries/sync-plc/request") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries/sync-plc/request", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc/request -H "$AUTH"Returns 400 Bad Request if the identity mode is not attach_account, no identity is configured, or no attached account is set. Returns 401 Unauthorized when the attached account’s PDS session has expired; re-authenticate on the Service Identity page.
Response: 204 No Content
Submit PLC token (attach_account mode)
Section titled “Submit PLC token (attach_account mode)”POST /admin/service-entries/sync-plc/submitBuilds the same changes as a direct sync, has the attached account’s PDS sign them with the email confirmation code, and submits the signed operation through that PDS.
await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc/submit", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ token: "123456" }),});await fetch("http://127.0.0.1:3000/admin/service-entries/sync-plc/submit", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ token: "123456" }),});let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/service-entries/sync-plc/submit") .bearer_auth(token) .json(&serde_json::json!({ "token": "123456" })) .send() .await?;body := bytes.NewBufferString(`{ "token": "123456"}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries/sync-plc/submit", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc/submit \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "token": "123456" }'| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Email confirmation code from the PDS |
Returns 400 Bad Request if the identity mode is not attach_account, no identity is configured, or no attached account is set. Returns 401 Unauthorized when the attached account’s PDS session has expired; re-authenticate on the Service Identity page.
Response: 204 No Content
© HappyProto

