Notification-nexgate-service(4)

In-App Notifications (The Bell)

Author: Josh S. Sakweli, Backend Lead Team Last Updated: 2026-09-18 Version: v1.0

Base URL: http://localhost:8765/api/v1 (local) β€” https://dev.api.nexgate.co/api/v1 (staging)

This document is ONLY for the in-app notification bell β€” the πŸ”” icon, its red badge, and the list that opens when you tap it. It is not about push notifications on the lock screen, email, SMS, or chat unread badges. Those are different systems; Part 0 tells you which one you need.

v1.0 β€” verified end to end on staging on 2026-09-18: one account liked another's post, the owner's badge went from 0 to 1, and the bell list showed "joshdoe liked your post" pointing at the right post. Every JSON sample below is a real staging response.

Short Description: Everything that happens to a user while they are not looking β€” a like, a new follower, an order shipped, a ticket bought β€” is saved as one row in their bell list. This API reads that list, counts unread rows for the badge, marks rows read, and deletes them. The server also sends a live notification.added event on the existing event stream so the badge moves without the app polling.

Hints:


Part 0 β€” Is this the right document?

You are building… System Where
The πŸ”” icon, its badge, the list of "X liked your post" rows In-app notifications (this doc) /api/v1/notifications/* + notification.added on /api/v1/events
A banner on the lock screen when the app is closed Push (Firebase) Not live yet. Needs the app to register its device token first β€” separate doc
Unread counts on chat threads Chat chatbox-api-doc.md β€” unread.changed
Email and SMS Notification server Nothing for the app to do

The same event can reach a user through several of these at once. A like creates one bell row and, once push is live, one push. They are independent: reading the bell row does not clear the push, and vice versa.


Part 1 β€” How the bell works

  somebody likes your post
            β”‚
            β–Ό
  backend saves one row in YOUR bell list  ──────►  row: isRead=false
            β”‚
            β–Ό
  backend sends on /api/v1/events:
     event: notification.added
     data:  {"type":"SOC_POST_LIKED","unreadCount":4}
            β”‚
            β–Ό
  app sets the badge to 4  (no request needed)
  if the bell list is open β†’ refetch page 1

What the app does, screen by screen

Moment Call Why
App starts or comes back to the foreground GET /notifications/unread-count The event stream was not connected while you were away; this is the truth
notification.added arrives none β€” set the badge to unreadCount from the event The event already carries the new count
User opens the bell GET /notifications/me?page=1&size=20 Newest first
User scrolls to the bottom GET /notifications/me?page=2… while hasNext is true
User taps a row PUT /notifications/{id}/read, then open targetType/targetId Mark read first so the badge is right when they come back
"Mark all as read" button PUT /notifications/read-all Then set the badge to 0 locally
Swipe to delete DELETE /notifications/{id}
"Clear read" button DELETE /notifications/read Removes every row already read

Things the server does NOT tell you live

The live event

The bell rides the same stream the chat uses β€” GET /api/v1/events (see chatbox-api-doc.md β†’ "SSE stream" for connecting, reconnecting with Last-Event-ID, and why web needs fetch-based streaming). Do not open a second stream for notifications.

Event Data Meaning
notification.added {"type": "SOC_POST_LIKED", "unreadCount": 4} A row was added to this user's bell. unreadCount is the new total, counted after the row was saved

type lets you do something special for a few types (a sound for a new order, for example). For everything else, just update the badge.


Part 2 β€” The notification object

Every list and single-row endpoint returns rows in this shape. Real staging row:

{
  "id": "6e902bb6-9b38-49c6-825e-22b951f39584",
  "userId": "02db7d92-b426-47b2-9171-cf15339c1376",
  "shopId": null,
  "serviceId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
  "serviceType": "SOCIAL",
  "targetType": "POST",
  "targetId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
  "title": "joshdoe liked your post",
  "message": "",
  "type": "SOC_POST_LIKED",
  "priority": "LOW",
  "isRead": false,
  "data": {
    "type": "SOC_POST_LIKED",
    "actor": { "name": "joshdoe" },
    "postId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
    "targetId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
    "targetType": "POST",
    "mailAccount": "general"
  },
  "createdAt": "2026-09-18T19:18:40.736493",
  "readAt": null
}
Field Type Description
id UUID The row. Use it to mark read or delete
userId UUID The owner β€” always the signed-in user
shopId UUID / null Set when the row is about one of the user's shops (new order, low stock…)
serviceId string Legacy β€” do not use. Kept for older apps. Use targetId
serviceType string Which area of the app it belongs to β€” use it for filter tabs. See values
targetType string Which screen a tap opens. See Part 3
targetId string / null The id that screen needs. null for screens that need none (e.g. WALLET)
title string The sentence to show. Always present
message string Optional second line β€” up to ~80 characters of the post, comment or review. Can be "" (e.g. a photo-only post) β€” hide the line when empty
type string The exact notification type, e.g. SOC_POST_LIKED, ORD_SHIPPED. 136 exist; new ones will be added. Do not switch on it for navigation β€” use targetType
priority string LOW, NORMAL, MEDIUM, HIGH, URGENT. Use it for styling only (e.g. bold HIGH/URGENT rows)
isRead boolean false until the user reads it
data object The values used to build the sentence. Useful for avatars (actor.name) or extra text. Ignore keys you do not know
createdAt string When it happened. ISO 8601, UTC, no offset
readAt string / null When it was marked read. UTC, no offset

serviceType values

For filter tabs ("All", "Social", "Orders"…) and for endpoint 5.

Value Covers
SOCIAL Likes, comments, follows, mentions, reposts
CHAT Group invites, join requests, offers, missed calls, meetings
LIVE Live streams and Spaces
ORDER Orders you bought or sold
SHOP Your shop: applications, stock, reviews, WhatsApp setup
CART Items in your cart
CHECKOUT Checkout sessions
PAYMENT Payments made and received
WALLET Wallet top-ups, balance, payouts
INSTALLMENT Installment plans and dues
GROUP_PURCHASE Group buying
EVENT Events, tickets, bookings
USER Your account: security, sessions, devices
PROMOTIONAL Offers and announcements
ADMIN Only admins ever receive these

Part 3 β€” Where a tap goes (targetType)

Map each targetType you support to a screen, passing targetId when the table says it carries one. Anything else β€” a targetType you have not mapped, or CUSTOM / NONE β€” stays on the bell list, with the row marked read. Never crash and never show an error on an unknown value; the backend adds new values over time.

targetType targetId is… Opens
POST post id The post
COMMENT comment id The comment, inside its post
POLL poll id The poll
STREAM stream id A live stream
PROFILE account id Somebody's profile
FOLLOW_REQUESTS β€” Pending follow requests
REPORT_HISTORY β€” The user's reports
MODERATION_NOTICE report id A moderation decision
CONVERSATION conversation id A chat thread
CHAT_LIST β€” The chat list
GROUP_INVITES conversation id Group invites
GROUP_JOIN_REQUESTS conversation id Join requests for a group you manage
CALL call id A call (e.g. missed-call details)
MEETING meeting id A meeting
SPACE space id A Space
OFFER offer id A personalised offer in chat
SHOP_INBOX shop id The shop's chat inbox
ORDER order id An order you bought
SHOP_ORDER order id An order your shop received
ORDER_TRACKING order id Delivery tracking
CART β€” The cart
PRODUCT product id A product
INVENTORY shop id The shop's stock screen
DISPUTE dispute id A dispute
REVIEW review id A review
GROUP_PURCHASE group id A group purchase
AGREEMENT agreement id An installment agreement
SHOP shop id A shop's public page
SHOP_DASHBOARD shop id Your shop's dashboard
SHOP_APPLICATION shop id Your shop application
WABA_SETTINGS shop id The shop's WhatsApp settings
DOWNLOAD order id Digital download for an order
TRANSACTION transaction id One transaction
TRANSACTION_HISTORY β€” All transactions
CHECKOUT session id A checkout session
WALLET β€” The wallet
PAYOUT_SETTINGS β€” Payout settings
OTP_SCREEN β€” The code entry screen
EVENT event id An event
EVENT_DASHBOARD event id Organiser's dashboard for an event
EVENTS_LIST β€” The events list
TICKET ticket id A ticket
BOOKING booking id A booking
MY_BOOKINGS booking id My bookings, scrolled to that booking
CLAIM claim id An organiser's fund claim
SCANNER_MODE event id The ticket scanner for an event
EVENT_REVIEW event id Review an event you attended
HOME β€” Home
ONBOARDING β€” Finish onboarding
ACCOUNT_SETTINGS β€” Account settings
SECURITY_SETTINGS β€” Security settings
ACTIVE_SESSIONS β€” Signed-in sessions
CHAT_DEVICES β€” Chat devices
APPEAL_FORM β€” Appeal a decision
ADMIN_* (7 values) varies Admin panel only β€” the consumer apps can treat these as unknown
CUSTOM, NONE β€” Stay on the bell list

If the target no longer exists (the post was deleted, for example), the screen's own endpoint returns 404. Show that screen's normal "not available" state; do not delete the bell row automatically.


Standard Response Format

All API responses follow a consistent structure using our Globe Response Builder pattern:

Success Response Structure

{
  "success": true,
  "httpStatus": "OK",
  "message": "Operation completed successfully",
  "action_time": "2026-09-18T19:23:04.211589898",
  "data": { }
}

Error Response Structure

{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "Error description",
  "action_time": "2026-09-18T19:23:06.226159977",
  "data": "Error description"
}

Standard Response Fields

Field Type Description
success boolean Always true for successful operations, false for errors
httpStatus string HTTP status name (OK, BAD_REQUEST, NOT_FOUND, etc.)
message string Human-readable message describing the operation result
action_time string ISO 8601 timestamp of when the response was generated
data object/string Response payload for success, error details for failures

Responses may also carry action and context (always null here) β€” ignore them.

The page object

Endpoints 1, 2, 5 and 6 return this inside data:

{
  "notifications": [ /* notification objects, newest first */ ],
  "currentPage": 1,
  "pageSize": 20,
  "totalElements": 1,
  "totalPages": 1,
  "hasNext": false,
  "hasPrevious": false,
  "isFirst": true,
  "isLast": true
}
Field Description
notifications The rows, newest first
currentPage The page you asked for (starts at 1)
pageSize Rows per page
totalElements Rows across all pages
totalPages Number of pages
hasNext Ask for currentPage + 1 while this is true
hasPrevious, isFirst, isLast Convenience flags

