Skip to main content
Winnerr’s API is designed with security, scalability, and developer experience as core principles. Built on Next.js API routes with comprehensive middleware, it provides a robust foundation for the CRM’s complex real estate workflows.

API Design Principles

Security First

Authentication on every endpoint with organization-scoped data access

RESTful Design

Consistent HTTP methods, status codes, and resource naming conventions

Type Safety

Full TypeScript implementation with runtime validation using Zod

Real-time Support

WebSocket integration for live updates and collaboration features

API Structure

Endpoint Organization

/api/
├── auth/                     # Authentication endpoints
│   ├── token/               # JWT token management
│   └── debug/               # Auth debugging (dev only)
├── people/                  # Contact management
│   ├── [personId]/         # Individual contact operations
│   ├── search/             # Contact search
│   ├── bulk/               # Bulk operations
│   └── suggest/            # Auto-suggestions
├── deals/                   # Deal management
│   ├── [id]/               # Individual deal operations
│   ├── pipeline/           # Pipeline management
│   └── favorites/          # Favorited deals
├── properties/              # Property management
│   ├── [id]/               # Individual property operations
│   ├── search/             # Property search
│   └── export/             # Data export
├── communications/          # Communication hub
│   ├── manual/             # Manual communications
│   └── sync/               # Communication sync
├── twilio/                  # Phone system
│   ├── calls/              # Call management
│   ├── sms/                # SMS messaging
│   └── recordings/         # Call recordings
├── nylas/                   # Email/calendar
│   ├── emails/             # Email management
│   ├── calendars/          # Calendar integration
│   └── events/             # Event management
├── ai/                      # AI services
│   ├── sentiment/          # Sentiment analysis
│   ├── voice-search/       # Voice commands
│   └── insights/           # AI insights
└── webhooks/                # External webhooks
    ├── twilio/             # Twilio webhooks
    ├── nylas/              # Nylas webhooks
    └── stripe/             # Stripe webhooks

Authentication & Authorization

Request Authentication

Every API request requires authentication and organization context:
// Standard API route structure
export async function POST(req: Request) {
  try {
    // 1. Authentication & organization context
    const { userId, orgId } = await auth();
    
    if (!userId || !orgId) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }
    
    // 2. Input validation
    const body = await req.json();
    const validatedData = createPersonSchema.parse(body);
    
    // 3. Permission check
    await requirePermission(userId, orgId, Permission.CONTACTS_CREATE);
    
    // 4. Business logic with organization isolation
    const person = await database.person.create({
      data: {
        ...validatedData,
        organizationId: orgId, // Enforce organization isolation
        createdBy: userId
      }
    });
    
    // 5. Structured response with CORS
    const headers = new Headers();
    addCorsHeaders(headers, req.headers.get('origin'));
    
    return NextResponse.json(person, { headers });
    
  } catch (error) {
    return handleApiError(error, req);
  }
}

Middleware Stack

// apps/api/middleware.ts
import { auth } from '@repo/auth/server';
import { rateLimit } from '@/lib/rate-limit';
import { addCorsHeaders } from '@/lib/cors';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Skip middleware for public routes
  if (isPublicRoute(pathname)) {
    return NextResponse.next();
  }
  
  // Rate limiting
  const rateLimitResult = await rateLimit(request);
  if (!rateLimitResult.success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { status: 429 }
    );
  }
  
  // Authentication
  try {
    const { userId, orgId } = await auth();
    
    if (!userId) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }
    
    // Add context to headers
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-user-id', userId);
    if (orgId) {
      requestHeaders.set('x-organization-id', orgId);
    }
    
    // CORS headers
    const response = NextResponse.next({
      request: { headers: requestHeaders }
    });
    
    addCorsHeaders(response.headers, request.headers.get('origin'));
    
    return response;
    
  } catch (error) {
    log.error('Middleware authentication failed', { 
      error: parseError(error),
      path: pathname 
    });
    
    return NextResponse.json(
      { error: 'Authentication failed' },
      { status: 401 }
    );
  }
}

export const config = {
  matcher: [
    '/api/((?!health|webhooks/stripe).*)'
  ]
};

Input Validation & Type Safety

Zod Schema Validation

import { z } from 'zod';

// Person creation schema
export const createPersonSchema = z.object({
  firstName: z.string().min(1).max(50),
  lastName: z.string().min(1).max(50),
  email: z.string().email().optional(),
  phone: z.string().regex(/^\+?[1-9]\d{1,14}$/).optional(),
  address: z.string().max(200).optional(),
  city: z.string().max(50).optional(),
  state: z.string().max(50).optional(),
  zipCode: z.string().max(10).optional(),
  source: z.enum(['WEBSITE', 'REFERRAL', 'SOCIAL_MEDIA', 'COLD_CALL']).optional(),
  tags: z.array(z.string()).default([]),
  customFields: z.record(z.any()).optional()
});

