Skip to main content

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.
  • 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.

Standard Response Format

Success

{
  "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

{
  "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:

{
  "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

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.


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:

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
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

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):

{ "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

curl -s $BASE/api/v1/chat/contexts/me -H "Authorization: Bearer $TOKEN"

Response (data is an array):

[
  {
    "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.

⚠️ 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
curl -s -X POST $BASE/api/v1/chat/conversations/user/$OTHER_ACCOUNT_ID \
  -H "Authorization: Bearer $TOKEN"

Response (data):

{ "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
curl -s "$BASE/api/v1/chat/conversations?limit=30" -H "Authorization: Bearer $TOKEN"

Response (data is an array of rows):

{
  "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

<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.


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:

// 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
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
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

<message type="chat" id="<uuid>" to="su_<accountId>@nexgate.tz">
  <body>habari yako</body>
  <request xmlns="urn:xmpp:receipts"/>
</message>

A group message

<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).

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

<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.

A receipt

Delivery — send when the message lands on the device:

<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:

<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.

<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).

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
curl -s "$BASE/api/v1/chat/conversations/$CONV/messages?limit=30" \
  -H "Authorization: Bearer $TOKEN"

Response (data):

{
  "messages": [ ],
  "pinnedMessage": null,
  "nextCursor": "2026-08-26T09:12:44Z",
  "readAs": "su_304e4324-...@nexgate.tz"
}
Field Notes
messages Newest first. Shape in Reference
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.


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 and your own users never see two ticks — and nothing logs an error.

Read the state from a message:

{ "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:

{ "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
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.


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
curl -s "$BASE/api/v1/chat/sync?since=918273&limit=200" -H "Authorization: Bearer $TOKEN"

Response (data):

{ "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
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

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.


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
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):

{
  "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.

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.

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
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}
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:

<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:

{ "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
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"}'

Refusals409 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.


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
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):

{
  "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?

{ "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:

{ "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

13.1 The message object

{
  "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

{
  "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.

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).


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