New rows can arrive while the user scrolls, which shifts every later page by one. If you see the same id twice, keep one.


HTTP Method Badge Standards


Endpoints

# Method Path Use it for
1 GET /notifications/me The bell list
2 GET /notifications/unread Only unread rows
3 GET /notifications/unread-count The badge
4 GET /notifications/summary Total / unread / read counts
5 GET /notifications/service/{serviceType} A filter tab
6 GET /notifications/shop/{shopId} A shop owner's shop notifications
7 GET /notifications/{notificationId} One row
8 PUT /notifications/{notificationId}/read Mark one read
9 PUT /notifications/read Mark several read
10 PUT /notifications/read-all Mark everything read
11 DELETE /notifications/{notificationId} Delete one
12 DELETE /notifications/batch Delete several
13 DELETE /notifications/read Delete every read row

1. List My Notifications

Purpose: The bell list β€” every row for the signed-in user, read and unread, newest first.

Endpoint: GET {base_url}/notifications/me

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Request Headers:

Header Type Required Description
Authorization string Yes Bearer <accessToken>

Query Parameters:

Parameter Type Required Description Validation Default
page integer No Page number Min: 1 1
size integer No Rows per page Min: 1 20

Success Response JSON Sample (staging, 2026-09-18):

{
  "success": true,
  "httpStatus": "OK",
  "message": "Notifications retrieved successfully",
  "action_time": "2026-09-18T19:23:04.211589898",
  "data": {
    "notifications": [
      {
        "id": "6e902bb6-9b38-49c6-825e-22b951f39584",
        "userId": "02db7d92-b426-47b2-9171-cf15339c1376",
        "shopId": null,
        "serviceId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
        "serviceType": "SOCIAL",
        "targetType": "POST",
        "targetId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68",
        "title": "joshdoe liked your post",
        "message": "",
        "type": "SOC_POST_LIKED",
        "priority": "LOW",
        "isRead": false,
        "data": { "actor": { "name": "joshdoe" }, "postId": "2b5f3f0c-6d6e-4c4a-a10a-34b622a1fc68" },
        "createdAt": "2026-09-18T19:18:40.736493",
        "readAt": null
      }
    ],
    "currentPage": 1,
    "pageSize": 5,
    "totalElements": 1,
    "totalPages": 1,
    "hasNext": false,
    "hasPrevious": false,
    "isFirst": true,
    "isLast": true
  }
}

