Skip to main content
Version: Latest
UPDATED 2026-09-08 This guide was wrong and is now corrected. It said "nothing is auto-hidden by a report" and "the reported user is never told". Both are false as of today: the two new first-person reasons (SHOWS_ME_WITHOUT_CONSENT, IMPERSONATION_OF_ME) hide the content immediately and come back with autoHidden: true, and an author whose content a human confirms as removed gets an email. Also new: GET /api/reports/reasons serves the picker, so a new reason no longer needs an app release. Read §1, §3 and §9 again — the old behaviour is not a fallback.
The two safety actions. **Block** is a state you own and can undo. **Report**is a one-way submission to a human being — there is no queue to poll, nostatus to show, and no notification when it is handled.goes to a human being — there is still no queue to poll and no status to show,but two reasons now hide the reported content the moment you submit, and theresponse tells you when that happened.
- **2026-09-08** — **This guide was wrong and is now corrected.** It said "nothing is auto-hidden by a report" and "the reported user is never told". Both are false as of today: the two new first-person reasons (`SHOWS_ME_WITHOUT_CONSENT`, `IMPERSONATION_OF_ME`) hide the content immediately and come back with `autoHidden: true`, and an author whose content a human confirms as removed gets an email. Also new: `GET /api/reports/reasons` serves the picker, so a new reason no longer needs an app release. Read §1, §3 and §9 again — the old behaviour is not a fallback.- **2026-09-07** — The report box is **never** profanity-filtered, on purpose: a child reporting bullying has to be able to quote it. Don't pre-filter that input in the app either. Every other text field in the app did just gain a blocked-word check — see [`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md).
Report → "Someone should look at this." one-way, queued, theirs → "That's me in it." …and it disappears now```
A block takes effect immediately and the user can undo it from their blocklist. A report goes into a moderation queue that a human reads out of band.**Nothing is auto-hidden by a report** — the post stays on screen until youhide it client-side, and the reported user is never told.list. A report goes to a human who reads it out of band. **Most reports hide nothing.** The post stays on screen until you remove itclient-side, and the reported user is never told a report exists. **Two reasons are different.** `SHOWS_ME_WITHOUT_CONSENT` and`IMPERSONATION_OF_ME` are first-person claims — "that is me, and I did notagree to it". Those hide the content the instant they are submitted, before anyhuman looks, and the response says so with `autoHidden: true`. Everything elsebehaves exactly as it always has. A hidden item is not deleted. A person reviews it and either leaves it hidden(and emails the author) or puts it back. Nobody is told anything at hide time —if a report turns out to be wrong, the content simply reappears.
reason: ReportReason; // slug from §3 details?: string; // ≤ 500 chars, trimmed. REQUIRED when reason = "OTHER" details?: string; // ≤ 500 chars, trimmed. REQUIRED for 4 reasons — see §3 alsoBlock?: boolean; // default false. Blocks the content's author.
"duplicate": false, "blocked": false "blocked": false, "autoHidden": false }
The **slug** is the contract. Labels are your UI copy — localize them in theapp; the server does not send display strings.The **slug** is the contract. Labels are still your UI copy — localize them inthe app; the server does not send display strings. Seven, in the order they should appear in the picker:**New: ask the server for the list.** | Slug | Label ||------|-------|| `BULLYING_HARASSMENT` | Bullying or harassment || `HATE_OR_DISCRIMINATION` | Hate or discrimination || `SPAM` | Spam || `FALSE_INFORMATION` | False information || `INAPPROPRIATE_CONTENT` | Inappropriate content || `SELF_HARM_OR_SAFETY` | Self-harm or safety concern || `OTHER` | Something else |```GET /api/reports/reasons # all of themGET /api/reports/reasons?targetType=CONVERSATION # only the ones valid there``` `OTHER` **requires** `details`. Whitespace does not count — `" "` isrejected exactly like an empty string. Disable the submit button until thefree-text field has real characters, or you will eat a 400.```json{ "success": true, "data": { "reasons": [ { "slug": "BULLYING_HARASSMENT", "requiresDetails": false,
… change truncated — download the .md for the full update

Mobile Report & Block Guide

