Back to Blog

How to Build a Public Developer API for Your SaaS — Documentation, SDKs and Sandbox

Published: 2026-06-23
How to Build a Public Developer API for Your SaaS — Documentation, SDKs and Sandbox

Every SaaS reaches a point where its API stops being an internal implementation detail and becomes a product that third-party developers build on top of. That transition is not optional — it is the moment your API shifts from "how our frontend talks to our backend" to "how our customers' developers integrate with our platform." A public developer API needs documentation that external developers can navigate, authentication they can implement without calling support, a sandbox where they can test without touching real data, SDKs that eliminate boilerplate, and rate limits that protect your infrastructure from their mistakes.

We have built this for enough SaaS clients to know that the difference between a public API developers love and one they tolerate is rarely the endpoint design. It is the surrounding program: the documentation experience, the sandbox availability, the SDK quality, and the versioning commitment. This is the playbook we follow for every public developer API we build, with NestJS code for each piece.

The market for this is large and growing: over 30,000 SaaS companies globally (industry trackers, 2025), and Gartner projects 85% of business software spending will be SaaS by 2026. Most of those companies will build a public API at some point. This is how to build one that developers actually want to integrate with.

SaaS public developer API documentation and SDK development — a laptop surrounded by reference books representing API docs and SDKs

When Your SaaS Needs a Public Developer API

You do not need a public developer API on day one. An internal API that serves your frontend and mobile app is sufficient until external developers start asking for access. The signals that it is time are specific: a support ticket saying "can we integrate your data into our dashboard," a partnership discussion that requires API access, or a competitor launching an integration marketplace that your customers are asking about.

When those signals arrive, resist the temptation to expose your internal API as-is. An internal API is designed for your frontend, which means it assumes your authentication, returns data in the shape your UI needs, and changes whenever your product changes. A public API is a contract with external developers who will build production systems against it. The two should not share a code path.

We made this mistake on an early project — exposed our internal endpoints under a /public prefix and called it a developer API. A month later we renamed a field to clean up the frontend code, and a partner integration broke at 2am. That is the night we learned that a public API is not an endpoint prefix. It is a separate product with its own guarantees, its own documentation, its own testing environment, and its own versioning.

Designing Your Public API Differently From Your Internal API

A public API has different constraints than an internal one. It must be stable, well-documented, rate-limited, and versioned. It should not expose internal IDs or database structures. It should return consistent error formats. It should support pagination from day one because you cannot add it later without breaking consumers.

We keep public API endpoints in separate NestJS modules from internal endpoints:

TypeScript
1// Internal module — can change freely, no versioning
2@Module({
3  controllers: [InternalProjectsController],
4  providers: [InternalProjectsService],
5})
6export class InternalModule {}
7
8// Public module — versioned, documented, stable contract
9@Module({
10  controllers: [V1ProjectsController],
11  providers: [V1ProjectsService],
12})
13export class PublicApiModule {}

The internal controllers return data shaped for your UI. The public controllers return data shaped by your API contract, with fields that you commit to supporting for the lifetime of that version. The business logic can be shared, but the serialisation layer — what fields are exposed, what names they use, what format they follow — is separate.

API Documentation With OpenAPI/Swagger in NestJS

NestJS has excellent built-in support for OpenAPI documentation through the @nestjs/swagger package. Decorate your public controllers with the same decorators used by the OpenAPI spec:

TypeScript
1import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
2
3@ApiTags('Projects')
4@Controller('api/v1/projects')
5export class V1ProjectsController {
6  constructor(private readonly projectsService: V1ProjectsService) {}
7
8  @Get()
9  @ApiOperation({ summary: 'List all projects for the authenticated user' })
10  @ApiQuery({ name: 'page', required: false, type: Number })
11  @ApiQuery({ name: 'per_page', required: false, type: Number })
12  @ApiResponse({ status: 200, description: 'Paginated list of projects' })
13  async findAll(
14    @Query('page') page = 1,
15    @Query('per_page') perPage = 20,
16  ) {
17    return this.projectsService.findAll({ page, perPage });
18  }
19}

The Swagger module generates an OpenAPI 3.0 specification automatically:

TypeScript
1import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
2
3const config = new DocumentBuilder()
4  .setTitle('SaaS Platform API')
5  .setDescription('Public API for third-party integrations')
6  .setVersion('1.0')
7  .addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'apiKey')
8  .build();
9
10const document = SwaggerModule.createDocument(app, config);
11SwaggerModule.setup('docs', app, document);

This gives you interactive documentation at /docs where developers can explore endpoints, see request/response schemas, and test calls directly. The generated OpenAPI spec also powers SDK generation, which we cover in the next section.