Success Response Fields: see the page object and the notification object. With no rows, notifications is [] and message is "No notifications found".

Error Response JSON Sample (staging, page=0):

{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "Page index must not be less than zero",
  "action_time": "2026-09-18T19:23:06.226159977",
  "data": "Page index must not be less than zero"
}

Standard Error Types:


2. List Unread Notifications

Purpose: Only rows with isRead=false, newest first β€” for an "Unread" tab.

Endpoint: GET {base_url}/notifications/unread

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Query Parameters:

Parameter Type Required Description Validation Default
page integer No Page number Min: 1 1
size integer No Rows per page Min: 1 20

Success Response: identical in shape to endpoint 1.

Marking a row read removes it from this list, so every later page shifts up by one. If you mark rows read while the user scrolls this tab, refetch from page 1 instead of asking for the next page.

Standard Error Types:


3. Get Unread Count

Purpose: The number on the badge.

Endpoint: GET {base_url}/notifications/unread-count

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Success Response JSON Sample (staging):

{
  "success": true,
  "httpStatus": "OK",
  "message": "Unread count retrieved successfully",
  "action_time": "2026-09-18T19:23:05.489594598",
  "data": { "unreadCount": 1 }
}

Success Response Fields:

Field Description
unreadCount Rows with isRead=false. Show 99+ above 99

Call it when the app starts and every time it returns to the foreground. While the app is open, notification.added keeps the badge current without calling this.

Standard Error Types:


4. Get Summary

Purpose: Total, unread and read counts in one call.

Endpoint: GET {base_url}/notifications/summary

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Success Response JSON Sample (staging):

{
  "success": true,
  "httpStatus": "OK",
  "message": "Notification summary retrieved successfully",
  "action_time": "2026-09-18T19:23:04.820411225",
  "data": { "total": 1, "unread": 1, "read": 0 }
}

Success Response Fields:

Field Description
total All rows
unread Rows with isRead=false β€” same number as endpoint 3
read total - unread

Standard Error Types:


5. List by Area (serviceType)

Purpose: One filter tab β€” e.g. only SOCIAL, or only ORDER.

Endpoint: GET {base_url}/notifications/service/{serviceType}

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Path Parameters:

Parameter Type Required Description Validation
serviceType string Yes The area One of the serviceType values, upper case. An unknown value returns an empty list, not an error

Query Parameters:

Parameter Type Required Description Validation Default
page integer No Page number Min: 1 1
size integer No Rows per page Min: 1 20

Success Response: identical in shape to endpoint 1.

Standard Error Types:


6. List a Shop's Notifications

Purpose: For a shop owner β€” only the rows about one of their shops (new orders, low stock, reviews…).

Endpoint: GET {base_url}/notifications/shop/{shopId}

Access Level: πŸ”’ Protected (returns only the signed-in user's own rows for that shop β€” another person's shop gives an empty list)

Authentication: Bearer Token

Path Parameters:

Parameter Type Required Description Validation
shopId UUID Yes The shop Valid UUID

Query Parameters:

Parameter Type Required Description Validation Default
page integer No Page number Min: 1 1
size integer No Rows per page Min: 1 20

Success Response: identical in shape to endpoint 1; every row has this shopId.

Standard Error Types:


7. Get One Notification

Purpose: Fetch a single row β€” e.g. when the app opens from a link that carries a notification id.

Endpoint: GET {base_url}/notifications/{notificationId}

Access Level: πŸ”’ Protected (own rows only)

Authentication: Bearer Token

Path Parameters:

Parameter Type Required Description Validation
notificationId UUID Yes The row Valid UUID

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "Notification retrieved successfully",
  "action_time": "2026-09-18T19:23:04.211589898",
  "data": { /* one notification object */ }
}

Error Response JSON Sample (staging β€” a row that does not exist or is not yours):