// Deal creation schema
export const createDealSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
  dealType: z.enum(['LISTING', 'BUYER_REPRESENTATION', 'RENTAL']),
  amount: z.number().positive().optional(),
  commission: z.number().positive().optional(),
  commissionRate: z.number().min(0).max(100).optional(),
  expectedCloseDate: z.string().datetime().optional(),
  personId: z.string().cuid().optional(),
  propertyId: z.string().cuid().optional(),
  stage: z.string().min(1),
  probability: z.number().min(0).max(100).default(50),
  tags: z.array(z.string()).default([])
});

// Property search schema
export const propertySearchSchema = z.object({
  query: z.string().optional(),
  propertyType: z.enum(['SINGLE_FAMILY', 'CONDO', 'TOWNHOUSE']).optional(),
  minPrice: z.number().positive().optional(),
  maxPrice: z.number().positive().optional(),
  bedrooms: z.number().int().min(0).optional(),
  bathrooms: z.number().min(0).optional(),
  city: z.string().optional(),
  state: z.string().optional(),
  zipCode: z.string().optional(),
  status: z.enum(['ACTIVE', 'PENDING', 'SOLD']).optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20)
});

Response Type Definitions

// Standard API response types
export interface ApiResponse<T = unknown> {
  data?: T;
  error?: string;
  message?: string;
  pagination?: PaginationMeta;
}

export interface PaginationMeta {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}

// Specific response types
export interface PersonResponse {
  id: string;
  firstName: string;
  lastName: string;
  email?: string;
  phone?: string;
  address?: string;
  city?: string;
  state?: string;
  zipCode?: string;
  source?: LeadSource;
  status: PersonStatus;
  leadScore?: number;
  lastContactAt?: string;
  tags: string[];
  createdAt: string;
  updatedAt: string;
  _count?: {
    deals: number;
    communications: number;
    tasks: number;
  };
}

export interface DealResponse {
  id: string;
  title: string;
  description?: string;
  dealType: DealType;
  amount?: number;
  commission?: number;
  commissionRate?: number;
  status: DealStatus;
  stage: string;
  probability?: number;
  expectedCloseDate?: string;
  actualCloseDate?: string;
  person?: PersonResponse;
  property?: PropertyResponse;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

Error Handling

Centralized Error Handler

// lib/api-error-handler.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export class ValidationError extends ApiError {
  constructor(message: string, public issues?: unknown[]) {
    super(400, message, 'VALIDATION_ERROR');
  }
}

export class PermissionError extends ApiError {
  constructor(message: string = 'Insufficient permissions') {
    super(403, message, 'PERMISSION_DENIED');
  }
}

export class NotFoundError extends ApiError {
  constructor(resource: string = 'Resource') {
    super(404, `${resource} not found`, 'NOT_FOUND');
  }
}

export function handleApiError(error: unknown, req: Request): NextResponse {
  log.error('API Error', { 
    error: parseError(error),
    url: req.url,
    method: req.method
  });
  
  // Zod validation errors
  if (error instanceof z.ZodError) {
    return NextResponse.json({
      error: 'Validation failed',
      issues: error.issues
    }, { status: 400 });
  }
  
  // Custom API errors
  if (error instanceof ApiError) {
    return NextResponse.json({
      error: error.message,
      code: error.code
    }, { status: error.statusCode });
  }
  
  // Prisma errors
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    if (error.code === 'P2002') {
      return NextResponse.json({
        error: 'A record with this information already exists',
        code: 'DUPLICATE_RECORD'
      }, { status: 409 });
    }
    
    if (error.code === 'P2025') {
      return NextResponse.json({
        error: 'Record not found',
        code: 'NOT_FOUND'
      }, { status: 404 });
    }
  }
  
  // Generic server error
  return NextResponse.json({
    error: 'Internal server error',
    code: 'INTERNAL_ERROR'
  }, { status: 500 });
}

Rate Limiting

Postgres Quota Buckets + In-Memory Guardrails

// lib/rate-limit.ts
import { createRateLimiter, slidingWindow } from '@repo/scheduling';

// @repo/scheduling stores distributed quota buckets in Postgres.
// This lightweight map still guards each API instance from local bursts.

type RateLimitOptions = {
  interval: number;
  uniqueTokenPerInterval: number;
  limit: number;
};

type CacheEntry = {
  timestamps: number[];
  expiresAt: number;
};

