Managing Spaces
Space authority
Section titled “Space authority”Every space has an authority: the DID that other services resolve to find the space’s host and the key that signs its credentials. Spaces created through HappyView use HappyView’s service identity DID, did:web or did:plc, as both the authority and the DID in the space URI:
at://did:web:happyview.example.com/space/com.example.forum/mainThe account that creates the space is its creator. The creator administers the space: only the creator, or an operator with superadmin, can update or delete it, change its member list, and manage its invites.
Because every space on an instance shares the instance’s DID, a space key is unique per space type across the whole instance. A second account creating a space with the same type and skey gets 409 Conflict.
If the instance has no published service identity, HappyView anchors new spaces on the creator’s DID instead. The creator’s DID is then both the authority and the DID in the URI, and only HappyView can verify the space’s credentials.
Publishing the space host
Section titled “Publishing the space host”When a space is created, HappyView adds two entries to its DID document if they are missing:
#atproto_space_host, a service entry of typeAtprotoSpaceHostpointing at the instance#atproto_space, the verification method that signs space credentials
With a did:web identity, HappyView serves both immediately. With a did:plc identity, the operator must sync service entries to the PLC directory before other services can resolve them.
OAuth scopes
Section titled “OAuth scopes”Apps reach spaces through space: OAuth scopes, which name the space’s authority. The default, authority=self, covers only spaces whose authority is the signed-in account. It does not cover spaces whose authority is HappyView. Apps must request authority=<HappyView's DID>, or authority=* for any authority:
atproto space:com.example.forum?authority=did:web:happyview.example.comA session without a covering grant receives 403 Forbidden. Scopes are fixed when a session is created, so fixing a missing grant requires a new authorization.
Creating a space
Section titled “Creating a space”const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ spaceType: "com.example.forum", skey: "main", displayName: "My Forum", description: "A place for discussion", readPolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, writePolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, }),});interface CreateSpaceResponse { uri: string;}const data: CreateSpaceResponse = await response.json();const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ spaceType: "com.example.forum", skey: "main", displayName: "My Forum", description: "A place for discussion", readPolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, writePolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" }, }),});const data = await response.json();let response = client .post("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "spaceType": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", "readPolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" }, "writePolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" } })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "spaceType": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", "readPolicy": {"$type": "com.atproto.simplespace.defs#memberListPolicy"}, "writePolicy": {"$type": "com.atproto.simplespace.defs#memberListPolicy"}}`)req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", 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 'https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP <token>' \ -H 'DPoP: <proof>' \ -H 'Content-Type: application/json' \ -d '{ "spaceType": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", "readPolicy": {"$type": "com.atproto.simplespace.defs#memberListPolicy"}, "writePolicy": {"$type": "com.atproto.simplespace.defs#memberListPolicy"} }'Input:
| Field | Type | Required | Description |
|---|---|---|---|
spaceType | string (NSID) | Yes | The space type; describes what this space is for. type, the earlier name, is accepted until v3. |
skey | string | Yes | Space key; differentiates spaces of the same type |
displayName | string | No | Human-readable name |
description | string | No | Description of the space |
readPolicy | object | No | Who can obtain credentials to read the space. See Policies. Defaults to the member-list policy. |
writePolicy | object | No | Whose writes the space accepts. See Policies. Defaults to the member-list policy. |
appAccess | object | No | Which apps can obtain credentials. See App access. Defaults to open. |
config | object | No | Space configuration (see below) |
Response (201):
{ "uri": "at://did:web:happyview.example.com/space/com.example.forum/main"}The creator is automatically added as a member with read and write. Use com.atproto.simplespace.getSpace to retrieve the full space object.
Policies
Section titled “Policies”A space has two independent policies. The read policy decides who can obtain a space credential. The write policy decides whose writes the space tracks: which repos appear in listRepos and whose write notifications HappyView accepts and forwards.
Each policy is one of:
$type | Grants access to |
|---|---|
com.atproto.simplespace.defs#memberListPolicy | Members with the matching flag: read for the read policy, write for the write policy. The default. |
com.atproto.simplespace.defs#publicPolicy | Everyone |
com.atproto.simplespace.defs#managingAppPolicy | Whoever the managing app approves |
A managing-app policy names the app’s service identifier in managingApp:
{ "$type": "com.atproto.simplespace.defs#managingAppPolicy", "managingApp": "did:web:forum.example.com#forum"}HappyView resolves the identifier’s fragment to a service entry in the app’s DID document, and a bare DID to its #atproto_pds entry. For each decision it calls com.atproto.simplespace.checkUserAccess at that endpoint with service auth from HappyView, passing space, user, and access (read or write). The managing-app policy requires the space’s authority to be HappyView’s instance DID, because the app expects the authority to sign the call.
A policy with an unknown $type fails with 400 UnsupportedPolicy.
App access
Section titled “App access”appAccess controls which apps can obtain credentials for the space:
| Value | Meaning |
|---|---|
{"$type": "com.atproto.simplespace.defs#open"} | Any app. The default. |
{"$type": "com.atproto.simplespace.defs#allowList", "allowed": ["https://app.example.com/client-metadata.json"]} | Only apps whose attested OAuth client_id is in allowed |
An unknown variant fails with 400 UnsupportedAppAccess. See App access control for how the check runs.
Space configuration
Section titled “Space configuration”The config object supports:
| Field | Type | Default | Description |
|---|---|---|---|
membership_public | boolean | false | Whether the space, its member list, and its repo list are visible without authentication |
records_public | boolean | false | Deprecated. Stored with the space but not enforced. Use a public readPolicy for a space anyone may read. Accepted until v3. |
The field names are snake_case, unlike the rest of the request.
Additional fields are preserved as-is. One recognized additional field is allowedCollections (a JSON array of collection NSID strings), auto-populated at creation time from the space type lexicon’s defs.main.collections. When present and non-empty, createRecord/putRecord/applyWrites reject writes (400) to any collection not on the list; deletes are never restricted. A space without this field, or with an empty list, allows writes to any collection.
Legacy policy fields
Section titled “Legacy policy fields”The following forms are deprecated and kept until v3:
policyormintPolicy: a single policy name,"public","member-list", or"managing-app", applied to both reads and writes. For"managing-app", the app goes in a siblingmanagingAppormanagingAppDidfield.readPolicyandwritePolicytake precedence when present.appAccesswithtype:{"type": "open"}and{"type": "allowList", "allowed": [...]}are read as the matching$typevariants.
Getting a space
Section titled “Getting a space”const response = await fetch( "https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace?space=at://did:web:happyview.example.com/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, }, },);const data = await response.json();const response = await fetch( "https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace?space=at://did:web:happyview.example.com/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, }, },);const data = await response.json();let response = client .get("https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace") .query(&[("space", "at://did:web:happyview.example.com/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace?space=at://did:web:happyview.example.com/space/com.example.forum/main", 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 'https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace?space=at://did:web:happyview.example.com/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP <token>' \ -H 'DPoP: <proof>'Response:
{ "uri": "at://did:web:happyview.example.com/space/com.example.forum/main", "space": { "id": "5d1c8e0a-7b2f-4c39-8e61-3f4a9b2d7e10", "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": "My Forum", "description": "A place for discussion", "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, "allowedCollections": ["com.example.forum.post"] }, "revision": "3l2tkbx7225co", "created_at": "2026-09-30T12:00:00Z", "updated_at": "2026-09-30T12:00:00Z" }, "config": { "$type": "com.atproto.simplespace.defs#spaceConfig", "readPolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" }, "writePolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" }, "appAccess": { "$type": "com.atproto.simplespace.defs#open" } }}Only the space’s creator, an operator with superadmin, or a caller presenting a space credential for the space can read it. Other accounts, members included, receive 404 Not Found, and unauthenticated callers 401 Unauthorized.
dev.happyview.space.getSpace is a deprecated alias, kept until v3.
Listing spaces
Section titled “Listing spaces”Returns spaces where the authenticated user is a member.
const response = await fetch( "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, }, },);interface SpaceView { uri: string; isOwner: boolean;}interface ListSpacesResponse { spaces: SpaceView[]; cursor?: string;}const data: ListSpacesResponse = await response.json();const response = await fetch( "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, }, },);const data = await response.json();let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.listSpaces") .query(&[("limit", "20")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", 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 'https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP <token>' \ -H 'DPoP: <proof>'Parameters:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
did | string | No | authenticated user | Filter by DID |
spaceType | string (NSID) | No | Lists only spaces of this type. type, the earlier name, is accepted until v3. | |
limit | integer | No | 50 | Max spaces to return (1-100) |
cursor | string | No | Pagination cursor |
Response:
{ "spaces": [ { "uri": "at://did:web:happyview.example.com/space/com.example.forum/main", "isOwner": true } ], "cursor": "MjAyNi0wOS0zMFQxMjowMDowMFp8YXQ6Ly9kaWQ6d2ViOmhhcHB5dmlldy5leGFtcGxlLmNvbS9zcGFjZS9jb20uZXhhbXBsZS5mb3J1bS9tYWlu"}isOwner is true when the user is the space’s creator.
Updating a space
Section titled “Updating a space”Only the space’s creator or an operator with superadmin can update a space.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ space: "at://did:web:happyview.example.com/space/com.example.forum/main", displayName: "Updated Forum Name", readPolicy: { $type: "com.atproto.simplespace.defs#publicPolicy" }, }),});const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ space: "at://did:web:happyview.example.com/space/com.example.forum/main", displayName: "Updated Forum Name", readPolicy: { $type: "com.atproto.simplespace.defs#publicPolicy" }, }),});let response = client .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "space": "at://did:web:happyview.example.com/space/com.example.forum/main", "displayName": "Updated Forum Name", "readPolicy": { "$type": "com.atproto.simplespace.defs#publicPolicy" } })) .send() .await?;body := bytes.NewBufferString(`{ "space": "at://did:web:happyview.example.com/space/com.example.forum/main", "displayName": "Updated Forum Name", "readPolicy": {"$type": "com.atproto.simplespace.defs#publicPolicy"}}`)req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", 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 'https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP <token>' \ -H 'DPoP: <proof>' \ -H 'Content-Type: application/json' \ -d '{ "space": "at://did:web:happyview.example.com/space/com.example.forum/main", "displayName": "Updated Forum Name", "readPolicy": {"$type": "com.atproto.simplespace.defs#publicPolicy"} }'All fields except space are optional, and take the same values as in createSpace, including the legacy policy fields. Only provided fields are updated. A supplied policy, appAccess, or config replaces the current value whole. To clear displayName or description, pass null.
The response contains the space URI and the updated space object, in the same shape as getSpace’s uri and space fields.
Deleting a space
Section titled “Deleting a space”Only the space’s creator or an operator with superadmin can delete a space.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ space: "at://did:web:happyview.example.com/space/com.example.forum/main", }),});const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ space: "at://did:web:happyview.example.com/space/com.example.forum/main", }),});let response = client .post("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "space": "at://did:web:happyview.example.com/space/com.example.forum/main" })) .send() .await?;body := bytes.NewBufferString(`{"space": "at://did:web:happyview.example.com/space/com.example.forum/main"}`)req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", 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 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP <token>' \ -H 'DPoP: <proof>' \ -H 'Content-Type: application/json' \ -d '{"space": "at://did:web:happyview.example.com/space/com.example.forum/main"}'Response (200):
{ "success": true}