{
  "success": false,
  "httpStatus": "NOT_FOUND",
  "message": "Notification not found or access denied",
  "action_time": "2026-09-18T19:23:06.905963899",
  "data": "Notification not found or access denied"
}

Standard Error Types:


8. Mark One as Read

Purpose: Call when the user taps a row.

Endpoint: PUT {base_url}/notifications/{notificationId}/read

Access Level: πŸ”’ Protected (own rows only)

Authentication: Bearer Token

Path Parameters:

Parameter Type Required Description Validation
notificationId UUID Yes The row Valid UUID

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "Notification marked as read",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": null
}

Marking a row that is already read succeeds and changes nothing β€” safe to retry. The badge is not sent back; subtract 1 locally.

Standard Error Types:


9. Mark Several as Read

Purpose: Mark a selection read in one call.

Endpoint: PUT {base_url}/notifications/read

Access Level: πŸ”’ Protected (own rows only)

Authentication: Bearer Token

Request JSON Sample:

{
  "notificationIds": [
    "6e902bb6-9b38-49c6-825e-22b951f39584",
    "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
  ]
}

Request Body Parameters:

Parameter Type Required Description Validation
notificationIds array of UUID Yes Rows to mark read Not empty

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "2 notification(s) marked as read",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": null
}

All or nothing: if even one id is missing or belongs to someone else, no row is changed and the call returns 400. Drop ids of rows you have deleted before sending.

Error Response JSON Sample:

{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "Some notifications not found or access denied",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": "Some notifications not found or access denied"
}

Standard Error Types:


10. Mark All as Read

Purpose: The "Mark all as read" button.

Endpoint: PUT {base_url}/notifications/read-all

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "All notifications marked as read",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": null
}

Set the badge to 0 locally afterwards.

Standard Error Types:


11. Delete One

Purpose: Swipe to delete.

Endpoint: DELETE {base_url}/notifications/{notificationId}

Access Level: πŸ”’ Protected (own rows only)

Authentication: Bearer Token

Path Parameters:

Parameter Type Required Description Validation
notificationId UUID Yes The row Valid UUID

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "Notification deleted successfully",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": null
}

Deleting an unread row lowers the unread count β€” adjust the badge. Deleted rows cannot be recovered.

Standard Error Types:


12. Delete Several

Purpose: Delete a selection in one call.

Endpoint: DELETE {base_url}/notifications/batch

Access Level: πŸ”’ Protected (own rows only)

Authentication: Bearer Token

This DELETE carries a JSON body. Most HTTP clients support that, but some default helpers drop it β€” check yours sends Content-Type: application/json and the body.

Request JSON Sample:

{
  "notificationIds": [
    "6e902bb6-9b38-49c6-825e-22b951f39584"
  ]
}

Request Body Parameters:

Parameter Type Required Description Validation
notificationIds array of UUID Yes Rows to delete Not empty

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "1 notification(s) deleted successfully",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": null
}

All or nothing, like endpoint 9: one bad id and nothing is deleted.

Standard Error Types:


13. Delete All Read

Purpose: The "Clear read" button β€” removes every row already read. Unread rows stay.

Endpoint: DELETE {base_url}/notifications/read

Access Level: πŸ”’ Protected

Authentication: Bearer Token

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "3 read notification(s) deleted successfully",
  "action_time": "2026-09-18T19:25:00.000000000",
  "data": 3
}

Success Response Fields:

Field Description
data How many rows were deleted (a number, not an object)

The badge does not change β€” only read rows were removed.

Standard Error Types:


Integration Checklist


Quick Reference Guide

Common HTTP Status Codes

Authentication

Data Format Standards

Push Notifications Documentation

Author: Josh S. Sakweli, Backend Lead Team Last Updated: 2026-10-28 Version: v1.1 (group calls and group messages; live on staging: Firebase and Apple VoIP are switched on; not yet verified on a real phone)

Base URL: https://dev.api.nexgate.co/api/v1 (staging) β€” http://localhost:8765/api/v1 (local)

Short Description: How NexGate reaches a phone or browser when the app is in the background or closed: what the app registers, what each push looks like, and exactly what Android, iOS and web must do with it. Every push is one of five contracts. The server sends facts (who, which chat, what text); the app decides how it looks.

Hints:


Contents


Part 0 β€” Is this the right document?

You are building… Use
Lock-screen / background notifications, ringing when the app is closed This document
The πŸ”” bell list and its badge notifications_inapp_bell_api_doc.md
Chat while the app is open (messages, typing, ticks) chatbox-api-doc.md (XMPP + live events)
The call screen once the call is answered calls_meetings_api_doc.md

The one rule that decides everything:


Part 1 β€” How a push travels

   something happens                       (a message, a call, an order shipped…)
            β”‚
            β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   decides WHO gets it and WHAT facts go in it
   β”‚     backend      β”‚   skips muted chats, picks devices, pairs iPhone tokens
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚  queue (RabbitMQ)
            β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   turns facts into one of the 5 contracts,
   β”‚ notification     β”‚   drops any key not on the contract's list,
   β”‚ server           β”‚   sets priority, expiry and "replace" keys
   β””β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
       β”‚          β”‚
       β”‚          └───────────────────────────┐ only CALL_INCOMING to an iPhone
       β–Ό                                      β–Ό that registered a VOIP token
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  Firebase   β”‚                     β”‚ Apple VoIP   β”‚
   β”‚  (FCM)      β”‚                     β”‚ (PushKit)    β”‚
   β””β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”˜                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚   β”‚   β”‚                               β”‚
      β–Ό   β–Ό   β–Ό                               β–Ό
  Android iPhone Browser                   iPhone β†’ CallKit rings
          (via Apple)

