# Chat messages

Messages are filed under **topics** (just General for now), each with threads one level deep: a reply to a reply joins its root's thread, in its topic. Without a topic, a request is General's. Messages and replies arrive live as `chat` events on [the event stream](https://raresats.net/docs/site-feeds.md#live-events), each naming its topic. Where the AI moderation is on, every message is checked before it is posted: [Moderation](https://raresats.net/docs/guide-chat.md#moderation).

## List topics

`GET /api/chat/topics`

The live topics, in their order, each with its newest message's id and time: a page keeps the last id it read per topic and shows the others as unread. New and archived topics come as `chat` events `{type: "topic", topic, archived}`.

#### Example request

```bash title="cURL"
curl 'https://raresats.net/api/chat/topics'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/topics');
const data = await response.json();
```
```python title="Python"
import requests

data = requests.get("https://raresats.net/api/chat/topics").json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `topics` | array of object |  |
| `topics[].id` | string | 1-32 of a-z, 0-9 and inner hyphens. |
| `topics[].name` | string | 1-40 characters, plain text. |
| `topics[].description` | string | At most 140 characters, plain text; may be empty. |
| `topics[].position` | integer | Its place in the list. |
| `topics[].created_at` | integer | Unix seconds. |
| `topics[].last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. |
| `topics[].last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. |

```json title="Response"
{
  "topics": [
    {
      "id": "general",
      "name": "General",
      "description": "Anything rare-sat: say gm, ask, share a find.",
      "position": 0,
      "created_at": 1790900000,
      "last_message_id": 1044,
      "last_activity_at": 1790949460
    },
    {
      "id": "market",
      "name": "Market & prices",
      "description": "Listings, offers, floors and what things sell for.",
      "position": 1,
      "created_at": 1790900000,
      "last_message_id": 1042,
      "last_activity_at": 1790949400
    }
  ]
}
```

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |

## Create a topic (admin)

`POST /api/chat/topics`

An admin session creates a topic, last in the list. Only admins may: topic names are not run through the AI moderation (an admin's text never is), but follow a message's text rules (NFC, no control, zero-width or bidi characters) and its link rule. An id is never reused, an archived topic's included, except a retired one's (a topic the defaults once seeded and no longer name): it comes back as the new topic, its messages with it. At most 50 topics. Pages are told with a `chat` event `{type: "topic", topic, archived: false}`. Logged.

#### Request body

`content-type: application/json`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | 1-32 of a-z, 0-9 and inner hyphens; not `main`. |
| `name` | string | yes | 1-40 characters. |
| `description` | string \| null | no | At most 140 characters. |

#### Example request

```bash title="cURL"
curl -X POST 'https://raresats.net/api/chat/topics' \
  -H 'content-type: application/json' \
  -d '{
  "id": "runes",
  "name": "Runes",
  "description": "Runes on rare sats, and what they do to a sat'\''s value."
}'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/topics', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    "id": "runes",
    "name": "Runes",
    "description": "Runes on rare sats, and what they do to a sat's value."
  })
});
const data = await response.json();
```
```python title="Python"
import requests

