# Texting API

**Author**: Josh S. Sakweli, Backend Lead Team
**Last Updated**: 2026-08-26
**Version**: v1.0

**Base URL**: `http://localhost:8765/api/v1` (local) — `server.port=8765`
**XMPP**: JID domain `nexgate.tz`, client port `5222` (STARTTLS)

**Short Description**: How to build the messaging screens — one-to-one chats,
group chats, secret (encrypted) chats, shop threads and the offers that live
inside them. This is a **teaching guide as well as a reference**: it assumes you
have never built a chat system before, and it introduces XMPP as it goes. The
single thing to understand before anything else is that **messaging spans two
channels**: REST manages conversations, and XMPP carries the messages. There is
almost no REST endpoint that sends a message.

**Scope — in**: personal DMs, groups, secret chat, commerce (shop) threads, and
offers. These are one feature with one message table, one sync cursor and one
set of rules; splitting them across documents would mean repeating all of it
four times.

**Scope — out**: calls, meetings, live streaming and Spaces. Those are media
features that merely *end* in a chat message, and they have their own documents
(`calls_meetings_api_doc.md`, `live_streaming_api_doc.md`, `spaces_api_doc.md`).

**Hints**:
- Every REST endpoint needs `Authorization: Bearer <accessToken>`.
- **You hold two tokens, not one.** The `accessToken` is for REST; the
  `xmppToken` is the password your XMPP client logs in with. They are signed
  with different keys, and **the XMPP one expires in 24 hours** — a quarter the
  life of the other. Refresh it on a schedule, not on failure.
- **The stanza `id` you send MUST be a UUID you generated.** It becomes the
  message's primary key. Get this wrong and deduplication and read receipts both
  fail silently — no error is logged anywhere.
