Bug Report: FCM invalid token cleanup may not handle NotRegistered / registration-token-not-registered consistently
Description
We noticed a potential inconsistency in the handling and automatic cleanup of invalid FCM device tokens in Novu self-hosted v3.19.0.
Novu already implements automatic removal of expired/invalid push tokens when the following feature flag is enabled on the worker:
IS_EXPIRED_TOKENS_REMOVAL_ENABLED=trueHowever, the logic used by the push worker to recognize subscriber/device-related errors is broader than the logic used by the FCM provider to determine whether a device token should actually be removed.
In:
apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.tsNovu defines:
export const SUBSCRIBER_ERROR_PATTERNS: string[] = [
'NotRegistered',
'InvalidRegistration',
'MismatchSenderId',
'Unregistered',
'BadDeviceToken',
'DeviceTokenNotForTopic',
'ExpiredPushToken',
'InvalidProviderToken',
'Requested entity was not found',
'SenderId mismatch',
'Make sure you have provided a server key as directed by the Expo FCM documentation',
'is not a valid Expo push token',
'The registration token is not a valid FCM registration token',
];Therefore errors such as:
NotRegistered
Unregistered
InvalidRegistration
The registration token is not a valid FCM registration token
Requested entity was not foundare explicitly recognized as subscriber/device-related push errors.
However, automatic token removal uses a different check:
const pushHandler = this.getIntegrationHandler(integration);
const isTokenInvalid = pushHandler?.isTokenInvalid?.(
e.message || e.toString()
);For the built-in FCM provider, in:
packages/providers/src/lib/push/fcm/fcm.provider.tsthe invalid-token detection in v3.19.0 is:
private readonly INVALID_TOKEN_ERRORS = [
'Requested entity was not found'
];
isTokenInvalid(errorMessage: string): boolean {
return this.INVALID_TOKEN_ERRORS.some((error) =>
errorMessage?.includes(error)
);
}This means there appears to be a mismatch between:
- errors recognized by Novu as subscriber/device-related errors; and
- errors that actually trigger automatic removal of the FCM device token.
If Firebase/Firebase Admin returns an invalid-token response such as:
NotRegistered
registration-token-not-registered
messaging/registration-token-not-registered
UNREGISTERED
The registration token is not a valid FCM registration tokenwithout the resulting exception message also containing the exact substring:
Requested entity was not foundthen:
isSubscriberError(errorMessage)can recognize the failure as subscriber-related, while:
FcmPushProvider.isTokenInvalid(errorMessage)returns false.
In that situation, the invalid token may remain stored in:
subscriber.channels[].credentials.deviceTokensand Novu may attempt delivery to the same invalid token again in subsequent workflows.
The existing removal implementation itself appears correct. The potential issue seems specifically related to the classification performed before removeInvalidDeviceToken() is called.
Reproduction steps
Run Novu self-hosted
v3.19.0.Configure the built-in FCM push provider.
Enable automatic invalid/expired token cleanup on the Novu worker:
IS_EXPIRED_TOKENS_REMOVAL_ENABLED=trueRestart the worker so the feature flag is loaded.
Create or use an existing subscriber containing one or more FCM device tokens under:
subscriber.channels[].credentials.deviceTokensMake one of the FCM registration tokens invalid, for example by using a stale token associated with an application installation that is no longer registered with FCM.
Trigger a Novu workflow containing an FCM push step for that subscriber.
Let Firebase reject the registration token with an invalid/unregistered-token response such as:
NotRegistered
registration-token-not-registered
messaging/registration-token-not-registered
UNREGISTEREDor another equivalent FCM response.
Observe the push failure in the Novu worker/provider logs.
Check the subscriber's FCM credentials after the failed delivery.
Verify whether the invalid token has been removed from:
subscriber.channels[].credentials.deviceTokens- The relevant code path shows that removal occurs only when:
FcmPushProvider.isTokenInvalid(errorMessage)returns true.
- In Novu
v3.19.0, the FCM implementation currently checks only for:
Requested entity was not foundExpected behavior
When:
IS_EXPIRED_TOKENS_REMOVAL_ENABLED=trueis enabled, every FCM response that definitively indicates that a registration token is no longer valid should trigger automatic removal of that specific token.
Equivalent FCM invalid-token responses should therefore be handled consistently, including where applicable:
NotRegistered
Unregistered
UNREGISTERED
registration-token-not-registered
messaging/registration-token-not-registered
The registration token is not a valid FCM registration token
Requested entity was not foundOnly the failing device token should be removed.
Other valid tokens associated with the same subscriber must remain untouched.
For example, if the subscriber contains:
token-A -> valid
token-B -> invalid
token-C -> validand FCM rejects token-B, the resulting subscriber credentials should contain:
token-A
token-Cand token-B should be removed automatically.
Ideally, when available, Firebase Admin SDK structured error codes should be used for this classification rather than relying exclusively on human-readable error-message substring matching.
Actual Behavior with Screenshots
The push worker already recognizes a relatively broad set of subscriber/device-related errors:
export const SUBSCRIBER_ERROR_PATTERNS: string[] = [
'NotRegistered',
'InvalidRegistration',
'MismatchSenderId',
'Unregistered',
'BadDeviceToken',
'DeviceTokenNotForTopic',
'ExpiredPushToken',
'InvalidProviderToken',
'Requested entity was not found',
'SenderId mismatch',
'Make sure you have provided a server key as directed by the Expo FCM documentation',
'is not a valid Expo push token',
'The registration token is not a valid FCM registration token',
];However, actual automatic token cleanup does not use SUBSCRIBER_ERROR_PATTERNS.
Instead, the worker executes:
const pushHandler = this.getIntegrationHandler(integration);
const isTokenInvalid = pushHandler?.isTokenInvalid?.(
e.message || e.toString()
);and removes the token only when isTokenInvalid evaluates to true.
The FCM provider in v3.19.0 currently defines:
private readonly INVALID_TOKEN_ERRORS = [
'Requested entity was not found'
];and:
isTokenInvalid(errorMessage: string): boolean {
return this.INVALID_TOKEN_ERRORS.some((error) =>
errorMessage?.includes(error)
);
}Therefore an FCM failure containing NotRegistered, registration-token-not-registered, UNREGISTERED, or another definitive invalid-token response may be recognized as a subscriber-related push failure while still not satisfying the FCM provider's invalid-token check.
The consequence is that the stale token may remain associated with the subscriber and be retried during subsequent push deliveries.
If available, the actual Novu worker log showing the FCM invalid-token response can be attached to this section.
Example:
worker | {"level":30,"time":1788338810453,"pid":1,"serviceName":"@novu/worker","serviceVersion":"3.19.0","platform":"Docker","tenant":"OS","transactionId":"txn_6a97...gisiq","jobId":"6a97e27a...e202","environmentId":"6a91a307...c913","organizationId":"6a91a306...c8e3","context":"AddJob","msg":"Scheduling New Job 6a97e27a...e203 of type: push"}
worker | {"level":30,"time":1788338810611,"pid":1,"serviceName":"@novu/worker","serviceVersion":"3.19.0","platform":"Docker","tenant":"OS","transactionId":"txn_6a97...gisiq","jobId":"6a97e27a...e203","environmentId":"6a91a307...c913","organizationId":"6a91a306...c8e3","context":"SendMessagePush","deviceToken":"c_wh9jdF...qsQXu9cw","overrides":{},"step":{"digest":false,"events":[],"total_count":0},"msg":"Sending push notification for jobId 6a97e27a...e203"}
worker | {"level":30,"time":1788338810698,"pid":1,"serviceName":"@novu/worker","serviceVersion":"3.19.0","platform":"Docker","tenant":"OS","transactionId":"txn_6a97...gisiq","jobId":"6a97e27a...e203","environmentId":"6a91a307...c913","organizationId":"6a91a306...c8e3","context":"SendMessagePush","errorContent":"{\"message\":\"Sending message failed due to \\\"NotRegistered\\\"\",\"name\":\"Error\"}","message":"Sending message failed due to \"NotRegistered\"","msg":"Failed push delivery for jobId 6a97e27a...e203 Sending message failed due to \"NotRegistered\""}
worker | {"level":30,"time":1788338810728,"pid":1,"serviceName":"@novu/worker","serviceVersion":"3.19.0","platform":"Docker","tenant":"OS","transactionId":"txn_6a97...gisiq","jobId":"6a97e27a...e203","environmentId":"6a91a307...c913","organizationId":"6a91a306...c8e3","context":"WorkflowRunService","notificationId":"6a97e27a...e200","resolvedStatus":"errored","channelJobStatuses":[{"id":"6a97e27a...e203","status":"failed"}],"messageCount":1,"msg":"Delivery lifecycle resolved"}
worker | {"level":30,"time":1788338810742,"pid":1,"serviceName":"@novu/worker","serviceVersion":"3.19.0","platform":"Docker","tenant":"OS","transactionId":"txn_6a97...gisiq","jobId":"6a97e27a...e203","environmentId":"6a91a307...c913","organizationId":"6a91a306...c8e3","context":"WorkflowRunService","notificationId":"6a97e27a...e200","resolvedStatus":"errored","channelJobStatuses":[{"id":"6a97e27a...e203","status":"failed"}],"messageCount":1,"msg":"Delivery lifecycle resolved"}Novu version
Self-hosted - Novu v3.19.0
npm version
No response
node version
No response
Provide any additional context for the Bug.
This issue was identified while testing a migration from OneSignal to Novu self-hosted.
Our subscriber model can contain multiple FCM device tokens for the same subscriber, representing multiple application installations/devices.
For example:
Subscriber
└── FCM
├── device-token-A
├── device-token-B
└── device-token-CReliable automatic cleanup of invalid tokens is therefore important.
Otherwise stale tokens originating from:
- application uninstallations;
- application reinstallations;
- FCM token rotations;
- previously registered devices;
- imported token datasets from another push provider;
can remain indefinitely attached to subscribers.
This causes repeated failed delivery attempts and accumulation of stale device registrations.
The interesting part is that Novu v3.19.0 already contains the required cleanup mechanism.
When a token is classified as invalid and:
IS_EXPIRED_TOKENS_REMOVAL_ENABLED=trueis enabled, Novu calls removeInvalidDeviceToken().
That method performs:
$pull: {
'channels.$.credentials.deviceTokens': deviceToken,
}It then invalidates the subscriber cache and creates an execution detail with:
PUSH_INVALID_TOKEN_REMOVEDTherefore the cleanup implementation itself appears to be correct.
The potential inconsistency appears to exist specifically in the classification step before removeInvalidDeviceToken() is executed.
The worker already recognizes:
NotRegistered
InvalidRegistration
Unregistered
Requested entity was not found
The registration token is not a valid FCM registration tokenwhile FcmPushProvider.isTokenInvalid() currently recognizes only:
Requested entity was not foundOne possible improvement would therefore be to expand the definitive invalid-token detection in the FCM provider.
For example:
private readonly INVALID_TOKEN_ERRORS = [
'Requested entity was not found',
'NotRegistered',
'registration-token-not-registered',
'messaging/registration-token-not-registered',
'UNREGISTERED',
'The registration token is not a valid FCM registration token',
];However, using Firebase Admin SDK structured error codes would probably be more robust than relying on message substring matching.
For example, where available:
messaging/registration-token-not-registered
messaging/invalid-registration-tokencould be classified directly as definitive invalid-token errors.
Could you please confirm whether limiting FcmPushProvider.isTokenInvalid() to:
Requested entity was not foundis intentional, or whether the other definitive FCM invalid/unregistered-token responses should trigger the same cleanup mechanism?
Have you spent some time to check if this bug has been raised before?
- I checked and didn't find a similar issue
Have you read the Contributing Guidelines?
- I have read the Contributing Guidelines
Are you willing to submit PR?
None
Source: novuhq/novu