Skip to content

Spaces

Browse the spaces on this instance for moderation. These endpoints give operators access to spaces they are not members of, so they live in the admin API rather than the space XRPC API.

The space inspector is off by default. An operator with settings:manage turns it on with Enable Space Inspector under System > General, the feature.space_inspector_enabled setting, or SPACE_INSPECTOR_ENABLED.

While it’s off, every route on this page answers 403 SpaceInspectorDisabled, except these:

Listing spaces and reading their metadata and members needs spaces:read. Reading a space’s records or blobs needs spaces:inspect and an active access grant that covers the request.

Every grant, every revocation and every read made under a grant is written to the event log as a protected event. The grant or revocation is saved together with its event. Content is returned only after its read event is written. If the event can’t be written, the request fails with 500 and nothing changes or is returned.

Section titled “Cookie reads come from the instance’s own pages”

Each content read is recorded against the operator making it. So List records in a space, Get a blob in a space and List an account’s records accept the dashboard’s session cookie only from the instance’s own pages. The browser has to send Sec-Fetch-Site: same-origin, or an Origin that is one of the instance’s domains. Any other request with the cookie gets 403 Forbidden.

API keys and other Authorization credentials are not affected. A browser that sends neither Sec-Fetch-Site nor Origin, such as Safari before 16.4, can’t load blobs with the session cookie.

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

Whether the space inspector is on, and how long an access grant can last. Requires spaces:read or spaces:inspect. It answers while the inspector is off, so a client can tell why the other routes are closed.

const response = await fetch("http://127.0.0.1:3000/admin/spaces/inspector", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"enabled": true,
"default_grant_minutes": 60,
"max_grant_minutes": 120
}

max_grant_minutes is the space_inspector_max_grant_minutes setting. default_grant_minutes is what a grant gets when duration_minutes is left out: 60, or max_grant_minutes if that’s shorter.

An access grant lets one operator read one space’s contents, or one account’s records, for a limited time. It records why. A grant lasts from 5 minutes to max_grant_minutes, and 60 minutes by default.

scopetargetCovers
spaceA space idEvery record and blob in that space, from every author
accountA DIDThat account’s own records in any space, and the blobs its own records reference

Only the caller’s own active grants count. When more than one covers a request, the one expiring last is used. Within a space, an account grant covers List records in a space only when its repo filter names the grant’s DID. List an account’s records always needs an account grant for that DID, and a space grant never covers it.

POST /admin/spaces/access-grants

Requires spaces:inspect, and the space inspector on.

FieldTypeRequiredDescription
scopestringyesspace or account
targetstringyesA space id for space, a DID for account
reasonstringyesWhy access is needed. Trimmed, then 1 to 2000 characters
duration_minutesnumbernoThe grant’s length, raised to 5 or lowered to max_grant_minutes when outside them. Defaults to 60, or max_grant_minutes if that’s shorter
const response = await fetch("http://127.0.0.1:3000/admin/spaces/access-grants", {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
"scope": "space",
"target": "0b6c1f0e-...",
"reason": "Report #482: harassment in a thread",
"duration_minutes": 30
}),
});
const data = await response.json();

Response: 201 Created

{
"id": "6f1ecb2a-...",
"user_id": "u_123",
"user_did": "did:plc:moderator",
"scope": "space",
"target": "0b6c1f0e-...",
"reason": "Report #482: harassment in a thread",
"created_at": "2026-01-01T00:00:00+00:00",
"expires_at": "2026-01-01T00:30:00+00:00",
"revoked_at": null,
"revoked_by": null
}

user_id and user_did name the operator the grant belongs to. Returns 403 SpaceInspectorDisabled while the inspector is off. Returns 400 Bad Request for a blank or overlong reason, or a target that isn’t a DID for account. Returns 404 Not Found when no space has the target id for space.

GET /admin/spaces/access-grants

The caller’s own grants, newest first, at most 200. Requires spaces:inspect.

ParamTypeRequiredDescription
activebooleannotrue lists only grants that haven’t expired or been revoked
const response = await fetch("http://127.0.0.1:3000/admin/spaces/access-grants?active=true", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"grants": [
{ "id": "6f1ecb2a-...", "scope": "space", "target": "0b6c1f0e-...", "...": "..." }
]
}
DELETE /admin/spaces/access-grants/{id}

