@bhaskardey772/fcm-backend
v1.1.1
Published
Backend helper for Firebase Cloud Messaging — send push notifications to devices, topics, and conditions without boilerplate
Maintainers
Readme
@bhaskardey772/fcm-backend
Backend helper for Firebase Cloud Messaging (FCM). Send push notifications to devices, topics, and conditions — without writing any Firebase Admin boilerplate.
Written in TypeScript. Ships with full type declarations.
Table of Contents
- Which version do I install?
- Installation
- Setup — Service Account
- Complete Express Example
- API
- Payload Reference
- Return Types
- Error Handling
- TypeScript
Which version do I install?
Answer one question: does your project already import admin from 'firebase-admin' anywhere?
| | Default | Slim |
|---|---|---|
| My project | Fresh project / no Firebase yet | Already uses firebase-admin |
| Install | npm install @bhaskardey772/fcm-backend | npm install @bhaskardey772/fcm-backend firebase-admin |
| Import path | @bhaskardey772/fcm-backend | @bhaskardey772/fcm-backend/slim |
| firebase-admin in bundle | Yes (bundled in — no extra install) | No (uses your existing copy) |
| Why slim? | — | Avoids shipping two copies of firebase-admin in your app |
If you have no idea — use Default. You can always switch later.
Installation
Default (bundled)
firebase-admin is bundled inside — nothing else to install.
npm install @bhaskardey772/fcm-backendimport * as notif from '@bhaskardey772/fcm-backend';Slim (already have firebase-admin)
If your project already uses firebase-admin (e.g. for Firestore, Auth, or custom admin operations), use the /slim entry to avoid bundling a second copy.
npm install @bhaskardey772/fcm-backend firebase-adminimport * as notif from '@bhaskardey772/fcm-backend/slim';The /slim entry uses your project's existing firebase-admin — no duplication, no extra weight.
Setup — Service Account
Firebase requires a service account to authenticate your server. This is a JSON file you download from the Firebase Console. It is a secret — treat it like a password.
Step 1: Download from Firebase Console
- Open console.firebase.google.com and select your project.
- Click the gear icon (⚙) next to "Project Overview" → Project settings.
- Go to the Service accounts tab.
- Click Generate new private key → Generate key.
- A JSON file is downloaded to your computer. This is your service account file.
The file looks like this (values are shortened for illustration):
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "abc123...",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n",
"client_email": "[email protected]",
"client_id": "123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
...
}You can only download it once. If you lose it, go back to the console and generate a new key.
Step 2: Keep it secret
Never commit this file to git. It gives full admin access to your Firebase project.
For local development, place the file in your project root and add it to .gitignore:
# .gitignore
service-account.jsonFor production / CI, do not copy the file to the server. Instead, store its contents as an environment variable:
# Store the entire JSON as a single env var (escape newlines if needed)
export FIREBASE_SERVICE_ACCOUNT='{"type":"service_account","project_id":"..."}'Then load it in code:
// Production: load from env
const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!);
notif.init(serviceAccount);
// Development: load from local file (never committed)
notif.init(require('./service-account.json'));Step 3: Initialize once at app startup
Call init() once before using any other function — typically in your index.ts or app.ts before your routes are registered.
import * as notif from '@bhaskardey772/fcm-backend';
// or: import * as notif from '@bhaskardey772/fcm-backend/slim';
// Pass the parsed JSON object
notif.init(require('./service-account.json'));
// Or pass a string path to the file
notif.init('/etc/secrets/service-account.json');
// Multi-project: name each app
notif.init(serviceAccountA, 'project-a');
notif.init(serviceAccountB, 'project-b');All other functions work exactly the same regardless of which entry (default or slim) you use.
Complete Express Example
import express, { Request, Response } from 'express';
import * as notif from '@bhaskardey772/fcm-backend';
// or: import * as notif from '@bhaskardey772/fcm-backend/slim';
notif.init(require('./service-account.json'));
const app = express();
app.use(express.json());
const tokens = new Set<string>();
app.post('/api/subscribe', (req: Request, res: Response) => {
const { token } = req.body;
if (!token) { res.status(400).json({ error: 'token required' }); return; }
tokens.add(token);
res.json({ success: true });
});
app.post('/api/unsubscribe', (req: Request, res: Response) => {
const { token } = req.body;
if (!token) { res.status(400).json({ error: 'token required' }); return; }
tokens.delete(token);
res.json({ success: true });
});
app.post('/api/notify/device', async (req: Request, res: Response) => {
const { token, title, body, imageUrl, data } = req.body;
try {
const result = await notif.sendToDevice(token, { title, body, imageUrl, data });
res.json({ success: true, messageId: result.messageId });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/notify/all', async (req: Request, res: Response) => {
const { title, body, imageUrl, data } = req.body;
if (tokens.size === 0) { res.status(400).json({ error: 'No registered tokens' }); return; }
try {
const result = await notif.sendToDevices([...tokens], { title, body, imageUrl, data });
res.json({ success: true, successCount: result.successCount, failureCount: result.failureCount });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/notify/topic', async (req: Request, res: Response) => {
const { topic, title, body, imageUrl, data } = req.body;
try {
const result = await notif.sendToTopic(topic, { title, body, imageUrl, data });
res.json({ success: true, messageId: result.messageId });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/topic/subscribe', async (req: Request, res: Response) => {
const { tokens: tokenList, topic } = req.body;
try {
const result = await notif.subscribeToTopic(tokenList, topic);
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/topic/unsubscribe', async (req: Request, res: Response) => {
const { tokens: tokenList, topic } = req.body;
try {
const result = await notif.unsubscribeFromTopic(tokenList, topic);
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/topic/add-tokens', async (req: Request, res: Response) => {
const { tokens: tokenList, topic } = req.body;
try {
const result = await notif.addTokensToTopic(tokenList, topic);
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/topic/remove-tokens', async (req: Request, res: Response) => {
const { tokens: tokenList, topic } = req.body;
try {
const result = await notif.removeTokensFromTopic(tokenList, topic);
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.listen(3000, () => console.log('Server running on :3000'));API
init(serviceAccount, appName?)
Call once at startup before using any other function.
// Pass parsed JSON object
notif.init(require('./service-account.json'));
// Or pass absolute path to the file
notif.init('/etc/secrets/service-account.json');
// Multi-project: name each app
notif.init(serviceAccountA, 'project-a');
notif.init(serviceAccountB, 'project-b');sendToDevice(token, payload)
Send to a single device token.
const result = await notif.sendToDevice(token, {
title: 'Order shipped!',
body: 'Your order #1234 is on its way.',
imageUrl: 'https://example.com/image.png',
data: { orderId: '1234', clickUrl: '/orders/1234' },
});
// result: { messageId: string }sendToDevices(tokens, payload)
Send to multiple device tokens. Automatically chunks arrays larger than 500 (FCM limit).
const result = await notif.sendToDevices(allTokens, {
title: 'Flash sale!',
body: '50% off for the next 2 hours.',
});
// result: { successCount, failureCount, errors }
for (const { token, error } of result.errors) {
if (error.includes('not-registered')) await db.tokens.delete(token);
}sendToTopic(topic, payload)
Send to all devices subscribed to a topic.
await notif.sendToTopic('breaking-news', {
title: 'Breaking News',
body: 'Tap to read more.',
data: { clickUrl: '/news/latest' },
});sendToCondition(condition, payload)
Send using a boolean topic expression.
await notif.sendToCondition("'sports' in topics || 'cricket' in topics", {
title: 'Match starts in 30 minutes!',
body: 'India vs Australia — tap to watch live.',
});subscribeToTopic(tokens, topic)
Subscribe one or more tokens to a topic.
await notif.subscribeToTopic(userToken, 'sports');
await notif.subscribeToTopic([tokenA, tokenB], 'announcements');unsubscribeFromTopic(tokens, topic)
Unsubscribe one or more tokens from a topic.
await notif.unsubscribeFromTopic(userToken, 'sports');addTokensToTopic(tokens, topic)
Add one or more device tokens to a topic. Accepts a single token or an array. Duplicate tokens in the input are deduplicated automatically.
await notif.addTokensToTopic('device-token-abc', 'news');
await notif.addTokensToTopic(['token1', 'token2', 'token3'], 'news');Returns the same TopicManagementResponse as subscribeToTopic. Use this when you want to explicitly express the intent of adding tokens to an existing topic, rather than a first-time subscription.
removeTokensFromTopic(tokens, topic)
Remove one or more device tokens from a topic. Accepts a single token or an array. Duplicate tokens in the input are deduplicated automatically.
await notif.removeTokensFromTopic('device-token-abc', 'news');
await notif.removeTokensFromTopic(['token1', 'token2'], 'news');Returns the same TopicManagementResponse as unsubscribeFromTopic.
Payload Reference
interface NotificationPayload {
title: string; // Required
body: string; // Required
imageUrl?: string; // Large image shown in the notification
icon?: string; // Small icon (web push)
data?: Record<string, unknown>; // Custom key-value pairs (auto-stringified)
android?: AndroidConfig; // Raw Firebase Android override
apns?: ApnsConfig; // Raw Firebase APNs (iOS) override
webpush?: WebpushConfig; // Raw Firebase Web Push override
}data field — pass custom values to the client:
data: {
clickUrl: '/orders/123',
orderId: '123',
userId: 42, // Numbers/booleans are auto-converted to strings
}Return Types
// sendToDevice, sendToTopic, sendToCondition
interface SendResult {
messageId: string;
}
// sendToDevices
interface BatchResult {
successCount: number;
failureCount: number;
errors: Array<{ token: string; error: string }>;
}Error Handling
try {
await notif.sendToDevice(token, payload);
} catch (err) {
if (err.errorInfo?.code === 'messaging/registration-token-not-registered') {
await db.tokens.delete(token);
} else {
console.error('FCM error:', err.message);
}
}Common error codes:
| Code | Meaning |
|---|---|
| messaging/registration-token-not-registered | Token expired or app uninstalled — delete it |
| messaging/invalid-registration-token | Malformed token |
| messaging/message-rate-exceeded | Throttled — back off and retry |
| messaging/invalid-argument | Bad payload (missing title or body) |
TypeScript
Full type declarations are included. No @types/ package needed.
import type {
NotificationPayload,
SendResult,
BatchResult,
TopicManagementResponse,
} from '@bhaskardey772/fcm-backend';