- **A client can only ever create one message type: `TEXT`.** `PRODUCT_REF`,
  `ORDER_REF`, `OFFER_REF`, `CALL_EVENT`, `GROUP_EVENT` and
  `CONVERSATION_EVENT` are written by the server and are read-only to you.
  See [The stanza reference](#stanza-reference).
- **The four conversation types share everything except how you address them.**
  One history endpoint, one sync cursor, one chat list. What differs is the JID
  you send to and who is allowed to speak.
- **Never poll.** Sync on cold start, reconnect, push and foreground — not on a
  timer.
- Coded refusals are `409` with the code in `data.code`. Permission failures are
  `403`. See [Refusal codes](#refusals).

---

## Standard Response Format

### Success

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Conversation ready",
  "action_time": "2026-08-26T10:30:45",
  "data": { }
}
```

The payload you care about is always under `data`. **Every JSON sample in this
document shows the contents of `data` only**, unless the envelope is the point.

### Failure

```json
{
  "success": false,
  "httpStatus": "FORBIDDEN",
  "message": "Not a participant of this conversation",
  "action_time": "2026-08-26T10:30:45",
  "data": "Not a participant of this conversation"
}
```

### Coded refusals — note the shape

A refusal the client is expected to *handle* (rather than a bug) comes back as
`409 CONFLICT` with a machine-readable code:

```json
{
  "success": false,
  "httpStatus": "CONFLICT",
  "message": "Only the sender can delete a message for everyone",
  "action_time": "2026-08-26T10:30:45",
  "data": { "code": "NOT_SENDER",
            "reason": "Only the sender can delete a message for everyone" }
}
```

**Branch on `data.code`, never on `message`.** The prose is written for a human
and will change; the code will not.

### Standard error types

| Status | When it happens in this API |
|---|---|
| `400 BAD_REQUEST` | Malformed body, unparseable UUID, missing required parameter |
| `401 UNAUTHORIZED` | Access token empty, invalid, expired or malformed |
| `403 FORBIDDEN` | **Not a participant of the conversation** — the common one here |
| `404 NOT_FOUND` | Account or conversation does not exist |
| `409 CONFLICT` | A coded refusal — read `data.code` |
| `422 UNPROCESSABLE_ENTITY` | Field validation failed; `data` is a field→message map |
| `429 TOO_MANY_REQUESTS` | Rate limited. **Honour the `Retry-After` header**, which is also repeated in the body |
| `500 INTERNAL_SERVER_ERROR` | Unexpected server error |

---

<a name="two-ideas"></a>
## The two ideas that decide everything

Read this before writing code. Every mistake this system tends to produce comes
from skipping it.

### Idea 1 — there are two channels, and only one carries messages

Most backends you have used are one channel: call REST, get data. This one is
two, and they do different jobs.

```
   ┌──────────────────────────────────────────────────────────────┐
   │                        YOUR APP                              │
   └───────────────┬──────────────────────────┬───────────────────┘
                   │                          │
        REST over HTTPS               XMPP over a socket
        (request → response)          (open, always connected)
                   │                          │
                   ▼                          ▼
        ┌────────────────────┐      ┌────────────────────┐
        │   Spring Boot      │      │     ejabberd       │
        │   :8765            │◄─────│     :5222          │
        │                    │ hook │                    │
        └──────────┬─────────┘      └────────────────────┘
                   │
                   ▼
             ┌──────────┐
             │ Postgres │
             └──────────┘

   REST gives you:  the conversation list, message history, unread counts,
                    mute/archive/pin, and catch-up after being offline.

   XMPP gives you:  the actual message — sending it, and receiving it the
                    instant it arrives.
```

**There is no REST endpoint to send a DM.** `POST /chat/conversations/{id}/messages`
exists, but it requires a `shopId` and is for shops replying to customers only —
a shop has no phone holding a socket, so its messages must go through the
server. A person has a socket, so their messages go over it.

**Why it is built this way:** the message reaches the other person's phone
whether or not the backend is healthy, and whether or not their app is open.
ejabberd delivers it and *then* tells the backend what happened. The backend
**observes** your messages; it does not relay them.

### Idea 2 — the screen never reads the network

> **The network writes to the local database. The screen reads the local
> database. They never touch each other.**

```
   WRONG — this is where inconsistent UI comes from
   ───────────────────────────────────────────────────

     XMPP stanza  ──────────────►  ┐
     REST response ─────────────►  ├──►  the screen
     SSE event    ──────────────►  ┘

     Three sources drawing to one screen. They arrive in different orders,
     they disagree, and the user watches messages appear, vanish, and
     duplicate. There is no bug to fix here — the architecture is the bug.


   RIGHT — one source, always
   ───────────────────────────────────────────────────

     XMPP stanza  ──────────────►  ┐
     REST response ─────────────►  ├──►  local DB  ──►  the screen
     SSE event    ──────────────►  ┘   (SQLite /       (observes, redraws)
                                        Room /
                                        Core Data)
```

What this buys you, all of it for free:

- **Correct ordering** — everything sorted by one column, in one place.
- **Instant cold start** — the app opens showing history before the network wakes.
- **Offline works** — the user scrolls their history on a dead connection.
- **One rendering path** — you write the message row once, not once per pipe.

Two rules that go with it:

- **Postgres is the source of truth for the data. Your local database is the
  source of truth for the screen.** On conflict the server wins and the client
  corrects toward it — never the reverse.
- **The one exception is a message the user just typed.** It exists locally
  before the server knows about it. If the server never accepts it, the client
  must surrender that row rather than leave it spinning forever.

The mechanics of this are in [The local database, and when to sync](#local-db).

---

## Vocabulary

| Word | What it means here |
|---|---|
| **JID** | An address on the chat network, shaped like an email address. Yours is `su_<accountId>@nexgate.tz`. `su_` means "system user". |
| **Stanza** | One XML message over the XMPP socket. It is to XMPP what a request is to HTTP. |
| **Conversation** | A thread between two parties. Has a UUID. Created by REST, filled by XMPP. |
| **Participant** | One side of a conversation, keyed by JID. Your unread count, mute setting and archive flag live on *your* participant row, not on the conversation. |
| **`stream_seq`** | A number the server puts on every message, counting upward, never reused. Your sync cursor, and the most important field in the system. |
| **Cursor** | The highest `stream_seq` you have successfully written to local storage. |
| **Context** | Which identity you are acting as. For DMs it is always `PERSONAL`. |
| **Receipt** | A tiny stanza the *receiving* device sends back to say "arrived" or "read". This is what turns one tick into two. |

### The two tokens

| Token | Used for | Lives |
|---|---|---|
| `accessToken` | Every REST call, as `Authorization: Bearer <token>` | ~4 days |
| `xmppToken` | The password your XMPP client logs in with | **24 hours** |

Different key material on purpose: a leaked chat credential must not be an app
credential. The practical consequence is the failure you will hit in week one —
**chat authentication failing while REST keeps working perfectly.**

---

## The flow, end to end

Everything below follows this order.

| # | Call | Purpose |
|---|---|---|
| 1 | `POST` `/auth/login/password` | accessToken + xmppToken |
| 2 | `POST` `/xmpp/xmpp-token` | refresh the chat password |
| 3 | `GET` `/chat/contexts/me` | your JID, your badge |
| 4 | *XMPP connect, port 5222* | the message channel |
| 5 | `POST` `/chat/conversations/user/{accountId}` | open a DM |
| 6 | `GET` `/chat/conversations` | the chat list |
| 7 | *XMPP `<message>`* | **send** |
| 8 | `GET` `/chat/conversations/{id}/messages` | history |
| 9 | *XMPP `<received/>` / `<displayed/>`* | ticks |
| 10 | `POST` `/chat/conversations/{id}/read` | clear unread |
| 11 | `GET` `/chat/sync` | catch-up loop |
| 12 | `GET` `/events` | SSE (web only, for DMs) |
| 13 | `PUT`/`DELETE` `.../mute`, `.../archive`, `.../pin` | thread state |
| 14 | `PUT`/`DELETE` `.../pinned-message` | the shared pin |
| 15 | `DELETE` `.../messages/{id}` | delete a message |
| 16 | `DELETE` `/chat/conversations/{id}` | delete your copy |

Then, once one-to-one works, the three features built on top of it:

| Part | Feature | Entry point |
|---|---|---|
| 9 | **Groups** | `POST /chat/groups` |
| 10 | **Secret chat** | `POST /chat/devices`, then `POST /chat/conversations/secret/{accountId}` |
| 11 | **Shop threads** | `POST /chat/conversations/shop/{shopId}` |
| 12 | **Offers** | `POST /chat/offers` |

Samples below assume:

```bash
BASE=http://localhost:8765
TOKEN=<accessToken>
```

---

# Part 1 — Getting connected

## `POST` /api/v1/auth/login/password — Sign in

**Purpose**: Exchange credentials for both tokens.

**Access Level**: 🌐 Public

**Request:**

| Field | Type | Required | Description |
|---|---|---|---|
| `checkToken` | string | **yes** | From `POST /api/v1/auth/check` |
| `password` | string | **yes** | |
| `deviceId` | string | **yes** | Stable per install |
| `deviceName` | string | no | Shown in the user's session list |
| `platform` | enum | **yes** | `ANDROID` / `IOS` / `WEB` |

```bash
curl -s -X POST $BASE/api/v1/auth/login/password \
  -H 'Content-Type: application/json' \
  -d '{"checkToken":"...","password":"...","deviceId":"abc-1","deviceName":"Pixel 7","platform":"ANDROID"}'
```

**Keep three things** from the response: `accessToken`, `xmppToken`, `accountId`.

---

## `POST` /api/v1/xmpp/xmpp-token — Refresh the chat password

**Purpose**: Mint a fresh XMPP password without making the user log in again.

**Access Level**: 🔒 Protected

```bash
curl -s -X POST $BASE/api/v1/xmpp/xmpp-token -H "Authorization: Bearer $TOKEN"
```

**Response** (this endpoint returns the raw object, not the standard envelope):

```json
{ "xmppToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." }
```

**When the XMPP token expires, refresh the access token first**
(`POST /api/v1/auth/token/refresh`), then call this. Sending the user back to a
login screen because a 24-hour chat credential lapsed is the wrong recovery.

---

## `GET` /api/v1/chat/contexts/me — Who am I

**Purpose**: Your JID and your app-wide unread badge. Call this before anything
else in chat.

**Access Level**: 🔒 Protected

```bash
curl -s $BASE/api/v1/chat/contexts/me -H "Authorization: Bearer $TOKEN"
```

**Response** (`data` is an array):

```json
[
  {
    "kind": "PERSONAL",
    "shopId": null,
    "displayName": "Joshua",
    "jid": "su_304e4324-3d4b-4d23-bcc1-e1c0ab6600de@nexgate.tz",
    "unreadCount": 4
  }
]
```

| Field | Type | Notes |
|---|---|---|
| `kind` | enum | `PERSONAL` or `SHOP`. A plain user gets one `PERSONAL` entry |
| `shopId` | UUID | Null for `PERSONAL` |
| `displayName` | string | |
| `jid` | string | **Take your JID from here** |
| `unreadCount` | number | The badge for the whole app |

**Do not build the JID string yourself.** The prefix and domain both come from
server configuration and differ between environments.

---

## XMPP connect — the message channel

Not an HTTP call. A socket you open once and keep.

| Setting | Value |
|---|---|
| JID | the `jid` from `/chat/contexts/me` |
| Password | the `xmppToken` |
| Host | the chat server host (local: `localhost`) |
| Port | `5222` |
| Security | STARTTLS |

**Libraries**: Smack (Android), XMPPFramework (iOS). **On web, do not run XMPP
in the browser** — use SSE instead, see [SSE](#sse).

> ⚠️ **The trap everyone hits.** Your JID says `@nexgate.tz`, so a client will
> try to resolve *that* to find the server, and in local development it fails.
> Set an explicit connection host of `localhost` and leave the JID alone. On a
> real deployment the domain resolves and this stops mattering.

> ⚠️ **If authentication fails**, the useful question is which side refused.
> ejabberd validates the `xmppToken` against a key set the backend publishes, so
> a rejection here is almost always an expired token or a malformed JID — not a
> wrong password. Check the `su_` prefix first.

---

# Part 2 — Opening a conversation

## `POST` /api/v1/chat/conversations/user/{accountId} — Open a DM

**Purpose**: Get the thread between you and this person, creating it if it does
not exist.

**Access Level**: 🔒 Protected

**Path Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `accountId` | UUID | **yes** | The other person |

```bash
curl -s -X POST $BASE/api/v1/chat/conversations/user/$OTHER_ACCOUNT_ID \
  -H "Authorization: Bearer $TOKEN"
```

**Response** (`data`):

```json
{ "id": "9f1c8b2a-...", "type": "PERSONAL", "inboxLevel": "PRIMARY" }
```

**Safe to call every time.** It returns the existing thread or creates one.
Calling it twice does not make two conversations — the pair is deduplicated by a
unique key, so two devices racing to open the same thread both get the same id.
There is no separate "get" endpoint.

**You never tell the server what type of conversation this is.** No endpoint
accepts `type` or `inboxLevel` as input; the server derives it from who the two
parties are. A parameter that let you assert it would be a security bug.

---

## `GET` /api/v1/chat/conversations — The chat list

**Purpose**: One query that returns everything the list screen draws. No second
call per row.

**Access Level**: 🔒 Protected

**Query Parameters:**

| Parameter | Type | Required | Description | Default |
|---|---|---|---|---|
| `archived` | boolean | no | `true` returns the archived folder instead | `false` |
| `limit` | integer | no | Page size | `30` |
| `cursor` | ISO-8601 | no | `lastMessageAt` of your last row | — |
| `cursorPinned` | boolean | no | `pinned` of your last row | `false` |

```bash
curl -s "$BASE/api/v1/chat/conversations?limit=30" -H "Authorization: Bearer $TOKEN"
```

**Response** (`data` is an array of rows):

```json
{
  "id": "9f1c8b2a-...",
  "type": "PERSONAL",
  "inboxLevel": "PRIMARY",
  "display": { "kind": "USER", "name": "Mama Yoyo", "avatar": "https://..." },
  "lastMessagePreview": "habari yako",
  "lastMessageType": "TEXT",
  "lastMessageStatus": "READ",
  "lastMessageAt": "2026-08-26T09:12:44Z",
  "lastMessageSenderJid": "su_3213c20e-...@nexgate.tz",
  "unreadCount": 2,
  "muted": false,
  "mutedUntil": null,
  "pinned": true,
  "pinnedMessageId": null
}
```

| Field | Notes |
|---|---|
| `display` | `kind` / `name` / `avatar` — joined in the same query so you never look up a row's identity separately |
| `lastMessagePreview` | **Can be null while `lastMessageType` is set** — that means media with no text |
| `lastMessageType` | Turn this into "Photo" / "Voice note" **in the user's own language**; the server never sends you an English sentence to display |
| `lastMessageStatus` | The tick mark. `SENT` / `DELIVERED` / `READ`, and **only when the last message was yours**. Null when they spoke last |
| `unreadCount` | Per participant — yours, not theirs |
| `mutedUntil` | Null while `muted` is true means **forever**. Draw "Muted", not "Muted until…" |
| `pinned` | Your own list, yours alone. The other party sees nothing |
| `pinnedMessageId` | The thread's *shared* pin — a different thing from `pinned` |

> ### ⚠️ Paging uses a two-part cursor
>
> Pinned rows sort first, then newest first — **and the sort happens in the
> query, not in your client**. So the cursor is *both* values from your last
> row: `cursor=<lastMessageAt>` **and** `cursorPinned=<pinned>`. Send only half
> of it and you will silently skip rows. Re-sorting client-side breaks paging
> for the same reason.

---

# Part 3 — Sending a message

## The round trip

```
  YOUR DEVICE                ejabberd              BACKEND           THEIR DEVICE
      │                         │                     │                    │
      │ 1. write local row      │                     │                    │
      │    status = SENDING     │                     │                    │
      │    (screen redraws now) │                     │                    │
      │                         │                     │                    │
      │ 2. <message id=UUID>    │                     │                    │
      │────────────────────────►│                     │                    │
      │                         │ 3. deliver          │                    │
      │                         │───────────────────────────────────────► │
      │                         │                     │                    │
      │                         │ 4. hook fires       │                    │
      │                         │────────────────────►│                    │
      │                         │                     │ 5. INSERT,         │
      │                         │                     │    assign          │
      │                         │                     │    stream_seq      │
      │                         │                     │                    │
      │                         │  6. <received/>     │                    │
      │◄────────────────────────┤◄───────────────────────────────────────┤
      │    status = DELIVERED   │                     │                    │
```

Read steps 3 and 4 again: **the message reaches the other person before the
backend knows it exists.** That is not a race condition, it is the design. It is
why a message still arrives while the backend is restarting.

## The stanza

```xml
<message type="chat"
         id="4f8a2c10-7b3e-4d51-9a2f-1e6c8b0d3a97"
         to="su_3213c20e-074b-4f4e-8adb-e1167aac5425@nexgate.tz">
  <body>habari yako</body>
  <request xmlns="urn:xmpp:receipts"/>
</message>
```

> ### ⚠️ Rule 1: the stanza `id` MUST be a UUID that you generate
>
> The server uses your stanza `id` as the message's primary key. That single
> decision is what gives you:
>
> - **Deduplication** — the same message delivered twice writes one row.
> - **Receipt correlation** — a read receipt refers back to this id. If it is
>   not a UUID the server cannot find the row to stamp, so `readAt` stays null
>   forever, the ticks never turn blue, and **nothing errors**.
> - **Optimistic UI** — you know the id before you send, so your local row and
>   the server's row are the same row.
>
> Generate a v4 UUID per message. Do not let your XMPP library invent its own
> id — most of them will, and the failure is completely silent.

| Rule | Why |
|---|---|
| `type` must be `chat` (or `groupchat` for a group) | The hook only captures those two |
| There must be a `<body>` | A body-less stanza is treated as a receipt or a typing indicator, never a message |
| Address the **bare** JID, no `/resource` | A resource identifies a device; conversations are not per-device, and the server strips it anyway |
| Include `<request xmlns="urn:xmpp:receipts"/>` | Without it the other device has no reason to acknowledge, and you get one tick forever |

## What your local row should do

```
   user hits send
        │
        ▼
   ┌─────────┐   stanza written    ┌──────┐   <received/>   ┌───────────┐
   │ SENDING │───────to socket────►│ SENT │────arrives─────►│ DELIVERED │
   └────┬────┘                     └──────┘                 └─────┬─────┘
        │                                                          │
        │ socket down                                    <displayed/>
        ▼                                                          ▼
   ┌────────┐                                                 ┌──────┐
   │ FAILED │◄── retry queue, survives app restart            │ READ │
   └────────┘                                                 └──────┘
```

Write the row **before** you touch the socket and let the screen draw it
immediately. **The retry queue must survive the process being killed** — a
message composed on a train and lost when Android reclaims memory is the bug
users remember.

---

<a name="stanza-reference"></a>
# Part 4 — The stanza reference

Which stanza goes with which message type. The answer is shorter than you
expect.

## 4.1 A client can only create `TEXT`

The backend hardcodes the type when it stores an inbound stanza:

```java
// MessageServiceImpl.receiveMessage
.type(MessageType.TEXT)
```

Whatever you put in that stanza, it is stored as `TEXT`. **There is no XMPP
element that makes the server write `IMAGE`, `AUDIO`, `PRODUCT_REF` or anything
else.**

So the other message types are not things you send. They are things the server
writes into the thread, and you render.

| Type | Created by | How you create it |
|---|---|---|
| `TEXT` | you, over XMPP | the stanza above |
| `IMAGE` `VIDEO` `AUDIO` `FILE` | — | **no path exists yet** — see [Known gaps](#gaps) |
| `PRODUCT_REF` | server, when a product is shared into a thread | you cannot; you render it |
| `ORDER_REF` | server, when an order is placed | you cannot; you render it |
| `OFFER_REF` | server, when a shop makes an offer | `POST /chat/offers` — see [Offers](#offers) |
| `CALL_EVENT` | server, when a call ends | out of scope |
| `GROUP_EVENT` | server, on membership changes | happens as a side effect of the group endpoints |
| `CONVERSATION_EVENT` | server, e.g. the disappearing timer changed | side effect of `PUT .../disappearing` |

Server-written messages reach you the same way any message does — over XMPP,
through sync, or through SSE. They carry their data in `metadata` and a
pre-rendered `card`. **Draw the card.** Never parse the body of one: there is no
English stored in it, because the fields are localisation keys and numbers so
your app writes the sentence in the user's own language.

## 4.2 The four stanzas you will write

### A one-to-one message

```xml
<message type="chat" id="<uuid>" to="su_<accountId>@nexgate.tz">
  <body>habari yako</body>
  <request xmlns="urn:xmpp:receipts"/>
</message>
```

### A group message

```xml
<message type="groupchat" id="<uuid>" to="g_<conversationId>@conference.nexgate.tz">
  <body>habari wote</body>
</message>
```

Note three differences: `type` is `groupchat`, the address is the **room**, and
you must have **joined the room** first (see [Groups](#groups)).

**The group id and the conversation id are the same UUID.** `GroupEntity`
exposes `getConversationId()` as an accessor over its own `id` rather than
storing it twice — so wherever you hold one, you hold the other.

Group messages are captured by a *different* hook, inside the room and after it
has accepted the message for broadcast. That matters to you: in a group with
announcement mode on, a non-admin's message is **refused by the room and never
stored**. Do not assume your own stanza became a message — wait for it to come
back.

### A secret message

```xml
<message type="chat" id="<uuid>" to="su_<accountId>@nexgate.tz">
  <body>BASE64_CIPHERTEXT</body>
  <secret session="<conversationId>"/>
</message>
```

The `<secret session="..."/>` element is what routes the message to the secret
thread instead of the pair's ordinary one. Without it, an encrypted body would
be filed in the normal conversation — wrong, and a disclosure. See
[Secret chat](#secret).

### A receipt

Delivery — send when the message lands on the device:

```xml
<message to="su_<sender>@nexgate.tz" id="<a new uuid>">
  <received xmlns="urn:xmpp:receipts" id="<the ORIGINAL message id>"/>
</message>
```

Read — send when it is actually on screen:

```xml
<message to="su_<sender>@nexgate.tz" id="<a new uuid>">
  <displayed xmlns="urn:xmpp:chat-markers:0" id="<the ORIGINAL message id>"/>
</message>
```

> The `id` **inside** `<received/>` or `<displayed/>` is the id of the message
> you are acknowledging — **not** of this stanza. That reference is the entire
> mechanism. The outer stanza gets its own fresh id.

## 4.3 The stanza that never reaches the backend

Typing indicators travel device → ejabberd → device and are dropped before they
reach Spring Boot, on purpose — per-keystroke traffic through the application
and the database buys nothing.

```xml
<message type="chat" to="su_<accountId>@nexgate.tz">
  <composing xmlns="http://jabber.org/protocol/chatstates"/>
</message>
```

Send it, listen for it, render it from the socket. **Never expect it in history,
in sync, or in SSE.** It exists only in the moment.

## 4.4 Summary

```
   what you send over XMPP           what the server does with it
   ──────────────────────────────────────────────────────────────────────
   type=chat + <body>           ──►  stores a row, type = TEXT
   type=groupchat + <body>      ──►  stores a row IF the room accepted it
   <body> + <secret session=…>  ──►  stores it in that secret thread,
                                     and does NOT keep the raw stanza
   <received id="…"/>           ──►  stamps deliveredAt on that message
   <displayed id="…"/>          ──►  stamps readAt on that message
   <composing/>                 ──►  nothing. dropped at ejabberd.

   what the server sends you         where it came from
   ──────────────────────────────────────────────────────────────────────
   TEXT                         ──►  another person's device
   OFFER_REF                    ──►  a shop called POST /chat/offers
   GROUP_EVENT                  ──►  somebody joined, left, or was removed
   CONVERSATION_EVENT           ──►  the disappearing timer changed
   PRODUCT_REF / ORDER_REF      ──►  commerce activity elsewhere in the app
```

---

# Part 5 — Receiving

Three ways a message reaches you. They are not alternatives — they cover
different situations, and you need all three.

```
   app open, socket alive     ──►  XMPP stanza arrives      (instant)
   app was closed / offline   ──►  GET /chat/sync           (catch-up)
   app killed by the OS       ──►  push wakes you, then sync
```

## 5.1 Live, over XMPP

Attach a listener to the connection. Every inbound `<message>` with a `<body>`
is a new message. Write it to your local table keyed on the stanza `id`, then
send a delivery receipt.

**Do not draw it directly.** Write it to the table and let the screen react
([Idea 2](#two-ideas)).

Inbound stanzas do **not** carry a `stream_seq` — the backend assigns it after
ejabberd has already handed you the stanza. You get the number later from sync
or SSE. Until then, sort that message locally by its timestamp.

---

## `GET` /api/v1/chat/conversations/{conversationId}/messages — History

**Purpose**: A page of the thread, newest first.

**Access Level**: 🔒 Protected — you must be a participant, else `403`

**Query Parameters:**

| Parameter | Type | Required | Description | Default |
|---|---|---|---|---|
| `before` | ISO-8601 | no | `createdAt` of the **oldest** message you hold | — |
| `limit` | integer | no | Page size | `30` |
| `shopId` | UUID | no | Read as a shop instead of as yourself | — |

```bash
curl -s "$BASE/api/v1/chat/conversations/$CONV/messages?limit=30" \
  -H "Authorization: Bearer $TOKEN"
```

**Response** (`data`):

```json
{
  "messages": [ ],
  "pinnedMessage": null,
  "nextCursor": "2026-08-26T09:12:44Z",
  "readAs": "su_304e4324-...@nexgate.tz"
}
```

| Field | Notes |
|---|---|
| `messages` | Newest first. Shape in [Reference](#message-object) |
| `pinnedMessage` | Fetched separately, because the pin is usually far older than the page you are reading. Null when nothing is pinned or the pinned message was deleted |
| `nextCursor` | Pass as `before` on the next page. Empty string when there is nothing more |
| `readAs` | Which identity the server decided you were reading as. Always your personal JID for a DM; check it when you add shop support |

> **Page with `before`, never with a page number.** Offsets get slower as the
> thread grows *and* skip messages, because new messages landing underneath the
> reader shift every page — which in a live conversation is constant.

---

<a name="sse"></a>
## `GET` /api/v1/events — SSE stream

**Purpose**: A live event stream for clients that do not hold an XMPP socket.

**Access Level**: 🔒 Protected

**Request Headers:**

| Header | Required | Description |
|---|---|---|
| `Authorization` | **yes** | `Bearer <accessToken>` |
| `Last-Event-ID` | on reconnect | Your cursor. The server replays everything after it |

**Events emitted today:**

| Event | Meaning |
|---|---|
| `message.created` | A new message exists |
| `conversation.created` | A thread you are part of was opened |
| `conversation.updated` | Mute, archive, pin, or a shared change |
| `unread.changed` | A badge moved |
| `message.pinned` / `message.unpinned` | The thread's shared pin changed |
| `call.*` | Out of scope for this document |

> **On Android and iOS you do not need SSE for personal, group or secret
> messages.** Your XMPP socket already carries them. Running both means
> receiving everything twice and writing reconciliation for a problem you
> created.
>
> **You do need it for shop threads**, because a shop has no socket — the
> owner's device holds exactly one XMPP connection, and it belongs to the
> person, not the duka.
>
> **On web, use SSE for everything and do not run XMPP at all.** The browser is
> a bad place for a long-lived XMPP connection; the server bridges for exactly
> this reason.

Expect a comment heartbeat roughly every 20 seconds — that is what stops proxies
closing the connection. `EventSource` cannot set an `Authorization` header, so
on web you need fetch-based streaming; a token in the query string would put
credentials into access logs and proxy history.

## 5.2 Push notifications

Push is a **wake-up signal only**. The payload deliberately carries no message
content, because notification content is visible on a lock screen — and for a
secret chat that is precisely what the feature exists to prevent.

```
   push arrives ──► app wakes ──► GET /chat/sync?since=<cursor> ──► local DB
                                                                       │
                                                                       ▼
                                                              notification drawn
                                                              from local data
```

---

# Part 6 — Ticks: sent, delivered, read

```
   ✓        SENT       your stanza left the device
   ✓✓       DELIVERED  their device received it and sent <received/>
   ✓✓ blue  READ       their device showed it and sent <displayed/>
```

**Those second and third ticks exist only because the receiving device sends the
receipt stanzas.** Skip [4.2](#stanza-reference) and your own users never see two
ticks — and nothing logs an error.

Read the state from a **message**:

```json
{ "deliveredAt": "2026-08-26T09:12:45Z", "readAt": null }
```

…or from a **chat list row**, where it is only ever set when the last message
was yours:

```json
{ "lastMessageStatus": "DELIVERED" }
```

> **A product note for commerce threads.** A customer seeing "read three hours
> ago, no reply" is worse for trust than not knowing. Consider showing
> `DELIVERED` but suppressing `READ` on shop threads.

---

## `POST` /api/v1/chat/conversations/{conversationId}/read — Clear unread

**Purpose**: Move your own unread counter. Unrelated to the ticks above, which
are about *their* view of *your* message.

**Access Level**: 🔒 Protected

**Query Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `upToSeq` | long | no | The highest `stream_seq` the user has actually seen on screen |
| `shopId` | UUID | no | Act as a shop instead of as yourself |

```bash
curl -s -X POST "$BASE/api/v1/chat/conversations/$CONV/read?upToSeq=918273" \
  -H "Authorization: Bearer $TOKEN"
```

**One ranged call, never one per message.** Call it when the thread is open and
scrolled to the bottom — not when the row is tapped. Then re-read
`GET /chat/contexts/me` if you need the app-wide badge to move.

---

<a name="local-db"></a>
# Part 7 — The local database, and when to sync

This decides whether your app is trustworthy. Read it twice.

## 7.1 Why the UI shows inconsistent data

Because three pipes are drawing to it, and they disagree:

- The XMPP stanza arrives with **no `stream_seq`** yet.
- The REST history call returns the same message **with** a `stream_seq`.
- The SSE event announces it a third time.

If each one paints the screen, the user watches the message jump position,
appear twice, or vanish when a refetch returns a page that does not include it
yet.

**The fix is not smarter merging in the UI. It is one table.**

```
   ┌──────────────┐
   │ XMPP stanza  │──┐
   └──────────────┘  │
   ┌──────────────┐  │      ┌──────────────────┐        ┌─────────────┐
   │ /chat/sync   │──┼─────►│  messages table  │───────►│  the screen │
   └──────────────┘  │      │  PK = message id │ observe└─────────────┘
   ┌──────────────┐  │      └──────────────────┘
   │ SSE event    │──┘
   └──────────────┘
        upsert, keyed on the message id
```

Because the message id is a UUID **you** generated, all three pipes carry the
same key. The same message arriving three times is three upserts on one row, not
three rows. **The duplicate problem disappears by construction.**

Whichever source has the `stream_seq`, keep it. Whichever has `deliveredAt`,
keep it. The row fills in over time and the screen redraws each time it does.

## 7.2 The cursor

Store one number per context: **the highest `stream_seq` you have successfully
written to local storage.**

Write it **after** the batch is saved, never before. If the app dies mid-batch
you replay a few messages you already have — free, because upserts on the same
id are harmless. Saving the cursor first leaves a **gap**, and a gap is silent.
The user finds out when someone says *"I replied — didn't you see it?"*

> This is the highest-severity class of bug in a messaging product and it does
> not throw an exception. Guard against it structurally.

---

## `GET` /api/v1/chat/sync — Catch-up

**Purpose**: Everything that happened since your cursor, across every
conversation in one context.

**Access Level**: 🔒 Protected

**Query Parameters:**

| Parameter | Type | Required | Description | Default |
|---|---|---|---|---|
| `since` | long | no | Your cursor | `0` |
| `limit` | integer | no | Page size | `200` |
| `shopId` | UUID | no | Sync a shop context instead of your personal one | — |

```bash
curl -s "$BASE/api/v1/chat/sync?since=918273&limit=200" -H "Authorization: Bearer $TOKEN"
```

**Response** (`data`):

```json
{ "messages": [ ], "nextCursor": 918274, "hasMore": true }
```

> ### ⚠️ Loop until `hasMore` is false
>
> ```
> cursor = local_cursor
> loop:
>     response = GET /chat/sync?since=cursor&limit=200
>     upsert every message into the local table
>     cursor  = response.nextCursor
>     save cursor locally
>     if not response.hasMore: break
> ```
>
> A device off for a week has far more than 200 messages waiting. Stopping after
> one page leaves it permanently behind, and nothing tells you.

**Postgres is authoritative here, not XMPP.** A message sent while your device
was offline exists in the database whether or not ejabberd managed to deliver
it, because capture happens on the sender's side. That is what makes this a
complete answer rather than a best-effort one.

**Shop contexts sync separately.** Pass `shopId` and you get that shop's
messages with that shop's own cursor. Keep one cursor per context, not one per
app.

## 7.3 When to sync

| Trigger | Action |
|---|---|
| App cold start | Sync loop, then connect XMPP |
| XMPP reconnected | **Sync loop** — you were deaf while it was down |
| Push notification | Sync loop |
| App returns to foreground | Sync loop |
| Pull-to-refresh | Sync loop |
| Every N seconds | **No. Never poll.** |

The rule behind the table: **sync whenever you have reason to believe you missed
something.** Reconnecting is the big one — while your socket was down, messages
were being written to Postgres that ejabberd could not hand you.

Sync is cheap when you are caught up (a `since` at the head returns an empty
list), so syncing too eagerly costs almost nothing. Syncing too rarely costs you
messages.

## 7.4 The whole lifecycle, once

```
   APP START
      │
      ├─► read local DB ──► draw the screen immediately (works offline)
      │
      ├─► GET /chat/sync?since=<cursor>  ──► loop until hasMore == false
      │        └─► upsert into local DB  ──► screen redraws itself
      │
      ├─► connect XMPP socket
      │        ├─► join every group room you belong to
      │        └─► inbound stanzas ──► upsert ──► send <received/>
      │
      ├─► user opens a thread
      │        ├─► GET /conversations/{id}/messages?before=…   (older pages)
      │        ├─► POST /conversations/{id}/read?upToSeq=…
      │        └─► send <displayed/> for what is on screen
      │
      ├─► user sends
      │        └─► local row SENDING ──► stanza ──► SENT ──► DELIVERED ──► READ
      │
      └─► socket drops
               └─► reconnect ──► sync loop again ──► back to the top
```

---

# Part 8 — Managing the thread

Each endpoint changes a different piece of state. There is no single "status"
field, because a chat can be muted **and** archived at once and they answer
different questions. All of these accept an optional `shopId` to act as a shop.

## `PUT` /api/v1/chat/conversations/{id}/mute — Mute
## `DELETE` /api/v1/chat/conversations/{id}/mute — Unmute

| Parameter | In | Required | Values | Default |
|---|---|---|---|---|
| `duration` | query | no | `HOURS_24` `ONE_WEEK` `ONE_MONTH` `FOREVER` | `FOREVER` |

```bash
curl -s -X PUT "$BASE/api/v1/chat/conversations/$CONV/mute?duration=HOURS_24" \
  -H "Authorization: Bearer $TOKEN"
curl -s -X DELETE $BASE/api/v1/chat/conversations/$CONV/mute \
  -H "Authorization: Bearer $TOKEN"
```

Read it back from the chat list as `muted` + `mutedUntil`. **A null `mutedUntil`
while `muted` is true means forever** — draw "Muted", not "Muted until…".

## `PUT` /api/v1/chat/conversations/{id}/archive — Archive

| Parameter | In | Required | Default |
|---|---|---|---|
| `archived` | query | no (`true`/`false`) | `true` |

Archive is **per participant**. Archiving your copy does nothing to theirs. For
a shop it archives for the **shop**, not for the staff member who tapped it —
one duka, one archive state.

> **A new message does not unarchive the thread.** That is deliberate, and it
> means unread piles up somewhere the user cannot see. So the archived folder
> needs its own badge:

## `GET` /api/v1/chat/conversations/archived/unread — Archived badge

```bash
curl -s $BASE/api/v1/chat/conversations/archived/unread -H "Authorization: Bearer $TOKEN"
```

List the folder itself with `GET /chat/conversations?archived=true`.

## `PUT` /api/v1/chat/conversations/{id}/pin — Pin the conversation

| Parameter | In | Required | Default |
|---|---|---|---|
| `pinned` | query | no (`true`/`false`) | `true` |

**Your own list, yours alone.** The other party sees nothing. Pinned rows come
back first, already sorted — do not re-sort client-side or cursor paging breaks.

## `PUT` /api/v1/chat/conversations/{id}/pinned-message — Pin a message
## `DELETE` /api/v1/chat/conversations/{id}/pinned-message — Unpin

| Parameter | In | Required |
|---|---|---|
| `messageId` | query | **yes** (on `PUT`) |

A completely different thing from pinning the conversation. **This is the
thread's shared notice board — both participants see it.** One pinned message
per conversation; pinning a second replaces the first. Always null on a secret
thread.

## `DELETE` /api/v1/chat/conversations/{id}/messages/{messageId} — Delete a message

| Parameter | In | Required | Default |
|---|---|---|---|
| `forEveryone` | query | no | `false` |
| `shopId` | query | no | — |

| | `forEveryone=false` | `forEveryone=true` |
|---|---|---|
| Who may | any participant | **the sender only** |
| When | any time | **within 1 hour of sending** |
| Their copy | untouched | gone |
| Refusal | — | `409` `NOT_SENDER` or `WINDOW_CLOSED` |

The one-hour window is measured **server-side** against `created_at`. A client
clock is not evidence.

Delete-for-everyone leaves a **tombstone**: the row survives with
`"deleted": true`, a null `body` and a null `card`, so ordering and reply chains
do not break. Render "This message was deleted" — **do not remove the row from
your local table.**

Knowing a message id is not enough to delete it: you must be a participant,
because ids are client-generated and travel in receipts.

## `DELETE` /api/v1/chat/conversations/{id} — Delete your copy

"Delete" means *your* copy. Their side keeps everything, and a new message from
them revives the thread without the history you removed. If the user's intent is
"never hear from this person again", that is blocking, not deleting.

For commerce threads, note that **retention overrides user deletion**: the
shop's copy and the underlying record persist for the retention period. This
belongs in your terms of service.

---

<a name="groups"></a>
# Part 9 — Groups

A group is a conversation whose transport is an XMPP **MUC room** rather than a
direct address. Everything you already learned still applies — same message
table, same sync cursor, same chat list, same receipts.

**The group id and the conversation id are the same UUID.** The room JID is
`g_<conversationId>@conference.nexgate.tz`.

## 9.1 The model: invite, then accept

```
   creator                        invitee
      │                              │
      ├─ POST /chat/groups           │
      │    (creator is OWNER)        │
      │                              │
      ├─ POST /{conv}/invites/{acct} │
      │                              │
      │                              ├─ GET  /chat/groups/invites
      │                              ├─ POST /invites/{id}/accept
      │                              │    ↳ NOW they are a member
      │                              │    ↳ NOW a GROUP_EVENT appears
      │                              │
      │                              └─ join the MUC room over XMPP
```

> **Inviting somebody does not put them in the group.** Until they accept they
> are not a member, cannot read the thread, and do not appear in
> `GET /{conv}/members`. Build the UI around that — an invite is a pending
> object, not a membership.

## `POST` /api/v1/chat/groups — Create

**Request:**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | **yes** | Not blank |
| `description` | string | no | |
| `groupType` | enum | no | `PRIVATE` (default) or `PUBLIC` |
| `iconFileId` | UUID | no | Upload to File Thunder as `DM_GROUP_ICON` first; this claims it |

```bash
curl -s -X POST $BASE/api/v1/chat/groups -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"Bei za Jumla"}'
```

**Response** (`data`):

```json
{
  "id": "7c2e...", "conversationId": "7c2e...",
  "name": "Bei za Jumla", "description": null,
  "iconMedia": null, "groupType": "PRIVATE",
  "onlyAdminsCanPost": false, "myRole": "OWNER"
}
```

`myRole` is **about the caller**, not a property of the group. The same group
returns a different `myRole` to each member.

## Membership

| Endpoint | Method | Notes |
|---|---|---|
| `/chat/groups/{conv}/invites/{accountId}` | `POST` | Invite someone |
| `/chat/groups/invites` | `GET` | **Your** pending invites |
| `/chat/groups/invites/{inviteId}/accept` | `POST` | Become a member |
| `/chat/groups/invites/{inviteId}/decline` | `POST` | |
| `/chat/groups/{conv}/members` | `GET` | Current members + roles |
| `/chat/groups/{conv}/members/{accountId}` | `DELETE` | Remove someone |
| `/chat/groups/{conv}/leave` | `POST` | Leave |
| `/chat/groups/{conv}/members/{accountId}/role` | `PUT` | Body `{"role":"ADMIN"}` |
| `/chat/groups/{conv}/transfer-ownership/{accountId}` | `POST` | |
| `/chat/groups/{conv}` | `GET` / `DELETE` | Read / delete the group |

Roles are `OWNER`, `ADMIN`, `MEMBER`.

**Leaving is a soft delete.** A member who left keeps their history and still
counts as a conversation participant — which means the participant list and the
active member list genuinely disagree, on purpose. Read members from
`GET /{conv}/members`, never by counting participants.

## Settings

| Endpoint | Method | Parameter | Effect |
|---|---|---|---|
| `/chat/groups/{conv}/icon` | `PUT` | `fileId` (omit to clear) | Group picture |
| `/chat/groups/{conv}/announcement-mode` | `PUT` | `enabled` | **Only admins may post** |
| `/chat/groups/{conv}/join-approval` | `PUT` | `required` | Link joins need approval |

> ### ⚠️ Announcement mode changes what "sent" means
>
> It is enforced by the **MUC room**, not by your client. A non-admin's stanza is
> **refused by the room and never becomes a message** — no row, no history, no
> error to your REST calls. Do not mark a message sent because the stanza left
> the socket; wait for it to come back from the room.

## Invite links and join requests

| Endpoint | Method | Notes |
|---|---|---|
| `/chat/groups/{conv}/invite-links` | `POST` | Body: `maxUses`, `expiresInHours` — both null = unlimited, never expires |
| `/chat/groups/invite-links/{linkId}` | `DELETE` | Revoke |
| `/chat/groups/join/{code}` | `POST` | Join via a link |
| `/chat/groups/{conv}/join-requests` | `GET` | Pending, when approval is required |
| `/chat/groups/join-requests/{id}/approve` | `POST` | |
| `/chat/groups/join-requests/{id}/reject` | `POST` | |

`POST /chat/groups/join/{code}` returns a **`JoinOutcome`** — joining a group
with approval switched on makes you a *pending request*, not a member. Branch on
the outcome rather than assuming success means membership.

## 9.2 What the client must do that it does not for a DM

1. **Join the MUC room** over XMPP for every group you belong to, on every
   connect. Until you join, you receive nothing from it.
2. **Send with `type="groupchat"`** to the room JID, not to a person.
3. **Render `GROUP_EVENT` messages** — joins, leaves, removals, ownership
   transfers all appear in the thread as messages with the event in `metadata`.
4. **Re-check `myRole`** after any role change; your compose box and your admin
   menu both depend on it.

---

<a name="secret"></a>
# Part 10 — Secret chat

A secret chat is an ordinary conversation with three differences: it is bound to
a **pair of devices**, the server stores only **ciphertext**, and messages can be
set to **disappear**.

> **The encryption itself is client work and is not built.** The server provides
> the key registry, the thread, the routing element and the expiry sweep. The
> actual crypto — generating identity keys, deriving session keys, encrypting the
> body — is yours to implement. Everything below is the server half.

## 10.1 The device registry

Before anyone can open a secret thread with you, their device must fetch your
device's public identity key. That is all this registry does: store public
bundles and hand them out. **Nothing else in the system reads it.**

## `POST` /api/v1/chat/devices — Register a device

| Field | Type | Required | Description |
|---|---|---|---|
| `deviceId` | UUID | **yes** | Chosen by the device, so it can also be its XMPP resource |
| `identityKey` | string | **yes** | Your **public** key |
| `label` | string | no | "Pixel 7" — shown to the user |

```bash
curl -s -X POST $BASE/api/v1/chat/devices -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"deviceId":"<uuid>","identityKey":"<base64 public key>","label":"Pixel 7"}'
```

| Endpoint | Method | Notes |
|---|---|---|
| `/chat/devices/{accountId}` | `GET` | **Their** devices — call this before opening a secret thread |
| `/chat/devices/{deviceId}` | `DELETE` | Forget one of your own |

Only public material goes here. A private key that leaves the device is not a
private key.

## `POST` /api/v1/chat/conversations/secret/{accountId} — Open a secret thread

| Parameter | In | Required | Description |
|---|---|---|---|
| `myDeviceId` | query | **yes** | Your registered device |
| `theirDeviceId` | query | **yes** | From `GET /chat/devices/{accountId}` |

```bash
curl -s -X POST "$BASE/api/v1/chat/conversations/secret/$OTHER?myDeviceId=$MINE&theirDeviceId=$THEIRS" \
  -H "Authorization: Bearer $TOKEN"