Ends a grant early. Ending your own grant requires spaces:inspect or users:update. Ending another operator’s requires users:update.

const response = await fetch("http://127.0.0.1:3000/admin/spaces/access-grants/6f1ecb2a-...", {
method: "DELETE",
headers,
});
const data = await response.json();

Response: 200 OK with the grant, its revoked_at set and revoked_by holding the revoking operator’s id. A grant that has already expired or been revoked comes back unchanged, and nothing is logged. Returns 404 Not Found if no grant has that id.

GET /admin/spaces/access-grants/{id}/reads

The space.moderator_read events made under a grant, oldest first. Requires events:read, for any operator’s grant.

ParamTypeRequiredDescription
limitnumbernoMax results per page (default 100, max 500)
cursorstringnoPagination cursor from a previous response
const response = await fetch("http://127.0.0.1:3000/admin/spaces/access-grants/6f1ecb2a-.../reads", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"events": [
{
"id": "...",
"event_type": "space.moderator_read",
"severity": "info",
"actor_did": "did:plc:moderator",
"subject": "at://did:web:happyview.example.com/space/com.example.forum/main",
"detail": { "action": "list_records", "grant_id": "6f1ecb2a-...", "scope": "space", "...": "..." },
"created_at": "2026-01-01T00:05:00Z"
}
],
"cursor": "..."
}

cursor is returned whenever a page is full.

GET /admin/spaces

Every space on the instance, newest first. Requires spaces:read, and the space inspector on.

ParamTypeRequiredDescription
limitnumbernoMax results per page (default 50, max 100)
cursorstringnoPagination cursor from a previous response
const response = await fetch("http://127.0.0.1:3000/admin/spaces?limit=20", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"spaces": [
{
"id": "0b6c1f0e-...",
"uri": "at://did:web:happyview.example.com/space/com.example.forum/main",
"did": "did:web:happyview.example.com",
"authority_did": "did:web:happyview.example.com",
"creator_did": "did:plc:creator123",
"type": "com.example.forum",
"skey": "main",
"display_name": "Forum",
"description": null,
"read_policy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
"write_policy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
"app_access": { "$type": "com.atproto.simplespace.defs#open" },
"config": { "membership_public": false, "records_public": false },
"revision": "3k...",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"cursor": "50"
}

cursor is an offset. It is returned whenever a page is full, so the last page can be empty. revision is null until the space’s first commit.

GET /admin/spaces/{id}

A space’s metadata, its resolved member list (including members added through delegation), and the record count of each collection in it. {id} is the space’s id from List spaces. Requires spaces:read, and the space inspector on.

const response = await fetch("http://127.0.0.1:3000/admin/spaces/0b6c1f0e-...", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"space": { "id": "0b6c1f0e-...", "uri": "at://...", "...": "..." },
"members": [
{ "did": "did:plc:creator123", "read": true, "write": true }
],
"collections": [
{ "collection": "com.example.forum.post", "count": 42 }
]
}

Returns 404 Not Found if no space has that ID.

GET /admin/spaces/{id}/access

The caller’s active grant that covers this space. That’s a space grant for it if there is one. Otherwise it’s the account grant expiring last whose account is a member of the space or has records in it. Requires spaces:inspect, and the space inspector on.

const response = await fetch("http://127.0.0.1:3000/admin/spaces/0b6c1f0e-.../access", {
headers,
});
const data = await response.json();

Response: 200 OK

{ "grant": { "id": "6f1ecb2a-...", "scope": "space", "target": "0b6c1f0e-...", "...": "..." } }

grant is null when none of the caller’s active grants covers the space. Returns 404 Not Found if no space has that ID.

GET /admin/spaces/{id}/records

Records in a space, newest first. Requires spaces:inspect, the space inspector on, and a grant covering the space. With repo set, an account grant for that DID covers it too.

ParamTypeRequiredDescription
repostringnoOnly records authored by this DID
collectionstringnoOnly records in this collection
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/spaces/0b6c1f0e-.../records?collection=com.example.forum.post", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"records": [
{
"uri": "at://did:web:happyview.example.com/space/com.example.forum/main/did:plc:abc/com.example.forum.post/3k...",
"did": "did:plc:abc",
"collection": "com.example.forum.post",
"rkey": "3k...",
"cid": "bafyrei...",
"indexed_at": "2026-01-01T00:00:00Z",
"record": { "...": "..." }
}
],
"cursor": "..."
}

