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 bootstrap

Dependencies

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/supertest

Configuration

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

Running 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

On this page