data = requests.post("https://raresats.net/api/chat/topics", json={
    "id": "runes",
    "name": "Runes",
    "description": "Runes on rare sats, and what they do to a sat's value."
}).json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `topic` | object | A chat topic (a channel). Its id is its slug: the page is `/chat/<id>`. |
| `topic.id` | string | 1-32 of a-z, 0-9 and inner hyphens. |
| `topic.name` | string | 1-40 characters, plain text. |
| `topic.description` | string | At most 140 characters, plain text; may be empty. |
| `topic.position` | integer | Its place in the list. |
| `topic.created_at` | integer | Unix seconds. |
| `topic.last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. |
| `topic.last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_json` | The body is not valid JSON. |
| 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). |
| 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. |
| 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |
| 409 | `topic_exists` | A topic (or an archived one) has that id already. |
| 413 | `too_large` | The body is over 256 KiB. |
| 415 | `not_json` | A POST without `content-type: application/json`. |
| 422 | `bad_topic_text` | A topic's name must be 1-40 characters, its description at most 140. |
| 422 | `suspicious_link` | A link in the message names a host that is not plain ASCII: look-alike letters from other scripts, punycode (`xn--`), or `user@host`. Write the link with its real host name. |
| 422 | `too_many_topics` | At most 50 chat topics: archive one first. |
| 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. |

## Archive a topic (admin)

`DELETE /api/chat/topics/{id}`

An admin session archives a topic: it leaves the list and takes no new messages or replies (404 `unknown_topic`); its messages stay in the store. General cannot be archived. Pages are told with a `chat` event `{type: "topic", topic, archived: true}`. Logged.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | The topic's id. |

#### Example request

```bash title="cURL"
curl -X DELETE 'https://raresats.net/api/chat/topics/off-topic' -H 'cookie: __Host-rs_chat=<token>'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/topics/off-topic', { method: 'DELETE', credentials: 'include' });
const data = await response.json();
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `topic` | object | A chat topic (a channel). Its id is its slug: the page is `/chat/<id>`. |
| `topic.id` | string | 1-32 of a-z, 0-9 and inner hyphens. |
| `topic.name` | string | 1-40 characters, plain text. |
| `topic.description` | string | At most 140 characters, plain text; may be empty. |
| `topic.position` | integer | Its place in the list. |
| `topic.created_at` | integer | Unix seconds. |
| `topic.last_message_id` | integer \| null | The newest live message's id (a reply counts); null when it has none. A page that keeps the last id it read per topic shows a topic as unread when this is larger. |
| `topic.last_activity_at` | integer \| null | That message's time, unix seconds; null when it has none. |
| `archived` | `true` |  |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_path` | The path is not valid percent-encoding. |
| 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. |
| 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |
| 422 | `not_archivable` | General cannot be archived. |
| 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. |

## List messages

`GET /api/chat/messages`

A topic's messages (the threads' roots), newest first, with each author's current display name and each thread's reply count and last reply time. Without `topic`, General's (`main`, the one room before topics, names General too). Replies are on their thread: [List a thread](https://raresats.net/docs/chat-messages.md#list-a-thread). Page back with `before`. Messages are kept 30 days, at most 10,000 a topic.

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `topic` | string | no | A topic id ([List topics](https://raresats.net/docs/chat-messages.md#list-topics)); default `general`. Default: `general`. |
| `before` | integer | no | A message id: only older ones (the previous page's `next`). (min 1) |
| `limit` | integer | no | Messages per page, 1-100. Default: `50`. (min 1, max 100) |

#### Example request

```bash title="cURL"
curl 'https://raresats.net/api/chat/messages?topic=market&limit=50'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/messages?topic=market&limit=50');
const data = await response.json();
```
```python title="Python"
import requests