cursor is returned whenever a page is full, so the last page can be empty. Each request is logged with the URIs of the records it returned.

Returns 403 SpaceAccessGrantRequired when no active grant covers the request, and 404 Not Found if no space has that ID.

GET /admin/spaces/{id}/blob

A blob referenced by a record in the space, fetched from its author’s PDS. The response body is the blob, with the content type the PDS reports. Requires spaces:inspect, the space inspector on, and a covering grant.

ParamTypeRequiredDescription
cidstringyesThe blob CID
const response = await fetch("http://127.0.0.1:3000/admin/spaces/0b6c1f0e-.../blob?cid=bafkrei...", {
headers,
});
const blob = await response.blob();

A space grant for this space opens any blob a record in it references. Without one, an account grant opens the blob only when that account’s own record references it. Another author’s record sharing the CID doesn’t count.

Returns 403 SpaceAccessGrantRequired when no grant covers the space and the caller holds no account grant. When the caller holds account grants but none of them opens the blob, the answer is 404 Not Found, as for a CID no record references. Returns 502 Bad Gateway if the author’s PDS cannot serve it.

GET /admin/accounts/{did}/spaces

The spaces an account is a member of or has records in, newest first, each with its count of the account’s records. This is metadata, so no grant is needed. Requires spaces:read, and the space inspector on.

const response = await fetch("http://127.0.0.1:3000/admin/accounts/did:plc:abc/spaces", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"spaces": [
{ "space": { "id": "0b6c1f0e-...", "uri": "at://...", "...": "..." }, "record_count": 12 }
]
}
GET /admin/accounts/{did}/access

The caller’s active account grant for {did}, the one expiring last. Requires spaces:inspect, and the space inspector on.

const response = await fetch("http://127.0.0.1:3000/admin/accounts/did:plc:abc/access", {
headers,
});
const data = await response.json();

Response: 200 OK with { "grant": { ... } }, or { "grant": null } when no active grant covers the account.

GET /admin/accounts/{did}/space-records

One account’s records across every space, newest first. Requires spaces:inspect, the space inspector on, and an account grant for {did}. A space grant never covers this route, even for a space the account belongs to.

ParamTypeRequiredDescription
spacestringnoOnly records in this space, by id
collectionstringnoOnly records in this collection
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/accounts/did:plc:abc/space-records?collection=com.example.forum.post", {
headers,
});
const data = await response.json();

Response: 200 OK

{
"records": [
{
"uri": "at://did:web:happyview.example.com/space/com.example.forum/main/did:plc:abc/com.example.forum.post/3k...",
"did": "did:plc:abc",
"collection": "com.example.forum.post",
"rkey": "3k...",
"cid": "bafyrei...",
"indexed_at": "2026-01-01T00:00:00Z",
"record": { "...": "..." },
"space_id": "0b6c1f0e-...",
"space_uri": "at://did:web:happyview.example.com/space/com.example.forum/main"
}
],
"cursor": "..."
}

Each request is logged with the URIs of the records it returned. Returns 403 SpaceAccessGrantRequired when the caller has no active account grant for {did}.

GET /admin/spaces/migration-status

How far each space’s repositories have moved from HappyView-hosted copies to their authors’ PDSes, and which PDSes support spaces. Requires spaces:read.

Response: 200 OK

{
"spaces": [
{ "spaceId": "0b6c1f0e-…", "type": "com.example.forum", "skey": "main", "polyfill": 12, "migrating": 3, "native": 40 }
],
"awaitingAuthorization": 5,
"detection": [
{ "pds": "https://pds.example.com", "supported": true, "tier": "descriptor", "missing": [], "checkedAt": "2026-10-01T12:00:00Z" }
]
}
  • polyfill, migrating and native count the space’s repositories by where they’re hosted: still on HappyView, moving, or on the author’s PDS. A space with no repositories is not listed.
  • awaitingAuthorization counts accounts whose repositories are still on HappyView and who haven’t signed in with a session granting space: access, so they can’t move yet.
  • detection is what HappyView last found out about each PDS’s support for spaces, and what it’s missing. tier says how it found out: descriptor (the PDS lists its own methods), probe (inferred from how it answers a method that needs auth) or unreachable (HappyView couldn’t reach the PDS, which says nothing about its support).