Skip to content

Records

Browse and manage indexed records. All endpoints require the appropriate records:* permission.

const TOKEN = "hv_..."; // your API key
const headers = { Authorization: `Bearer ${TOKEN}` };
GET /admin/records

Paginated list of records in a collection, ordered by indexed_at descending.

ParamTypeRequiredDescription
collectionstringyesCollection NSID to list records from
limitnumbernoMax results per page (default 20, max 100)
cursorstringnoPagination 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();

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.

GET /admin/records/collections

Returns 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();

Response: 200 OK

{
"collections": [
"xyz.statusphere.status",
"app.bsky.feed.post"
],
"shared": []
}
DELETE /admin/records

Delete a single record by AT URI.

ParamTypeRequiredDescription
uristringyesAT 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,
});

Response: 204 No Content

Returns 404 if the record is not found.

DELETE /admin/records/collection

Delete all indexed records for a given collection. Requires both records:delete and records:delete-collection permissions.

ParamTypeRequiredDescription
collectionstringyesCollection 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();

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.

POST /admin/records/collection/reconcile

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

FieldTypeRequiredDescription
collectionstringyesCollection NSID to reconcile
dry_runbooleannoDefault true: report only. false deletes what it finds
didsstring[]noOnly these accounts. Default: every account holding records of the collection
pds_requests_per_secondintegernoPace of PDS requests, 1 to 20. Default 5
allow_suspiciousstring[]noAccounts 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.

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:

ReasonWhen
pds_unresolvedIts PDS can’t be resolved
listing_failedA listing page fails, is empty while still offering a cursor, or repeats a cursor
rate_limitedThe PDS is still rate limiting after five tries
repo_unavailableThe PDS says the repo is missing, deactivated, taken down or suspended
suspicious_listingThe 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_read_failedHappyView 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.

{
"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.