```

> **This creates a NEW thread every call** — unlike `/conversations/user/{id}`,
> which is idempotent. That is deliberate: the same two people may hold several
> secret conversations at once, and the session id in the dedup key is what
> allows it. **Do not call it to "get" a thread**; store the id you were given.

## `PUT` /api/v1/chat/conversations/{id}/disappearing — Disappearing messages

| Parameter | In | Required | Description |
|---|---|---|---|
| `seconds` | query | no | **Omit to switch off** |

Not per participant, unlike mute: messages vanish **for everyone or for
nobody**, so one side cannot quietly keep a copy. Changing it writes a
`CONVERSATION_EVENT` into the thread.

## 10.2 Sending

Use the secret stanza from [4.2](#stanza-reference):

```xml
<message type="chat" id="<uuid>" to="su_<accountId>@nexgate.tz">
  <body>BASE64_CIPHERTEXT</body>
  <secret session="<conversationId>"/>
</message>
```

What the server does differently:

- Routes on `<secret session>` instead of deriving the thread from the pair —
  without it your ciphertext lands in the ordinary conversation, which is both
  wrong and a disclosure.
- **Does not store the raw stanza.** Everywhere else it is kept for debugging;
  here it would hold the ciphertext a second time and outlive the body the
  expiry sweep clears.
- Sends a **content-free push**. A lock screen is exactly what a secret chat is
  hiding from.

**A forged session id does not work.** The id only selects among threads the
sender is *already* in, and that is checked rather than believed — a forged one
resolves to a thread they are not in and the message drops.

## 10.3 What the chat list shows

Secret rows carry `display` (name and avatar) but **never a preview**:

```json
{ "type": "PERSONAL", "inboxLevel": "SECRET",
  "display": { "kind": "USER", "name": "Mama Yoyo" },
  "lastMessagePreview": null, "lastMessageType": null,
  "pinnedMessageId": null }
