Skip to main content
Version: Latest

Mobile Content Moderation — Integration Guide

Blocked-word filtering is no longer chat-only. Every request body and query string the app sends is now checked server-side, so any screen with a text input can come back with a 422. One rejection shape, one handler. Chat's existing behaviour is unchanged — see mobile-chat-moderation-guide.md for the composer-specific detail.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> on every endpoint that had it before

What's new

  • 2026-09-07 — Blocked-word checking now covers every text field in the app, not just chat. Posts, comments, events, your profile, activities, achievements, school submissions, onboarding and the search box can all return 422 CONTENT_BLOCKED with a field telling you which input to highlight. Chat keeps MESSAGE_BLOCKED exactly as before. Report descriptions are deliberately never filtered.

How it works in one paragraph

The server runs one gate in front of every route. It walks the whole request body (including nested objects and arrays) and the query string, and rejects the request if any user-typed value trips the blocklist. There is nothing to opt into and no new endpoint — existing endpoints simply gained a rejection case. The client-side list you already ship for the chat composer is still UX only; the server is the boundary and always re-checks.


The rejection shape

HTTP 422 Unprocessable Content — the request is well-formed, its content violates policy (not a 400 malformed request).

{
"success": false,
"error": "Please use kinder words.", // display-ready, kid-appropriate
"code": "CONTENT_BLOCKED", // switch on this, not the status code
"reason": "PROFANITY", // may gain values later
"field": "data.profile.bio", // dotted path into YOUR request body
}

field is the path of the offending value inside the request you sent — "content", "title", "media[1].altText", "data.profile.bio", or "q" for a query string. Use it to focus and outline the right input. It is the only new property; everything else matches the chat contract you already handle.

Three codes exist. They differ only in copy — treat all three the same way:

CodeWhere
MESSAGE_BLOCKEDPOST /api/chat/conversations/:id/messages, PATCH /api/chat/messages/:messageId, POST /api/posts/:id/requests
SCHOOL_NAME_BLOCKEDPOST /api/schools
CONTENT_BLOCKEDeverywhere else

If you only handle 422 generically, you are already correct. The two older codes are byte-identical to what shipped before (same status, code, reason and copy — field is purely additive), and CONTENT_BLOCKED only appears on endpoints that never returned a 422 at all. A screen with no special case falls through to its normal error path and shows error, which is written to be displayed as-is.

There is one more reason you can receive: UNSCANNABLE (no field). It means the payload was too deep or too large for the server to inspect and was rejected rather than waved through. A normal screen cannot produce it — if you see it, you are sending something pathological. Treat it as a bug in the request.


Where it applies

Everything a user types, in the body or the query string:

SurfaceFields
POST /api/posts, PATCH /api/posts/:idtitle, content, location, details
POST /api/posts/:id/commentscontent
POST /api/events, PATCH /api/events/:idtitle, description, location
PATCH /api/users/me/profiledisplayName, bio, location
POST /api/auth/registerusername, displayName
POST/PATCH /api/users/me/activities, …/achievements, …/educationtitle, role, description, typeOther, shareCaption, schoolName, grade, city
POST /api/onboarding/session, PATCH …, POST …/completethe whole nested data blob
POST /api/chat/conversations, PATCH /api/chat/conversations/:idgroup name
POST /api/schoolsname, state, district
GET /api/search, mention search, school search, GIF searchq
any media payloadaltText

Search is gated too. A blocked query returns 422 instead of an empty result set — show the message rather than "no results".

Never moderated

  • Passwords (password, newPassword) and the login identifier. A password containing an awkward substring still works, and an existing account whose username predates this gate can still sign in.
  • Emails, OTP codes, tokens, cursors, storage keys, file names, ids, slugs, URLs and timestamps. Machine strings, not prose.
  • Path parameters. GET /api/users/:username is a read and is never gated.
  • POST /api/reportsdetails. Deliberate: reporting bullying means quoting it. Never filter the report box in your UI either — let the child type what happened. Everything else about the endpoint is unchanged (auth, rate limit, 500-char cap).

What to build

  1. One shared 422 handler. On status === 422 with a code ending in _BLOCKED, show error and, if field is present, focus that input.
  2. Keep the composer pre-check you already have for chat, and reuse the same helper on the other big text inputs (post body, comment, bio, event description) so the user gets instant feedback before the round trip. Copy the CLIENT_BLOCKED_TERMS block and matcher from mobile-chat-moderation-guide.md — it is the same list, it is not chat-specific.
  3. Roll back optimistic UI. A blocked request never reaches the handler: nothing is written, no socket event fires, no rate-limit budget is spent.

Don't

  • Don't rely on the client check. It is a courtesy; the server 422 is the boundary.
  • Don't echo the blocked word back to the user or into analytics. The response deliberately never tells you which word tripped.
  • Don't reconstruct the server list. You only ever hold the small superficial one.
  • Don't pre-filter the report box.

One ordering detail

401 wins. On any authenticated endpoint, a request with no Authorization header gets the normal 401 — never a 422 — so a signed-out user is told to sign in, not told to watch their language. The gate only ever fires for a caller who presented a token.

The exception is the endpoints that are public by design — POST /api/auth/register and the onboarding routes. Those are how an unauthenticated user submits text (username, displayName, the onboarding data blob), so they stay gated with no token and can return 422.


Reference

⤓ Download .md