The two safety actions. Block is a state you own and can undo. Report goes to a human being — there is still no queue to poll and no status to show, but two reasons now hide the reported content the moment you submit, and the response tells you when that happened.

All routes below are authenticated (Authorization: Bearer <access>). Prefixes: reports = /api/reports, blocks = /api/blocks.

Companion to mobile-follow-graph-guide.md.

What's new

  • 2026-09-08This guide was wrong and is now corrected. It said "nothing is auto-hidden by a report" and "the reported user is never told". Both are false as of today: the two new first-person reasons (SHOWS_ME_WITHOUT_CONSENT, IMPERSONATION_OF_ME) hide the content immediately and come back with autoHidden: true, and an author whose content a human confirms as removed gets an email. Also new: GET /api/reports/reasons serves the picker, so a new reason no longer needs an app release. Read §1, §3 and §9 again — the old behaviour is not a fallback.
  • 2026-09-07 — The report box is never profanity-filtered, on purpose: a child reporting bullying has to be able to quote it. Don't pre-filter that input in the app either. Every other text field in the app did just gain a blocked-word check — see mobile-content-moderation-guide.md.
  • 2026-09-01 — Blocked DMs still disappear for both sides — deliberately. Groups you left became visible-and-read-only in the same release; blocks were carved out, because restoring a blocked thread would undo what the blocker asked for and quietly tell the other child they were blocked.
  • 2026-08-31 — Some chat decisions are recorded but not built yet — see the "decided, not yet shipped" note before building against them.

1. Mental Model

Don't merge the two buttons. They answer different questions.

Block → "I never want to see this person again." reversible, instant, mine
Report → "Someone should look at this." one-way, queued, theirs
→ "That's me in it." …and it disappears now

A block takes effect immediately and the user can undo it from their block list. A report goes to a human who reads it out of band.

Most reports hide nothing. The post stays on screen until you remove it client-side, and the reported user is never told a report exists.

Two reasons are different. SHOWS_ME_WITHOUT_CONSENT and IMPERSONATION_OF_ME are first-person claims — "that is me, and I did not agree to it". Those hide the content the instant they are submitted, before any human looks, and the response says so with autoHidden: true. Everything else behaves exactly as it always has.

A hidden item is not deleted. A person reviews it and either leaves it hidden (and emails the author) or puts it back. Nobody is told anything at hide time — if a report turns out to be wrong, the content simply reappears.

The one place they meet is alsoBlock, which lets a single tap do both.


2. Report

POST /api/reports # body below; no path params
interface CreateReportBody {
targetType: "USER" | "POST" | "COMMENT" | "MESSAGE" | "CONVERSATION";
targetId: string; // user / post / comment / message / group ID — matches targetType
reason: ReportReason; // slug from §3
details?: string; // ≤ 500 chars, trimmed. REQUIRED for 4 reasons — see §3
alsoBlock?: boolean; // default false. Blocks the content's author.
}
{
"success": true,
"data": {
"id": "0198e2...",
"status": "PENDING",
"duplicate": false,
"blocked": false,
"autoHidden": false
}
}

targetId follows targetType. For USER it is the user's ID (not the username — same rule as /api/follows/*). For POST and COMMENT it is the content ID. For MESSAGE it is the chat message ID; for CONVERSATION it is the group conversation ID.

Chat targets need you to be in the chat. A MESSAGE or CONVERSATION report requires you to be an active participant — anything else is 404, never 403, so ids can't be probed. Two consequences worth designing around:

  • Leaving a group means you can no longer report it or its messages. Offer the report action before the leave action in the overflow menu.
  • A DM is not a CONVERSATION target (404). There is no group to answer for — report the other person with targetType: "USER" instead.

A MESSAGE report resolves to the message's sender, so alsoBlock blocks them. A CONVERSATION report resolves to the group's admin — meaning the admin cannot report their own group (400); they can delete it instead. Soft-deleted messages stay reportable: the harm was seen before it was pulled.

alsoBlock blocks the author, not the target. Report a post with alsoBlock: true and you block whoever wrote it. On a USER report those are the same person.

Reporting is idempotent per (you, target). Reporting the same thing twice never creates a second row and never errors — you get duplicate: true.


3. Reason slugs

The slug is the contract. Labels are still your UI copy — localize them in the app; the server does not send display strings.

