Browse and manage indexed records. All endpoints require the appropriate records:* 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 records
Section titled “List records”GET /admin/recordsPaginated list of records in a collection, ordered by indexed_at descending.
| Param | Type | Required | Description |
|---|---|---|---|
collection | string | yes | Collection NSID to list records from |
limit | number | no | Max results per page (default 20, max 100) |
cursor | string | no | Pagination cursor from a previous response |
const response = await fetch("http://127.0.0.1:3000/admin/records?collection=xyz.statusphere.status&limit=10", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/records?collection=xyz.statusphere.status&limit=10", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/records?collection=xyz.statusphere.status&limit=10") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/records?collection=xyz.statusphere.status&limit=10", 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/records?collection=xyz.statusphere.status&limit=10" -H "$AUTH"Response: 200 OK
{ "records": [ { "uri": "at://did:plc:abc/xyz.statusphere.status/3k...", "did": "did:plc:abc", "collection": "xyz.statusphere.status", "rkey": "3k...", "cid": "bafyrei...", "indexed_at": "2025-01-01T00:00:00Z", "record": { "...": "..." }, "labels": [] } ], "cursor": "20"}cursor is omitted when there are no more results. indexed_at is null until the record has arrived from the network. labels holds the record’s unexpired labels and its self-labels, each with src, val and cts (empty for a self-label).
Returns 503 when the collection is served from external record storage that can’t be reached.
List collections
Section titled “List collections”GET /admin/records/collectionsReturns the collection NSIDs of the instance’s record lexicons, without counting records. shared names the ones served by external record storage, whose records this instance can’t delete.
const response = await fetch("http://127.0.0.1:3000/admin/records/collections", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/records/collections", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/records/collections") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/records/collections", 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/records/collections -H "$AUTH"Response: 200 OK
{ "collections": [ "xyz.statusphere.status", "app.bsky.feed.post" ], "shared": []}Delete a record
Section titled “Delete a record”DELETE /admin/recordsDelete a single record by AT URI.
| Param | Type | Required | Description |
|---|---|---|---|
uri | string | yes | AT URI of the record to delete |
await fetch("http://127.0.0.1:3000/admin/records?uri=at://did:plc:abc/xyz.statusphere.status/3k...", { method: "DELETE", headers,});await fetch("http://127.0.0.1:3000/admin/records?uri=at://did:plc:abc/xyz.statusphere.status/3k...", { method: "DELETE", headers,});let client = reqwest::Client::new();let response = client .delete("http://127.0.0.1:3000/admin/records?uri=at://did:plc:abc/xyz.statusphere.status/3k...") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/records?uri=at://did:plc:abc/xyz.statusphere.status/3k...", 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/records?uri=at://did:plc:abc/xyz.statusphere.status/3k..." -H "$AUTH"Response: 204 No Content
Returns 404 if the record is not found.
Delete all records in a collection
Section titled “Delete all records in a collection”DELETE /admin/records/collectionDelete all indexed records for a given collection. Requires both records:delete and records:delete-collection permissions.
| Param | Type | Required | Description |
|---|---|---|---|
collection | string | yes | Collection NSID to delete from |
const response = await fetch("http://127.0.0.1:3000/admin/records/collection?collection=xyz.statusphere.status", { method: "DELETE", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/records/collection?collection=xyz.statusphere.status", { method: "DELETE", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .delete("http://127.0.0.1:3000/admin/records/collection?collection=xyz.statusphere.status") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/records/collection?collection=xyz.statusphere.status", 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/records/collection?collection=xyz.statusphere.status" -H "$AUTH"Response: 202 Accepted
{ "job_id": "550e8400-e29b-41d4-a716-446655440000"}The delete runs as a happyview.delete-collection job, in batches, so a large collection doesn’t hold the database. Follow, pause or cancel it through the jobs API. Records arriving from the network while it runs are deleted too, until a batch comes back short.
Returns 400 Bad Request when collection is empty, and for a collection served by external record storage: the delete would reach only this instance’s own rows, and the shared records would stay. GET /admin/records/collections names those collections in shared. To delete a lexicon and its records together, see Delete a lexicon.
Reconcile a collection
Section titled “Reconcile a collection”POST /admin/records/collection/reconcileCompares an indexed collection with what each account’s PDS holds now, and finds records the index still has but the PDS no longer does. Use it when records were deleted on the network while HappyView wasn’t tracking the collection. A dry run reports them; an apply deletes them. It runs as a happyview.reconcile-collection job, whose result is the report.
| Field | Type | Required | Description |
|---|---|---|---|
collection | string | yes | Collection NSID to reconcile |
dry_ | boolean | no | Default true: report only. false deletes what it finds |
dids | string[] | no | Only these accounts. Default: every account holding records of the collection |
pds_ | integer | no | Pace of PDS requests, 1 to 20. Default 5 |
allow_ | string[] | no | Accounts to reconcile even though their PDS lists suspiciously few records, which the job otherwise skips |
Permission: records:read for a dry run, records:delete to apply.
Response: 202 Accepted
{ "job_id": "550e8400-e29b-41d4-a716-446655440000"}Errors: 400 when collection is empty, isn’t indexed, isn’t a record lexicon, is com.atproto.lexicon.schema, or is served by external record storage, and when the rate is out of range.
How accounts are checked
Section titled “How accounts are checked”For each account, the job reads the records it stores, resolves the account’s PDS from its DID document, lists the collection with com.atproto.repo.listRecords, and confirms each stored record the listing lacks with com.atproto.repo.getRecord. Only a 400 or 404 naming RecordNotFound counts as missing. A bare 404 leaves the record alone, because a proxy or a PDS that doesn’t route the method answers that too.
An account is skipped, and nothing of it is deleted, when:
| Reason | When |
|---|---|
pds_ | Its PDS can’t be resolved |
listing_ | A listing page fails, is empty while still offering a cursor, or repeats a cursor |
rate_ | The PDS is still rate limiting after five tries |
repo_ | The PDS says the repo is missing, deactivated, taken down or suspended |
suspicious_ | The listing is empty while records are stored, or lacks at least 10 stored records and more than half of them. Name the account in allow_suspicious to act on it anyway |
store_ | HappyView couldn’t read its own records for the account |
In an apply, the collection’s record.delete script runs for each missing record and can keep it. A record is also kept if that script fails or times out, since the job is inferring the delete rather than hearing it from the network. A record is deleted only while it still has the CID and rev it was read with, so one recreated at the same key during the run stays. Every delete goes to the event log as record.deleted, with detail.origin set to happyview.reconcile-collection, whatever the event-logging setting. A record whose stored CID differs from its PDS’s is reported and left alone.
Pausing or cancelling stops the job before its next record or listing page. A stopped job has no result, so what it did so far stays in its progress under report. A resumed job starts again from the first account, which is safe: what it already deleted is no longer stored.
The report
Section titled “The report”{ "collection": "xyz.statusphere.status", "dry_run": true, "dids_checked": 412, "dids_skipped": [ { "did": "did:plc:abc", "reason": "repo_unavailable", "detail": "listRecords answered 400 Bad Request: ..." } ], "missing": [ { "uri": "at://did:plc:def/xyz.statusphere.status/3k...", "cid": "bafyrei...", "outcome": "missing" } ], "missing_total": 1, "unconfirmed_total": 0, "deleted": 0, "kept_by_hook": 0, "hook_failed": 0, "changed_since_check": 0, "failed": 0, "stale_cid": [], "stale_cid_total": 0}Each missing entry’s outcome is missing in a dry run. In an apply it’s deleted, kept_by_hook, hook_failed, changed_since_check (changed or removed since it was read) or failed, counted by the fields of the same names. unconfirmed_total counts records the listing lacked but getRecord didn’t confirm gone. missing lists the first 1,000 and stale_cid the first 100; the totals count them all. stopped appears only on a report from a job that was paused or cancelled.
© HappyProto