We covered REST API design patterns in our REST API mistakes post, which includes the naming conventions and error format decisions that apply directly to public API documentation.

Sandbox Environment

A sandbox is not a staging server. It is a fully isolated environment that mimics your production API but uses fake data, test payment processors, and mock external integrations. Developers get sandbox API keys that behave exactly like production keys but route to the sandbox backend.

Public developer API sandbox environment — a laptop showing debugging code, representing isolated integration testing

In NestJS, implement sandbox mode with a config-driven data source:

TypeScript
1@Injectable()
2export class SandboxableProjectsService {
3  constructor(
4    @Inject('DATA_SOURCE')
5    private readonly dataSource: DataSource,
6    @Inject('PAYMENT_PROVIDER')
7    private readonly paymentProvider: PaymentProvider,
8  ) {}
9
10  async create(data: CreateProjectDto) {
11    const project = await this.dataSource.projects.create(data);
12    // Sandbox provider does not charge real cards
13    await this.paymentProvider.charge(project.id, data.amount);
14    return project;
15  }
16}
17
18// Sandbox & production providers
19@Injectable()
20export class SandboxPaymentProvider implements PaymentProvider {
21  async charge(projectId: string, amount: number) {
22    return { status: 'succeeded', transactionId: `sandbox_${uuid()}`, amount };
23  }
24}
25
26@Injectable()
27export class ProductionPaymentProvider implements PaymentProvider {
28  constructor(private readonly stripe: Stripe) {}
29  async charge(projectId: string, amount: number) {
30    return this.stripe.charges.create({ amount, currency: 'usd' });
31  }
32}

Switch between sandbox and production at the module level based on the API key's environment:

TypeScript
1@Module({})
2export class ProjectsModule {
3  static forEnvironment(env: 'sandbox' | 'production'): DynamicModule {
4    return {
5      module: ProjectsModule,
6      providers: [
7        {
8          provide: 'PAYMENT_PROVIDER',
9          useClass: env === 'sandbox' ? SandboxPaymentProvider : ProductionPaymentProvider,
10        },
11      ],
12    };
13  }
14}

The sandbox needs its own database (seeded with realistic fake data), its own API keys, and its own rate limits. It also needs a reset mechanism — a way for developers to wipe their sandbox data and start fresh. Every developer who has built against a sandbox with polluted test data will tell you this is not optional.

Developer Portal: API Key Management

Your developer portal is the home page for every third-party developer integrating with your API. It needs:

  • API key management. Generate, list, revoke, and rename keys. Show the key once on creation and never again.
  • Usage dashboard. Show per-key request counts, rate limit status, and error rate.
  • Documentation hosting. Either serve the generated Swagger UI or embed it in your portal.
  • Webhook management. Configure delivery URLs and event subscriptions.
  • Activity log. Show recent API calls with timestamps, endpoints, and status codes.

A minimal NestJS API key service:

TypeScript
1import { Injectable } from '@nestjs/common';
2import { randomBytes, createHash } from 'crypto';
3
4@Injectable()
5export class ApiKeyService {
6  constructor(
7    @InjectRepository(ApiKey)
8    private readonly keyRepo: Repository<ApiKey>,
9  ) {}
10
11  async createKey(userId: string, label: string, environment: 'sandbox' | 'production') {
12    const rawKey = `saas_${randomBytes(32).toString('hex')}`;
13    const hashedKey = createHash('sha256').update(rawKey).digest('hex');
14
15    await this.keyRepo.save({
16      keyPrefix: rawKey.substring(0, 8),
17      hashedKey,
18      userId,
19      label,
20      environment,
21    });
22
23    return rawKey; // Return once, never store plaintext
24  }
25
26  async validateKey(rawKey: string): Promise<ApiKey | null> {
27    const hashedKey = createHash('sha256').update(rawKey).digest('hex');
28    return this.keyRepo.findOneBy({ hashedKey, revokedAt: null });
29  }
30}

Never store API keys in plaintext. Hash them with SHA-256 on creation and compare hashes on validation. Show the full key exactly once — when it is created — and only display the prefix thereafter. Developers who lose their key can revoke it and generate a new one.

Rate Limiting for Public API With Tiers

Public API rate limiting is different from internal rate limiting. Internal limits protect your infrastructure from frontend bugs. Public limits enforce usage tiers and prevent a single customer from degrading API performance for everyone else.

Use @nestjs/throttler with tiered limits:

