Permissioned Spaces

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/dev.happyview.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:

FieldTypeRequiredDescription
spacestringYesThe space to write into
collectionstring (NSID)YesThe record collection
recordobjectYesThe 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/dev.happyview.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:

FieldTypeRequiredDescription
spacestringYesThe space to write into
collectionstring (NSID)YesThe record collection
rkeystringYesThe record key
recordobjectYesThe record data
swapRecordstringNoExpected 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).

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/dev.happyview.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:

FieldTypeRequiredDescription
spacestringYesThe space containing the record
collectionstring (NSID)YesThe record collection
rkeystringYesThe 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/dev.happyview.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:

FieldTypeRequiredDefaultDescription
spacestringYesThe space to list from
repostringNoFilter by author DID
collectionstringNoFilter by collection NSID
limitintegerNo50Max records to return (1-100)
cursorstringNoPagination cursor
reversebooleanNofalseReverse 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/dev.happyview.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:

FieldTypeRequiredDescription
spacestringYesThe space containing the record
collectionstring (NSID)YesThe record collection
rkeystringYesThe record key
swapRecordstringNoExpected 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/dev.happyview.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:

FieldTypeRequiredDescription
spacestringYesThe space to write into
swapCommitstringNoExpected space revision (for optimistic concurrency)
writesarrayYesList of write operations

Each write operation has an action field:

ActionFieldsDescription
createcollection, value, rkey?Insert a new record. Auto-generates rkey if omitted.
updatecollection, rkey, value, swapRecord?Upsert a record.
deletecollection, 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 dev.happyview.space.getSpace.

{
  "space": "ats://did:plc:abc123/com.example.forum/main",
  "swapCommit": "3l2tkbx7225co",
  "writes": [...]
}

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/dev.happyview.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 owner to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request.