Audit log
Every mutation that changes a group’s state writes one or more rows to that group’s audit log. Reads return the entries that were written for a single group, newest first, with optional filtering by action and timestamp.
The audit log is the durable counterpart to the SSE event stream. SSE delivers events to live subscribers but does not replay across reconnects; the audit log is the source of truth for “what has happened to this group” and supports pagination over the full history.
GET /v1/groups/:id/audit
Returns the most recent audit entries for the group, sorted by createdAt descending (and id descending as a tiebreaker for entries that share a timestamp).
Path parameters
| Field | Required | Notes |
|---|---|---|
id | yes | The group to fetch audit entries for. Must belong to the calling game and not be soft-deleted. |
Query parameters
| Field | Type | Default | Notes |
|---|---|---|---|
limit | integer (1-100) | 50 | Max number of entries to return. |
before | string | none | Cursor for pagination: feed the response’s nextCursor (an opaque entry id) back in. Also accepts a bare ISO 8601 timestamp (the original contract, kept working for stored cursors and hand-written calls) with createdAt < before semantics. |
actions | repeated string | (no filter) | Filter to entries whose action is one of the supplied values. Repeat the parameter for OR semantics: ?actions=group.created&actions=group.updated. |
The actions strings must match the AuditAction union in @junjo.io/shared. Unknown values are rejected with 400 bad_request.
Response
200 OK with the standard Page<AuditEntry> envelope:
{
"items": [
{
"id": "audit_2",
"groupId": "grp_xyz",
"actorUserId": "user_alice",
"action": "member.invited",
"targetId": "user_bob",
"payload": { "invitationId": "inv_1", "code": "abc123" },
"createdAt": "2026-04-28T05:01:00.000Z"
},
{
"id": "audit_1",
"groupId": "grp_xyz",
"actorUserId": null,
"action": "group.created",
"targetId": "grp_xyz",
"payload": { "kind": "guild", "name": "Crimson Wolves" },
"createdAt": "2026-04-28T05:00:00.000Z"
}
],
"nextCursor": "audit_1"
}nextCursor is the id of the last item when more entries exist; null when this is the last page. To fetch the next page, pass nextCursor back as before.
Audit entry shape
| Field | Type | Notes |
|---|---|---|
id | string | Stable cuid for the entry. |
groupId | string | The group the entry belongs to. |
actorUserId | string | null | The Junjo user id that performed the action, when known. null for system-driven actions and for actions taken via the API key (no auth-adapter actor wired for most mutations). |
action | AuditAction | One of the strings in the union. |
targetId | string | null | Free-form pointer to whatever the action targeted: a user id, role id, permission key, group id. Type depends on action. |
payload | object | Action-specific details. The before / after fields on update actions only contain the keys that actually changed. |
createdAt | ISO 8601 timestamp | When the entry was written. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | The query is malformed (out-of-range limit, invalid before cursor, unknown actions value). A before id that does not resolve inside the group also reads as an invalid cursor rather than leaking whether the row exists. |
| 401 | invalid_api_key | API key missing or invalid. |
| 404 | not_found | Group does not exist, is soft-deleted, or belongs to a different game. |
Pagination semantics
To walk all entries:
- Call without
before. ReceiveitemsandnextCursor(the id of the last item, ornull). - If
nextCursoris non-null, call again withbefore = nextCursor. - Repeat until
nextCursoris null.
Feeding an id back as before gives exact keyset pagination on the (createdAt, id) sort order, so page boundaries are immune to entries sharing a millisecond. The timestamp form of before remains strictly exclusive on createdAt: entries sharing the exact boundary timestamp can be skipped, so prefer the id cursor the server hands back.