TypeScript
1import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
2
3@Module({
4  imports: [
5    ThrottlerModule.forRoot([
6      {
7        name: 'free',
8        ttl: 3600000,
9        limit: 1000,
10      },
11      {
12        name: 'pro',
13        ttl: 3600000,
14        limit: 10000,
15      },
16      {
17        name: 'enterprise',
18        ttl: 3600000,
19        limit: 100000,
20      },
21    ]),
22  ],
23})
24export class AppModule {}

Apply the guard per route based on the API key's tier:

TypeScript
1@Controller('api/v1/projects')
2export class V1ProjectsController {
3  @UseGuards(ThrottlerGuard)
4  @Throttle({ default: { limit: 1000, ttl: 3600000 } })
5  @Get()
6  async findAll() {
7    return this.projectsService.findAll();
8  }
9}

Return a 429 Too Many Requests response with a Retry-After header when the limit is exceeded. The header tells the developer exactly when they can retry, which removes the guesswork from handling rate limits in their integration code.

Track usage per API key in your database so developers can see their consumption in the developer portal. Postman's State of the API research has repeatedly flagged unclear or undocumented rate-limit policies as a top developer complaint. Transparent, documented, per-key rate limits eliminate that complaint.

Auto-Generating SDKs From OpenAPI Spec

The single best investment you can make in developer experience is SDK generation. An SDK eliminates the boilerplate of constructing HTTP requests, handling authentication, parsing responses, and managing pagination. Developers who use your SDK integrate in hours instead of days.

Use OpenAPI Generator to generate client SDKs from your OpenAPI spec. Run it as part of your build pipeline:

Bash
1npx @openapitools/openapi-generator-cli generate \
2  -i openapi.json \
3  -g typescript-fetch \
4  -o sdks/typescript \
5  --additional-properties=npmName=@saas/api-client

Integrate this into your NestJS build process — generate the spec from SwaggerModule, then run the SDK generator:

TypeScript
1import { execSync } from 'child_process';
2import { writeFileSync } from 'fs';
3
4async function generateSdks(app) {
5  const config = new DocumentBuilder()
6    .setTitle('SaaS Platform API')
7    .setVersion('1.0')
8    .build();
9
10  const document = SwaggerModule.createDocument(app, config);
11  writeFileSync('openapi.json', JSON.stringify(document, null, 2));
12
13  // Generate TypeScript SDK
14  execSync(
15    'npx @openapitools/openapi-generator-cli generate ' +
16    '-i openapi.json -g typescript-fetch -o sdks/typescript',
17  );
18
19  // Generate Python SDK
20  execSync(
21    'npx @openapitools/openapi-generator-cli generate ' +
22    '-i openapi.json -g python -o sdks/python',
23  );
24}

Commit the generated SDKs to your repository or publish them to package registries (npm, PyPI). Stale SDKs are worse than no SDKs because developers integrate against code that no longer matches your API. Regenerate SDKs on every API version release and publish the update automatically.

The planning document for public developer APIs often skips SDK generation, but it is the single highest-leverage investment in developer experience. We have seen integration time drop from weeks to hours when an SDK is available, and support tickets about "how do I call this endpoint" drop to nearly zero.

Webhooks for Your Public API

Your public API should let developers subscribe to events. When a project status changes, a payment completes, or a user hits a rate limit, your platform should push an HTTP request to a URL the developer registered.

In NestJS, implement webhook delivery with a queue-based dispatcher (the same pattern we covered in our event-driven architecture post):

TypeScript
1@Injectable()
2export class WebhookDispatcherService {
3  constructor(
4    @InjectQueue('webhooks') private readonly webhookQueue: Queue,
5  ) {}
6
7  async dispatch(event: string, payload: unknown) {
8    const subscriptions = await this.webhookSubscriptionRepo.findBy({ event });
9
10    for (const sub of subscriptions) {
11      await this.webhookQueue.add({
12        url: sub.callbackUrl,
13        secret: sub.signingSecret,
14        event,
15        payload,
16        retryCount: 0,
17      });
18    }
19  }
20}

Sign each webhook payload with a per-subscription secret so developers can verify the request came from your platform:

TypeScript
1import { createHmac } from 'crypto';
2
3function signPayload(payload: string, secret: string): string {
4  return createHmac('sha256', secret).update(payload).digest('hex');
5}

Include the signature in a X-Signature-256 header. Publish the signing method in your documentation. Developers verify webhooks the same way they verify Stripe webhooks — and every developer already knows how that works.

Versioning Commitment for Public APIs

Public API versioning is not a technical decision — it is a trust decision. Every developer evaluating your API wants to know: "If I build against this endpoint, will it still work next year?"