New: ask the server for the list.

GET /api/reports/reasons # all of them
GET /api/reports/reasons?targetType=CONVERSATION # only the ones valid there
{
"success": true,
"data": {
"reasons": [
{ "slug": "BULLYING_HARASSMENT", "requiresDetails": false,
"targetTypes": ["USER", "POST", "COMMENT", "MESSAGE", "CONVERSATION"] },
{ "slug": "COPYRIGHT", "requiresDetails": true,
"targetTypes": ["USER", "POST", "COMMENT", "MESSAGE", "CONVERSATION"] }
]
}
}

Render them in the order you receive them — that array order is the picker order, not an implementation detail. Adding a reason no longer needs an app release, so build the picker off this call rather than a hard-coded list. Cache it for the session; it changes on the order of once a year.

Ten today:

SlugLabeldetailsNotes
BULLYING_HARASSMENTBullying or harassmentoptional
HATE_OR_DISCRIMINATIONHate or discriminationoptional
SPAMSpamoptional
FALSE_INFORMATIONFalse informationoptional
INAPPROPRIATE_CONTENTInappropriate contentoptional
SELF_HARM_OR_SAFETYSelf-harm or safety concernoptionalReaches a human first. Consider showing crisis resources alongside it
COPYRIGHTThis uses my work without permissionrequiredNever hides anything — a person decides
SHOWS_ME_WITHOUT_CONSENTThis shows me without my consentrequiredHides on submit
IMPERSONATION_OF_METhis is pretending to be merequiredHides on submit. Not offered on a group
OTHERSomething elserequired

Four reasons require details — read requiresDetails rather than hard-coding that set. Whitespace does not count: " " is rejected exactly like an empty string. Disable submit until the field has real characters, or you will eat a 400.

The two first-person reasons are worded as a claim the user is making about themselves. Keep that in your labels — "this shows me", not "this shows someone". Selecting one is the assertion; the server cannot verify who is in a photo, and it hides the content on the strength of that statement alone. A confirmation step before submitting is worth building.

targetTypes filters the picker. Both first-person reasons are absent on CONVERSATION: there is no way to hide a whole group, and doing so on one person's claim would be wrong. Either pass ?targetType= and render what comes back, or filter locally on targetTypes.


4. Reading the response

Five fields now, and the booleans are independent. Render from all three:

duplicateautoHiddenblockedWhat happenedShow
falsefalsefalseNew report filed, nothing hidden"Thanks — we're looking into it."
falsefalsetrueNew report filed and author blocked"Reported and blocked."
falsetruefalseFiled and the content is hidden right now"We've hidden this while someone checks it."
falsetruetrueSame, plus the author is blocked"Hidden while we check it. You've also blocked them."
truefalsefalseYou already reported this. Nothing was written."You've already reported this." — not "Report submitted."
truefalsetrueAlready reported, but this call applied the block"Already reported. Blocked."

status mirrors it: "AUTO_HIDDEN" when autoHidden is true, "PENDING" otherwise. Both mean no human has looked yet — neither is a resolution, and there is still nothing to poll.

autoHidden: false on a first-person reason is not an error, and you cannot tell why. It means either that reason does not hide, or that this user has filed a lot of first-person reports recently and the hide was withheld. The API will not tell you which, deliberately — otherwise the limit is discoverable by anyone probing it. The report itself is always filed either way, so never retry and never show a failure. Just fall back to the ordinary "we're looking into it" copy.

Remove the item from your local list yourself, exactly as before. Even with autoHidden: true, the copy of the post already in your state does not disappear on its own — the next fetch will 404 or omit it. On a hidden chat message you will also receive the existing message:deleted socket event, which your current handler already drops from the thread; no new event to wire.

And the failure case worth handling:

You sent alsoBlock: true and got blocked: false. The report is safe and durable. The block is the thing that failed. Show "Reported — couldn't block, try again", and retry with POST /api/blocks/:userId. Do not re-submit the report — you'll just get duplicate: true.

So the block state you render is blocked, never the alsoBlock you sent.


5. Block / unblock / list

POST /api/blocks/:userId # block — 204, idempotent
DELETE /api/blocks/:userId # unblock — 204, idempotent
GET /api/blocks?cursor=&limit= # who I have blocked, newest first

