Zalo Notification Service (ZNS) NestJS module for sending notifications via Zalo ZNS API.
npm install @hapo-congbv/zalo-zns-nestjsor
yarn add @hapo-congbv/zalo-zns-nestjsThe recommended approach is to use OAuth for automatic token management. This eliminates the need to manually manage access tokens.
First, create token storage services. Here's an example using Prisma:
Token Storage Service:
import { Injectable } from '@nestjs/common';
import { TokenStorage, ZaloTokenData } from '@hapo-congbv/zalo-zns-nestjs';
import { PrismaService } from './prisma.service';
@Injectable()
export class PrismaTokenStorageService implements TokenStorage {
constructor(private readonly prisma: PrismaService) {}
async getToken(): Promise<ZaloTokenData | null> {
const token = await this.prisma.zaloToken.findUnique({
where: { id: 'zalo-oauth-token' },
});
if (!token) return null;
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresAt: Number(token.expiresAt),
refreshExpiresAt: token.refreshExpiresAt ? Number(token.refreshExpiresAt) : undefined,
};
}
async saveToken(token: ZaloTokenData): Promise<void> {
await this.prisma.zaloToken.upsert({
where: { id: 'zalo-oauth-token' },
create: {
id: 'zalo-oauth-token',
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresAt: BigInt(token.expiresAt),
refreshExpiresAt: token.refreshExpiresAt ? BigInt(token.refreshExpiresAt) : null,
},
update: {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresAt: BigInt(token.expiresAt),
refreshExpiresAt: token.refreshExpiresAt ? BigInt(token.refreshExpiresAt) : null,
},
});
}
async clearToken(): Promise<void> {
await this.prisma.zaloToken
.delete({
where: { id: 'zalo-oauth-token' },
})
.catch(() => {});
}
}OAuth State Storage Service:
import { Injectable } from '@nestjs/common';
import { OAuthStateStorage } from '@hapo-congbv/zalo-zns-nestjs';
import { PrismaService } from './prisma.service';
@Injectable()
export class ZaloOAuthStateService implements OAuthStateStorage {
constructor(private readonly prisma: PrismaService) {}
async storeState(state: string, codeVerifier: string): Promise<void> {
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await this.prisma.zaloOAuthState.upsert({
where: { state },
create: { state, codeVerifier, expiresAt },
update: { codeVerifier, expiresAt },
});
}
async getCodeVerifier(state: string): Promise<string | null> {
const record = await this.prisma.zaloOAuthState.findUnique({
where: { state },
});
if (!record || new Date(record.expiresAt) < new Date()) {
await this.deleteState(state);
return null;
}
return record.codeVerifier;
}
async deleteState(state: string): Promise<void> {
await this.prisma.zaloOAuthState
.delete({
where: { state },
})
.catch(() => {});
}
}Module Configuration:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ZnsModule } from '@hapo-congbv/zalo-zns-nestjs';
import { PrismaModule } from './prisma/prisma.module';
import { PrismaTokenStorageService } from './config/zalo-token-storage.service';
import { ZaloOAuthStateService } from './config/zalo-oauth-state.service';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
PrismaModule, // Import PrismaModule first to ensure services are available
ZnsModule.forRootAsyncGlobal({
imports: [ConfigModule, PrismaModule],
useFactory: (
configService: ConfigService,
tokenStorage: PrismaTokenStorageService,
oauthStateStorage: ZaloOAuthStateService,
) => ({
oauthOptions: {
appId: configService.get<string>('ZALO_APP_ID'),
appSecret: configService.get<string>('ZALO_APP_SECRET'),
oaId: configService.get<string>('ZALO_OA_ID'), // Official Account ID
redirectUri: configService.get<string>('ZALO_REDIRECT_URI'),
},
tokenStorage,
oauthStateStorage,
enableOAuthController: true, // Enables /zalo/oauth/* endpoints
apiUrl: configService.get<string>('ZALO_API_URL', 'https://business.openapi.zalo.me'),
timeout: configService.get<number>('ZALO_TIMEOUT', 30000),
}),
inject: [ConfigService, PrismaTokenStorageService, ZaloOAuthStateService],
}),
],
})
export class AppModule {}Note: If you don't provide custom tokenStorage and oauthStateStorage, the package will use in-memory storage (tokens will be lost on server restart).
If you want to use ZnsService globally without importing ZnsModule in every module:
ZnsModule.forRootAsyncGlobal({
imports: [ConfigModule, PrismaModule],
useFactory: (
configService: ConfigService,
tokenStorage: PrismaTokenStorageService,
oauthStateStorage: ZaloOAuthStateService,
) => ({
oauthOptions: {
appId: configService.get<string>('ZALO_APP_ID'),
appSecret: configService.get<string>('ZALO_APP_SECRET'),
oaId: configService.get<string>('ZALO_OA_ID'),
redirectUri: configService.get<string>('ZALO_REDIRECT_URI'),
},
tokenStorage,
oauthStateStorage,
enableOAuthController: true,
}),
inject: [ConfigService, PrismaTokenStorageService, ZaloOAuthStateService],
});import { Injectable } from '@nestjs/common';
import { ZnsService } from '@hapo-congbv/zalo-zns-nestjs';
import { ZnsMessage } from '@hapo-congbv/zalo-zns-nestjs';
@Injectable()
export class NotificationService {
constructor(private readonly znsService: ZnsService) {}
async sendNotification() {
const message: ZnsMessage = {
phone: '0912345678',
templateId: 'your-template-id',
templateData: {
name: 'John Doe',
code: '123456',
},
trackingId: 'optional-tracking-id',
};
const result = await this.znsService.sendMessage(message);
if (result.error === 0) {
console.log('Message sent successfully!', result.data?.trackingId);
} else {
console.error('Failed to send message:', result.message);
}
}
async sendBulkNotifications() {
const messages: ZnsMessage[] = [
{
phone: '0912345678',
templateId: 'template-1',
templateData: { name: 'User 1' },
},
{
phone: '0987654321',
templateId: 'template-2',
templateData: { name: 'User 2' },
},
];
const results = await this.znsService.sendBulkMessages(messages);
console.log('Bulk send results:', results);
}
}Register ZNS module with synchronous options.
Register ZNS module with asynchronous options.
Register ZNS module as global with synchronous options.
Register ZNS module as global with asynchronous options.
Send a single ZNS notification.
Send multiple ZNS notifications.
interface ZnsModuleOptions {
// Option 1: OAuth configuration (recommended)
oauthOptions?: {
appId: string;
appSecret: string;
oaId: string; // Official Account ID
redirectUri: string;
};
tokenStorage?: TokenStorage; // Optional: Custom token storage (defaults to in-memory)
oauthStateStorage?: OAuthStateStorage; // Optional: Custom OAuth state storage (defaults to in-memory)
enableOAuthController?: boolean; // Optional: Enable OAuth endpoints (default: true if oauthOptions provided)
// Option 2: Direct access token (legacy mode)
accessToken?: string;
// Common options
apiUrl?: string; // Optional: API URL (default: 'https://business.openapi.zalo.me')
timeout?: number; // Optional: Request timeout in ms (default: 30000)
}Note: Either provide oauthOptions (recommended) or accessToken (legacy). OAuth mode requires tokenStorage and oauthStateStorage for production use.
interface ZnsMessage {
phone: string; // Required: Phone number
templateId: string; // Required: ZNS template ID
templateData?: Record<string, any>; // Optional: Template data
trackingId?: string; // Optional: Tracking ID
}interface ZnsSendResponse {
error: number; // 0 = success, non-zero = error
message: string; // Response message
data?: {
trackingId: string; // Tracking ID if successful
};
}When enableOAuthController is true, the following endpoints are available:
GET /zalo/oauth/authorize- Get authorization URLGET /zalo/oauth/callback- Handle OAuth callbackGET /zalo/oauth/token-status- Check authorization statusPOST /zalo/oauth/refresh- Manually refresh tokenDELETE /zalo/oauth/token- Clear/revoke stored token (useful when changing app configuration)
- Call
GET /zalo/oauth/authorizeto get the authorization URL - Redirect user to the authorization URL
- User authorizes the application on Zalo
- Zalo redirects to your callback URL with authorization code
- The callback endpoint automatically exchanges the code for tokens
- Tokens are stored and automatically refreshed when needed
The package automatically refreshes tokens when they expire, so you don't need to manually manage token lifecycle.
If you already have a Zalo access token and prefer to use it directly without OAuth flow, you can configure it as follows:
import { Module } from '@nestjs/common';
import { ZnsModule } from '@hapo-congbv/zalo-zns-nestjs';
@Module({
imports: [
ZnsModule.forRootGlobal({
accessToken: 'your-zalo-access-token',
apiUrl: 'https://business.openapi.zalo.me', // Optional
timeout: 30000, // Optional, default 30000ms
}),
],
})
export class AppModule {}import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ZnsModule } from '@hapo-congbv/zalo-zns-nestjs';
@Module({
imports: [
ConfigModule.forRoot(),
ZnsModule.forRootAsyncGlobal({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
accessToken: configService.get<string>('ZALO_ACCESS_TOKEN'),
apiUrl: configService.get<string>('ZALO_API_URL', 'https://business.openapi.zalo.me'),
timeout: configService.get<number>('ZALO_TIMEOUT', 30000),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}- Go to Zalo Developer Console
- Navigate to your app settings
- Generate or copy your access token
- Add it to your
.envfile asZALO_ACCESS_TOKEN
Important Notes:
⚠️ Token Expiration: Static access tokens expire after 25 hours and require manual refresh⚠️ Manual Management: You must manually obtain a new token from Zalo Developer Console when the token expires- ✅ OAuth Recommended: OAuth mode is strongly recommended for production use as it handles token refresh automatically
- 💡 Use Cases: Static token mode is suitable for:
- Development and testing
- Quick prototyping
- When you have a specific reason not to use OAuth
ZALO_APP_ID=your-app-id
ZALO_APP_SECRET=your-app-secret
ZALO_OA_ID=your-official-account-id
ZALO_REDIRECT_URI=https://your-domain.com/zalo/oauth/callback
ZALO_API_URL=https://business.openapi.zalo.me
ZALO_TIMEOUT=30000Required for OAuth:
ZALO_APP_ID- Your Zalo App IDZALO_APP_SECRET- Your Zalo App SecretZALO_OA_ID- Your Official Account IDZALO_REDIRECT_URI- OAuth callback URL (must match Zalo app configuration)
Optional:
ZALO_API_URL- API base URL (default:https://business.openapi.zalo.me)ZALO_TIMEOUT- Request timeout in milliseconds (default:30000)
If you already have a Zalo access token and prefer to use it directly without OAuth flow:
ZALO_ACCESS_TOKEN=your-access-token
ZALO_API_URL=https://business.openapi.zalo.me
ZALO_TIMEOUT=30000Required for Legacy Mode:
ZALO_ACCESS_TOKEN- Your Zalo access token (obtained from Zalo Developer Console)
Optional:
ZALO_API_URL- API base URL (default:https://business.openapi.zalo.me)ZALO_TIMEOUT- Request timeout in milliseconds (default:30000)
Important Notes:
- Static access tokens expire after 25 hours and require manual refresh
- You need to manually obtain a new token from Zalo Developer Console when the token expires
- OAuth mode is strongly recommended for production use as it handles token refresh automatically
- Use static token mode only for development, testing, or when you have a specific reason not to use OAuth
MIT
For issues and feature requests, please visit GitHub Issues.