const MAX_CACHE_SIZE = 5000;
const rateLimitCache = new Map<string, CacheEntry>();

const apiLimiter = createRateLimiter({
  prefix: 'api',
  limiter: slidingWindow(100, '60s'),
});

export async function rateLimit(
  request: NextRequest,
  options: RateLimitOptions = {
    interval: 60 * 1000,
    uniqueTokenPerInterval: 500,
    limit: 100,
  }
): Promise<{ success: boolean; limit: number; remaining: number; reset: number }> {
  const ip = getClientIP(request);
  const userId = request.headers.get('x-user-id');
  const identifier = userId ? `user:${userId}` : `ip:${ip}`;

  const now = Date.now();
  const cached = rateLimitCache.get(identifier);
  const timestamps =
    cached && cached.expiresAt > now ? [...cached.timestamps] : [];

  const windowStart = now - options.interval;
  const recentRequests = timestamps.filter((timestamp) => timestamp > windowStart);

  if (recentRequests.length >= options.limit) {
    const oldestRequest = Math.min(...recentRequests);
    const resetTime = oldestRequest + options.interval;

    return {
      success: false,
      limit: options.limit,
      remaining: 0,
      reset: resetTime,
    };
  }

  recentRequests.push(now);

  if (rateLimitCache.size >= MAX_CACHE_SIZE) {
    const oldestKey = rateLimitCache.keys().next().value;
    if (oldestKey) {
      rateLimitCache.delete(oldestKey);
    }
  }

  rateLimitCache.set(identifier, {
    timestamps: recentRequests,
    expiresAt: now + options.interval,
  });

  const distributed = await apiLimiter.limit(identifier);
  if (!distributed.success) {
    return distributed;
  }

  return {
    success: true,
    limit: options.limit,
    remaining: options.limit - recentRequests.length,
    reset: now + options.interval,
  };
}

export const rateLimits = {
  auth: { interval: 15 * 60 * 1000, limit: 5 },
  api: { interval: 60 * 1000, limit: 100 },
  webhook: { interval: 60 * 1000, limit: 1000 },
  upload: { interval: 60 * 1000, limit: 10 },
};

CORS Configuration

Cross-Origin Resource Sharing

// lib/cors.ts
export function addCorsHeaders(
  headers: Headers, 
  origin?: string | null
): void {
  // Allow requests from app domains
  const allowedOrigins = [
    'http://localhost:3000',    // Local development
    'http://localhost:3001',    // Local development (alt port)
    'https://app.winnerr.com',  // Production app
    'https://staging.winnerr.com', // Staging app
    'chrome-extension://*'       // Chrome extension
  ];
  
  // Check if origin is allowed
  const isAllowed = origin && (
    allowedOrigins.includes(origin) ||
    origin.startsWith('chrome-extension://') ||
    (process.env.NODE_ENV === 'development' && origin.includes('localhost'))
  );
  
  if (isAllowed) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  headers.set('Access-Control-Allow-Headers', 
    'Content-Type, Authorization, x-organization-id, x-user-id');
  headers.set('Access-Control-Max-Age', '86400'); // 24 hours
}

// Handle preflight requests
export async function handlePreflight(request: Request): Promise<Response> {
  const headers = new Headers();
  addCorsHeaders(headers, request.headers.get('origin'));
  
  return new Response(null, {
    status: 200,
    headers
  });
}

Real-time Communication

WebSocket Integration

// lib/socket-io.ts
import { Server as SocketIOServer } from 'socket.io';
import { auth } from '@repo/auth/server';

export function initializeSocketIO(server: unknown) {
  const io = new SocketIOServer(server, {
    cors: {
      origin: process.env.NEXT_PUBLIC_APP_URL,
      credentials: true
    }
  });
  
  // Authentication middleware
  io.use(async (socket, next) => {
    try {
      const token = socket.handshake.auth.token;
      const { userId, orgId } = await verifyToken(token);
      
      if (!userId || !orgId) {
        return next(new Error('Authentication failed'));
      }
      
      socket.userId = userId;
      socket.orgId = orgId;
      
      // Join organization room
      socket.join(`org:${orgId}`);
      
      next();
    } catch (error) {
      next(new Error('Authentication failed'));
    }
  });
  
  io.on('connection', (socket) => {
    log.info('Socket connected', { 
      userId: socket.userId, 
      orgId: socket.orgId 
    });
    
    // Handle presence updates
    socket.on('presence:join', (data) => {
      socket.to(`org:${socket.orgId}`).emit('presence:user_joined', {
        userId: socket.userId,
        ...data
      });
    });
    
    socket.on('presence:leave', () => {
      socket.to(`org:${socket.orgId}`).emit('presence:user_left', {
        userId: socket.userId
      });
    });
    
    // Handle real-time data updates
    socket.on('deal:update', async (data) => {
      try {
        // Validate permissions
        await requirePermission(socket.userId, socket.orgId, Permission.DEALS_EDIT);
        
        // Update deal
        const deal = await updateDeal(data.dealId, data.updates, socket.orgId);
        
        // Broadcast to organization members
        socket.to(`org:${socket.orgId}`).emit('deal:updated', {
          dealId: data.dealId,
          updates: data.updates,
          updatedBy: socket.userId
        });
        
      } catch (error) {
        socket.emit('error', { message: 'Failed to update deal' });
      }
    });
    
    socket.on('disconnect', () => {
      socket.to(`org:${socket.orgId}`).emit('presence:user_left', {
        userId: socket.userId
      });
    });
  });
  
  return io;
}

