Records
Space records are stored separately from public AT Protocol records. They follow the same URI pattern but use the ats:// scheme and include the space identity:
ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / did:plc:author / com.example.forum.post / abcdefghijklmnop1234567890
└── space DID ───────────────────┘ └── space type ─┘ └── skey ─┘ └── author ──┘ └── collection ──────┘ └── rkey ────────────────┘Creating a record
Requires write membership in the space. The rkey is auto-generated using a TID.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", {
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
space: "ats://did:plc:abc123/com.example.forum/main",
collection: "com.example.forum.post",
record: {
$type: "com.example.forum.post",
text: "Hello from the forum!",
createdAt: "2026-05-09T12:00:00Z",
},
}),
});
interface CreateRecordResponse {
uri: string;
cid: string;
}
const data: CreateRecordResponse = await response.json();Input:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space to write into |
collection | string (NSID) | Yes | The record collection |
record | object | Yes | The record data |
Response (201):
{
"uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3l2tkbx7225co",
"cid": "bafyrei..."
}createRecord always inserts a new record. If a record with the generated URI already exists, it returns 409 Conflict.
Updating a record
Requires write membership in the space.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", {
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
space: "ats://did:plc:abc123/com.example.forum/main",
collection: "com.example.forum.post",
rkey: "3k2abc",
record: {
$type: "com.example.forum.post",
text: "Hello from the forum!",
createdAt: "2026-05-09T12:00:00Z",
},
}),
});
interface PutRecordResponse {
uri: string;
cid: string;
}
const data: PutRecordResponse = await response.json();Input:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space to write into |
collection | string (NSID) | Yes | The record collection |
rkey | string | Yes | The record key |
record | object | Yes | The record data |
swapRecord | string | No | Expected CID of the existing record (for optimistic concurrency) |
Response (201):
{
"uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc",
"cid": "bafyrei..."
}The author DID is taken from the authenticated user. You can only write records as yourself, so the URI's author component will always be your DID.
putRecord performs an upsert: if a record with the same collection + rkey already exists for this author in this space, it's overwritten. Use swapRecord to prevent unintended overwrites (see Optimistic concurrency below).
Getting a record
Requires read membership (or a valid space credential).
Members with read_self access can only retrieve their own records. Attempting to read another user's record returns 403 Forbidden.
const params = new URLSearchParams({
space: "ats://did:plc:abc123/com.example.forum/main",
collection: "com.example.forum.post",
rkey: "3k2abc",
});
const response = await fetch(
`https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`,
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
interface GetRecordResponse {
uri: string;
cid: string;
value: Record<string, unknown>;
}
const data: GetRecordResponse = await response.json();Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space containing the record |
collection | string (NSID) | Yes | The record collection |
rkey | string | Yes | The record key |
Response:
{
"uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc",
"cid": "bafyrei...",
"value": {
"$type": "com.example.forum.post",
"text": "Hello from the forum!",
"createdAt": "2026-05-09T12:00:00Z"
}
}Listing records
const params = new URLSearchParams({
space: "ats://did:plc:abc123/com.example.forum/main",
collection: "com.example.forum.post",
limit: "20",
});
const response = await fetch(
`https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`,
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
interface RecordEntry {
collection: string;
rkey: string;
cid: string;
}
interface ListRecordsResponse {
records: RecordEntry[];
cursor?: string;
}
const data: ListRecordsResponse = await response.json();Parameters:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
space | string | Yes | The space to list from | |
repo | string | No | Filter by author DID | |
collection | string | No | Filter by collection NSID | |
limit | integer | No | 50 | Max records to return (1-100) |
cursor | string | No | Pagination cursor | |
reverse | boolean | No | false | Reverse sort order (oldest first) |
Response:
{
"records": [
{
"collection": "com.example.forum.post",
"rkey": "3k2abc",
"cid": "bafyrei..."
}
],
"cursor": "MjAyNi0wNS0wOVQxMjowMDowMFp8YXRzOi8vZGlkOnBsYzphYmMxMjMvY29tLmV4YW1wbGUuZm9ydW0vbWFpbg"
}Deleting a record
You can only delete your own records. Requires write membership.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", {
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
space: "ats://did:plc:abc123/com.example.forum/main",
collection: "com.example.forum.post",
rkey: "3k2abc",
}),
});Input:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space containing the record |
collection | string (NSID) | Yes | The record collection |
rkey | string | Yes | The record key |
swapRecord | string | No | Expected CID of the existing record (for optimistic concurrency) |
Attempting to delete another user's record returns 403 Forbidden.
Batch writes (applyWrites)
applyWrites performs multiple create, update, and delete operations in a single request. Requires write membership.
const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", {
method: "POST",
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
"Content-Type": "application/json",
},
body: JSON.stringify({
space: "ats://did:plc:abc123/com.example.forum/main",
writes: [
{
action: "create",
collection: "com.example.forum.post",
value: { $type: "com.example.forum.post", text: "First post" },
},
{
action: "update",
collection: "com.example.forum.post",
rkey: "3k2abc",
value: { $type: "com.example.forum.post", text: "Edited post" },
swapRecord: "bafyrei...",
},
{
action: "delete",
collection: "com.example.forum.post",
rkey: "old-post",
},
],
}),
});
interface ApplyWritesResult {
uri?: string;
cid?: string;
}
const data: { results: ApplyWritesResult[] } = await response.json();Input:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space to write into |
swapCommit | string | No | Expected space revision (for optimistic concurrency) |
writes | array | Yes | List of write operations |
Each write operation has an action field:
| Action | Fields | Description |
|---|---|---|
create | collection, value, rkey? | Insert a new record. Auto-generates rkey if omitted. |
update | collection, rkey, value, swapRecord? | Upsert a record. |
delete | collection, rkey, swapRecord? | Delete a record. |
Response:
{
"results": [
{ "uri": "ats://...", "cid": "bafyrei..." },
{ "uri": "ats://...", "cid": "bafyrei..." },
{}
]
}Each entry in results corresponds to the write at the same index. Create and update operations return uri and cid; delete operations return an empty object.
Optimistic concurrency
swapRecord and swapCommit provide optimistic concurrency control to prevent lost updates when multiple clients write to the same space.
swapRecord
Pass the swapRecord field on putRecord, deleteRecord, or individual operations within applyWrites. The value is the CID of the record you expect to be replacing. If the record's current CID doesn't match, the operation fails with 409 Conflict.
{
"space": "ats://did:plc:abc123/com.example.forum/main",
"collection": "com.example.forum.post",
"rkey": "3k2abc",
"record": { "text": "updated safely" },
"swapRecord": "bafyrei_old_cid"
}swapCommit
Pass the swapCommit field on applyWrites to assert the space's current revision. If another client has written to the space since you last read its state, the operation fails with 409 Conflict before any writes are applied.
The space's current revision is available as revision in the space object returned by com.atproto.space.getSpace.
{
"space": "ats://did:plc:abc123/com.example.forum/main",
"swapCommit": "3l2tkbx7225co",
"writes": [...]
}Repo state
Returns the per-user repo state for a space, including the current revision and deniable commit data.
const response = await fetch(
"https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author",
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
interface RepoStateResponse {
rev: string | null;
commit: {
hash: string;
ikm: string;
sig: string;
mac: string;
rev: string;
} | null;
}
const data: RepoStateResponse = await response.json();Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space URI |
did | string | Yes | The DID of the user to get state for |
Response:
| Field | Type | Description |
|---|---|---|
rev | string/null | Current revision for this user's repo in the space |
commit | object/null | Deniable commit data (base64url-encoded hash, ikm, sig, mac, and rev) |
Record operation log
Returns the operation log for a user in a space. Each write (create, update, delete) is recorded as an oplog entry.
const response = await fetch(
"https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author",
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
const data = await response.json();
// data.ops — array of oplog entriesParameters:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space URI |
did | string | Yes | The DID of the user whose ops to list |
limit | integer | No | Max number of entries to return (default 100, max 1000) |
cursor | string | No | Revision to start after (for pagination) |
Response:
{
"ops": [
{
"id": "...",
"rev": "3l2tkbx7225co",
"idx": 0,
"action": "create",
"collection": "com.example.forum.post",
"rkey": "3k2abc",
"cid": "bafyrei...",
"prev": null,
"createdAt": "2026-05-09T12:00:00Z"
}
]
}Each entry records a single write operation. The action is one of create, update, or delete. The prev field contains the CID of the record before the operation (for updates and deletes).
Listing repos
Returns the list of users who have records in a space, along with their current revision.
const response = await fetch(
"https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main",
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
interface Repo {
did: string;
rev: string | null;
}
const data: { repos: Repo[] } = await response.json();Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space URI |
Response:
{
"repos": [
{ "did": "did:plc:author1", "rev": "3l2tkbx7225co" },
{ "did": "did:plc:author2", "rev": null }
]
}Getting a blob
Retrieves a blob from a space. The blob is fetched from the author's PDS and proxied through HappyView with access control.
const response = await fetch(
"https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...",
{
headers: {
"X-Client-Key": CLIENT_KEY,
"Authorization": `DPoP ${ACCESS_TOKEN}`,
"DPoP": DPOP_PROOF,
},
},
);
const blob = await response.blob();Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
space | string | Yes | The space URI |
cid | string | Yes | The CID of the blob |
The response body is the raw blob data with the original Content-Type header preserved.
Cross-service access
Records can also be read using a space credential instead of direct membership. Pass the credential as a Bearer token:
const response = await fetch(
"https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...",
{
headers: {
"Authorization": `Bearer ${SPACE_CREDENTIAL}`,
},
},
);
const data = await response.json();A feed generator or other service that isn't a direct member can use a credential issued by the space authority to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request.