data = requests.get("https://raresats.net/api/chat/messages", params={"topic": "market", "limit": 50}).json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `topic` | string | The topic. |
| `room` | string | Deprecated: the same as `topic`. |
| `messages` | array of object | The topic's threads' roots, newest first (replies are on their thread). |
| `messages[].id` | integer | Increasing; the paging cursor. |
| `messages[].topic` | string | The topic it is in (a reply: its root's). |
| `messages[].room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). |
| `messages[].address` | string | The author. |
| `messages[].display_name` | string \| null | The author's display name now. |
| `messages[].admin` | boolean | The author is a chat admin. |
| `messages[].text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. |
| `messages[].created_at` | integer | Unix seconds. |
| `messages[].reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). |
| `messages[].reply_count` | integer | On a root: its live replies (0 on a reply). |
| `messages[].last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. |
| `messages[].quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. |
| `messages[].quote.id` | integer |  |
| `messages[].quote.address` | string |  |
| `messages[].quote.display_name` | string \| null |  |
| `messages[].quote.text` | string |  |
| `next` | integer \| null | Pass as `before` for the older page; null at the start. |

```json title="Response"
{
  "topic": "market",
  "room": "market",
  "messages": [
    {
      "id": 1042,
      "topic": "market",
      "room": "market",
      "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
      "display_name": "Rare Hunter",
      "admin": false,
      "text": "gm, any block 9 for sale?",
      "created_at": 1790949400,
      "reply_to": null,
      "reply_count": 2,
      "last_reply_at": 1790949460,
      "quote": null
    }
  ],
  "next": 1042
}
```

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_paging` | `limit` or `offset` out of range. |
| 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |

## List a thread

`GET /api/chat/messages/{id}/replies`

A thread: its root (with its reply count) and its replies, newest first, each with its author's current display name and the message it answered (`quote`). Page back with `before`. The id of a reply names its thread too. The answer names the thread's `topic`: a link to a message (`/chat/<topic>?m=<id>`) finds its page and thread from it.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes | The thread's root (or one of its replies). |

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `before` | integer | no | A reply id: only older ones (the previous page's `next`). (min 1) |
| `limit` | integer | no | Replies per page, 1-100. Default: `50`. (min 1, max 100) |

#### Example request

```bash title="cURL"
curl 'https://raresats.net/api/chat/messages/1042/replies?limit=50'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/messages/1042/replies?limit=50');
const data = await response.json();
```
```python title="Python"
import requests

data = requests.get("https://raresats.net/api/chat/messages/1042/replies", params={"limit": 50}).json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `topic` | string | The thread's topic (its root's): a link to a reply finds its page. |
| `room` | string | Deprecated: the same as `topic`. |
| `root` | object | A message, or a reply in a thread (one level: a reply to a reply joins its root's thread). |
| `root.id` | integer | Increasing; the paging cursor. |
| `root.topic` | string | The topic it is in (a reply: its root's). |
| `root.room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). |
| `root.address` | string | The author. |
| `root.display_name` | string \| null | The author's display name now. |
| `root.admin` | boolean | The author is a chat admin. |
| `root.text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. |
| `root.created_at` | integer | Unix seconds. |
| `root.reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). |
| `root.reply_count` | integer | On a root: its live replies (0 on a reply). |
| `root.last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. |
| `root.quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. |
| `root.quote.id` | integer |  |
| `root.quote.address` | string |  |
| `root.quote.display_name` | string \| null |  |
| `root.quote.text` | string |  |
| `messages` | array of object | The replies, newest first. |
| `messages[].id` | integer | Increasing; the paging cursor. |
| `messages[].topic` | string | The topic it is in (a reply: its root's). |
| `messages[].room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). |
| `messages[].address` | string | The author. |
| `messages[].display_name` | string \| null | The author's display name now. |
| `messages[].admin` | boolean | The author is a chat admin. |
| `messages[].text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. |
| `messages[].created_at` | integer | Unix seconds. |
| `messages[].reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). |
| `messages[].reply_count` | integer | On a root: its live replies (0 on a reply). |
| `messages[].last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. |
| `messages[].quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. |
| `messages[].quote.id` | integer |  |
| `messages[].quote.address` | string |  |
| `messages[].quote.display_name` | string \| null |  |
| `messages[].quote.text` | string |  |
| `next` | integer \| null | Pass as `before` for older replies; null at the start. |

```json title="Response"
{
  "topic": "market",
  "room": "market",
  "root": {
    "id": 1042,
    "topic": "market",
    "room": "market",
    "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
    "display_name": "Rare Hunter",
    "admin": false,
    "text": "gm, any block 9 for sale?",
    "created_at": 1790949400,
    "reply_to": null,
    "reply_count": 2,
    "last_reply_at": 1790949460,
    "quote": null
  },
  "messages": [
    {
      "id": 1044,
      "topic": "market",
      "room": "market",
      "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
      "display_name": null,
      "admin": false,
      "text": "I have two, 0.0021 each",
      "created_at": 1790949460,
      "reply_to": 1042,
      "reply_count": 0,
      "last_reply_at": null,
      "quote": {
        "id": 1042,
        "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
        "display_name": "Rare Hunter",
        "text": "gm, any block 9 for sale?"
      }
    }
  ],
  "next": null
}
```

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_paging` | `limit` or `offset` out of range. |
| 400 | `bad_path` | The path is not valid percent-encoding. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |

## Post a message

`POST /api/chat/messages`

Posts as the session's address (the `__Host-rs_chat` cookie of [Sign in](https://raresats.net/docs/chat-sessions.md#sign-in-to-chat)), in a topic (`topic`, default `general`) or, with `reply_to`, as a reply in its root's topic: one level of threads, so a reply to a reply joins its root's thread (and quotes the reply it answered). Plain text: normalized to NFC, control, zero-width and bidi characters dropped, line breaks made spaces; 1-500 characters. One message every 3 s and 20 a minute per address; the same text twice within 2 minutes is refused; the whole chat takes at most 5 messages a second (429 `room_busy`). A link must name a plain ASCII host (422 `suspicious_link`). Then, where the AI moderation is on (the session's `moderated`), the text is checked before it is posted: refused, 422 `moderated` with its `category`; when it cannot be checked, 503 `moderation_unavailable` and nothing is posted (try again); 3 refusals in 10 minutes mute the address for an hour, and 6 from one IPv4 address or IPv6 /48 within an hour pause that network (403 `muted`); at most 6 texts a minute are checked per address and per network (429 `rate_limited`). Delivered live as a `chat` event on `/api/events`, which names the topic; a reply's answer and event carry its root's new `thread` counts.