Commit to a versioning policy in writing. Ours is:

  • URL path versioning (/api/v1/, /api/v2/). Always.
  • Minimum 12 months support for each version from the date the replacement ships.
  • Sunset header on deprecated versions at least 90 days before removal.
  • Migration guide published with every new version.

In NestJS, use versioned controllers:

TypeScript
1@Controller({ path: 'projects', version: '1' })
2export class V1ProjectsController {
3  @Get()
4  async findAll() {
5    return this.projectsService.findAll({ includeMetadata: false });
6  }
7}
8
9@Controller({ path: 'projects', version: '2' })
10export class V2ProjectsController {
11  @Get()
12  async findAll() {
13    return this.projectsService.findAll({ includeMetadata: true });
14  }
15}

We covered versioning strategies in depth in our API versioning strategies post, which includes the deprecation timeline, Sunset header implementation, and client communication patterns that apply directly to public APIs.

Developer portal and API key management for SaaS public API — programmers collaborating at desk representing developer experience

Handling Abuse and Fraudulent API Usage

A public API is exposed to the internet, which means it will be probed, scraped, and abused. Plan for this before it happens.

The abuse detection patterns we use:

Key velocity monitoring. Track the number of requests per API key per minute. A key that makes 1000 requests in 10 seconds is likely compromised, even if it has not hit the rate limit yet. Flag it, log it, and if the pattern continues, revoke it.

Payload validation. Validate every request against your OpenAPI schema. Reject unexpected fields, oversized payloads, and malformed data at the gateway level before it reaches your controllers.

Geographic anomaly detection. If a key is used from the US for months and suddenly starts receiving requests from three countries simultaneously, that key has been leaked. Automatically revoke keys with geographically impossible access patterns.

Tier enforcement. A free-tier key should not be able to access enterprise-only endpoints. Enforce this at the guard level:

TypeScript
1@Injectable()
2export class TierGuard implements CanActivate {
3  constructor(private readonly requiredTier: string) {}
4
5  canActivate(context: ExecutionContext): boolean {
6    const request = context.switchToHttp().getRequest();
7    return request.apiKey.tier === this.requiredTier;
8  }
9}

Build a revocation endpoint into the developer portal so users can revoke compromised keys themselves. Most abuse comes from leaked keys, not malicious actors, and self-service revocation resolves it faster than support tickets.

Conclusion

A public developer API is not your internal API with a different URL prefix. It is a separate product with documentation that external developers can navigate, authentication they can implement without calling support, a sandbox where they can test without touching real data, SDKs that eliminate boilerplate, rate limits that protect your infrastructure, webhooks that push data when events happen, and a versioning commitment that makes developers confident enough to build production integrations against your platform.

We built the first version of our internal API (the one we covered in the REST API design mistakes post) before we built any of this. The public API came later, and it was built differently — because the audience is different, the contract is different, and the consequences of breaking it are different.

If you are building a public developer API program today, start with the sandbox and the documentation. They are the foundation everything else builds on — and they are the two things developers notice first when they decide whether your API is worth integrating with.

Frequently Asked Questions

A public developer API is an externally accessible API that third-party developers use to integrate with your SaaS platform. It typically includes API documentation, authentication via API keys or OAuth, a developer portal for key management, usage analytics, rate limits, and a sandbox environment for testing without affecting production data.

Use OpenAPI Generator or Swagger Codegen. These tools take your OpenAPI 3.0 specification file and generate client SDKs in multiple languages (TypeScript, Python, Java, Go, etc.). Run the generator as part of your build pipeline so SDKs are regenerated whenever your API spec changes.

An API sandbox is an isolated environment that mirrors your production API behavior but uses fake data and test payment processors. It lets third-party developers build and test integrations without consuming real resources, creating real transactions, or risking production data. Every public developer API needs one.

Use tiered rate limits: a free tier with lower limits (e.g., 1,000 requests/hour), a paid tier with higher limits (e.g., 10,000/hour), and a custom enterprise tier. Track usage per API key. Return 429 Too Many Requests with a Retry-After header when limits are exceeded.

Implement API key-based authentication, rate limiting per key, request validation for unexpected payloads, and monitoring for unusual usage patterns. Watch for rapid key rotation, requests from unusual geographies, and payloads that probe for vulnerabilities. Set up alerts for abnormal traffic patterns and have a process for revoking compromised keys.

Use URL path versioning (/api/v1/, /api/v2/) for public APIs. It is visible in logs, easy to test in a browser, and unambiguous for third-party developers. Commit to supporting each version for a minimum of 6-12 months with a documented deprecation policy.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio