# Messages

## Before you start

- A conversation exists. See [Conversations](/guides/conversations/).
- User routes take `Authorization: Bearer <session token>`. Server routes take your server secret and the app id; see [Sessions and tokens](/guides/sessions-and-tokens/).
- The `policy` row from the sender's role to the peer's role has `message` on. Your `limits` bound `messages_per_minute` (30 by default) and `messages_to_unanswered`.

```sh
export MC=https://app.mondochat.io/v1
export SESSION=<session token>
export ID=<conversation id>
```

## Steps

1. Send a message with `POST /v1/conversations/{id}/messages`. Generate `client_msg_id` on the client for every message.

   ```sh
   curl -X POST $MC/conversations/$ID/messages -H "Authorization: Bearer $SESSION" \
     -H 'content-type: application/json' \
     -d '{"client_msg_id": "7d6f3c0e-2b0e-4f5e-9a55-0e3c1a1f6a01", "kind": "text", "text": "Hello"}'
   ```

   ```js
   const res = await fetch(`https://app.mondochat.io/v1/conversations/${id}/messages`, {
     method: 'POST',
     headers: { authorization: `Bearer ${session}`, 'content-type': 'application/json' },
     body: JSON.stringify({ client_msg_id: crypto.randomUUID(), kind: 'text', text: 'Hello' }),
   })
   const message = await res.json()
   ```

   The answer is 201 with the `Message`. Send the same `client_msg_id` again in the same conversation and you get 200 with the stored message and nothing else happens, so a retry after a timeout never duplicates it. Text is limited by `chat.text_max_length` (4000 by default); longer text answers 422.

   The user route accepts only `kind` `text` and `photo`. Video and voice go through [Media](/guides/media/). Cards and system messages come from your server, see step 10.

2. Quote a message with `reply_to_id`. It must be a message of this conversation that the sender can see and that is not deleted or redacted, else 422. Every `Message` carries `reply_to: {id, sender_id, kind, preview} | null`. The preview is the quoted text up to 140 characters. `reply_to` is null once the quoted message is deleted, redacted or not visible to the viewer.

3. Mark a send as forwarded with `"forwarded": true`. The server stores the claim and checks nothing else. To forward for real, use `POST /v1/conversations/{id}/forward`, where `{id}` is the target conversation:

   ```sh
   curl -X POST $MC/conversations/$TARGET/forward -H "Authorization: Bearer $SESSION" \
     -H 'content-type: application/json' \
     -d '{"message_id": "<source message id>", "client_msg_id": "<new uuid>"}'
   ```

   The copy has `forwarded: true`, no `reply_to`, and is never view once. Media is copied inside storage and never downloaded. The source must be a message of your app, in a conversation the caller is in, that is sent, not deleted, not redacted and not hidden by the caller. Anything else answers 404. A view-once message, a card or a system message answers 403 `policy_denied` with reason `forward`. The same gates as a send or an upload apply, and `client_msg_id` dedupes as on send.

4. Edit with `PATCH /v1/conversations/{id}/messages/{message_id}` and `{"text": "..."}`. Only the sender's own text message, not held, deleted or redacted, and only within `chat.edit_window_seconds` (172800, two days, by default). Outside the window, or with the window at 0, the answer is 403 `policy_denied` with reason `edit`. The edit sets `edited_at`, publishes `message.updated` and is moderated again.

5. Delete with `DELETE /v1/conversations/{id}/messages/{message_id}`.

   | `scope` | Effect |
   |---|---|
   | `me` (default) | Hides the message from the viewer's own lists and inbox preview. The peer still sees it. |
   | `everyone` | The sender's own non-card message, within `chat.unsend_window_seconds` (172800 by default). It becomes a tombstone with `deleted: true` and no text, caption, photo, card or quote, leaves the peer's unread count and publishes `message.updated`. Outside the window, or with the window at 0, the answer is 403 `policy_denied` with reason `unsend`. |

   Both answer 204.

6. React with `PUT /v1/conversations/{id}/messages/{message_id}/reaction` and `{"emoji": "👍"}`. A user has one reaction per message, and a second `PUT` replaces it. `DELETE` on the same path removes it and answers 204, also when there was none. `emoji` is one grapheme cluster of at most 48 UTF-8 bytes, else 422. You can react only to a message you list that is not deleted, redacted, held or rejected, and a view-once message only after you opened it; anything else answers 404. A reaction is a write to the peer, so your policy `message` and your message limits apply to it. `Message.reactions` is `[{emoji, count, mine}]`, and a change publishes `message.updated`.

7. Show typing with `POST /v1/conversations/{id}/typing`. It answers 204 and stores nothing. Send it at most every 3 seconds and show it for 5.

8. Report delivery and reads.

   - `POST /v1/conversations/{id}/delivered` with `{message_id}` moves the delivered pointer forward and publishes `delivered.updated` to the peer when it moved.
   - `POST /v1/conversations/{id}/read` with `{message_id}` moves the read pointer forward, never back, and answers `{unread_count}`. `read.updated` goes to the peer only while both users have `read_receipts` on.
   - `GET /v1/unread` answers `{count}` for the whole inbox.
   - `GET /v1/conversations/{id}/messages/{message_id}/info` answers `{delivered_at, read_at}` to the sender of that message. `read_at` is null while either user has `read_receipts` off. Anyone else gets 404.

   From your own server, `POST /v1/server/conversations/{id}/delivered` with `{external_id, message_id}` does the same as the user route for that user, with no session. Use it when your push or email channel confirms delivery. It answers 204, and 404 for an unknown user, a user who is not in the conversation, another app's conversation, or a message the user cannot see.

   ```sh
   curl -X POST $MC/server/conversations/$ID/delivered \
     -H "Authorization: Bearer $MONDOCHAT_SERVER_SECRET" -H "Mondochat-App-Id: $MONDOCHAT_APP_ID" \
     -H 'content-type: application/json' -d '{"external_id": "bob", "message_id": "<message id>"}'
   ```

9. List messages with `GET /v1/conversations/{id}/messages`. The answer is `{items, next_cursor, latest_seq}`, newest first.

   | Query | Result |
   |---|---|
   | `before=<cursor>&limit=<n>` | The next older page. `limit` is at most 50. |
   | `around=<message_id>` | That message, up to `limit / 2` older ones and the rest newer. 404 when the viewer cannot see it. |
   | `after=<message_id>` | Newer messages, oldest first. Pass `next_cursor` back as `after`. |
   | `q=<text>` | Visible messages whose text or caption contains it, ignoring case, newest first. 1 to 100 characters after trimming, else 422. Not with `around` or `after`. |

   Search never matches a deleted, redacted or hidden message, another user's held or undelivered message, or a view-once caption.

10. Post a system message or a card from your server with `POST /v1/server/messages`. `from` and `to` are external ids and pick the existing conversation, or 404 when they have none. A `system` message has no sender. A `card` is sent by `from` and carries `{type, data}`. Send an `Idempotency-Key`.

   ```sh
    curl -X POST $MC/server/messages \
      -H "Authorization: Bearer $MONDOCHAT_SERVER_SECRET" -H "Mondochat-App-Id: $MONDOCHAT_APP_ID" \
      -H 'Idempotency-Key: welcome-alice-bob' -H 'content-type: application/json' \
      -d '{"from": "alice", "to": "bob", "kind": "system", "text": "Welcome to the chat"}'
    ```

    Users never post cards. Offers go through the offers route and call cards come from calls; see [Commerce](/guides/commerce/) and [Calls](/guides/calls/). The `Card` types are `call_invite`, `call_ended`, `call_missed`, `purchase`, `offer`, `system` and `signup_invite`.

## Check it works

1. Send a message and read the answer. `status` is `sent` and `seq` is set.
2. Send it again with the same `client_msg_id`. The answer is 200 and the `id` is the same.
3. List the conversation as the peer. The message is there, `read_by_peer` is false, and `unread_count` in the inbox is 1.
4. Mark it read as the peer. The sender gets `read.updated` while both users have `read_receipts` on.

## When it fails

| Answer | Cause |
|---|---|
| 403 `forbidden` with reason `you_blocked` | The sender blocked the peer. |
| 403 `forbidden` with reason `undelivered` | The peer blocked the sender and `chat.refuse_blocked_send` is on. Nothing is stored. |
| 403 `policy_denied`, reason `message` | The policy row from the sender's role to the peer's has `message` off. This covers reactions too. |
| 403 `policy_denied`, reason `photos` | A photo send, with `chat.photos` off or the policy row's `photos` off. |
| 403 `policy_denied`, reason `inbound` | `workspace.inbound` is on, the sender is a guest and the peer takes messages from members only. |
| 403 `policy_denied`, reason `edit`, `unsend` or `forward` | The edit or unsend window is closed or set to 0, or the message cannot be forwarded. |
| 403 `limit_reached`, reason `messages_to_unanswered` or `photos_per_day` | The role's limit in `limits` is reached. |
| 429 `rate_limited` | `limits.messages_per_minute` is reached, or the app's request rate. Wait `Retry-After`. |
| 404 | The message is not visible to the caller, or the caller is not a participant. |
| 422 | Text over `chat.text_max_length`, a bad `reply_to_id`, a bad emoji, or a malformed query. |

## Limits

- A blocked recipient is not an error. By default the send succeeds for the sender, is stored as `undelivered`, and nothing reaches the peer. The sender sees one tick forever on it. Turn on `chat.refuse_blocked_send` to refuse it instead.
- Receipts: while either user has `read_receipts` off, `read_by_peer` is false for both and no `read.updated` is published. `delivered_to_peer` still counts a message the peer read.
- Held messages (moderation `hold`, or photo consent) are visible to the sender only. A moderation `block` redacts the message: `redacted` is true and the text is gone.
- Every message has a `seq` that orders it inside its conversation. Keep the copy with the higher `seq`. See [ordering](/realtime/ordering/).
- Webhook bodies carry `text` only with `webhooks.message_text`.

## Related topics

- [Send a message](/api/operations/send-message/), [edit](/api/operations/edit-message/), [delete](/api/operations/delete-message/), [forward](/api/operations/forward-message/), [list messages](/api/operations/list-messages/)
- [Set a reaction](/api/operations/set-reaction/), [mark read](/api/operations/mark-read/), [message info](/api/operations/message-info/), [post a server message](/api/operations/post-server-message/)
- [message.created webhook](/api/webhooks/message-created/)
- [Settings reference](/reference/settings/) for `chat`, `limits` and `policy`
- [Errors](/errors/)