#### Request body

`content-type: application/json`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | string | yes | The message. |
| `topic` | string \| null | no | The topic's id; absent or null: `general`. With `reply_to` it may be left out; given, it must be the root's topic. |
| `reply_to` | integer \| null | no | Reply to this message (a root, or a reply: the reply joins its root's thread, in its topic). Absent or null: a new message in the topic. |

#### Example request

```bash title="cURL"
curl -X POST 'https://raresats.net/api/chat/messages' \
  -H 'content-type: application/json' \
  -d '{"text": "I have two, 0.0021 each", "topic": "market", "reply_to": 1042}'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/messages', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({"text": "I have two, 0.0021 each", "topic": "market", "reply_to": 1042})
});
const data = await response.json();
```
```python title="Python"
import requests

data = requests.post("https://raresats.net/api/chat/messages", json={
    "text": "I have two, 0.0021 each",
    "topic": "market",
    "reply_to": 1042
}).json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `message` | object | A message, or a reply in a thread (one level: a reply to a reply joins its root's thread). |
| `message.id` | integer | Increasing; the paging cursor. |
| `message.topic` | string | The topic it is in (a reply: its root's). |
| `message.room` | string | Deprecated: the same as `topic` (the one room before topics was `main`, now `general`). |
| `message.address` | string | The author. |
| `message.display_name` | string \| null | The author's display name now. |
| `message.admin` | boolean | The author is a chat admin. |
| `message.text` | string | Plain text (NFC, no control, zero-width or bidi characters, one line, 1-500 characters). Show it as text, never as HTML. |
| `message.created_at` | integer | Unix seconds. |
| `message.reply_to` | integer \| null | The thread's root, on a reply; null on a root (a message of the topic). |
| `message.reply_count` | integer | On a root: its live replies (0 on a reply). |
| `message.last_reply_at` | integer \| null | On a root: its newest reply's time, unix seconds; null without replies. |
| `message.quote` | object \| null | On a reply: the message it answered (the root, or a reply in the thread), while it is live; text only, cut at 140 characters. Null on a root. |
| `message.quote.id` | integer |  |
| `message.quote.address` | string |  |
| `message.quote.display_name` | string \| null |  |
| `message.quote.text` | string |  |
| `thread` | object | On a reply: its root's thread now. |
| `thread.id` | integer | The root. |
| `thread.reply_count` | integer |  |
| `thread.last_reply_at` | integer \| null |  |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_json` | The body is not valid JSON. |
| 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). |
| 400 | `bad_topic` | A topic id that is not one: 1-32 of a-z, 0-9 and inner hyphens (and, to create one, not `main`). On a reply: a `topic` other than its root's. |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `banned` | The address is banned from chat: it may not post or set a display name. |
| 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. |
| 403 | `muted` | The address is muted (by the AI moderation after repeated refusals: 3 in 10 minutes), or its network (an IPv4 address or IPv6 /48) is paused after 6 refusals within an hour, until `until`, unix seconds: it may not post or set a display name until then. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. |
| 404 | `unknown_topic` | No such chat topic, or an archived one (which takes no new messages). |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |
| 409 | `duplicate` | The same message (ignoring case) from the same address within 2 minutes. |
| 413 | `too_large` | The body is over 256 KiB. |
| 415 | `not_json` | A POST without `content-type: application/json`. |
| 422 | `empty_text` | Nothing is left of the message once control, zero-width and bidi characters are dropped and spaces collapsed. |
| 422 | `moderated` | The AI moderation refused the chat message or display name; `category` says why (the message is not posted, the name not taken). Three refusals in 10 minutes mute the address for an hour (`until`). |
| 422 | `suspicious_link` | A link in the message names a host that is not plain ASCII: look-alike letters from other scripts, punycode (`xn--`), or `user@host`. Write the link with its real host name. |
| 422 | `text_too_long` | A chat message is at most 500 characters. |
| 429 | `chat_busy` | Too many chat messages and names are being checked across the site right now (over the global cap of model calls a minute); wait `retry-after` seconds. |
| 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. |
| 429 | `room_busy` | Over 5 chat messages a second across the whole chat; wait `retry-after` seconds. |
| 503 | `moderation_unavailable` | The AI moderation could not check the text right now (timeout or model error): nothing was posted. Try again in a moment. |

```json title="Error (503)"
{
  "error": "Couldn't check your message right now: try again in a moment",
  "code": "moderation_unavailable"
}
```