All three take a user ID, not a username.

{
"success": true,
"data": {
"items": [
{
"id": "0198c1...",
"username": "someone",
"profile": { "displayName": "Someone", "avatarUrl": "https://..." },
"blockedAt": "2026-08-10T09:14:22.031Z"
}
],
"nextCursor": "2026-08-10T09:14:22.031Z"
}
}

The cursor is an ISO date string, not an ID. Pass nextCursor straight back as ?cursor=. nextCursor: null means you have the whole list. limit defaults to 30 and is capped at 100.

The block list is the one screen that deliberately ignores the visibility rules — you can always see who you blocked.


6. What blocking actually does

One transaction, the moment you POST /api/blocks/:userId:

  • Follows are deleted both ways, at any status. Following, followers and pending requests between you both disappear.

  • Shared 1-on-1 DMs are soft-left by both sides, request or accepted. The conversation row survives and stays in history; new messages 403 either way. Block gates the other person; it does not erase what they already sent. In the requests tray, block is enough on its own — the row is gone from your tray, and you can't dismiss it afterwards, since /clear 404s once you are no longer an active participant.

    What that looks like on screen today: the DM disappears from both people's conversation lists, GET /conversations/:id 404s for both, and a send attempt is refused by the soft-leave branch — so the error reads "You were removed from this conversation", which is not what happened. Message history is still served (clamped at the block) by GET /conversations/:id/messages, but nothing in the UI leads there.

    Settled 2026-09-01: this stays as it is. Groups you left or were removed from became visible and read-only that day (see mobile-chat-groups-guide.md §8) — blocked DMs deliberately did not join them. Resurrecting the thread in the blocker's list undoes the point of blocking, and a "you can't message this account" banner would tell the other child they were blocked. So: no list row, no viewerLeft, still 404 on fetch, and no system message — a block is reversible state, and a permanent timeline row would outlive it. docs/CHAT_SYSTEM_MESSAGES.md §3.3.

    Still worth fixing one day: the send error says "You were removed from this conversation", because the soft-leave branch catches it first.

  • Follow notifications between you both are deleted (follow, follow_request, follow_accepted).

  • Group chats are untouched — leaving would tell the other members something happened.

What it does not do:

  • Existing comments, likes and mentions stay visible. Only future interactions are gated. This is the product decision, not a bug.
  • Unblock restores nothing. Follows stay gone, DM participation stays left, notifications stay deleted. Both users start from zero. Say so in the confirm dialog before you call DELETE.

After a block, the other user's profile returns 404 to you and theirs to you — see the Gotchas.


7. Errors

CodeWhenWhat to do
400Reporting your own post / comment / accountNever render the Report action on your own content.
400A requiresDetails reason without details, or details > 500 charsFour reasons need it — COPYRIGHT, SHOWS_ME_WITHOUT_CONSENT, IMPERSONATION_OF_ME, OTHER. Read requiresDetails from GET /reasons instead of hard-coding that list; the error names the details field.
400Blocking yourselfHide the Block action on your own profile.
404Target doesn't exist, or you're not allowed to see itThe content is gone or restricted. Refresh the screen; do not retry.
429More than 20 reports in an hourRead Retry-After (seconds). Show a calm "you've reported a lot recently" message.

409 is never returned. A duplicate report is a 200 — see §4.


8. Reference Snippets

const H = { headers: { "X-Client-Type": "mobile" } }; // + auth interceptor

export type ReportTargetType =
| "USER" | "POST" | "COMMENT" | "MESSAGE" | "CONVERSATION";

export interface ReportReason {
slug: string;
requiresDetails: boolean;
targetTypes: ReportTargetType[];
}

export interface ReportResult {
id: string;
status: "PENDING" | "AUTO_HIDDEN";
duplicate: boolean;
blocked: boolean;
/** true = the content is hidden now. false is ambiguous — see §4. */
autoHidden: boolean;
}

/** Render in the order returned — that IS the picker order. */
export const fetchReportReasons = (targetType?: ReportTargetType) =>
api
.get<{ data: { reasons: ReportReason[] } }>("/api/reports/reasons", {
...H,
params: targetType ? { targetType } : undefined,
})
.then((r) => r.data.data.reasons);

