Notification-nexgate-service(4)
In-App Notifications (The Bell)
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:
- Every endpoint needs
Authorization: Bearer <accessToken>. A user only ever sees their own rows β there is no way to read someone else's bell. - Pages start at 1, not 0.
page=0returns400 "Page index must not be less than zero". - The row's
titleis the whole sentence ("joshdoe liked your post").messageis an optional second line (a snippet of the post or comment). It is an empty string when there is nothing to quote, e.g. a photo-only post. Never show an empty grey line. createdAtandreadAtcarry no timezone. They are UTC. AppendZbefore parsing, or "2 minutes ago" will be off by your offset.- Tapping a row opens
targetType+targetId. If your app does not know thetargetType, stay on the bell list. That rule is what lets the backend add new notification types without breaking old app versions (Part 3). - Ignore unknown keys inside
data. It carries values the server used to build the sentence; some of them are internal.
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
- Marking read sends no event. If the same user has the app open on a
phone and a tablet and reads on one, the other keeps its old badge until it
next calls
unread-count. Call it whenever the app returns to the foreground. - Deleting sends no event either. Update your list locally.
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
idtwice, keep one.
HTTP Method Badge Standards
- GET - GET - Green (Safe, read-only operations)
- PUT - PUT - Yellow (Update)
- DELETE - DELETE - Red (Remove resources)
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:
400 BAD_REQUEST:pageis 0 or negative401 UNAUTHORIZED: Token missing, invalid or expired
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:
400 BAD_REQUEST:pageis 0 or negative401 UNAUTHORIZED: Token missing, invalid or expired
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:
400 BAD_REQUEST:pageis 0 or negative401 UNAUTHORIZED: Token missing, invalid or expired
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:
400 BAD_REQUEST:shopIdis not a UUID, orpageis 0 or negative401 UNAUTHORIZED: Token missing, invalid or expired
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:
400 BAD_REQUEST: A listed id is missing or not yours; nothing was changed401 UNAUTHORIZED: Token missing, invalid or expired422 UNPROCESSABLE_ENTITY:notificationIdsis empty
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/jsonand 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:
400 BAD_REQUEST: A listed id is missing or not yours; nothing was deleted401 UNAUTHORIZED: Token missing, invalid or expired422 UNPROCESSABLE_ENTITY:notificationIdsis empty
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
- Badge loads from
unread-counton app start and on every return to the foreground -
notification.addedon the existing/api/v1/eventsstream sets the badge fromunreadCountβ no second stream - Pages start at 1; paging stops when
hasNextisfalse; duplicateids are dropped -
titleis shown as the sentence;messageline hidden when"" -
createdAtparsed as UTC - A tap marks the row read, then opens
targetType+targetId - An unknown
targetTypestays on the bell list β no crash, no error - A
404from the target screen shows that screen's "not available" state - Badge adjusted locally after mark-read and delete (no event comes back)
- Batch mark-read and batch delete send only ids still in the list
- Unknown keys in
dataand unknowntypevalues are ignored
Quick Reference Guide
Common HTTP Status Codes
200 OK: Successful request400 Bad Request: Invalid request data (e.g.page=0, an id that is not yours in a batch)401 Unauthorized: Authentication required/failed404 Not Found: Row does not exist or is not yours422 Unprocessable Entity: Validation errors (emptynotificationIds)500 Internal Server Error: Server error
Authentication
- Bearer Token: Include
Authorization: Bearer <accessToken>in headers
Data Format Standards
- Dates: ISO 8601, UTC without an offset (
2026-09-18T19:18:40.736493) - IDs: UUID strings
- Pagination:
page(from 1) andsize; response carriescurrentPage,totalPages,hasNext
Push Notifications Documentation
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:
- Register the device token after every login and every time Firebase or PushKit gives you a new one.
- Delete the token on logout, or the next person on that phone gets this person's pushes.
- Read
contractfirst. Ignore a contract you don't know, and ignore keys you don't know. - All values in a push are strings (a Firebase rule):
"ringSeconds": "45", not45. - Times are ISO-8601 UTC with a
Z(2026-10-28T09:14:03Z). - All three apps use the same Firebase project:
nextgate-484910. A token from another project never receives anything.
Contents
- Part 0 β Is this the right document?
- Part 1 β How a push travels (the big picture)
- Part 2 β Setup per platform
- Part 3 β Endpoints (register, remove, and the call endpoints a push leads to)
- Part 4 β The five contracts (what arrives, key by key)
- Part 5 β Flows (chat, call, answered elsewhere, read elsewhere, system)
- Part 6 β App open vs closed: what to show
- Part 7 β Android guide
- Part 8 β iOS guide
- Part 9 β Web guide
- Part 10 β What the server already does for you
- Part 11 β Limits, known gaps and who does what
- Integration checklist
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:
- App open β you already get everything live (XMPP for chat,
GET /api/v1/eventsfor the rest). A push may arrive too; the app decides whether to show anything (Part 6). - App in the background or closed β the push is the only thing that reaches you.
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)
- Only the backend decides who gets a push. Only the notification server talks to Firebase and Apple. Apps never call Firebase or Apple to send anything.
- One message reaches every registered device of the user (phone, tablet, browser), even while the user is connected somewhere else. The app on each device decides what to show.
- If Firebase or Apple says a token is dead, the server deletes it. The app gets it back by registering again (Part 2).
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:
- Same token sent again β nothing new is created; it is refreshed.
- The same token registered by another account (shared phone) β it moves to the new account.
- A new token with the same
deviceIdandkindβ the old token of that device is deleted. This is whydeviceIdmatters: without it, old tokens pile up until Firebase reports them dead.
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
- Declining a group call never ends it for the others. If everyone declines before anyone answers, the call ends
(
DECLINED) for the caller too. - The same
POST /calls/{callId}/endis Decline (before you joined) and Leave (after you joined). - Anyone who never picked up and did not decline gets "Missed call" β also when the call ended before their 45 s.
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
google-services.jsonfrom projectnextgate-484910, Firebase Messaging SDK.- Android 13+: ask
POST_NOTIFICATIONSat a good moment (after login, with a one-line reason), not on first launch. - Create notification channels once at start:
| 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
- Finish
onMessageReceivedfast (under ~10 s). Load avatars with a short timeout and show the notification without the picture if it is slow. - Urgent pushes (chat, call) are delivered high-priority. Always show a notification for them β Android lowers the priority of apps that receive urgent pushes and show nothing.
- Calls: Android 14+ restricts full-screen intents to calling apps; declare
USE_FULL_SCREEN_INTENTand checkcanUseFullScreenIntent(), fall back to a heads-up notification. Use aphoneCallforeground service during the call. - Some phones (Xiaomi, Oppo, Huaweiβ¦) kill background apps. Show a one-time hint to allow "autostart" / remove battery optimisation if calls are missed.
Part 8 β iOS guide
What arrives:
CHAT_MESSAGE,SYSTEM, andCALL_INCOMINGwithout VoIP β a normal alert (title + body already set by the server, sound,mutable-content: 1,thread-id= chat or target type). The data keys sit next toapsinuserInfo.CALL_ENDED,READ_ELSEWHEREβ silent background pushes (content-available: 1).CALL_INCOMINGto an iPhone with aVOIPtoken β PushKit push; the data keys are top-level in the payload.
8.1 Setup
GoogleService-Info.plistfromnextgate-484910; capabilities: Push Notifications, Background Modes β Remote notifications + Voice over IP.- Ask notification permission after login with a reason, register for remote notifications, pass the APNs token to
Firebase, then register the Firebase token with us (
kind: PUSH). PKPushRegistrywith type.voIPβ register that token with us (kind: VOIP, samedeviceId).- A Notification Service Extension (for avatars and pictures).
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
- Report every VoIP push to CallKit. If the app receives one and shows no call, iOS kills the app and, after a few times, stops delivering VoIP pushes to it.
- Silent pushes are best-effort. iOS may delay or drop them (low battery, Low Power Mode, an app the user
swiped away). So never rely on them alone: after any wake and on every app open, check the call (
GET /calls/{callId}) and refresh unread counts. For "answered elsewhere" while CallKit rings, also listen to the live stream once the app is awake. - Xcode builds use Apple's sandbox, TestFlight / App Store use production. Nothing to configure β the server tries both.
- The simulator cannot receive VoIP pushes; calls must be tested on a real iPhone.
Part 9 β Web guide
What arrives: data-only, like Android. Nothing shows unless the site or the service worker shows it.
9.1 Setup
- Firebase JS SDK with the
firebaseConfigwe give you. - A service worker file
/firebase-messaging-sw.jsat the site root. - Ask permission only after a user click ("Turn on notifications"), never on page load β browsers block or hide the prompt otherwise, and a "Block" is hard to undo.
getToken(messaging, { vapidKey })βPOST /notifications/deviceswithplatform: "WEB",kind: "PUSH"and adeviceIdkept in local storage for this browser.
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
- Using
tag=conversationIdmakes a new message replace the chat's notification instead of stacking. - Safari on iPhone only receives web push when the site is added to the home screen (iOS 16.4+).
- A token can change when the user clears site data; register on every load while logged in.
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
- Same Firebase project
nextgate-484910in the app - Register the token after login, on every start and on refresh β always with
deviceId - Delete every token of the device on logout, before dropping the access token
- iPhone: also register the PushKit token as
kind: VOIP; report every VoIP push to CallKit - Switch on
contract; ignore unknown contracts and keys - Chat: group by
conversationId, skip knownmessageIds, secret = "New message" only - Group chat: group picture + name as title, sender on each line;
replyToYoushows even when muted - Group call: ring as
groupName/groupAvatar;CALL_ENDEDwithcallActive= stop ringing, show "Join" - Apply the Part 6 table when the app is open
- Calls: skip if
expiresAtpassed; ring untilringSecondsorCALL_ENDED; Answer usesurl+token; Decline =POST /calls/{callId}/end -
ANSWERED_ELSEWHERE/call.updatedACTIVE: stop ringing unless this device is in the call -
READ_ELSEWHEREclears that chat's notifications -
SYSTEM: tap openstargetType+targetId; unknown β bell list - After any wake and on app open: re-check calls and unread counts (don't trust silent pushes alone)