#12532·novu

Bug Report: FCM invalid token cleanup may not handle NotRegistered / registration-token-not-registered consistently

Author: nikdom1272Created Sep 2, 2026Updated Sep 7, 2026
Labelstriage

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=true

However, 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.ts

Novu defines:

typescript
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 found

are explicitly recognized as subscriber/device-related push errors.

However, automatic token removal uses a different check:

typescript
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.ts

the invalid-token detection in v3.19.0 is:

typescript
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:

  1. errors recognized by Novu as subscriber/device-related errors; and
  2. 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 token

without the resulting exception message also containing the exact substring:

Requested entity was not found

then:

typescript
isSubscriberError(errorMessage)

can recognize the failure as subscriber-related, while:

typescript
FcmPushProvider.isTokenInvalid(errorMessage)

returns false.

In that situation, the invalid token may remain stored in:

subscriber.channels[].credentials.deviceTokens

and 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

  1. Run Novu self-hosted v3.19.0.

  2. Configure the built-in FCM push provider.

  3. Enable automatic invalid/expired token cleanup on the Novu worker:

IS_EXPIRED_TOKENS_REMOVAL_ENABLED=true
  1. Restart the worker so the feature flag is loaded.

  2. Create or use an existing subscriber containing one or more FCM device tokens under:

subscriber.channels[].credentials.deviceTokens
  1. Make 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.

  2. Trigger a Novu workflow containing an FCM push step for that subscriber.

  3. Let Firebase reject the registration token with an invalid/unregistered-token response such as:

NotRegistered
registration-token-not-registered
messaging/registration-token-not-registered
UNREGISTERED

or another equivalent FCM response.

  1. Observe the push failure in the Novu worker/provider logs.

  2. Check the subscriber's FCM credentials after the failed delivery.

  3. Verify whether the invalid token has been removed from:

subscriber.channels[].credentials.deviceTokens
  1. The relevant code path shows that removal occurs only when:
typescript
FcmPushProvider.isTokenInvalid(errorMessage)

returns true.

  1. In Novu v3.19.0, the FCM implementation currently checks only for:
Requested entity was not found

Expected behavior

When:

IS_EXPIRED_TOKENS_REMOVAL_ENABLED=true

is 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 found

Only 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   -> valid

and FCM rejects token-B, the resulting subscriber credentials should contain:

token-A
token-C

and 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:

typescript
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:

typescript
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:

typescript
private readonly INVALID_TOKEN_ERRORS = [
  'Requested entity was not found'
];

and:

typescript
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-C

Reliable 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=true

is enabled, Novu calls removeInvalidDeviceToken().

That method performs:

typescript
$pull: {
  'channels.$.credentials.deviceTokens': deviceToken,
}

It then invalidates the subscriber cache and creates an execution detail with:

PUSH_INVALID_TOKEN_REMOVED

Therefore 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 token

while FcmPushProvider.isTokenInvalid() currently recognizes only:

Requested entity was not found

One possible improvement would therefore be to expand the definitive invalid-token detection in the FCM provider.

For example:

typescript
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-token

could be classified directly as definitive invalid-token errors.

Could you please confirm whether limiting FcmPushProvider.isTokenInvalid() to:

Requested entity was not found

is 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?

Are you willing to submit PR?

None