All endpoints (except /health and /xrpc/_health) require authentication. The primary mode is a signed service-auth JWT in the Authorization: Bearer <token> header (below); group-scoped read and write methods additionally accept a long-lived, scope-limited API key in the X-API-Key header (see Authenticating with an API key). The JWT must include:
iss— the caller's DIDaud— the service DID (its standard RFC 7519 meaning: the audience is the service receiving the request)lxm— the XRPC method being calledjti— a unique nonce (each token can only be used once)exp— expiration timestamp
A client reaches the service by one of two routes, referred to throughout this reference:
- Non-proxied call — the client fetches a service-auth token from the user's PDS (
com.atproto.server.getServiceAuth), then sends the XRPC request to the group service itself with that token in theAuthorizationheader. The client chooses theaudit requests. - Proxied call — the client sends the request to the user's PDS with an
atproto-proxyheader; the PDS forwards it to the group service. The PDS choosesaud(the DID being proxied to), not the client. This is the standard AT Protocol pattern.
The user's PDS signs the token in both cases; the routes differ in who chooses aud and who sends the final request to the group service.
The service DID — the value of aud — is found in two steps:
- Find the service URL. A group's DID document carries a
certified_groupservice entry whoseserviceEndpointis the service URL. Resolve thegroupDidand read that entry. This is the only on-protocol link from a group to the service hosting it (register/importreturn thegroupDid, not the service DID). Caution: immediately afterregister, a freshly created group's DID document may still be cached (by your resolver or an intermediary PDS) in its initial form, before thecertified_groupentry was added — so the entry can be transiently absent. If it is missing right after registration, retry with a forced refresh / after a short delay rather than treating the group as having no service. - Derive the service DID from that URL's host: a
did:webformed by stripping the scheme —https://group-service.example.com→did:web:group-service.example.com. This is pure string manipulation, no further lookup.
A non-proxied call sets this value as the JWT aud directly. On a proxied call the client never sets aud itself — the PDS does — so the value is supplied differently; see How aud is set on a proxied call.
On a proxied call the PDS sets aud to the DID in the atproto-proxy header (<did>#<service-id>), then resolves that DID's document and forwards to its service endpoint. So the client controls aud only by choosing which DID it proxies to:
- Proxy to the service DID (
atproto-proxy: <serviceDid>#certified_group_service): the PDS resolves the service's owndid:webdocument at/.well-known/did.json, forwards, and mintsaud= the service DID. This is the supported form. - Proxy to the group DID (
atproto-proxy: <groupDid>#certified_group): the PDS resolves the group's document and mintsaud= the group DID — the deprecated legacy form (see Legacyaud= group DID form).
The two service ids differ because they live in different documents: certified_group_service is the entry in the service's did:web document; certified_group is the entry in a group's document.
Either way the PDS delivers aud bare (did:web:<host>, no fragment). The service also accepts the fragment-qualified did:web:<host>#certified_group_service for forward-compatibility (some PDS versions will stop stripping the fragment), and rejects a fragment naming a different service.
Group-scoped methods name their target group with an explicit repo field — an at-identifier (a handle or a DID), resolved to the group DID server-side. The JWT aud is the service DID. Where repo goes depends on the method kind:
- Query methods (
member.list,audit.query) and raw-body / body-less methods (repo.uploadBlob,group.destroy) readrepofrom the querystring (?repo=<handle-or-did>). - JSON-body procedures (
createRecord,putRecord,deleteRecord,member.add,member.remove,role.set) readrepofrom the request body.
repo is the group selector itself; the service enforces authorization per-group via RBAC (membership/role), so a caller can only act on groups they already hold a role in. A repo that names no registered group is rejected with 401 Unknown group; a handle that does not resolve is rejected with 401 Could not resolve repo to a DID. The repo value is not covered by the JWT signature — the service-auth JWT signs only iss/aud/exp/lxm/jti, matching standard atproto, which never signs the resource. See docs/design/aud-deprecation.md for the security rationale.
Cross-group endpoints under app.certified.groups.* and the group-lifecycle methods register / import target the service itself (aud = service DID) and take no repo.
A transitional form remains accepted during the migration window: set the JWT aud to the group DID (omitting repo). This is deprecated (issue #27) and will be removed in a later release once clients migrate.
| Legacy (deprecated) | New (supported) | |
|---|---|---|
| Group named by | JWT aud |
explicit repo |
JWT aud |
the group DID | the service DID |
repo field |
absent | present |
| Deprecation header | Deprecation: true + Link |
none |
repo and the service-DID aud change together: for a query, sending repo with aud = a group DID is rejected with jwt audience does not match service did — there is no half-migrated state. Responses on the legacy path carry RFC 8594 headers (Deprecation: true + a Link); no Sunset date is set yet.
For the full migration walkthrough (per-method repo placement, direct vs proxied, detecting un-migrated calls) see aud-migration.md; for the design rationale see design/aud-deprecation.md.
As an alternative to a per-request service-auth JWT, an owner can issue a long-lived API key (see API key management). A backend authenticates by sending the key in the X-API-Key header instead of Authorization: Bearer:
X-API-Key: cgsk_<keyRef>.<secret>
The group is named with repo on the querystring (?repo=<handle-or-did>). Unlike the JWT path, an API-key request must put repo on the querystring even for procedures (e.g. record writes): API-key auth resolves and authenticates against the group before the JSON body is parsed, so a body repo is invisible at authentication time. Omitting the querystring repo is rejected with 401 Missing repo for API-key request. If a procedure body also carries a repo, it must resolve to the same group as the querystring — a mismatch is rejected (400), since the key was authenticated against the querystring group and cannot be redirected to another. There is no aud, no nonce, and no 2-minute lifetime: the key is valid until revoked. A key is constrained by its granted scopes and by the role of the owner that issued it; a request outside the key's scopes is rejected with 403.
curl "https://group-service.example.com/xrpc/app.certified.group.member.list?repo=did:plc:group123" \
-H "X-API-Key: cgsk_ab12cd34.Zlen…"Returns service health status. No authentication required. Both paths return
the identical body; /xrpc/_health exists for parity with the upstream PDS
convention.
Response:
200 OK
{ "status": "ok", "service": "group-service", "version": "0.1.0+90d10b96" }The version is resolved from the CGS_VERSION env var, a build-time
.cgs-version file, or package.json (in that order). When the global
database is unreachable, both endpoints return 503 with
{ "status": "error", "message": "database unreachable" }.
These procedures create, import, and remove groups. register and import are service-scoped: they target the service itself (JWT aud = the service DID) and take no repo, because they are not acting on an existing group — register creates one, import adopts one. The examples below show them as non-proxied calls, which is the simplest way to invoke a service-scoped method; they could also be reached by proxying to the service DID (certified_group_service), since that does not depend on a group existing. destroy operates on an existing group.
Create a new group: provision a fresh account on the group's PDS and seed the caller-named owner.
Authentication: service-level (JWT aud = service DID). The JWT iss must equal ownerDid.
Request body:
{
"handle": "mygroup",
"ownerDid": "did:plc:owner123",
"email": "owner@example.com"
}handle is the short name (combined with the PDS hostname to form the full handle); ownerDid is seeded as the immutable owner and must match the JWT iss; email is optional (a recovery email enabling the forgot-password flow for credible exit).
Response (200):
{
"groupDid": "did:plc:group123",
"handle": "mygroup.pds.example.com",
"accountPassword": "generated-primary-password"
}The owner must save accountPassword — it is the group account's primary credential for credible exit.
Errors:
| Code | Name | Description |
|---|---|---|
| 400 | InvalidRequest | Missing/invalid fields |
| 401 | AuthenticationRequired | Missing or invalid JWT, or iss ≠ ownerDid |
| 409 | HandleNotAvailable | The handle is already taken |
| 409 | GroupAlreadyRegistered | A group already exists for this account |
Promote an existing PDS account into a group (the sibling of register, reusing an account rather than creating one).
Authentication: service-level (JWT aud = service DID). The JWT iss must equal groupDid — the account being imported signs the request; the prospective owner does not. An app password cannot mint such a JWT, so this proves control of the account beyond merely holding its app password. See docs/design/group-import.md (the "Auth model" decision) for the rationale.
Request body:
{
"groupDid": "did:plc:existing123",
"appPassword": "abcd-efgh-ijkl-mnop",
"ownerDid": "did:plc:owner123"
}groupDid is the existing account's DID; appPassword is an app password for it, stored encrypted so the service can act on its behalf; ownerDid is seeded as owner. ownerDid is not separately authenticated and may differ from groupDid. The service resolves the account's PDS (which must be https) and handle from its DID document — there is no handle input.
Response (200):
{
"groupDid": "did:plc:existing123",
"handle": "existing.pds.example.com"
}Errors:
| Code | Name | Description |
|---|---|---|
| 400 | InvalidRequest | Missing/invalid fields, unresolvable DID, or a non-https PDS endpoint |
| 401 | AuthenticationRequired | Missing/invalid JWT, or iss ≠ groupDid |
| 401 | InvalidAppPassword | App password is wrong/revoked, or the account is not on the resolved PDS |
| 409 | GroupAlreadyRegistered | A group already exists for this account |
Unlike registered groups, the service holds no recovery key for an imported account (the owner's own credentials are their credible exit), and import does not modify the account's DID document.
Remove the group from the service.
Required role: owner
The service-level inverse of register / import: it removes the group's stored credentials, membership, and per-group data. It does not delete the underlying PDS account — the DID, handle, and repo continue to exist, so the account can be re-imported afterwards with app.certified.group.import.
Request body: none. The target group is named by the repo querystring parameter (?repo=<handle-or-did>), with JWT aud = service DID.
The legacy
aud= group DID form (norepo) still works but is deprecated; see Targeting a group.
Response (200):
{
"groupDid": "did:plc:group1"
}Errors:
| Code | Name | Description |
|---|---|---|
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 401 | Unknown group | repo names no registered group (or fails to resolve) |
| 403 | Forbidden | Caller lacks the owner role |
| 404 | GroupNotFound | The group is not registered on the service |
Because the per-group data (including the audit log) is deleted, the destroy is not written to the group's audit log — it is recorded only in the service's operational log.
These endpoints proxy requests to the group's backing PDS after authentication and authorization.
Each record operation accepts both the standard AT Protocol NSID and a custom alias. For example, com.atproto.repo.createRecord and app.certified.group.repo.createRecord are interchangeable. The custom NSIDs are useful when the client's PDS needs an explicit lexicon to route via atproto-proxy.
Alias: POST /xrpc/app.certified.group.repo.createRecord
Create a new record in the group's repository.
Required role: member
The target group is named by the repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"collection": "app.bsky.feed.post",
"rkey": "optional-record-key",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the group!",
"createdAt": "2026-01-15T12:00:00Z"
}
}Response (200):
{
"uri": "at://did:plc:group123/app.bsky.feed.post/3abc123",
"cid": "bafyrei..."
}Errors:
| Code | Name | Description |
|---|---|---|
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 401 | Unknown group | repo names no registered group (or fails to resolve) |
| 403 | Forbidden | Caller lacks member role |
Example:
curl -X POST https://group-service.example.com/xrpc/com.atproto.repo.createRecord \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the group!",
"createdAt": "2026-01-15T12:00:00Z"
}
}'Alias: POST /xrpc/app.certified.group.repo.putRecord
Update an existing record or create one at a specific key.
Required role: Depends on context:
| Scenario | Operation | Required role |
|---|---|---|
Updating app.bsky.actor.profile with rkey self |
putRecord:profile |
admin |
| Updating a record you authored | putOwnRecord |
member |
| Updating another member's record | putAnyRecord |
admin |
| Creating a new record (no existing author) | createRecord |
member |
The target group is named by the repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"collection": "app.bsky.feed.post",
"rkey": "3abc123",
"record": {
"$type": "app.bsky.feed.post",
"text": "Updated post content",
"createdAt": "2026-01-15T12:00:00Z"
}
}Response (200):
{
"uri": "at://did:plc:group123/app.bsky.feed.post/3abc123",
"cid": "bafyrei..."
}Errors:
| Code | Name | Description |
|---|---|---|
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 401 | Unknown group | repo names no registered group (or fails to resolve) |
| 403 | Forbidden | Caller lacks required role for this operation |
Example:
curl -X POST https://group-service.example.com/xrpc/com.atproto.repo.putRecord \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"collection": "app.bsky.actor.profile",
"rkey": "self",
"record": {
"$type": "app.bsky.actor.profile",
"displayName": "Our Group",
"description": "A collaborative group account"
}
}'Alias: POST /xrpc/app.certified.group.repo.deleteRecord
Delete a record from the group's repository.
Required role:
| Scenario | Operation | Required role |
|---|---|---|
| Deleting a record you authored | deleteOwnRecord |
member |
| Deleting another member's record | deleteAnyRecord |
admin |
The target group is named by the repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"collection": "app.bsky.feed.post",
"rkey": "3abc123"
}Response (200):
{}Errors:
| Code | Name | Description |
|---|---|---|
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 401 | Unknown group | repo names no registered group (or fails to resolve) |
| 403 | Forbidden | Caller lacks required role |
Example:
curl -X POST https://group-service.example.com/xrpc/com.atproto.repo.deleteRecord \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"collection": "app.bsky.feed.post",
"rkey": "3abc123"
}'Alias: POST /xrpc/app.certified.group.repo.uploadBlob
Upload a blob (image, file, etc.) to the group's PDS.
Required role: member
The request body is the raw blob bytes, so the target group is named by the repo querystring parameter (?repo=<handle-or-did>), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request:
- Send the raw binary data as the request body
Content-Typeheader must match the blob's MIME typeContent-Lengthheader is required
Response (200):
{
"blob": {
"$type": "blob",
"ref": { "$link": "bafyrei..." },
"mimeType": "image/png",
"size": 123456
}
}Errors:
| Code | Name | Description |
|---|---|---|
| 400 | BlobTooLarge | Blob exceeds MAX_BLOB_SIZE (default 5 MB) |
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 401 | Unknown group | repo names no registered group (or fails to resolve) |
| 403 | Forbidden | Caller lacks member role |
Example:
curl -X POST "https://group-service.example.com/xrpc/com.atproto.repo.uploadBlob?repo=did:plc:group123" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: image/png" \
--data-binary @photo.pngAdd a new member to the group.
Required role: admin
The target group is named by the optional repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"memberDid": "did:plc:newmember",
"role": "member"
}The role field must be "member" or "admin". The owner role cannot be assigned via any endpoint — it is fixed at registration and is immutable.
Response (200):
{
"memberDid": "did:plc:newmember",
"role": "member",
"addedBy": "did:plc:caller",
"addedAt": "2026-01-15T12:00:00Z"
}Errors:
| Code | Name | Description |
|---|---|---|
| 400 | InvalidRole | Role is not member or admin |
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 403 | Forbidden | Caller lacks admin role |
| 409 | MemberAlreadyExists | The DID is already a member |
Example:
curl -X POST https://group-service.example.com/xrpc/app.certified.group.member.add \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"memberDid": "did:plc:newmember",
"role": "member"
}'Remove a member from the group.
Required role: admin (or any role for self-removal)
The target group is named by the optional repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"memberDid": "did:plc:targetmember"
}Response (200):
{}Errors:
| Code | Name | Description |
|---|---|---|
| 400 | CannotRemoveOwner | Cannot remove a member with the owner role |
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 403 | Forbidden | Caller lacks admin role, or target has equal/higher role than caller (and is not removing self) |
| 404 | MemberNotFound | Target is not a group member |
Example:
curl -X POST https://group-service.example.com/xrpc/app.certified.group.member.remove \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"memberDid": "did:plc:targetmember"
}'List group members with pagination.
Required role: member
The target group is named by the repo querystring parameter (?repo=<handle-or-did>), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
repo |
string | — | Target group (handle or DID); see above |
limit |
number | 50 | Results per page (1-100) |
cursor |
string | — | Pagination cursor from a previous response |
Response (200):
{
"members": [
{
"did": "did:plc:owner1",
"role": "owner",
"addedBy": "did:plc:owner1",
"addedAt": "2026-01-01T00:00:00Z"
},
{
"did": "did:plc:admin1",
"role": "admin",
"addedBy": "did:plc:owner1",
"addedAt": "2026-01-02T00:00:00Z"
}
],
"cursor": "MjAyNi0wMS0wMlQwMDowMDowMFo6OmRpZDpwbGM6YWRtaW4x"
}Members are ordered by added_at ASC, member_did ASC. The cursor is a base64-encoded string of added_at::member_did.
Example:
curl "https://group-service.example.com/xrpc/app.certified.group.member.list?repo=did:plc:group123&limit=10" \
-H "Authorization: Bearer $JWT"Change a member's role.
Required role: owner
The target group is named by the optional repo body field (a handle or DID), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Request body:
{
"repo": "did:plc:group123",
"memberDid": "did:plc:targetmember",
"role": "admin"
}The role field can be "member" or "admin". The owner role is immutable: a member cannot be promoted to owner, and an existing owner's role cannot be changed. Ownership transfer is a separate operation (not yet implemented).
Response (200):
{
"memberDid": "did:plc:targetmember",
"role": "admin"
}Errors:
| Code | Name | Description |
|---|---|---|
| 400 | InvalidRole | Role is not a recognized role (member, admin, or owner) |
| 400 | CannotModifyOwner | Target already holds the owner role |
| 400 | CannotPromoteToOwner | Cannot promote a member to owner |
| 401 | AuthenticationRequired | Missing or invalid JWT |
| 403 | Forbidden | Caller lacks owner role, or attempted to promote above own role |
| 404 | MemberNotFound | Target is not a group member |
Example:
curl -X POST https://group-service.example.com/xrpc/app.certified.group.role.set \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"repo": "did:plc:group123",
"memberDid": "did:plc:targetmember",
"role": "admin"
}'Owner-only methods for issuing and revoking API keys. All three are authenticated with a normal owner JWT (not a key — a key can never manage keys). They target a group the same way as other group-scoped methods (repo in the body for the procedures, on the querystring for the list query).
Mint a key. Owner-only.
Request body:
{
"repo": "did:plc:group123",
"name": "platform backend",
"scopes": ["rpc:app.certified.group.member.list"]
}Scope kinds:
| kind | form | grants |
|---|---|---|
rpc: |
rpc:<method> (friendly) |
a service read method (member.list, audit.query) |
repo: |
repo:<collection>?action=create|update|delete |
a PDS-repo write (createRecord / putRecord / deleteRecord) on that collection |
blob: |
blob:<accept> (e.g. blob:image/*, blob:*/*) |
uploadBlob of a matching content type |
For rpc: scopes, pass the friendly rpc:<method> name — a key only ever calls the CGS it was minted on, so the service binds each scope to its own audience (?aud=did:web:<host>%23certified_group_service) before storing; you do not supply an aud, and the response echoes the stored canonical form. repo: and blob: scopes carry no aud and are stored as given. For a repo: write, the scope picks the collection + action; the caller's role still decides whose records may be touched (a member-issued key can only mutate records that member authored — repo: scopes have no own-vs-any axis).
Response — the plaintext key is returned only here:
{
"keyRef": "ab12cd34",
"key": "cgsk_ab12cd34.Zlen…",
"scopes": [
"rpc:app.certified.group.member.list?aud=did:web:group-service.example.com%23certified_group_service"
],
"createdAt": "2026-06-06T12:00:00.000Z"
}Errors: Forbidden (not the owner), InvalidScope (a scope that is unparseable, names a non-RPC method, or carries an aud for a different service).
List the group's keys. Owner-only. Never returns the secret or its hash. Params: repo, limit, cursor, includeRevoked (default false).
{
"keys": [
{
"keyRef": "ab12cd34",
"name": "platform backend",
"scopes": [
"rpc:app.certified.group.member.list?aud=did:web:group-service.example.com%23certified_group_service"
],
"createdBy": "did:plc:owner",
"createdAt": "2026-06-06T12:00:00.000Z",
"lastUsedAt": "2026-06-06T12:05:00.000Z"
}
]
}Revoke a key (soft-delete; rejected on next use). Owner-only. Idempotent.
Request body: { "repo": "did:plc:group123", "keyRef": "ab12cd34" }. Response: { "keyRef": "ab12cd34", "revokedAt": "2026-06-06T13:00:00.000Z" }. Errors: Forbidden, KeyNotFound.
These endpoints operate at the service level rather than on a single group. The JWT aud must be the service DID (not a group DID), and lxm must match the endpoint's NSID.
Discovering the service DID: The service DID is published at the /.well-known/did.json endpoint. Resolve it once and cache it for the lifetime of your session.
Minting a service-level JWT: Build the JWT exactly as you would for a group-level call, but set aud to the service DID instead of a group DID. The iss, lxm, jti, and exp fields work the same way. Sign the token with your DID's signing key as usual.
List all groups the authenticated user belongs to on this group service.
Authentication: service-level (JWT aud = service DID)
Required role: none (any authenticated user can list their own memberships)
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
number | 50 | Results per page (1-100) |
cursor |
string | — | Pagination cursor from a previous response |
Response (200):
{
"groups": [
{
"groupDid": "did:plc:group123",
"role": "admin",
"joinedAt": "2026-01-15T12:00:00.000Z"
},
{
"groupDid": "did:plc:group456",
"role": "member",
"joinedAt": "2026-02-01T09:30:00.000Z"
}
],
"cursor": "MjAyNi0wMi0wMVQwOTozMDowMFo6OmRpZDpwbGM6Z3JvdXA0NTY="
}Groups are ordered by joinedAt ASC, groupDid ASC. Paginate by passing the returned cursor value into the next request until cursor is absent from the response, which indicates the final page.
Treat the cursor as opaque. Its internal format may change between service versions. Do not construct, parse, or modify cursor values — always use them exactly as returned.
Errors:
| Code | Name | Description |
|---|---|---|
| 400 | InvalidCursor | Malformed pagination cursor |
| 401 | AuthenticationRequired | Missing or invalid JWT |
Error response format:
{
"error": "InvalidCursor",
"message": "Invalid cursor"
}{
"error": "AuthenticationRequired",
"message": "Authentication Required"
}Important — single-instance scope: This endpoint only lists groups managed by this group service instance. If the caller is a member of groups on other group service instances, those memberships will not appear here. There is currently no cross-service federation or discovery mechanism for memberships.
Example:
curl "https://group-service.example.com/xrpc/app.certified.groups.membership.list?limit=10" \
-H "Authorization: Bearer $JWT"Query the group's audit log.
Required role: admin
The target group is named by the repo querystring parameter (?repo=<handle-or-did>), with JWT aud = service DID. The legacy aud = group DID form (no repo) still works but is deprecated; see Targeting a group.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
repo |
string | — | Target group (handle or DID); see above |
limit |
number | 50 | Results per page (1-100) |
cursor |
string | — | Pagination cursor from a previous response |
actorDid |
string | — | Filter by actor DID |
action |
string | — | Filter by action (e.g. createRecord, member.add) |
collection |
string | — | Filter by collection NSID |
Response (200):
{
"entries": [
{
"id": 42,
"actorDid": "did:plc:member1",
"action": "createRecord",
"collection": "app.bsky.feed.post",
"rkey": "3abc123",
"result": "permitted",
"detail": {
"collection": "app.bsky.feed.post",
"rkey": "3abc123"
},
"createdAt": "2026-01-15T12:00:00Z"
}
],
"cursor": "NDI="
}Entries are ordered newest first (id DESC). The detail field is a JSON object parsed from the stored JSON string.
Every audited operation produces one of the following action strings. Denied operations use the same action value with "result": "denied" and an additional reason field in detail.
| Action | Trigger | detail fields |
|---|---|---|
group.register |
Group created via app.certified.group.register |
{ handle } |
group.import |
Existing account imported via app.certified.group.import |
{ handle } |
member.add |
Member added via member.add |
{ memberDid, role } |
member.remove |
Member removed via member.remove |
{ memberDid } |
role.set |
Role changed via role.set |
{ memberDid, previousRole, newRole } |
createRecord |
Record created (via createRecord or putRecord for a new rkey) |
{ collection, rkey } |
putOwnRecord |
Caller updated a record they authored | { collection, rkey } |
putAnyRecord |
Caller updated another member's record | { collection, rkey } |
putRecord:profile |
Group profile updated (app.bsky.actor.profile rkey self) |
{ collection, rkey } |
deleteOwnRecord |
Caller deleted a record they authored | { collection, rkey } |
deleteAnyRecord |
Caller deleted another member's record | { collection, rkey } |
uploadBlob |
Blob uploaded via uploadBlob |
(none) |
Denied entries include the same detail fields as permitted entries, plus a reason string explaining why the operation was denied:
{
"action": "deleteAnyRecord",
"result": "denied",
"detail": {
"collection": "app.bsky.feed.post",
"rkey": "3abc123",
"reason": "Forbidden: role 'member' cannot perform 'deleteAnyRecord'"
}
}Example:
# All audit entries
curl "https://group-service.example.com/xrpc/app.certified.group.audit.query?repo=did:plc:group123" \
-H "Authorization: Bearer $JWT"
# Filter by actor
curl "https://group-service.example.com/xrpc/app.certified.group.audit.query?repo=did:plc:group123&actorDid=did:plc:member1" \
-H "Authorization: Bearer $JWT"
# Filter by action
curl "https://group-service.example.com/xrpc/app.certified.group.audit.query?repo=did:plc:group123&action=member.add" \
-H "Authorization: Bearer $JWT"