Texting API
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
accessTokenis for REST; thexmppTokenis 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
idyou 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_EVENTandCONVERSATION_EVENTare 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
409with the code indata.code. Permission failures are403. 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 oflocalhostand 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
xmppTokenagainst 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 thesu_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>andcursorPinned=<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
idMUST be a UUID that you generateThe server uses your stanza
idas 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
readAtstays 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
idinside<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
DELIVEREDbut suppressingREADon 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_seqyet. - 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
hasMoreis falsecursor = 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: breakA device off for a week has far more than 200 messages waiting. Stopping after one page leaves it permanently behind, and nothing tells you.
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.
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
- Join the MUC room over XMPP for every group you belong to, on every connect. Until you join, you receive nothing from it.
- Send with
type="groupchat"to the room JID, not to a person. - Render
GROUP_EVENTmessages — joins, leaves, removals, ownership transfers all appear in the thread as messages with the event inmetadata. - Re-check
myRoleafter 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"}'
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.
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
200either 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
kindandlabelKeyis 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
hasMoreis 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 fromoffer.status - Money is formatted client-side from minor units
-
labelKeyis 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
No comments to display
No comments to display