Real-time Event Broadcasting

// lib/realtime-events.ts
export class RealtimeEventBus {
  constructor(private io: SocketIOServer) {}
  
  // Broadcast to specific organization
  emitToOrganization(orgId: string, event: string, data: unknown) {
    this.io.to(`org:${orgId}`).emit(event, {
      ...data,
      timestamp: new Date().toISOString()
    });
  }
  
  // Broadcast deal updates
  broadcastDealUpdate(orgId: string, dealId: string, updates: unknown, userId: string) {
    this.emitToOrganization(orgId, 'deal:updated', {
      dealId,
      updates,
      updatedBy: userId
    });
  }
  
  // Broadcast new communications
  broadcastCommunication(orgId: string, communication: unknown) {
    this.emitToOrganization(orgId, 'communication:new', {
      communication
    });
  }
  
  // Broadcast presence updates
  broadcastPresence(orgId: string, userId: string, status: 'online' | 'offline') {
    this.emitToOrganization(orgId, 'presence:update', {
      userId,
      status,
      timestamp: new Date().toISOString()
    });
  }
}

// Usage in API routes
export async function POST(req: Request) {
  // ... handle deal creation ...
  
  // Broadcast real-time update
  realtimeEvents.broadcastDealUpdate(
    orgId, 
    deal.id, 
    { status: 'created' }, 
    userId
  );
  
  return NextResponse.json(deal);
}

API Documentation

OpenAPI Specification

// lib/openapi-spec.ts
export const openApiSpec = {
  openapi: '3.0.0',
  info: {
    title: 'Winnerr CRM API',
    version: '1.0.0',
    description: 'Comprehensive API for real estate CRM operations'
  },
  servers: [
    {
      url: 'https://api.winnerr.com',
      description: 'Production server'
    },
    {
      url: 'http://localhost:3002',
      description: 'Development server'
    }
  ],
  components: {
    securitySchemes: {
      bearerAuth: {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT'
      }
    },
    schemas: {
      Person: {
        type: 'object',
        properties: {
          id: { type: 'string' },
          firstName: { type: 'string' },
          lastName: { type: 'string' },
          email: { type: 'string', format: 'email' },
          phone: { type: 'string' },
          status: { 
            type: 'string', 
            enum: ['PROSPECT', 'LEAD', 'QUALIFIED', 'ACTIVE_CLIENT', 'PAST_CLIENT'] 
          },
          leadScore: { type: 'number', minimum: 0, maximum: 100 },
          tags: { type: 'array', items: { type: 'string' } },
          createdAt: { type: 'string', format: 'date-time' },
          updatedAt: { type: 'string', format: 'date-time' }
        }
      }
    }
  },
  security: [{ bearerAuth: [] }]
};

Performance Optimization

Data Access Strategy

// lib/deals-service.ts
import { database } from '@repo/database/server';
import { parseError } from '@repo/observability/error';

export async function listActiveDeals(orgId: string) {
  try {
    return await database.deal.findMany({
      where: {
        organizationId: orgId,
        status: 'OPEN',
      },
      include: { person: true, property: true },
      orderBy: { updatedAt: 'desc' },
      take: 100,
    });
  } catch (error) {
    log.error('Failed to list active deals', { error: parseError(error) });
    throw error;
  }
}

// Usage in API routes
export async function GET(req: Request) {
  const { orgId } = await auth();
  const deals = await listActiveDeals(orgId);

  return NextResponse.json(deals);
}

Next Steps

Real-time Features

Deep dive into WebSocket and collaboration features

Integration Patterns

Learn about third-party service integrations

Performance Optimization

API optimization and scaling strategies

This API architecture provides a solid foundation for real estate CRM operations while maintaining security, performance, and developer experience.