```

The name is there on purpose: what is secret is *what was said*, not *who it was
with*. Without it, a list of three secret rows reading "1 unread" from nobody
cannot be told apart.

---

# Part 11 — Shop threads

A commerce thread is a conversation between a **person** and a **shop**. It is
the one place where the two-channel rule bends, and understanding why explains
the whole architecture.

**A shop has no device and holds no XMPP connection.** The owner's phone holds
exactly one connection and it belongs to *them*, not to the duka. So:

```
   customer → shop     XMPP  (the customer has a socket, so they use it)
   shop → customer     REST  (the shop has none, so the server sends for it)
```

That asymmetry is not an inconsistency — **it is what makes the reply window
enforceable.** Every shop-originated message passes through application code
before it exists, so it can be refused. Person-to-person messages cannot be,
because they are delivered before the backend hears about them.

## `POST` /api/v1/chat/conversations/shop/{shopId} — Open a shop thread

| Parameter | In | Required | Description |
|---|---|---|---|
| `productId` | query | no | Seeds the thread with a `PRODUCT_REF` card — "Asking about…" |

**One ongoing thread per customer per shop**, not one per listing. Asking about
a second product adds a card to the same thread rather than starting a new one.

## `POST` /api/v1/chat/conversations/{conversationId}/messages — Send as a shop

**The only REST send endpoint in the entire system.**

**Access Level**: 🔒 Protected — the caller must have access to the shop

| Parameter | In | Required | Description |
|---|---|---|---|
| `shopId` | query | **yes** | Which shop you are speaking as |

**Request:**

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | UUID | **yes** | Client-generated, so a retry cannot duplicate |
| `body` | string | **yes** | Not blank |

```bash
curl -s -X POST "$BASE/api/v1/chat/conversations/$CONV/messages?shopId=$SHOP" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"id":"<uuid you generate>","body":"Bei ni 35,000"}'
```

**Refusals** — `409 CONFLICT`, code in `data.code`:

| Code | Meaning |
|---|---|
| `WINDOW_CLOSED` | The 24-hour reply window has expired. A customer message reopens it |
| `SHOP_TO_SHOP_DENIED` | A shop cannot message another shop |
| `SHOP_IN_GROUP_DENIED` | Shops are never members of groups |
| `UNSUPPORTED_SENDER` | Only users and shops may send |

**Rate limits are counted per shop, not per caller.** The limit protects the
customer from the shop's output, so an owner adding staff must not multiply how
loud that shop can be. On `429`, honour `Retry-After`.

## What the customer never sees

`senderUserId` **does not exist on the wire**. Which staff member pressed send
must never reach the customer, and it is stripped server-side rather than hidden
by the client — a field on the wire is readable whether you draw it or not.
Attribution is always to the shop.

## Reading as a shop

Almost every endpoint in this document takes an optional `shopId` — `/sync`,
`/conversations`, `/messages`, `/read`, `/mute`, `/archive`, `/pin`. Passing it
means "act as this shop" rather than "act as me".

**Keep a separate sync cursor per context.** A shop's messages belong to the
shop, not to whichever staff member is looking, and unread is counted per shop —
if it were per person, two staff would each see the same five unread and both
reply to the same customer.

---

<a name="offers"></a>
# Part 12 — Offers

An offer is a **price cut on one product, made to specific people, with a
clock**. It appears in the thread as an `OFFER_REF` message carrying an `OFFER`
card, and the buyer checks out at the reduced price.

## 12.1 The model: terms plus recipients

```
   chat_offers              ONE row per send — the terms
                            audience, product, both prices, min/max per buyer,
                            maxTotalUnits, unitsClaimed, status, expiresAt
        │
        └── chat_offer_recipients   a thin row per person
                                    accountId, status, orderId
