Back to Journal
SaaS Engineering

SaaS API Design Best Practices for Enterprise Teams

Battle-tested best practices for SaaS API Design tailored to Enterprise teams, including anti-patterns to avoid and a ready-to-use checklist.

Muneer Puthiya Purayil 13 min read

Enterprise SaaS APIs serve as integration points for Fortune 500 customers who expect contract-grade reliability, comprehensive documentation, and backwards-compatible evolution. These best practices address the specific API design challenges enterprise teams face beyond basic REST conventions.

API Versioning Strategy

URL-Based Versioning for Enterprise Stability

GET /api/v2/tenants/{tenantId}/users GET /api/v2/tenants/{tenantId}/invoices

Enterprise customers sign contracts with specific API version commitments. URL-based versioning makes version pinning explicit and auditable:

typescript
1// NestJS versioning setup
2import { VersioningType } from '@nestjs/common';
3 
4app.enableVersioning({
5 type: VersioningType.URI,
6 defaultVersion: '2',
7 prefix: 'api/v',
8});
9 
10@Controller({ version: '2' })
11export class UsersController {
12 @Get('users')
13 listUsers(@Query() query: ListUsersDto) { /* ... */ }
14}
15 
16// Maintain v1 with deprecation notices
17@Controller({ version: '1' })
18@Deprecated()
19export class UsersControllerV1 {
20 @Get('users')
21 @Header('Deprecation', 'true')
22 @Header('Sunset', 'Sat, 01 Mar 2026 00:00:00 GMT')
23 listUsers(@Query() query: ListUsersV1Dto) { /* ... */ }
24}
25 

Version Sunset Policy

Document a clear deprecation timeline:

  • Announcement: 12 months before sunset
  • Deprecation headers: 6 months before sunset (add Deprecation: true and Sunset headers)
  • Soft sunset: 3 months before — return 299 warnings for v1 calls
  • Hard sunset: Version returns 410 Gone with migration guide URL

Authentication and Authorization

API Key + OAuth2 Hybrid

Enterprise APIs typically support both patterns:

typescript
1@Injectable()
2export class AuthGuard implements CanActivate {
3 async canActivate(context: ExecutionContext): Promise<boolean> {
4 const request = context.switchToHttp().getRequest();
5
6 // Check API key first (machine-to-machine)
7 const apiKey = request.headers['x-api-key'];
8 if (apiKey) {
9 const tenant = await this.apiKeyService.validate(apiKey);
10 if (!tenant) throw new UnauthorizedException('Invalid API key');
11 request.tenant = tenant;
12 request.authMethod = 'api_key';
13 return true;
14 }
15 
16 // Fall back to OAuth2 bearer token (user context)
17 const bearer = request.headers.authorization?.replace('Bearer ', '');
18 if (bearer) {
19 const token = await this.oauth2Service.validate(bearer);
20 if (!token) throw new UnauthorizedException('Invalid token');
21 request.tenant = token.tenant;
22 request.user = token.user;
23 request.authMethod = 'oauth2';
24 return true;
25 }
26 
27 throw new UnauthorizedException('Authentication required');
28 }
29}
30 

Scoped API Keys

Enterprise customers need granular API key permissions:

typescript
1interface ApiKeyScope {
2 resources: string[]; // ['users', 'invoices']
3 actions: string[]; // ['read', 'write']
4 ipWhitelist?: string[]; // ['10.0.0.0/24']
5 rateLimit?: number; // requests per minute
6 expiresAt?: Date;
7}
8 

Pagination

Cursor-Based Pagination for Large Datasets

Enterprise tenants often have millions of records. Cursor-based pagination scales where offset-based fails:

typescript
1interface PaginatedResponse<T> {
2 data: T[];
3 pagination: {
4 nextCursor: string | null;
5 previousCursor: string | null;
6 hasMore: boolean;
7 totalCount?: number;
8 };
9}
10 
11@Get('users')
12async listUsers(
13 @Query('cursor') cursor?: string,
14 @Query('limit') limit: number = 25,
15 @Query('direction') direction: 'forward' | 'backward' = 'forward',
16): Promise<PaginatedResponse<User>> {
17 const maxLimit = Math.min(limit, 100);
18 const decodedCursor = cursor ? this.decodeCursor(cursor) : null;
19 
20 const users = await this.userService.list({
21 cursor: decodedCursor,
22 limit: maxLimit + 1, // Fetch one extra to determine hasMore
23 direction,
24 });
25 
26 const hasMore = users.length > maxLimit;
27 const data = hasMore ? users.slice(0, maxLimit) : users;
28 
29 return {
30 data,
31 pagination: {
32 nextCursor: hasMore ? this.encodeCursor(data[data.length - 1]) : null,
33 previousCursor: decodedCursor ? this.encodeCursor(data[0]) : null,
34 hasMore,
35 },
36 };
37}
38 

Rate Limiting

Tiered Rate Limits by Plan

