TypeScript SDK
Express.js Integration
Production-ready Express.js integration with dependency injection, validation, structured logging, and modular architecture.
This example follows Express.js 5 best practices with TypeScript, including service layer pattern, Zod validation, and proper error handling.
Project Structure
src/
├── config/
│ └── env.ts # Environment configuration
├── container/
│ └── index.ts # Dependency injection container
├── controllers/
│ ├── health.controller.ts # Health check endpoints
│ ├── messages.controller.ts # Message API endpoints
│ └── webhooks.controller.ts # Webhook handlers
├── middleware/
│ ├── async-handler.ts # Async error wrapper
│ ├── error-handler.ts # Global error handler
│ ├── request-logger.ts # Request logging
│ └── validate.ts # Zod validation middleware
├── services/
│ ├── messages.service.ts # Business logic
│ └── sent.service.ts # SDK client wrapper
├── types/
│ └── index.ts # TypeScript interfaces
├── app.ts # Express app configuration
└── server.ts # Server bootstrapDependencies
npm install express @sentdm/sentdm zod pino pino-pretty express-rate-limit helmet cors
npm install -D @types/express @types/node typescript ts-node nodemon vitest supertest @types/supertestConfiguration
Environment Configuration
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
SENT_DM_API_KEY: z.string().min(1, 'SENT_DM_API_KEY is required'),
SENT_DM_WEBHOOK_SECRET: z.string().min(1, 'SENT_DM_WEBHOOK_SECRET is required'),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'),
RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'),
});
export type Env = z.infer<typeof envSchema>;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.format());
process.exit(1);
}
export const env = parsed.data;Logger Configuration
// src/config/logger.ts
import pino from 'pino';
import { env } from './env';
export const logger = pino({
level: env.LOG_LEVEL,
transport: env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
base: { pid: process.pid, env: env.NODE_ENV },
});
export type Logger = typeof logger;Dependency Injection Container
// src/container/index.ts
import SentDm from '@sentdm/sentdm';
import { env } from '../config/env';
import { logger } from '../config/logger';
import { SentService } from '../services/sent.service';
import { MessagesService } from '../services/messages.service';
export interface Container {
logger: typeof logger;
sentService: SentService;
messagesService: MessagesService;
}
export function createContainer(): Container {
const sentClient = new SentDm(env.SENT_DM_API_KEY);
const sentService = new SentService(sentClient, logger);
const messagesService = new MessagesService(sentService, logger);
return { logger, sentService, messagesService };
}
let container: Container | null = null;
export function getContainer(): Container {
if (!container) container = createContainer();
return container;
}
export function setContainer(c: Container): void { container = c; }
export function resetContainer(): void { container = null; }Types and DTOs
// src/types/index.ts
import { z } from 'zod';
export const SendMessageSchema = z.object({
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'),
templateId: z.string().uuid('Invalid template ID'),
templateName: z.string().min(1).max(100),
parameters: z.record(z.string()).optional(),
channels: z.array(z.enum(['whatsapp', 'sms', 'email'])).optional(),
});
export const WelcomeMessageSchema = z.object({
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'),
name: z.string().min(1).max(100).optional(),
});
export const OrderConfirmationSchema = z.object({
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'),
orderNumber: z.string().min(1),
total: z.string().regex(/^\d+\.?\d{0,2}$/, 'Invalid amount'),
});
export type SendMessageDto = z.infer<typeof SendMessageSchema>;
export type WelcomeMessageDto = z.infer<typeof WelcomeMessageSchema>;
export type OrderConfirmationDto = z.infer<typeof OrderConfirmationSchema>;
export interface MessageResponse {
messageId: string;
status: string;
channels: string[];
}
export const WebhookEventSchema = z.object({
type: z.enum(['message.status.updated', 'message.delivered', 'message.failed', 'message.read']),
data: z.object({
id: z.string(),
status: z.string().optional(),
error: z.object({ message: z.string(), code: z.string().optional() }).optional(),
timestamp: z.string().datetime().optional(),
}),
});
export type WebhookEvent = z.infer<typeof WebhookEventSchema>;
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiError';
Error.captureStackTrace(this, this.constructor);
}
}Services
Sent Service (SDK Wrapper)
// src/services/sent.service.ts
import type SentDm from '@sentdm/sentdm';
import type { Logger } from '../config/logger';
import { ApiError } from '../types';
export interface SentMessageInput {
to: string[];
template: { id: string; name: string; parameters?: Record<string, string> };
channels?: string[];
}
export interface SentMessageOutput {
id: string;
status: string;
channel: string;
}
export class SentService {
constructor(private readonly client: SentDm, private readonly logger: Logger) {}
async sendMessage(input: SentMessageInput): Promise<SentMessageOutput[]> {
try {
const response = await this.client.messages.send({
to: input.to,
template: input.template,
channels: input.channels,
});
const messages = response.data.messages;
this.logger.info({ messageIds: messages.map(m => m.id) }, `Sent ${messages.length} message(s)`);
return messages.map(msg => ({ id: msg.id, status: msg.status, channel: msg.channel || 'unknown' }));
} catch (error) {
this.logger.error({ error, input }, 'Failed to send message');
if (error instanceof this.client.APIError) {
throw new ApiError(error.status || 500, error.name, error.message, { headers: error.headers });
}
throw new ApiError(500, 'InternalError', 'Failed to send message');
}
}
verifyWebhookSignature(payload: Buffer, signature: string, secret: string): boolean {
try {
return this.client.webhooks.verifySignature({ payload, signature, secret });
} catch (error) {
this.logger.error({ error }, 'Webhook signature verification failed');
return false;
}
}
constructWebhookEvent(payload: Buffer): unknown {
try {
return JSON.parse(payload.toString());
} catch {
throw new ApiError(400, 'InvalidPayload', 'Invalid JSON in webhook payload');
}
}
}Messages Service (Business Logic)
// src/services/messages.service.ts
import type { Logger } from '../config/logger';
import { SentService } from './sent.service';
import type { SendMessageDto, WelcomeMessageDto, OrderConfirmationDto, MessageResponse } from '../types';
export class MessagesService {
constructor(private readonly sentService: SentService, private readonly logger: Logger) {}
async sendMessage(dto: SendMessageDto): Promise<MessageResponse> {
const messages = await this.sentService.sendMessage({
to: [dto.phoneNumber],
template: { id: dto.templateId, name: dto.templateName, parameters: dto.parameters },
channels: dto.channels,
});
const primary = messages[0];
return { messageId: primary.id, status: primary.status, channels: messages.map(m => m.channel) };
}
async sendWelcomeMessage(dto: WelcomeMessageDto): Promise<MessageResponse> {
this.logger.info({ phoneNumber: dto.phoneNumber }, 'Sending welcome message');
return this.sendMessage({
phoneNumber: dto.phoneNumber,
templateId: 'welcome-template-id',
templateName: 'welcome',
parameters: { name: dto.name || 'Valued Customer' },
channels: ['whatsapp'],
});
}
async sendOrderConfirmation(dto: OrderConfirmationDto): Promise<MessageResponse> {
this.logger.info({ phoneNumber: dto.phoneNumber, orderNumber: dto.orderNumber }, 'Sending order confirmation');
return this.sendMessage({
phoneNumber: dto.phoneNumber,
templateId: 'order-confirmation-id',
templateName: 'order_confirmation',
parameters: { order_number: dto.orderNumber, total: dto.total },
channels: ['sms', 'whatsapp'],
});
}
}Middleware
// src/middleware/async-handler.ts
import type { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export function asyncHandler(fn: AsyncRequestHandler): RequestHandler {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}// src/middleware/validate.ts
import type { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';
import { ApiError } from '../types';
export function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, _res: Response, next: NextFunction): void => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof ZodError) {
const details = error.errors.map(e => ({ path: e.path.join('.'), message: e.message }));
next(new ApiError(400, 'ValidationError', 'Request validation failed', { errors: details }));
} else {
next(error);
}
}
};
}
export function validateParams<T>(schema: z.ZodSchema<T>) {
return (req: Request, _res: Response, next: NextFunction): void => {
try {
req.params = schema.parse(req.params) as Record<string, string>;
next();
} catch (error) {
if (error instanceof ZodError) {
next(new ApiError(400, 'ValidationError', 'Invalid URL parameters'));
} else {
next(error);
}
}
};
}// src/middleware/error-handler.ts
import type { Request, Response, NextFunction } from 'express';
import { env } from '../config/env';
import { logger } from '../config/logger';
import { ApiError } from '../types';
interface ErrorResponse {
error: { code: string; message: string; details?: Record<string, unknown>; stack?: string };
timestamp: string;
path: string;
}
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void {
const timestamp = new Date().toISOString();
if (err instanceof ApiError) {
logger.warn({ statusCode: err.statusCode, code: err.code, path: req.path, message: err.message }, 'API error');
const response: ErrorResponse = { error: { code: err.code, message: err.message, details: err.details }, timestamp, path: req.path };
res.status(err.statusCode).json(response);
return;
}
logger.error({ error: err.message, stack: err.stack, path: req.path, method: req.method }, 'Unexpected error');
const response: ErrorResponse = {
error: { code: 'InternalError', message: env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message },
timestamp,
path: req.path,
};
if (env.NODE_ENV === 'development') response.error.stack = err.stack;
res.status(500).json(response);
}// src/middleware/request-logger.ts
import type { Request, Response, NextFunction } from 'express';
import { logger } from '../config/logger';
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
const requestId = crypto.randomUUID();
res.setHeader('X-Request-Id', requestId);
logger.trace({ requestId, method: req.method, path: req.path, query: req.query, ip: req.ip }, 'Incoming request');
res.on('finish', () => {
const duration = Date.now() - start;
const logData = { requestId, method: req.method, path: req.path, statusCode: res.statusCode, duration: `${duration}ms` };
if (res.statusCode >= 500) logger.error(logData, 'Request failed');
else if (res.statusCode >= 400) logger.warn(logData, 'Request failed');
else logger.debug(logData, 'Request completed');
});
next();
}Controllers
Messages Controller
// src/controllers/messages.controller.ts
import { Router } from 'express';
import { getContainer } from '../container';
import { asyncHandler } from '../middleware/async-handler';
import { validateBody } from '../middleware/validate';
import { SendMessageSchema, WelcomeMessageSchema, OrderConfirmationSchema } from '../types';
import type { Request, Response } from 'express';
const router = Router();
router.post('/send', validateBody(SendMessageSchema), asyncHandler(async (req: Request, res: Response) => {
const { messagesService } = getContainer();
const result = await messagesService.sendMessage(req.body);
res.status(200).json({ success: true, data: result });
}));
router.post('/welcome', validateBody(WelcomeMessageSchema), asyncHandler(async (req: Request, res: Response) => {
const { messagesService } = getContainer();
const result = await messagesService.sendWelcomeMessage(req.body);
res.status(200).json({ success: true, data: result });
}));
router.post('/order-confirmation', validateBody(OrderConfirmationSchema), asyncHandler(async (req: Request, res: Response) => {
const { messagesService } = getContainer();
const result = await messagesService.sendOrderConfirmation(req.body);
res.status(200).json({ success: true, data: result });
}));
export { router as messagesRouter };Webhooks Controller
// src/controllers/webhooks.controller.ts
import { Router } from 'express';
import { env } from '../config/env';
import { getContainer } from '../container';
import { asyncHandler } from '../middleware/async-handler';
import { WebhookEventSchema, ApiError } from '../types';
import type { Request, Response } from 'express';
const router = Router();
router.post('/sent', asyncHandler(async (req: Request, res: Response) => {
const { sentService, logger } = getContainer();
const signature = req.headers['x-webhook-signature'] as string;
if (!signature) throw new ApiError(401, 'Unauthorized', 'Missing webhook signature');
const isValid = sentService.verifyWebhookSignature(req.body, signature, env.SENT_DM_WEBHOOK_SECRET);
if (!isValid) throw new ApiError(401, 'Unauthorized', 'Invalid webhook signature');
const rawEvent = sentService.constructWebhookEvent(req.body);
const event = WebhookEventSchema.parse(rawEvent);
logger.info({ eventType: event.type, messageId: event.data.id }, 'Processing webhook');
switch (event.type) {
case 'message.status.updated':
logger.info({ messageId: event.data.id, status: event.data.status }, 'Status updated');
break;
case 'message.delivered':
logger.info({ messageId: event.data.id }, 'Delivered');
break;
case 'message.read':
logger.info({ messageId: event.data.id }, 'Read');
break;
case 'message.failed':
logger.error({ messageId: event.data.id, error: event.data.error }, 'Failed');
break;
default:
logger.warn({ eventType: event.type }, 'Unhandled event type');
}
res.json({ received: true });
}));
export { router as webhooksRouter };Health Controller
// src/controllers/health.controller.ts
import { Router } from 'express';
import { getContainer } from '../container';
import type { Request, Response } from 'express';
const router = Router();
interface HealthStatus {
status: 'healthy' | 'unhealthy';
timestamp: string;
uptime: number;
version: string;
services: { sentdm: 'connected' | 'disconnected' };
}
router.get('/', async (_req: Request, res: Response) => {
const { logger } = getContainer();
const health: HealthStatus = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: process.env.npm_package_version || '1.0.0',
services: { sentdm: 'connected' },
};
logger.debug(health, 'Health check');
res.status(200).json(health);
});
router.get('/ready', (_req: Request, res: Response) => res.status(200).json({ ready: true }));
router.get('/live', (_req: Request, res: Response) => res.status(200).json({ alive: true }));
export { router as healthRouter };App Configuration
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { env } from './config/env';
import { logger } from './config/logger';
import { requestLogger } from './middleware/request-logger';
import { errorHandler } from './middleware/error-handler';
import { messagesRouter } from './controllers/messages.controller';
import { webhooksRouter } from './controllers/webhooks.controller';
import { healthRouter } from './controllers/health.controller';
export function createApp(): express.Application {
const app = express();
app.use(helmet());
app.use(cors({
origin: env.NODE_ENV === 'production' ? [/\.sentdm\.io$/] : ['http://localhost:3000', 'http://localhost:5173'],
credentials: true,
}));
const limiter = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS,
max: env.RATE_LIMIT_MAX_REQUESTS,
standardHeaders: true,
legacyHeaders: false,
handler: (_req, res) => res.status(429).json({ error: { code: 'RateLimitExceeded', message: 'Too many requests' } }),
});
app.use(limiter);
const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, skipSuccessfulRequests: true });
app.use(requestLogger);
app.use('/api', express.json({ limit: '10mb' }));
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use('/health', healthRouter);
app.use('/api/messages', messagesRouter);
app.use('/webhooks', webhookLimiter, webhooksRouter);
app.use((_req, res) => res.status(404).json({ error: { code: 'NotFound', message: 'Resource not found' } }));
app.use(errorHandler);
return app;
}Server Bootstrap
// src/server.ts
import { createApp } from './app';
import { env } from './config/env';
import { logger } from './config/logger';
async function bootstrap(): Promise<void> {
const app = createApp();
const server = app.listen(env.PORT, () => {
logger.info({ port: env.PORT, env: env.NODE_ENV }, '🚀 Server started');
});
const shutdown = (signal: string) => {
logger.info({ signal }, 'Shutting down...');
server.close(() => { logger.info('Server closed'); process.exit(0); });
setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('uncaughtException', (error) => { logger.fatal({ error }, 'Uncaught exception'); shutdown('uncaughtException'); });
process.on('unhandledRejection', (reason) => { logger.fatal({ reason }, 'Unhandled rejection'); shutdown('unhandledRejection'); });
}
bootstrap();Testing
Test Setup
// src/tests/setup.ts
import { beforeEach, afterEach } from 'vitest';
import { resetContainer, setContainer } from '../container';
import type { Container } from '../container';
export function setupTestContainer(container: Partial<Container> = {}): void {
const defaultContainer: Container = {
logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {} } as Container['logger'],
sentService: { sendMessage: vi.fn(), verifyWebhookSignature: vi.fn(), constructWebhookEvent: vi.fn() } as Container['sentService'],
messagesService: { sendMessage: vi.fn(), sendWelcomeMessage: vi.fn(), sendOrderConfirmation: vi.fn() } as Container['messagesService'],
...container,
};
setContainer(defaultContainer);
}
beforeEach(() => resetContainer());
afterEach(() => { resetContainer(); vi.clearAllMocks(); });Service Tests (Condensed)
// src/services/messages.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessagesService } from './messages.service';
const mockSentService = { sendMessage: vi.fn() };
const mockLogger = { info: vi.fn(), error: vi.fn() };
describe('MessagesService', () => {
let service: MessagesService;
beforeEach(() => {
service = new MessagesService(mockSentService as any, mockLogger as any);
vi.clearAllMocks();
});
it('should send message and return response', async () => {
mockSentService.sendMessage.mockResolvedValue([{ id: 'msg_123', status: 'pending', channel: 'whatsapp' }]);
const result = await service.sendMessage({ phoneNumber: '+1234567890', templateId: 'template-123', templateName: 'welcome' });
expect(result).toEqual({ messageId: 'msg_123', status: 'pending', channels: ['whatsapp'] });
});
it('should send welcome with default name', async () => {
mockSentService.sendMessage.mockResolvedValue([{ id: 'msg_456', status: 'queued', channel: 'whatsapp' }]);
await service.sendWelcomeMessage({ phoneNumber: '+1234567890' });
expect(mockSentService.sendMessage).toHaveBeenCalledWith({
to: ['+1234567890'], template: { id: 'welcome-template-id', name: 'welcome', parameters: { name: 'Valued Customer' } }, channels: ['whatsapp']
});
});
});Integration Tests (Condensed)
// src/tests/messages.integration.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app';
import { setupTestContainer } from './setup';
describe('Messages API', () => {
let app: ReturnType<typeof createApp>;
beforeEach(() => {
setupTestContainer({
messagesService: {
sendMessage: vi.fn().mockResolvedValue({ messageId: 'msg_123', status: 'pending', channels: ['whatsapp'] }),
sendWelcomeMessage: vi.fn().mockResolvedValue({ messageId: 'msg_456', status: 'queued', channels: ['whatsapp'] }),
} as any,
});
app = createApp();
});
it('POST /api/messages/send - success', async () => {
const response = await request(app).post('/api/messages/send').send({
phoneNumber: '+1234567890', templateId: '550e8400-e29b-41d4-a716-446655440000', templateName: 'welcome', parameters: { name: 'John' }
});
expect(response.status).toBe(200);
expect(response.body.data.messageId).toBe('msg_123');
});
it('POST /api/messages/send - validation error', async () => {
const response = await request(app).post('/api/messages/send').send({ phoneNumber: 'invalid', templateId: '550e8400-e29b-41d4-a716-446655440000', templateName: 'welcome' });
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('ValidationError');
});
});Environment Variables
# .env
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100Running the Application
// package.json
{
"scripts": {
"dev": "nodemon --exec ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest",
"test:coverage": "vitest --coverage",
"lint": "eslint src/**/*.ts",
"typecheck": "tsc --noEmit"
}
}// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"]
}Next Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the TypeScript SDK reference for advanced features