Grievance Channel API
Anonymous grievances every employee can read and answer, and the queue super admins work through.
For the web and mobile clients. Employees raise a concern anonymously, and every employee reads every grievance and can answer on any of them. Super admins work the queue and move each grievance through its statuses.
The web app and the mobile app call the same routes. Only the session differs: the web app sends its cookie, the mobile app sends a bearer token. See Authentication for both.
- Interactive reference (always current):
GET /api/v1/docs - Machine-readable spec:
GET /api/v1/openapi.json - Base URLs:
http://localhost:3101local,https://api-peoples.superlabs.coproduction
Conventions this feature shares with the rest of the API — paging, error
bodies, date formats, Content-Type — are in
Conventions. The short version:
- Lists are cursor-paged:
{ "items": [...], "nextCursor": "…" }. PassnextCursorback as?cursor=;nullmeans the end.limitdefaults to 20, caps at 50. - Errors are Nest bodies:
{ "statusCode": 400, "message": "…", "error": "Bad Request" }.messageis a string or an array of strings. Show it to the person as-is. - Timestamps are ISO 8601 with offset. Ids are opaque strings.
- An unknown field in a request body is a 400, not an ignored key.
Contents
- Anonymous is not private
- Employee routes
- Handler routes (super admin)
- Data models
- Enums and limits
- Building the screens
- Things that will bite you
1. Anonymous is not private
This is the part to read before writing any code, because several rules will look like bugs otherwise.
Identity is private. Content is not. A grievance is visible to every signed-in employee the moment it is raised — the list, the full description, the thread and the handling record. What no route returns, to any role, is who raised it or who replied.
submitterName and authorName are the literal string "Anonymous". For
every grievance, every reply and every viewer, including the person who wrote
it. Print the field as given. Do not substitute "You", do not look the person
up, and do not compare it with the signed-in person's name. A screen glanced at
over somebody's shoulder must not say "You" next to a report.
There is no route that names a submitter. Not for an employee, not for a
handler, not for a super admin, not later. The queue carries no submitter
field at all, and GET /admin/grievances/{id}/submitter — an audited lookup
that used to exist — was removed and now 404s. If you were calling it, delete
the call and the screen around it; there is no replacement and no flag that
re-enables it.
A handler's reply also reads "Anonymous". The employee-facing thread does
not distinguish the grievance team from a colleague: authorRole tells a
client which side a message came from without naming anybody, and the queue —
and only the queue — carries author on a handler's own reply so the team can
see which of them answered.
Everyone can reply, on any grievance. authorRole records whether the
message came from the person who raised it (submitter), another employee
(employee) or the grievance team (handler). There is no per-reply anonymity
choice, and sending one is a 400.
Read state is per person. Every grievance carries unread for the caller,
and GET /grievances/unread-count is that person's badge number. Opening a
thread should POST /grievances/{id}/read, which marks it read for them and
nobody else.
Tagging a colleague is the one identity a thread shows. Put an
@[userId] token in a reply body, exactly as in the feed, and the person is
notified that they were mentioned in an anonymous grievance. The notification
carries no actor, so being tagged never reveals who tagged you.
Nothing about grievances is announced on the live-update (SSE) stream, even though most of the app is. The arrival of an event is itself a hint about who raised something and when. Poll or refetch when a screen opens; do not wait for a realtime event that will never come.
2. Employee routes
Open to anyone signed in. Prefix: /api/v1/grievances.
2.1 Raise a grievance
POST /api/v1/grievances
Content-Type: application/json
{
"subject": "Workload and work-life balance",
"description": "The workload has been very high lately and it is affecting my work-life balance. Please look into this.",
"category": "workload"
}anonymous may be sent as true and may be left out entirely — every
grievance on the board is anonymous either way. Sending submittedById, or any
other undeclared field, is a 400.
201 returns the Grievance. It is visible to the whole
company immediately, attributed to "Anonymous".
2.2 The board
GET /api/v1/grievances?category=workload&status=open&sort=latest&limit=20| Query | Values |
|---|---|
status | open, in_review, resolved, closed |
category | see §5.1 |
sort | latest (default), oldest |
cursor, limit | paging, as everywhere |
Returns a page of Grievance plus total, the size of the
whole filtered set. Resolved and closed grievances stay on the board — the
history of an issue and how it was settled is the point.
There is no "only mine" filter, and adding one client-side would defeat the
point. Every row reads "Anonymous" so that a screen glanced at over
somebody's shoulder says nothing; a list narrowed to one person's own
grievances says exactly whose they are. Someone following up on what they
raised comes back through the notification it sent them. ?mine=true is a
400, like any other unknown query parameter.
2.3 One grievance
GET /api/v1/grievances/{grievanceId}Any grievance, raised by anybody. 404 means the id does not exist; there is no "somebody else's" case any more.
2.4 The thread
GET /api/v1/grievances/{grievanceId}/replies?limit=50Oldest first, so it reads as a conversation — the opposite of every other list
in this API. Every message carries authorName: "Anonymous".
2.5 Reply
POST /api/v1/grievances/{grievanceId}/replies
Content-Type: application/json
{ "body": "The workload is very high. @[usr_0f2c] please look into this." }201 returns the GrievanceReply, with mentions
resolved to names for rendering. Sending anonymous or authorRole is a
400. Replying does not move the status and does not reorder the board.
2.6 How it has been handled
GET /api/v1/grievances/{grievanceId}/status-changesEvery status move with the reason given for it, newest first, starting with the entry that records it being raised. This is the answer to "why is this still open"; show it wherever you show the status.
2.7 Your unread count
GET /api/v1/grievances/unread-count
→ { "count": 4 }Grievances you did not raise and have not marked read. Per person: another employee opening one does not change yours.
2.8 Mark one read
POST /api/v1/grievances/{grievanceId}/read
→ 204Call it when the thread opens. Idempotent, and it never marks anything read for anybody else.
3. Handler routes (super admin)
Prefix: /api/v1/admin/grievances. A non-handler gets 403.
A handler reads the same grievances, in the same shape, with nobody named. What the queue adds is filtering built for working through a list, the ability to move a grievance, and the team's own signatures on their replies.
3.1 The queue
GET /api/v1/admin/grievances?status=open&category=harassment&sort=latestReturns a page of Grievance plus total. There is no
submitter field on it.
3.2 One grievance
GET /api/v1/admin/grievances/{grievanceId}3.3 Change the status
PATCH /api/v1/admin/grievances/{grievanceId}/status
Content-Type: application/json
{ "status": "in_review", "reason": "Picked this up; talking to the team leads this week." }reason is required, 3–1000 characters. It is shown to everyone reading
the grievance, attributed to the grievance team rather than to you, and kept on
the handling record. A move with no reason is a 400 and nothing moves.
resolvedAt is set on the first move to resolved or closed, kept if a
resolved grievance is then closed, and cleared if it is reopened.
3.4 The thread
GET /api/v1/admin/grievances/{grievanceId}/replies
POST /api/v1/admin/grievances/{grievanceId}/repliesThe same thread. A handler's reply carries author here so the team can see
which of them wrote it — and reads as "Anonymous" to everybody else. An
employee's reply never carries author, whether they raised the grievance or
are answering it.
3.5 How a grievance has been handled
GET /api/v1/admin/grievances/{grievanceId}/status-changesSame record as §2.6, with changedBy on each
move the team made.
4. Data models
4.1 Grievance
{
"id": "grv_8c1f",
"submitterName": "Anonymous", // always this string, for everyone
"unread": true, // whether YOU have opened it
"subject": "Workload and work-life balance",
"description": "The workload has been very high lately…",
"category": "workload",
"anonymous": true,
"status": "open",
"resolvedAt": null, // set when first resolved or closed
"replyCount": 3,
"statusNote": "Picked this up; talking to the team leads this week.",
"statusNoteAt": "2026-09-22T09:14:00.000+05:30",
"createdAt": "2026-09-22T08:02:00.000+05:30",
"updatedAt": "2026-09-22T09:14:00.000+05:30"
}The same shape on the employee routes and in the handler queue. statusNote is
the latest reason a handler gave, and is null on a grievance raised before
reasons were recorded.
4.2 GrievanceReply
{
"id": "rpl_11a",
"grievanceId": "grv_8c1f",
"authorName": "Anonymous", // always this string, on both sides
"authorRole": "employee", // submitter | employee | handler
"body": "The workload is very high. @[usr_0f2c] please look into this.",
"mentions": [
{ "id": "usr_0f2c", "name": "Chinraj", "image": "/api/v1/files/avatars/…" }
],
"createdAt": "2026-09-22T10:20:00.000+05:30",
"author": null // handler routes only, and only on a handler's reply
}body stores each tag as an @[userId] token; mentions resolves those to
names. Render the token with the same component the feed uses. The people named
are the ones tagged — the author is not among them.
4.3 GrievanceStatusChange
{
"id": "chg_4b2",
"grievanceId": "grv_8c1f",
"fromStatus": "open", // null on the entry that records it being raised
"toStatus": "in_review",
"reason": "Picked this up; talking to the team leads this week.",
"changedByName": "Grievance team",
"createdAt": "2026-09-22T09:14:00.000+05:30",
"changedBy": null // handler routes only
}5. Enums and limits
5.1 Categories
harassment, discrimination, workplace_safety, compensation,
management, workload, policy, facilities, other
Wire values are snake_case; write your own display labels. The web app reads
management as "Manager / Leadership", policy as "HR & Policies", workload
as "Work Environment" and other as "Others" — match it. Treat the list as
closed: an unknown value is a 400 on write.
5.2 Statuses
| Value | Meaning | Web label |
|---|---|---|
open | Raised, not yet picked up. | Open |
in_review | A handler is working on it. | In Progress |
resolved | Dealt with. | Resolved |
closed | Finished without a resolution, or closed out. | Closed |
resolved and closed both count as finished and both carry resolvedAt.
Both stay on the board.
5.3 Reply roles
| Value | Who |
|---|---|
submitter | The person who raised it. |
employee | Any other colleague answering on the board. |
handler | The grievance team. |
Use it to place or tint a message. It names a side, never a person — and on the
employee side every message is labelled "Anonymous" regardless.
5.4 Limits
subject | 3–150 characters |
description | 20–5000 characters |
Reply body | 1–5000 characters (no minimum beyond non-empty) |
Status reason | 3–1000 characters, required on every move |
| Mentions per reply | 20; ids that match nobody are dropped |
| Page size | 20 default, 50 max |
| Attachments | none, on a grievance or a reply |
Attachments stay out until the file route can be scoped. The existing one serves any file to any signed-in user, and document metadata can carry the author's name — which on this board would undo the anonymity of the person who uploaded it. That is a backend change, not something a client can work around.
6. Building the screens
A minimal, correct client is four screens.
Raise a grievance. Subject, description, category picker — and no identity choice, because there is nothing to choose. Say plainly that colleagues will read it and that nobody can see it was them. Enforce the lengths on trimmed text so the button state matches what the server will say.
The board. GET /grievances, newest first, cursor-paged, with category and
status filters — and no "mine" switch, for the reason in
§2.2. A row reads well as
"Anonymous" + timestamp, category and status chips, subject, two lines of the
description, and the reply count. Mark unread rows with unread. It is a list
of issues, not a feed: no likes, no shares, no saves.
Grievance detail + thread, beside the list rather than on a page of its
own. GET /grievances/{id}/replies and /status-changes. Put statusNote
directly under the status chip. POST /grievances/{id}/read when it opens.
Give the reply box an @ typeahead and say it is sent as "Anonymous".
Handler queue + detail. Behind a role === "super_admin" check: the same
list and thread, plus a status picker that asks for a reason before it
applies the move. There is nothing on this screen that reveals a submitter, and
nothing to build for it.
Because there are no realtime events here, refetch on screen focus (mobile) or on mount / window focus (web), and refresh the unread count when you do.
7. Things that will bite you
submitterNameandauthorNamealways say "Anonymous". To the person who wrote the thing as much as to anybody else. Not a bug; do not "fix" it in the client, and do not build a UI that implies two anonymous messages came from the same person or from different ones.- Anonymous does not mean private. Everybody can read everything. If your UI says "only you and the grievance team can see this", it is now wrong.
- 404 means the id does not exist. There is no "not yours" case left.
- Sending
submittedByIdis a 400. So is any other undeclared field. unreadand the count are per person. Never cache them across accounts, and refresh them afterPOST /read. They are the only per-viewer thing on the board: one employee's list is otherwise byte-for-byte another's.- There is no way to list your own grievances, by design — not through a filter, and not worth rebuilding client-side out of data you kept.
- Your own grievances never count as unread, including the moment you raise one.
resolvedAtcan go back tonullwhen a grievance is reopened.- Resolved grievances stay on the board. Do not filter them out by default; people come back to read how something was settled.
- No SSE events, ever. Refetch instead of waiting for a stream — a new reply included.
- There is no reveal route, and no
submitterfield anywhere. An anonymous grievance is not attributable to anybody, for any role. - A thread reads oldest-first, unlike every other list in this API.
- Replying does not change the status, and does not touch
updatedAt. - A status move without a
reasonis a 400, and nothing moves. Ask for the reason before applying the change, not after. - Mentions are
@[userId]tokens inbody. Send tokens, not names, and render them frommentions. A tag that matches nobody is dropped silently. - Being tagged never names the tagger. The notification carries no actor; do not invent one.
- Cursors are opaque. Do not parse, build or store them between sessions.
- Everything needs a session.
401means the session is gone: clear it and route to sign-in, no retry.
Questions, or a field you need that is not here? Ask the backend team before working around it — most gaps in this feature are deliberate.