```

Everyone who receives one send gets **identical terms by construction**, so the
terms are stored once. What varies per person is only whether they have used it.

**Two statuses, and you need both:**

| Status | Values | About |
|---|---|---|
| `OfferStatus` | `ACTIVE` `CANCELLED` | the send |
| `OfferRecipientStatus` | `PENDING` `COMPLETED` | one person |

*"No longer available"* and *"you have already used this"* are different
sentences, and one column could not tell them apart.

## `POST` /api/v1/chat/offers — Create an offer

**Access Level**: 🔒 Protected — shop owner; **plus** group `OWNER`/`ADMIN` for a
group offer

| Field | Type | Required | Description |
|---|---|---|---|
| `conversationId` | UUID | **yes** | Where the offer lands |
| `productId` | UUID | **yes** | Must belong to your shop |
| `offerPrice` | decimal | **yes** | Greater than zero |
| `expiresInMinutes` | integer | **yes** | Min 1 |
| `audience` | enum | no | `INDIVIDUAL` / `GROUP`. **A check only** — the conversation decides, and a mismatch is refused rather than silently overridden |
| `recipientAccountIds` | UUID[] | no | Group threads only. Empty or absent = every active member |
| `minQuantity` | integer | no | Per buyer. Null = the product's own limit |
| `maxQuantity` | integer | no | Per buyer |
| `maxTotalUnits` | integer | no | **Across everyone.** Null = uncapped |

```bash
curl -s -X POST $BASE/api/v1/chat/offers -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"conversationId":"'$CONV'","productId":"'$PROD'","offerPrice":250,
       "maxTotalUnits":5,"expiresInMinutes":60}'