typescript
1const RATE_LIMITS: Record<string, RateConfig> = {
2 starter: { requestsPerMinute: 60, burstLimit: 10, dailyLimit: 10_000 },
3 professional: { requestsPerMinute: 600, burstLimit: 50, dailyLimit: 100_000 },
4 enterprise: { requestsPerMinute: 6_000, burstLimit: 200, dailyLimit: 1_000_000 },
5};
6 
7@Injectable()
8export class RateLimitGuard implements CanActivate {
9 async canActivate(context: ExecutionContext): Promise<boolean> {
10 const request = context.switchToHttp().getRequest();
11 const tenant = request.tenant;
12 const config = RATE_LIMITS[tenant.plan];
13 const key = `rl:${tenant.id}:${this.getWindow()}`;
14 
15 const current = await this.redis.incr(key);
16 if (current === 1) await this.redis.expire(key, 60);
17 
18 const response = context.switchToHttp().getResponse();
19 response.setHeader('X-RateLimit-Limit', config.requestsPerMinute);
20 response.setHeader('X-RateLimit-Remaining', Math.max(0, config.requestsPerMinute - current));
21 
22 if (current > config.requestsPerMinute) {
23 response.setHeader('Retry-After', '60');
24 throw new HttpException('Rate limit exceeded', 429);
25 }
26 
27 return true;
28 }
29}
30 

Error Responses

RFC 7807 Problem Details

typescript
1interface ProblemDetails {
2 type: string;
3 title: string;
4 status: number;
5 detail: string;
6 instance: string;
7 errors?: Array<{ field: string; message: string; code: string }>;
8 traceId: string;
9}
10 
11@Catch()
12export class GlobalExceptionFilter implements ExceptionFilter {
13 catch(exception: unknown, host: ArgumentsHost) {
14 const ctx = host.switchToHttp();
15 const response = ctx.getResponse();
16 const request = ctx.getRequest();
17 
18 const problem: ProblemDetails = {
19 type: 'https://api.example.com/errors/validation',
20 title: 'Validation Error',
21 status: 400,
22 detail: 'One or more fields failed validation',
23 instance: request.url,
24 traceId: request.headers['x-request-id'] ?? crypto.randomUUID(),
25 };
26 
27 response.status(problem.status).json(problem);
28 }
29}
30 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Webhook Design

Reliable Webhook Delivery

typescript
1interface WebhookEvent {
2 id: string;
3 type: string;
4 createdAt: string;
5 data: Record<string, unknown>;
6 apiVersion: string;
7}
8 
9class WebhookService {
10 async deliver(event: WebhookEvent, endpoint: WebhookEndpoint): Promise<void> {
11 const payload = JSON.stringify(event);
12 const signature = this.sign(payload, endpoint.secret);
13 
14 const response = await fetch(endpoint.url, {
15 method: 'POST',
16 headers: {
17 'Content-Type': 'application/json',
18 'X-Webhook-Signature': signature,
19 'X-Webhook-Id': event.id,
20 'X-Webhook-Timestamp': event.createdAt,
21 },
22 body: payload,
23 signal: AbortSignal.timeout(30000),
24 });
25 
26 if (!response.ok) {
27 await this.scheduleRetry(event, endpoint, response.status);
28 }
29 }
30 
31 private sign(payload: string, secret: string): string {
32 return createHmac('sha256', secret).update(payload).digest('hex');
33 }
34}
35 

Idempotency

Idempotency Keys for Mutation Endpoints

typescript
1@Post('invoices')
2async createInvoice(
3 @Headers('Idempotency-Key') idempotencyKey: string,
4 @Body() dto: CreateInvoiceDto,
5): Promise<Invoice> {
6 if (!idempotencyKey) {
7 throw new BadRequestException('Idempotency-Key header required for POST requests');
8 }
9 
10 const existing = await this.idempotencyService.get(idempotencyKey);
11 if (existing) {
12 return existing.response;
13 }
14 
15 const invoice = await this.invoiceService.create(dto);
16 await this.idempotencyService.store(idempotencyKey, invoice, 86400);
17 return invoice;
18}
19 

Checklist

  • URL-based API versioning with sunset policy
  • API key + OAuth2 authentication
  • Cursor-based pagination for all list endpoints
  • Tiered rate limiting with response headers
  • RFC 7807 error responses
  • Idempotency keys for all mutation endpoints
  • Webhook delivery with HMAC signatures and retry logic
  • Request/response audit logging
  • OpenAPI 3.1 specification generated from code
  • SDK generation for Python, TypeScript, Go, Java

Anti-Patterns to Avoid

Breaking changes in minor versions: Enterprise customers integrate your API into automated workflows. Any breaking change — even adding a required field — must be in a new major version.

Inconsistent naming conventions: Pick snake_case or camelCase and use it everywhere. Enterprise API consumers code-generate clients from your OpenAPI spec; inconsistent naming creates broken generated code.

Returning unbounded responses: Every list endpoint must be paginated. An enterprise tenant with 2 million users can bring down your API server with a single unparameterized GET request.

Conclusion

Enterprise SaaS API design is a contract between your platform and your customers' engineering teams. Every design decision — versioning strategy, authentication model, pagination approach, error format — becomes a commitment that enterprise customers build their systems around. Design for stability, document exhaustively, and evolve the API through additive changes rather than breaking modifications.

The best enterprise APIs are boring by design. They follow established conventions (REST, RFC 7807, cursor pagination, HMAC webhooks), provide comprehensive SDKs, and change infrequently. Innovation belongs in your product features, not in your API surface.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026