export async function report(body: {
targetType: ReportTargetType;
targetId: string;
reason: string;
details?: string;
alsoBlock?: boolean;
}): Promise<ReportResult> {
const { data } = await api.post("/api/reports", body, H);
return data.data as ReportResult;
}

export async function block(userId: string) {
await api.post(`/api/blocks/${userId}`, null, H);
}

export async function unblock(userId: string) {
await api.delete(`/api/blocks/${userId}`, H);
}

export async function listBlocked(cursor?: string) {
const { data } = await api.get("/api/blocks", { ...H, params: { cursor } });
return data.data as { items: BlockedUser[]; nextCursor: string | null };
}

// "Report and block" sheet — one call, then reconcile the block separately.
export async function reportAndBlock(
targetType: ReportTargetType,
targetId: string,
authorId: string,
reason: string,
details?: string,
) {
const r = await report({ targetType, targetId, reason, details, alsoBlock: true });
if (!r.blocked) {
// Report landed, block didn't. Retry just the block.
await block(authorId);
}
return r;
}

curl

# Report a post
curl -X POST http://localhost:3001/api/reports \
-H "Authorization: Bearer <access>" -H "X-Client-Type: mobile" \
-H "Content-Type: application/json" \
-d '{"targetType":"POST","targetId":"<postId>","reason":"BULLYING_HARASSMENT"}'

# Report a user and block them in one call
curl -X POST http://localhost:3001/api/reports \
-H "Authorization: Bearer <access>" -H "X-Client-Type: mobile" \
-H "Content-Type: application/json" \
-d '{"targetType":"USER","targetId":"<userId>","reason":"OTHER","details":"keeps messaging me","alsoBlock":true}'

# Block / unblock directly
curl -i -X POST http://localhost:3001/api/blocks/<userId> -H "Authorization: Bearer <access>"
curl -i -X DELETE http://localhost:3001/api/blocks/<userId> -H "Authorization: Bearer <access>"

# My block list
curl http://localhost:3001/api/blocks?limit=30 -H "Authorization: Bearer <access>"

9. Gotchas

  • You can report a user you already blocked — but not a post you can no longer see. The asymmetry is deliberate. Reporting someone after blocking them is the normal flow, so USER reports have no visibility gate. Content reports do, because post IDs are guessable enough to fish with. If you need "report the post too", collect the report before you block.
  • A blocked profile is a 404, not a 403. GET /api/users/:username now hides users on either side of a block. Render it as "unavailable" — never as "private", and never retry.
  • Most reports hide nothing — two do. Ordinary reasons leave the post in the feed until a human acts, no matter how many people report it. The two first-person reasons hide it on submit and tell you with autoHidden: true. Either way, remove the item from your local list yourself — your already- loaded copy does not vanish on its own.
  • Don't build a picker from a hard-coded list. GET /api/reports/reasons serves it, in order, with requiresDetails and targetTypes. A new reason should not need an app release.
  • There is no report history endpoint. Track "I reported this" locally. After a reinstall that state is lost — the server will tell you again via duplicate: true on the next attempt.
  • Never show the reporter's identity anywhere in the app. Reports stay invisible to the reported user by design, and nothing in the API exposes them. That still holds: even the removal email an author may receive quotes the reviewer's own note, never the reporter's words.
  • An author only hears from us when something was actually removed. If a person upholds a report and the content is hidden as a result, the author gets an email saying what was removed and why, and can reply to appeal. Upholding a report that hid nothing — an ordinary reason, or any group report — sends nothing at all. Nothing is sent at hide time either, and a restored item is silent. There is no in-app surface for any of this and nothing to poll — do not build a moderation inbox.
  • A hidden item is not deleted. It comes back if the report is rejected. Render a 404 on something the user just reported as "unavailable", not as "deleted", and refetch rather than caching the absence.
  • duplicate: true is not an error. It's a 200. Don't show a red toast.
  • Feed and repostedBy lag a block by up to 5 minutes. Both are cached. A just-blocked user can appear in an already-loaded feed page — drop them client-side after a block rather than waiting for the server.
  • Report before you block, if you're doing both by hand. alsoBlock on a single call is ordered correctly for you. Two separate calls are not — block first and the content report 404s.
⤓ Download .md