```

**Response** (`data`):

```json
{
  "offerId": "3b7f...",
  "audience": "INDIVIDUAL",
  "recipientCount": 1,
  "maxTotalUnits": 5,
  "offer": { }
}
```

`recipientCount × maxQuantity` is what the discount could cost you — that is why
it is returned.

## 12.2 Who authors the message, by thread type

This differs by design, and your UI has to expect it:

| Thread | Author of the card | Why |
|---|---|---|
| `COMMERCE` | the **shop** | the shop is a participant |
| `PERSONAL` | the **seller, as themselves** | the shop is not in the room; the shop comes from the product |
| `GROUP` | the sender | one send, many recipients, one card |
| `SECRET` | — | **refused.** A server-written card would be plaintext in an E2EE thread |

## 12.3 The card is built per viewer

`OFFER` cards are rendered **per reader**. One group card is live for a member
who has not bought and spent for one who has — state that cannot live in a
message the whole room shares.

**The card targets the offer, never the recipients.** A group card is read by
the whole room, so naming the audience would publish who got a discount.

## `GET` /api/v1/chat/offers/{offerId}/mine — Am I eligible?

```json
{ "eligible": true, "myStatus": "PENDING", "offer": { } }
```

> **Returns `200` either way**, and gives an **identical response for an offer
> that does not exist** — a non-recipient must not be able to tell "this offer
> excludes me" from "there is no such offer". Do not treat a non-eligible
> response as an error.

`myStatus` already collapses the offer's state with this reader's. **Use it.**
Computing your own from `offer.status` reproduces a bug that shipped once: a
cancelled offer read `CANCELLED` in the thread and `PENDING` through the API, so
a client drew a live Buy button on a dead offer. Precedence is **expired beats
cancelled beats the recipient's own row**.

## `GET` /api/v1/chat/offers/{offerId} — Full offer

For the seller, and for a recipient who needs the detail:

| Field | Notes |
|---|---|
| `productNameSnapshot`, `productPrimaryMedia`, `originalPriceSnapshot` | Frozen when the offer was made — the product may have changed since |
| `offerPrice` | The discounted price |
| `minQuantity` / `maxQuantity` | Raw seller override; **null means "not overridden"** |
| `effectiveMinQuantity` / `effectiveMaxQuantity` | **What is actually enforced at checkout.** Draw these |
| `status`, `expired`, `expiresAt` | |

`originalPriceSnapshot` beside `offerPrice` is what lets you draw a *discount*
rather than just a price.

## `POST` /api/v1/chat/offers/{offerId}/cancel — Withdraw

Seller only, `ACTIVE` offers only. **Withdraws from everyone in one write** —
that falls out of the terms-plus-recipients model.

## 12.4 Checkout

Redeeming happens through the normal checkout flow with a `CHAT_OFFER` session,
not through an endpoint here. Two behaviours to expect:

- **Units are claimed on success and released on cancel or failure.** A refusal
  reads *"Only 2 left at this price"* and leaves the counter untouched.
- **The duplicate-checkout refusal fires before the claim**, so it leaks nothing
  about how many units remain.

## 12.5 Offer refusals are not coded

Unlike delete and shop-send, offer validation throws plain exceptions that reach
the catch-all handler. **You get `400 BAD_REQUEST` with prose in `message` and
no `data.code`:**

```json
{ "success": false, "httpStatus": "BAD_REQUEST",
  "message": "You can only send offers for products in your own shop",
  "data": "You can only send offers for products in your own shop" }