Part 2 β€” Setup per platform

2.1 What each team gets from the backend lead

Team Gets Nothing else needed
Android google-services.json (package com.nexgate.nexgate)
iOS GoogleService-Info.plist (bundle co.nexgate.ios) The Apple push key is already on the server, and the server accepts Xcode, TestFlight and App Store builds
Web firebaseConfig (web app config) and the VAPID public key

2.2 Registration flow (all platforms)

  app starts / user logs in
            β”‚
            β–Ό
  ask permission to show notifications  (Android 13+, iOS, browser)
            β”‚
            β–Ό
  get the token from Firebase  ──────────►  POST /notifications/devices
            β”‚                                { token, platform, kind:"PUSH", deviceId }
            β”‚
            β”œβ”€β”€ iPhone only: get the PushKit token ──► POST /notifications/devices
            β”‚                                          { token, platform:"IOS", kind:"VOIP", deviceId }
            β–Ό
  Firebase / PushKit gives a NEW token later  ──►  POST again (same deviceId β€” old one is replaced)
            β”‚
            β–Ό
  user logs out  ──►  DELETE /notifications/devices/{token}   (every token of this device)
                      then delete the token locally (Firebase deleteToken)

deviceId is the same device id the app already sends at login (deviceId in the auth check request). Keep it stable for the life of the install. It ties an iPhone's two tokens together, so a call rings that iPhone once.

When to register: on every app start while logged in, after every login, and in the "new token" callback. It is safe to call many times with the same token.


Part 3 β€” Endpoints

1. Register a device token

Purpose: Tell the backend where this device receives pushes.

Endpoint: POST {base_url}/notifications/devices

Access Level: πŸ”’ Protected (logged-in user)

Authentication: Bearer token

Request Headers:

Header Type Required Description
Authorization string Yes Bearer <accessToken>
Content-Type string Yes application/json

Request JSON Sample (Android):

{
  "token": "fcm-token-from-firebase",
  "platform": "ANDROID",
  "kind": "PUSH",
  "deviceId": "6f1c2a9e-device"
}

Request JSON Sample (iPhone β€” two calls, same deviceId):

{ "token": "fcm-token-from-firebase", "platform": "IOS", "kind": "PUSH", "deviceId": "6f1c2a9e-device" }
{ "token": "pushkit-token-hex", "platform": "IOS", "kind": "VOIP", "deviceId": "6f1c2a9e-device" }

Request Body Parameters:

Parameter Type Required Description Validation
token string Yes The Firebase registration token, or the PushKit token for VOIP not blank
platform string Yes Where the token came from enum: ANDROID, IOS, WEB (send exactly one of these)
kind string No PUSH = Firebase token (default). VOIP = iPhone PushKit token, used only to ring for calls enum: PUSH, VOIP
deviceId string Yes for VOIP, send always Stable id of this install, the same one sent at login max 128 characters

Rules:

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "Device registered",
  "action_time": "2026-10-28T09:14:03",
  "data": null
}

Error Response JSON Sample:

{
  "success": false,
  "httpStatus": "UNPROCESSABLE_ENTITY",
  "message": "Validation failed",
  "action_time": "2026-10-28T09:14:03",
  "data": { "voipPaired": "deviceId is required for a VOIP token" }
}

Standard Error Types:


2. Remove a device token

Purpose: Stop pushes to this device for this user (logout).

Endpoint: DELETE {base_url}/notifications/devices/{token}

Access Level: πŸ”’ Protected

Authentication: Bearer token

Path Parameters:

Parameter Type Required Description Validation
token string Yes The token to remove. URL-encode it (Firebase tokens contain :) must belong to the caller, otherwise nothing happens

Success Response JSON Sample:

{
  "success": true,
  "httpStatus": "OK",
  "message": "Device removed",
  "action_time": "2026-10-28T09:14:03",
  "data": null
}

Always 200, even if the token was not found, so logout never gets stuck on it. Call it before the access token is thrown away β€” after logout the request has no user. iPhone: delete both the PUSH and the VOIP token.


3. Endpoints a push leads to

These live in calls_meetings_api_doc.md and the bell doc; listed here so you know which one each push needs.

Method Endpoint Used when
POST /calls/{callId}/end User taps Decline on a ringing call (the same call also hangs up or cancels)
POST /calls/{callId}/join Only if you do not have url + token (e.g. learned about the call from the live stream). A push already carries them
GET /calls/{callId} After a wake-up: is this call still ringing? (catches a cancel you missed)
GET /events Live stream (SSE) while the app is open: call.updated, notification.added, …
GET /notifications/... The bell list β€” a SYSTEM push with an unknown target opens it

Part 4 β€” The five contracts

Every push carries these three keys:

Key Meaning
contract CHAT_MESSAGE, CALL_INCOMING, CALL_ENDED, READ_ELSEWHERE or SYSTEM
v Contract version, "1" today. New keys can appear without a new version
type The server's notification type, e.g. CHAT_NEW_MESSAGE, ORDER_SHIPPED
Contract Shows something? Urgent Expires Replaces
CHAT_MESSAGE yes yes β€” β€”
CALL_INCOMING rings yes after ringSeconds (45) β€”
CALL_ENDED silent yes 60 s an undelivered ring of the same call
READ_ELSEWHERE silent no 24 h an older read push of the same chat
SYSTEM yes when priority is HIGH/URGENT β€” β€”

4.1 CHAT_MESSAGE β€” someone sent a message

