Super People Docs
Getting Started

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:3101 local, https://api-peoples.superlabs.co production

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": "…" }. Pass nextCursor back as ?cursor=; null means the end. limit defaults to 20, caps at 50.
  • Errors are Nest bodies: { "statusCode": 400, "message": "…", "error": "Bad Request" }. message is 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

  1. Anonymous is not private
  2. Employee routes
  3. Handler routes (super admin)
  4. Data models
  5. Enums and limits
  6. Building the screens
  7. 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
QueryValues
statusopen, in_review, resolved, closed
categorysee §5.1
sortlatest (default), oldest
cursor, limitpaging, 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=50

Oldest 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-changes

Every 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
→ 204

Call 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=latest

Returns 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}/replies

The 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-changes

Same 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

ValueMeaningWeb label
openRaised, not yet picked up.Open
in_reviewA handler is working on it.In Progress
resolvedDealt with.Resolved
closedFinished 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

ValueWho
submitterThe person who raised it.
employeeAny other colleague answering on the board.
handlerThe 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

subject3–150 characters
description20–5000 characters
Reply body1–5000 characters (no minimum beyond non-empty)
Status reason3–1000 characters, required on every move
Mentions per reply20; ids that match nobody are dropped
Page size20 default, 50 max
Attachmentsnone, 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

  1. submitterName and authorName always 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.
  2. 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.
  3. 404 means the id does not exist. There is no "not yours" case left.
  4. Sending submittedById is a 400. So is any other undeclared field.
  5. unread and the count are per person. Never cache them across accounts, and refresh them after POST /read. They are the only per-viewer thing on the board: one employee's list is otherwise byte-for-byte another's.
  6. 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.
  7. Your own grievances never count as unread, including the moment you raise one.
  8. resolvedAt can go back to null when a grievance is reopened.
  9. Resolved grievances stay on the board. Do not filter them out by default; people come back to read how something was settled.
  10. No SSE events, ever. Refetch instead of waiting for a stream — a new reply included.
  11. There is no reveal route, and no submitter field anywhere. An anonymous grievance is not attributable to anybody, for any role.
  12. A thread reads oldest-first, unlike every other list in this API.
  13. Replying does not change the status, and does not touch updatedAt.
  14. A status move without a reason is a 400, and nothing moves. Ask for the reason before applying the change, not after.
  15. Mentions are @[userId] tokens in body. Send tokens, not names, and render them from mentions. A tag that matches nobody is dropped silently.
  16. Being tagged never names the tagger. The notification carries no actor; do not invent one.
  17. Cursors are opaque. Do not parse, build or store them between sessions.
  18. Everything needs a session. 401 means 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.

On this page