```

Show the message; do not try to branch on it. Known refusals include: product
not in your shop, offer price not greater than zero, only a group owner or admin
may send a group offer, the group has no other members, only the creating seller
may cancel, and cancelling an offer that is not `ACTIVE`.

---

# Part 13 — Reference

<a name="message-object"></a>
## 13.1 The message object

```json
{
  "id": "4f8a2c10-7b3e-4d51-9a2f-1e6c8b0d3a97",
  "conversationId": "9f1c8b2a-...",
  "senderJid": "su_3213c20e-...@nexgate.tz",
  "type": "TEXT",
  "body": "habari yako",
  "metadata": null,
  "replyToId": null,
  "streamSeq": 918273,
  "createdAt": "2026-08-26T09:12:44Z",
  "editedAt": null,
  "deliveredAt": "2026-08-26T09:12:45Z",
  "readAt": null,
  "deleted": false,
  "card": null
}
```

| Field | Notes |
|---|---|
| `id` | Your UUID from the stanza. Primary key everywhere |
| `senderJid` | Compare with your own JID to decide left/right alignment |
| `type` | See 13.2 |
| `body` | Null when `deleted`, and on some server-written types |
| `metadata` | Structured payload for non-`TEXT` types. Null for `TEXT` |
| `replyToId` | The message being replied to |
| `streamSeq` | Your sync cursor. **Null on a stanza that arrived over XMPP** |
| `deliveredAt` / `readAt` | Filled by the receipt stanzas |
| `deleted` | Tombstone. Keep the row, change what you draw |
| `card` | Pre-rendered `metadata`. **Draw this.** Null for `TEXT` and for deleted rows |

**`senderUserId` does not exist on the wire, on purpose.**

## 13.2 The card object

```json
{
  "kind": "OFFER",
  "labelKey": "card.offer.made_you_an_offer",
  "title": "Canvas shoes — white",
  "thumbUrl": "https://...",
  "amount": { "minor": 25000, "currency": "TZS" },
  "status": "PENDING",
  "target": { "type": "offer", "id": "3b7f..." },
  "details": { }
}
```

| Field | Notes |
|---|---|
| `kind` | `PRODUCT` `ORDER` `OFFER` `CALL` `GROUP_EVENT` |
| `labelKey` | **A localisation token, not text.** Key your translations off it |
| `title` | Data-derived only — a product name, an order number. Never a phrase |
| `amount` | **Minor units.** `25000` TZS is 250.00. Formatting is your job; a server-formatted "TZS 25,000" would be a display string in disguise |
| `status` | Domain status as stored. Passed through, not interpreted — you decide how to colour it |
| `target` | What tapping the card opens |
| `details` | Kind-specific extras, e.g. a call's `durationSeconds` |

> **Every field except `kind` and `labelKey` is nullable.** Metadata is a
> snapshot taken when the message was sent, and a snapshot written by an older
> build will be missing fields a newer one adds. **Render what you have.**

## 13.3 Message types

`TEXT` · `IMAGE` · `VIDEO` · `AUDIO` · `FILE` · `PRODUCT_REF` · `ORDER_REF` ·
`OFFER_REF` · `CALL_EVENT` · `GROUP_EVENT` · `CONVERSATION_EVENT`

> **Treat this list as open.** A server that adds a type must not crash your
> app — render an unknown type as a neutral "unsupported message" row rather
> than throwing.

## 13.4 Conversation types

| `type` | `inboxLevel` | Transport | Address you send to |
|---|---|---|---|
| `PERSONAL` | `PRIMARY` | XMPP `chat` | `su_<accountId>@nexgate.tz` |
| `PERSONAL` | `SECRET` | XMPP `chat` + `<secret session>` | `su_<accountId>@nexgate.tz` |
| `GROUP` | `PRIMARY` | XMPP `groupchat` | `g_<conversationId>@conference.nexgate.tz` |
| `COMMERCE` | `PRIMARY` | XMPP in, **REST out** | `sh_<shopId>@shops.nexgate.tz` |

## 13.5 JID shapes

| Shape | Is |
|---|---|
| `su_<accountId>@nexgate.tz` | a person |
| `sh_<shopId>@shops.nexgate.tz` | a shop |
| `g_<conversationId>@conference.nexgate.tz` | a group room |
| `system@nexgate.tz` | the platform — server-written messages |

Domains come from server configuration and differ per environment. **Read them
from `/chat/contexts/me`; do not hardcode them.** An address the server cannot
classify is rejected loudly rather than defaulting to a person — a routing path
that silently fell through to the personal conversation is a bug this system has
already had.

<a name="refusals"></a>
## 13.6 Refusal codes

`409 CONFLICT` with the code in `data.code`:

| Code | Endpoint | Meaning |
|---|---|---|
| `NOT_SENDER` | delete message | Only the sender may delete for everyone |
| `WINDOW_CLOSED` | delete message | Past the 1-hour window |
| `WINDOW_CLOSED` | send as shop | The 24-hour reply window has expired |
| `SHOP_TO_SHOP_DENIED` | send as shop | A shop cannot message another shop |
| `SHOP_IN_GROUP_DENIED` | send as shop | Shops are never group members |
| `UNSUPPORTED_SENDER` | send as shop | Only users and shops may send |

Everything else in this document refuses with a plain status — `403` for "not a
participant", `404` for a missing resource, `400` with prose for offer
validation (see 12.5).

---

<a name="gaps"></a>
# Part 14 — What is not built yet

Do not design around these. They are named so you do not spend a day looking for
an endpoint that does not exist.

**Media messages have no send path.** `IMAGE`, `VIDEO`, `AUDIO` and `FILE` exist
in the enum, but nothing links a chat message to an uploaded file, and the
inbound path stores every stanza as `TEXT` regardless. A voice note can be
uploaded today and **the recipient cannot fetch it** — the ownership check
resolves to the sender only, because there is no message-to-file link to check a
participant against. This is the next piece of work.

**Secret chat has no client crypto.** The registry, the thread, the routing
element and the expiry sweep exist. Key exchange, session derivation and the
encryption itself do not. Registering an `identityKey` today stores a string
nothing yet consumes.

**Message requests are a stub.** `MessageRequestController` is an empty class.
Anyone can currently open a DM with anyone.

**Editing is not implemented.** `editedAt` is on the wire and always null.

**`CONVERSATION_EVENT` has no card builder.** It arrives with `metadata` and a
**null `card`**. Render from `metadata`, or skip the row — but do not assume
every non-`TEXT` type gives you a card.

**Cancelling a group offer cancels the whole send.** That is correct today, but
note it is one write affecting everyone — there is no per-recipient withdrawal.

**Typing indicators are client-to-client only.** They will never appear in
history, sync or SSE.

**`presence.changed` is specified but not implemented.** Online/offline status
is not available.

---

# Appendix — the shortest possible checklist

Before you call the chat screens done:

- [ ] Every message id is a **UUID your client generated**
- [ ] Every pipe writes to **one local table**, keyed on that id
- [ ] The screen reads **only** the local table
- [ ] The cursor is saved **after** the batch, never before
- [ ] The sync loop runs **until `hasMore` is false**
- [ ] Sync fires on cold start, reconnect, push and foreground — **never on a timer**
- [ ] One cursor **per context** (yourself, and each shop)
- [ ] You send `<received/>` on arrival and `<displayed/>` on view
- [ ] The send queue **survives the process being killed**
- [ ] You **join every MUC room** on each XMPP connect
- [ ] A group message is not marked sent until it **comes back from the room**
- [ ] Secret rows never render a preview, in the list **or** in a notification
- [ ] Offer eligibility is read from **`myStatus`**, never computed from `offer.status`
- [ ] Money is formatted **client-side** from minor units
- [ ] `labelKey` is translated, never displayed raw
- [ ] An unknown message type renders a neutral row instead of crashing
- [ ] The XMPP token is refreshed on a **24-hour** schedule