{
  "contract": "CHAT_MESSAGE", "v": "1", "type": "CHAT_NEW_MESSAGE",
  "messageId": "m-81", "conversationId": "c-12", "conversationType": "GROUP",
  "messageType": "IMAGE", "senderJid": "su_ali@dev.chat.nexgate.co",
  "senderName": "Ali", "senderAvatar": "https://…/small.jpg",
  "groupId": "g-3", "groupName": "Family", "groupAvatar": "https://…/g.jpg",
  "preview": "πŸ“· Photo Β· at the beach", "sentAt": "2026-10-28T09:14:03Z"
}
Key Always? Meaning
messageId yes Skip it if you already have this message
conversationId yes Group notifications by it; open this chat on tap
conversationType yes PERSONAL, GROUP, COMMERCE or SECRET
sentAt yes When it was sent
messageType not SECRET TEXT, IMAGE, VIDEO, AUDIO, VOICE_NOTE, FILE, PRODUCT_REF, ORDER_REF, OFFER_REF
senderJid not SECRET Who sent it
senderName not SECRET User display name or shop name
senderAvatar when set Small image link
preview not SECRET Ready to show: the text, or πŸ“· Photo, 🎀 Voice note, πŸ“· Photo Β· caption… Max 120 characters
groupId, groupName, groupAvatar GROUP only The group, for title and icon
replyToYou only when "true" Someone replied to your message. Show it even if the chat is muted

Secret chats carry only messageId, conversationId, conversationType, sentAt. Show "New message" and nothing else.

Chat Title Line
Personal / shop senderName preview
Group groupName senderName: preview
Secret NexGate New message

A muted chat gets no push β€” except a reply to your own message, which arrives with replyToYou: "true".

Which picture where (group messages carry both):

Main picture Small picture / per line
Android groupAvatar (large icon), groupName as conversation title senderAvatar on each line (MessagingStyle person)
iOS groupAvatar (communication notification, group image) sender name in the text
Web groupAvatar as icon β€”
No groupAvatar senderAvatar, or the group's initials β€”

4.2 CALL_INCOMING β€” ring

{
  "contract": "CALL_INCOMING", "v": "1", "type": "CHAT_INCOMING_CALL",
  "callId": "k-55", "conversationId": "c-12", "kind": "VIDEO",
  "callerJid": "su_ali@dev.chat.nexgate.co", "callerName": "Ali", "callerAvatar": "https://…",
  "roomName": "call-k-55", "url": "wss://…livekit…", "token": "eyJ…",
  "ringSeconds": "45", "expiresAt": "2026-10-28T09:14:48Z",
  "groupId": "g-3", "groupName": "Family", "groupAvatar": "https://…/g.jpg", "participantCount": "5"
}
Key Meaning
callId The call
conversationId The chat the call belongs to
kind AUDIO or VIDEO
callerJid, callerName, callerAvatar Who is calling
groupId, groupName, groupAvatar Group calls only. Ring as the group: name + picture of the group, "Ali is calling" underneath
participantCount Everyone invited, caller included ("2" on a 1:1)
roomName, url, token Everything needed to answer at once (LiveKit). Token valid 10 minutes
ringSeconds How long to ring
expiresAt Do not ring if this time has passed (a late push)

4.3 CALL_ENDED β€” stop ringing (silent)

{ "contract": "CALL_ENDED", "v": "1", "type": "CHAT_CALL_ENDED",
  "callId": "k-55", "conversationId": "c-12", "outcome": "ANSWERED_ELSEWHERE",
  "answeredBy": "su_me@dev.chat.nexgate.co" }
Key Meaning
callId Stop ringing for this call, remove its notification
conversationId The chat
outcome COMPLETED, DECLINED, MISSED, CANCELLED, BUSY, FAILED, ANSWERED_ELSEWHERE
answeredBy Only with ANSWERED_ELSEWHERE: who picked up
callActive Group calls only, "true": your ringing stopped (you declined, or it rang 45 s), but the call goes on β€” show "Join" in the chat
durationSeconds Only when the call actually happened

If you are already in this call, ignore it β€” never hang up on a CALL_ENDED with ANSWERED_ELSEWHERE. A missed call also gets its own SYSTEM push (CHAT_MISSED_CALL, "Missed call from Ali").

4.4 READ_ELSEWHERE β€” read on another device (silent)

{ "contract": "READ_ELSEWHERE", "v": "1", "type": "CHAT_READ_ELSEWHERE", "conversationId": "c-12" }

Remove every notification of this chat from this device.

4.5 SYSTEM β€” everything else (orders, shipping, follows, payments…)

{
  "contract": "SYSTEM", "v": "1", "type": "ORDER_SHIPPED",
  "title": "Your order is on the way", "body": "Order #1042 left the shop",
  "targetType": "ORDER", "targetId": "o-1042", "imageUrl": "https://…",
  "priority": "HIGH", "sentAt": "2026-10-28T09:14:03Z"
}
Key Meaning
title Ready-made title
body Second line (may be empty β€” then title only)
targetType What a tap opens β€” same values as the bell doc, Part 3
targetId The id to open (missing for targets that need none)
imageUrl A picture, when the type has one
priority LOW, NORMAL, HIGH, URGENT β€” HIGH/URGENT get sound + heads-up
sentAt When it was sent

Unknown targetType β†’ open the bell list. Not every notification type sends a push; that is decided per type on the server. The app just handles what arrives.


Part 5 β€” Flows

