# Reports, blocks and bans

Users block and report each other. You and your moderators ban users and IP ranges.

## Before you start

- For blocks: the `policy` row from the user's role to the peer's role has `block` on. `Conversation.can.block` shows it to a client.
- For bans from your server: your server secret and app id, and the user's `external_id` or an IP range. See [Sessions and tokens](/guides/sessions-and-tokens/).
- Set `links.support_email` if you want banned users to see how to reach you.

```sh
export MC=https://app.mondochat.io/v1
export SESSION=<session token>
auth=(-H "Authorization: Bearer $MONDOCHAT_SERVER_SECRET" -H "Mondochat-App-Id: $MONDOCHAT_APP_ID" -H 'content-type: application/json')
```

## Steps

1. Block and unblock a user. `user_id` is the peer's `id` in `Peer`, not the external id. Both calls answer 204.

   ```sh
   curl -X PUT $MC/blocks/$USER_ID -H "Authorization: Bearer $SESSION"
   curl -X DELETE $MC/blocks/$USER_ID -H "Authorization: Bearer $SESSION"
   ```

   A block applies both ways for delivery: messages, calls, photos and presence stop in both directions. `call_ended` and `purchase` cards still arrive. A block also clears the blocker's waiting state in that conversation, and sends your server the `block.created` or `block.removed` webhook with `{blocker, blocked}`.

2. List what a user blocked with `GET /v1/blocks?cursor=...&limit=200`. It answers `{items: [{user, created_at}], next_cursor}`, newest first. Deleted users are left out and the `Peer` has no presence. Another user's blocks are never listed.

3. Decide what a blocked sender sees. By default the sender is not told:

   - The send succeeds for the sender, the message is stored as `undelivered`, and the peer receives nothing and gets no unread count.
   - A blocked reaction is stored and shown only to its author.
   - The blocker who writes to someone they blocked gets 403 `forbidden` with reason `you_blocked`.

   Set `chat.refuse_blocked_send` to `true` to refuse such sends instead, with 403 `forbidden` and reason `undelivered`, and store nothing. It applies to text, photos, videos, voice notes and reactions.

4. Let users report. Send `POST /v1/reports` with `{user_id, conversation_id?, reason}`. It answers 202.

   ```sh
   curl -X POST $MC/reports -H "Authorization: Bearer $SESSION" -H 'content-type: application/json' \
     -d "{\"user_id\": \"$USER_ID\", \"conversation_id\": \"$ID\", \"reason\": \"spam\"}"
   ```

   `reason` is free text of 1 to 1000 characters and is stored as sent. The frame offers `spam`, `harassment`, `inappropriate`, `scam`, `underage` and `other`; your own client may send any text. The reported user must be one the reporter has a conversation with, and `conversation_id`, when given, must be that conversation. A report about a conversation peer opens a moderation case of kind `report` and sends the `report.created` webhook with `{report_id, reporter, target, reason}`. A second report from the same user about the same target while the first is open is a duplicate: there is still one open report. With `safety.report_reasons` off, the frame asks for no reason and reports with reason `other`.

5. Ban from your server with `POST /v1/server/bans`. Ban a user by `external_id`, or an IP range by `cidr` (IPv4 or IPv6). `reason` is required. `expires_at` is optional. Send an `Idempotency-Key`.

   ```sh
   curl -X POST $MC/server/bans "${auth[@]}" -H 'Idempotency-Key: ban-carol-1' \
     -d '{"external_id": "carol", "reason": "spam", "expires_at": "2026-12-31T00:00:00Z"}'
   curl -X POST $MC/server/bans "${auth[@]}" -d '{"cidr": "203.0.113.0/24", "reason": "abuse"}'
   ```

   ```js
   const res = await fetch('https://app.mondochat.io/v1/server/bans', {
     method: 'POST',
     headers: {
       authorization: `Bearer ${process.env.MONDOCHAT_SERVER_SECRET}`,
       'mondochat-app-id': process.env.MONDOCHAT_APP_ID,
       'idempotency-key': 'ban-carol-1',
       'content-type': 'application/json',
     },
     body: JSON.stringify({ external_id: 'carol', reason: 'spam' }),
   })
   const ban = await res.json()
   ```

   The answer is a `Ban`: `{id, user_id, external_id, cidr, reason, expires_at, created_by, created_by_name, created_at, lifted_at}`. For a ban made through the server API, `created_by` and `created_by_name` are null. A ban sends the `ban.created` webhook.

6. Lift a ban from your server with `DELETE /v1/server/bans/{id}`. It answers 204 and sends `ban.lifted`.

   ```sh
   curl -X DELETE $MC/server/bans/$BAN_ID "${auth[@]}"
   ```

7. Ban from the dashboard. Moderators list bans, create them with `{user_id?, cidr?, reason, expires_at?}`, change `expires_at` (or clear it with null) and lift them. The bans list shows who made each ban, and "API" for those from your server.

8. Know where bans bite. A ban is checked at session exchange, refresh, conversation open, message send, call start, call token and upload. A new ban on a user publishes `session.revoked` to that user with `{reason: "banned", temporary}`, so a connected client learns at once. `temporary` is true when the ban expires. Lookups are cached for 60 seconds per user and per IP, so a new ban can take up to a minute to reach every server.

## Check it works

1. Ban a user with `POST /v1/server/bans`. The answer has `lifted_at: null`.
2. Send a message as that user. The answer is 403 `banned`. With an `expires_at`, the problem carries `reason: "temporary"`.
3. Lift the ban. The same user can send again.

## When it fails

| Answer | Cause |
|---|---|
| 403 `banned` | The user or their IP is banned. `detail` is your `links.support_email` if you set one. `reason` is `temporary` for a ban with an expiry, and absent otherwise. It never carries the expiry or the kind of ban. |
| 403 `policy_denied` with reason `block` | `PUT /v1/blocks/{user_id}` and the policy row from the user's role to the peer's has `block` off. |
| 404 `not_found` | A block or report names a user that is not in your app, or a report names a user the reporter has no conversation with. A server ban names an `external_id` that is not a user of your app. |
| 422 | A block or report names the caller, a ban gives both or neither of `external_id` and `cidr`, or a body field is invalid. |
| 403 `forbidden` with reason `you_blocked` | The user blocked the peer they are writing to. |
| 403 `forbidden` with reason `undelivered` | The peer blocked the sender and `chat.refuse_blocked_send` is on. |
| 409 `idempotency_conflict` | The same `Idempotency-Key` was sent with a different body. |
| 401 | The server secret or app id is wrong. |

## Limits

- An expired or lifted ban is not a ban.
- Blocked recipients are not an error unless you turn on `chat.refuse_blocked_send`.
- A repeat of a server ban call with the same `Idempotency-Key` within 24 hours returns the first answer with `Idempotent-Replayed: true`.
- There are no automatic bans. A moderator or your server makes each one.
- The client IP is the viewer's address as the edge reports it. `X-Forwarded-For` is ignored.

## Related topics

- [Create a server ban](/api/operations/create-server-ban/), [lift a server ban](/api/operations/lift-server-ban/), [block](/api/operations/block/), [unblock](/api/operations/unblock/), [list blocks](/api/operations/list-blocks/), [report](/api/operations/report/)
- [ban.created webhook](/api/webhooks/ban-created/), [ban.lifted webhook](/api/webhooks/ban-lifted/), [block.created webhook](/api/webhooks/block-created/), [report.created webhook](/api/webhooks/report-created/)
- [Moderation](/guides/moderation/)
- [Settings reference](/reference/settings/) for `chat.refuse_blocked_send`, `safety` and `links.support_email`
- [Errors](/errors/)