## Delete a message (admin)

`DELETE /api/chat/messages/{id}`

An admin session deletes a message. Deleting a thread's root deletes its replies too (`deleted` lists them all). Pages are told with a `chat` event `{type: "delete", ids, threads, topics}`. Logged.

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes | The message id. |

#### Example request

```bash title="cURL"
curl -X DELETE 'https://raresats.net/api/chat/messages/1042' -H 'cookie: __Host-rs_chat=<token>'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/messages/1042', { method: 'DELETE', credentials: 'include' });
const data = await response.json();
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `deleted` | array of integer |  |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_path` | The path is not valid percent-encoding. |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. |
| 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 404 | `not_found` | No such listing or offer. In chat: no such message. A display name to remove that does not exist. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |
| 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. |

## List bans (admin)

`GET /api/chat/bans`

The banned addresses, newest first. An admin session only.

#### Example request

```bash title="cURL"
curl 'https://raresats.net/api/chat/bans'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/bans');
const data = await response.json();
```
```python title="Python"
import requests

data = requests.get("https://raresats.net/api/chat/bans").json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `bans` | array of object |  |
| `bans[].address` | string |  |
| `bans[].reason` | string \| null |  |
| `bans[].banned_by` | string | The admin's address, or `moderation` for an automatic mute. |
| `bans[].created_at` | integer |  |
| `bans[].expires_at` | integer \| null | When a mute ends, unix seconds; null: banned until unbanned. |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |

## Ban or unban (admin)

`POST /api/chat/bans`

An admin session bans (`banned: true`) or unbans an address. A banned address cannot post or set a display name, and a ban removes its display name for good. `delete_recent` also deletes its messages of the last 24 hours. Logged.

#### Request body

`content-type: application/json`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `address` | string | yes |  |
| `banned` | boolean | yes |  |
| `reason` | string \| null | no | At most 200 characters. |
| `delete_recent` | boolean | no | Also delete its messages of the last 24 hours. |

#### Example request

```bash title="cURL"
curl -X POST 'https://raresats.net/api/chat/bans' \
  -H 'content-type: application/json' \
  -d '{
  "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
  "banned": true,
  "reason": "spam",
  "delete_recent": true
}'
```
```js title="JavaScript"
const response = await fetch('https://raresats.net/api/chat/bans', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
    "banned": true,
    "reason": "spam",
    "delete_recent": true
  })
});
const data = await response.json();
```
```python title="Python"
import requests

data = requests.post("https://raresats.net/api/chat/bans", json={
    "address": "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
    "banned": True,
    "reason": "spam",
    "delete_recent": True
}).json()
```

#### Response `200`

| Field | Type | Description |
| --- | --- | --- |
| `address` | string |  |
| `banned` | boolean |  |
| `ban` | object \| null |  |
| `deleted` | array of integer | The messages deleted with it. |

#### Errors

`{error, code}`; see [Errors](https://raresats.net/docs/errors.md) for every code.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `bad_address` | An address is not a mainnet address (or not one at all). |
| 400 | `bad_json` | The body is not valid JSON. |
| 400 | `bad_request` | The body is not a JSON object, or the engine refused a malformed request (e.g. a purchase submitted without its signed setup PSBT). |
| 401 | `no_session` | No chat session: sign in (`POST /api/session`) and send its cookie. |
| 403 | `cross_origin` | A POST from a browser on another site (a foreign `Origin`, or `sec-fetch-site: cross-site`). Server-side clients send no `Origin` and are not affected. |
| 403 | `not_admin` | Only a chat admin (`SUPPLY_CHAT_ADMINS`) may do that. |
| 404 | `chat_disabled` | Chat and display names are off on this server. |
| 405 | `method_not_allowed` | Wrong method for the route; the `allow` header lists the right ones. |
| 413 | `too_large` | The body is over 256 KiB. |
| 415 | `not_json` | A POST without `content-type: application/json`. |
| 422 | `not_bannable` | An admin cannot be banned. |
| 429 | `rate_limited` | Over 30 POSTs a minute from your IPv4 address or IPv6 /64 (in chat, counted per IPv4 address or IPv6 /64: 30 writes and 20 login nonces a minute, 20 sign-ins and 10 display-name changes an hour; one message every 3 s and 20 a minute per address); wait `retry-after` seconds. With the AI moderation on, at most 6 messages and names a minute are checked per address and per IPv4 address or IPv6 /48. |