5.1 Chat message

  Ali sends "hi" to Mary
            β”‚
            β–Ό
  backend ──► Mary's chat muted? ── yes ──► no push (unread count only)
            β”‚ no
            β–Ό
  push CHAT_MESSAGE to EVERY device of Mary
            β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β–Ό                                       β–Ό
  app CLOSED / background                 app OPEN
   β”‚                                       β”‚
   β–Ό                                       β–Ό
  show notification                        message already came over XMPP
  (grouped by conversationId)              messageId known? ── yes ──► drop the push
   β”‚                                       β”‚ no (rare)
   β–Ό                                       β–Ό
  tap ──► open chat c-12                   apply Part 6 (banner or nothing)

5.2 Incoming call β€” Android and web

  Ali calls Mary
       β”‚
       β–Ό
  push CALL_INCOMING (urgent, expires in 45 s)
       β”‚
       β–Ό
  expiresAt passed? ── yes ──► do nothing
       β”‚ no
       β–Ό
  RING (full-screen on Android, notification on web)
       β”‚
   β”Œβ”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β–Ό   β–Ό                         β–Ό                             β–Ό
 Answer                        Decline                     CALL_ENDED arrives,
   β”‚                             β”‚                         or 45 s pass
   β–Ό                             β–Ό                             β”‚
 join LiveKit with              POST /calls/{callId}/end       β–Ό
 url + token from the push                                 stop ringing
 (no extra request)

5.3 Incoming call β€” iPhone

  Ali calls Mary (iPhone registered PUSH + VOIP with one deviceId)
       β”‚
       β–Ό
  server sends the ring over Apple VoIP ONLY to this iPhone
  (its Firebase token is skipped for the ring, so it rings once)
       β”‚
       β–Ό
  PushKit wakes the app  ──►  reportNewIncomingCall to CallKit  ── IMMEDIATELY, EVERY TIME
       β”‚                      (even if expired: report it, then end it at once)
       β–Ό
  open the live stream / GET /calls/{callId}  ── already ended? ──► end the CallKit call
       β”‚ still ringing
       β–Ό
  CallKit UI:  Answer ──► join LiveKit with url + token
               Decline ──► POST /calls/{callId}/end

An iPhone without a VOIP token gets a normal Firebase notification ("Ali is calling") instead β€” no ringing.

5.4 Answered on another device

  Mary's phone + tablet + browser are ringing
              β”‚
   Mary answers on the PHONE
              β”‚
              β–Ό
  phone joins LiveKit  ──►  backend learns "Mary joined"
              β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β–Ό                                     β–Ό
  devices with the app OPEN                devices CLOSED / background
  live event call.updated                  push CALL_ENDED
  { state: ACTIVE, answeredBy }            { outcome: ANSWERED_ELSEWHERE }
      β”‚                                     β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β–Ό
       am I the device in the call?  ── yes ──► ignore
                      β”‚ no
                      β–Ό
       stop ringing ("Answered on another device")

5.4b Group call

  Ali calls the Family group (Bea, Cleo, Dan)
              β”‚
   all three ring ── title "Family", line "Ali is calling"
              β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β–Ό          β–Ό                       β–Ό                          β–Ό
 Bea       Cleo declines            Dan does nothing           Ali hangs up
 answers   POST /calls/{id}/end     (45 s pass)                before anyone answers
   β”‚          β”‚                       β”‚                          β”‚
   β”‚          β–Ό                       β–Ό                          β–Ό
   β”‚   only Cleo stops ringing   only Dan stops ringing      everyone stops
   β”‚   CALL_ENDED                CALL_ENDED                  CALL_ENDED CANCELLED
   β”‚   { DECLINED,               { MISSED, callActive }
   β”‚     callActive }            + "Missed call" (SYSTEM)
   β–Ό
 call goes on with Ali + Bea; Cleo and Dan can still join from the chat
 it ends when the last person leaves

