9.3 Device Tokens
Controller: DeviceTokenController · Route: api/v1/DeviceToken · GroupName: notifications ·
9 endpoints · Registers and manages FCM device tokens for push notifications. Full lifecycle detail in
Chapter 11 — Device Tokens.
| Verb + route | Permission | Request | Response (data) |
|---|---|---|---|
POST GetDataTable | DeviceTokens.ViewAll | DtParameters | DtResult<DeviceTokenDto> (unwrapped) |
GET GetDetails/{id} | DeviceTokens.View | — | DeviceTokenDetailsDto |
GET GetEdit/{id} | DeviceTokens.Edit | — | DeviceTokenEditDto |
GET GetUserDevices/{userId} | DeviceTokens.View | — | DeviceTokenViewListDto[] |
POST Register | DeviceTokens.Register | DeviceTokenEditDto | Guid |
PUT Update/{id} | DeviceTokens.Edit | DeviceTokenEditDto | Guid |
DELETE Delete?restore= | DeviceTokens.Delete | DeleteDto[] | Guid |
PUT Active | DeviceTokens.Active | DeleteDto[] | Guid |
PUT Deactive | DeviceTokens.Deactive | DeleteDto[] | Guid |
POST Register — the endpoint every mobile client calls on login/token refresh
Request (DeviceTokenEditDto — note there is no userId field; the server always uses the
authenticated caller's own ID):
{
"id": "00000000-0000-0000-0000-000000000000",
"isActive": true,
"displayOrder": 0,
"deviceId": "a1b2c3d4-device-uuid-from-os-keychain",
"platform": 1,
"token": "fcm-token-string-from-firebase-sdk"
}
platform: Android=1, Ios=2, Web=3.
Response:
{ "succeeded": true, "message": "DeviceToken is created successfully", "data": "9c2d3e50-0000-0000-0000-000000000001" }
(or "DeviceToken is updated successfully" if an existing row for this UserId + DeviceId was
reactivated/refreshed — see the upsert logic below.)
Business notes — upsert-by-(UserId, DeviceId), not simple insert:
DeviceTokenService.RegisterAsync looks up any existing row (including soft-deleted ones, via
IgnoreQueryFilters()) matching UserId == currentUser and DeviceId == request.DeviceId:
- If found: updates
Token,Platform,LastSeenOn = now, forcesIsActive = true, and — if the row was previously soft-deleted — reverses the soft delete (Is_Deleted = false,DeletedOn/DeletedBy = null). This is exactly what happens on a normal FCM token refresh, or a user who re-registers after having removed the app. - If not found: creates a new row with a fresh
NewId.Next().ToGuid(),UserId = currentUser,LastSeenOn = now.
UserId is a globally unique GUID, so a cross-tenant (UserId, DeviceId) collision is not possible even
though this lookup uses IgnoreQueryFilters() (bypassing the tenant filter is safe here specifically because
of that global uniqueness — this is not a general license to bypass tenant filters elsewhere, see
.claude/rules/multitenancy.md).
Angular (web push): call Register after obtaining an FCM web token via the Firebase JS SDK, and again
whenever the SDK reports a token refresh event.
Flutter: call Register on app start (after login) and on FirebaseMessaging.instance.onTokenRefresh.
Use a stable per-install deviceId (not the FCM token itself) so re-registration on token refresh naturally
upserts rather than creating duplicate rows.
Backend: feeds IDeviceTokenService.GetActiveTokensForUserAsync(userId), which is what
FirebasePushNotificationService.SendToMultipleAsync (via the Push channel provider) queries at dispatch
time — see §7.4.
GET GetUserDevices/{userId}
Returns only IsActive == true && Is_Deleted != true devices for the given user, ordered by
LastSeenOn descending — i.e. most recently active device first.
{
"succeeded": true,
"message": null,
"data": [
{ "id": "9c2d3e50-0000-0000-0000-000000000001", "deviceId": "a1b2c3d4-device-uuid", "platform": 1, "platformName": "Android", "token": "fcm-token-string" }
]
}
Possible errors: empty array (not an error) if the user has no active devices. Related APIs: Push channel, Chapter 11.