Mobile push notifications
Hand-off for the iOS and Android app. The backend already sends a push when an in-app notification is created. The app’s job is to obtain the Firebase Cloud Messaging token and tell the backend which user is logged in on that install.
Base URL:
https://events.pana.space/api
Authenticated calls use the access token from login:
Authorization: Bearer <access>
Content-Type: application/json
How the token works
The FCM token identifies the app install, not the user. Firebase creates it. Login does not create a new one, and the same phone keeps the same token when a different person logs in.
The backend stores a mapping: one token belongs to one user at a time. One user can have many tokens (phone and tablet).
| Event | What the app does |
|---|---|
| App install / Firebase ready | Ask Firebase for the token. Do not call the backend yet. |
| Login succeeds | POST /api/v1/devices/ with that token |
| Firebase rotates the token while logged in | POST /api/v1/devices/ again with the new token. If you still have the previous token, DELETE it first. |
| Logout | DELETE /api/v1/devices/ while the access token is still valid, then call logout |
Do not generate a token per user. Do not register before login. Registering does not replay old notifications. The inbox API below is how the app loads history.
1. Login
POST /api/v1/users/auth/login/
Content-Type: application/json
{
"email": "user@example.com",
"password": "secret"
}
200
{
"access": "<jwt>",
"refresh": "<jwt>"
}
The response also includes the user profile fields. Keep access for the calls below. Keep refresh for logout.
2. Register the device
Call this after every successful login, and again on onTokenRefresh while the user is still logged in.
POST /api/v1/devices/
Authorization: Bearer <access>
Content-Type: application/json
{
"fcm_token": "fcm-registration-token",
"platform": "ios"
}
platform is ios or android (any letter case; stored lowercase). fcm_token is required, trimmed, and at most 512 characters.
If this token is already stored for someone else, the row is reassigned to the current user. That is how a shared phone switches accounts.
200
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"platform": "ios",
"last_active_at": "2026-10-08T14:30:00.123456+00:00"
}
The same 200 is returned for a first registration and for a repeat call.
400
{ "detail": "fcm_token is required." }
{ "detail": "platform must be ios or android." }
401 when the bearer token is missing or expired.
{ "detail": "Authentication credentials were not provided." }
3. Unregister on logout
Call this before logout, with the same access token and the token currently on the device.
DELETE /api/v1/devices/
Authorization: Bearer <access>
Content-Type: application/json
{
"fcm_token": "fcm-registration-token"
}
204 empty body. The row is deleted only when it belongs to the current user.
404 when this user has no row for that token (already removed, or never registered).
{ "detail": "Not found." }
400 and 401 use the same detail shape as register.
Then log out:
POST /api/v1/users/auth/logout/
Authorization: Bearer <access>
Content-Type: application/json
{
"refresh": "<refresh token from login>"
}
200
{ "message": "Logged out successfully." }
If unregister is skipped, the next person who opens the app on that phone can receive the previous user’s pushes until someone else logs in and registers the same token.
4. What a push looks like
A push is sent only when an in-app notification is created or updated for that user (the App channel is on for that category). Email-only preferences do not produce a push. A failed push does not block the in-app notification.
Firebase notification payload:
| Field | Source |
|---|---|
notification.title | Notification title (max 200 characters) |
notification.body | Description, or the title if there is no description (max 1000 characters) |
data values are all strings. Empty string means “none”.
| Key | Meaning |
|---|---|
notification_id | UUID of the in-app notification |
workspace_id | Workspace the event belongs to |
category | task, lead, demo_booking, event, expense, or admin |
verb | Short action name, for example assigned |
target_url | In-app path to open, for example /leads/<id>. May be empty. |
target_type | Record type. May be empty. |
target_id | UUID of that record. Empty string when there is no record. |
Example data:
{
"notification_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"workspace_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"category": "lead",
"verb": "assigned",
"target_url": "/leads/7c9e6679-7425-40de-944b-e07fc1f90ae7",
"target_type": "lead",
"target_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
Use workspace_id and target_url to open the right screen. The device record itself is not tied to one workspace.
5. Inbox (history, not push)
Push does not include notifications that already exist. Load those from the workspace inbox.
GET /api/v1/{workspace_id}/notifications/
Authorization: Bearer <access>
GET /api/v1/{workspace_id}/notifications/unread-count/
Mark one read:
PATCH /api/v1/{workspace_id}/notifications/{notification_id}/read/
Checklist
- Configure the app with the same Firebase project the backend uses.
- On login, read the FCM token and
POST /api/v1/devices/. - On token refresh, register the new token (and delete the old one if you still have it).
- On logout,
DELETE /api/v1/devices/beforePOST /api/v1/users/auth/logout/. - On a received push, route with
workspace_idandtarget_url. - Load older items from
GET /api/v1/{workspace_id}/notifications/.