5.5 Read on another device

  Mary opens chat c-12 on the browser
              β”‚
              β–Ό
  backend ──► push READ_ELSEWHERE { conversationId: c-12 } to Mary's devices
              β”‚
              β–Ό
  phone removes every notification of c-12  (and resets that chat's badge)

5.6 System notification

  order o-1042 shipped
         β”‚
         β–Ό
  backend saves a bell row ──► live event notification.added (app open)
         β”‚
         └──► push SYSTEM (title, body, targetType ORDER, targetId o-1042)
                    β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β–Ό                     β–Ό
       app OPEN               app CLOSED
       act on the live        show title + body
       event, ignore push     β”‚
                              β–Ό
                       tap ──► targetType known? ── yes ──► open ORDER o-1042
                                     β”‚ no
                                     β–Ό
                               open the bell list

Part 6 β€” App open vs closed: what to show

The server cannot know which screen you are on, so the app decides.

Where the user is New chat message System notification
Anywhere else in the app In-app banner + sound; tap opens the chat Banner if priority HIGH/URGENT, otherwise only the bell badge
Chat list Banner + sound; the chat moves to the top Same as above
Inside chat A, message from A No banner β€” it appears in the thread (soft sound) Same as above
Inside chat A, message from B Banner + sound Same as above
On the exact screen it is about (that order) β€” No banner; refresh the screen
Muted chat Unread count only β€” but a push with replyToYou: "true" is shown β€”
Incoming call Full-screen ringing, always β€”

Never show the same thing twice. When the app is open you get each message over XMPP and as a push. Keep the messageIds you have shown (the last few hundred are enough); drop a push whose messageId you know. For system notifications, when the app is open act on the live notification.added event and ignore the push.

Background / closed:

Contract Android iOS Web
CHAT_MESSAGE App builds the notification iOS shows title + line; extension adds the photo Service worker builds it
CALL_INCOMING Full-screen ringing CallKit (VoIP push) Notification only β€” a closed browser cannot ring
CALL_ENDED Stop ringing, remove it Silent; remove the call notification / end CallKit call Close the notification
READ_ELSEWHERE Remove the chat's notifications Silent; remove them Close them
SYSTEM App builds it iOS shows title + body Service worker builds it

Part 7 β€” Android guide

What arrives: every push is data-only. Android never draws anything by itself β€” onMessageReceived is called for every push, app open or closed, and the app builds every notification.

7.1 Setup

Channel Importance Used for
chats HIGH (sound + heads-up) CHAT_MESSAGE
calls HIGH, ringtone, full-screen CALL_INCOMING
alerts HIGH SYSTEM with HIGH/URGENT
general DEFAULT other SYSTEM

7.2 Handling (FirebaseMessagingService)

onMessageReceived(data)
   switch data["contract"]
     CHAT_MESSAGE   β†’ skip if messageId known; skip if this chat is open (Part 6);
                      MessagingStyle notification, id = conversationId hash,
                      add the line to the existing one; person icon = senderAvatar
     CALL_INCOMING  β†’ skip if expiresAt passed; CallStyle notification with
                      full-screen intent (or ConnectionService); ring until
                      ringSeconds / CALL_ENDED; keep url + token for Answer
     CALL_ENDED     β†’ cancel the call notification for callId; stop ringtone
                      (ignore if this device is IN the call)
     READ_ELSEWHERE β†’ cancel all notifications of conversationId
     SYSTEM         β†’ channel by priority; title + body; imageUrl as BigPicture;
                      tap intent carries targetType + targetId
     else           β†’ ignore
onNewToken(token)   β†’ POST /notifications/devices

7.3 Best practice


Part 8 β€” iOS guide

What arrives:

8.1 Setup

8.2 Handling

Notification Service Extension (alert pushes)
   CHAT_MESSAGE β†’ download senderAvatar / groupAvatar (short timeout), attach;
                  optional: communication notification (INSendMessageIntent) to show the face
   SYSTEM       β†’ download imageUrl, attach
   always call the contentHandler before the time limit (~30 s)

userNotificationCenter(willPresent:)   app OPEN β†’ apply Part 6; return [] to hide
userNotificationCenter(didReceive:)    tap β†’ CHAT_MESSAGE open conversationId;
                                             SYSTEM open targetType/targetId, unknown β†’ bell
application(didReceiveRemoteNotification:) silent pushes
   CALL_ENDED     β†’ end the CallKit call / remove the notification for callId
                    (ignore if this device is in the call)
   READ_ELSEWHERE β†’ remove delivered notifications with thread-id = conversationId

pushRegistry(didReceiveIncomingPushWith:) VoIP
   β†’ CXProvider.reportNewIncomingCall(callId, callerName, hasVideo: kind == VIDEO)
     BEFORE the completion handler, EVERY TIME
   β†’ expired or already ended? report it, then end it straight away
   β†’ Answer: join LiveKit with url + token     Decline: POST /calls/{callId}/end

8.3 Best practice


Part 9 β€” Web guide

What arrives: data-only, like Android. Nothing shows unless the site or the service worker shows it.

9.1 Setup

9.2 Handling

page open         onMessage(payload)         β†’ apply Part 6 (in-page banner, no system notification)
page closed /     service worker
background        onBackgroundMessage(payload)
                    CHAT_MESSAGE   β†’ showNotification(title, { body, icon: senderAvatar,
                                       tag: conversationId, renotify: true, data })
                    CALL_INCOMING  β†’ showNotification("Ali is calling", { tag: callId,
                                       requireInteraction: true, data })   (no ringing)
                    CALL_ENDED     β†’ close notifications with tag = callId
                    READ_ELSEWHERE β†’ close notifications with tag = conversationId
                    SYSTEM         β†’ showNotification(title, { body, image: imageUrl, data })
notificationclick β†’ focus an open tab or open the URL for the chat / target

9.3 Best practice


Part 10 β€” What the server already does for you

You do not need to… Because the server…
Skip muted chats never sends a push for a muted chat (only a reply to you gets through, marked replyToYou)
Stop a group ring stops each person's ring on its own: decline, 45 s, or the call ending
Send a "cancel" for a late ring lets the ring expire after ringSeconds, and a CALL_ENDED replaces a ring not yet delivered
Ring an iPhone twice sends the ring over VoIP only, skipping that device's Firebase token
Hide secret-chat text sends no name and no text for secret chats
Collapse many "read" pushes collapses them per chat
Clean up dead tokens deletes a token Firebase or Apple rejects (app re-registers on next start)
Format previews sends preview ready to show (πŸ“· Photo Β· caption, max 120 characters)
Translate titles SYSTEM title / body come from our templates

Part 11 β€” Limits, known gaps and who does what

Item Status
Staging Firebase + Apple VoIP on since 2026-10-28
Production Off until this release ships
Real-device test Not done yet. Each app registers a token on staging, then we send a message and make a call while the backend lead watches the server log
Silent pushes on iOS Best-effort by Apple's design β€” always re-check on wake (8.3)
Web ringing Not possible; a closed browser can only show a notification
Who Does
Backend Sends the five contracts; keys, Firebase project, server logs during testing
Android Channels, onMessageReceived for all five, full-screen call, token register/remove
iOS Service Extension, PushKit + CallKit, silent push handling, two tokens with one deviceId
Web Service worker, permission on click, token register/remove

Something not behaving as written here? Send the backend lead the time, the user and the device β€” the server log shows every push it sent and what Firebase or Apple answered.


Integration Checklist