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 . 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_@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 ~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 send 8 GET /chat/conversations/{id}/messages history 9 XMPP / 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= 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= and cursorPinned= . 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. │ │ │ │────────────────────────►│ │ │ │ │ 3. deliver │ │ │ │───────────────────────────────────────► │ │ │ │ │ │ │ 4. hook fires │ │ │ │────────────────────►│ │ │ │ │ 5. INSERT, │ │ │ │ assign │ │ │ │ stream_seq │ │ │ │ │ │ │ 6. │ │ │◄────────────────────────┤◄───────────────────────────────────────┤ │ 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 habari yako ⚠️ 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 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 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 ┌──────┐ ┌───────────┐ │ SENDING │───────to socket────►│ SENT │────arrives─────►│ DELIVERED │ └────┬────┘ └──────┘ └─────┬─────┘ │ │ │ socket down ▼ ▼ ┌────────┐ ┌──────┐ │ 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 habari yako A group message habari wote 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 BASE64_CIPHERTEXT The 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: Read — send when it is actually on screen: The id inside or 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. 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 + ──► stores a row, type = TEXT type=groupchat + ──► stores a row IF the room accepted it + ──► stores it in that secret thread, and does NOT keep the raw stanza ──► stamps deliveredAt on that message ──► stamps readAt on that message ──► 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 with a 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 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= ──► 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 ✓✓ blue READ their device showed it and sent 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= ──► 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 │ ├─► user opens a thread │ ├─► GET /conversations/{id}/messages?before=… (older pages) │ ├─► POST /conversations/{id}/read?upToSeq=… │ └─► send 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_@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_EVENT messages — joins, leaves, removals, ownership transfers all appear in the thread as messages with the event in metadata . 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":"","identityKey":"","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 : BASE64_CIPHERTEXT What the server does differently: Routes on 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":"","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 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_@nexgate.tz PERSONAL SECRET XMPP chat + su_@nexgate.tz GROUP PRIMARY XMPP groupchat g_@conference.nexgate.tz COMMERCE PRIMARY XMPP in, REST out sh_@shops.nexgate.tz 13.5 JID shapes Shape Is su_@nexgate.tz a person sh_@shops.nexgate.tz a shop g_@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 on arrival and 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