Back to Blog

REST API Design Mistakes We Made on Our First SaaS and How We Fixed Them

Published: 2026-06-23
REST API Design Mistakes We Made on Our First SaaS and How We Fixed Them

We renamed a field. We thought it was housekeeping. It broke a client's integration at 2am, because to them our "housekeeping" was a contract change they never agreed to. That is the night we adopted real API versioning, a written definition of "breaking change," and the uncomfortable realisation that every REST API design mistake we had made was suddenly visible under the glare of a production incident.

That first SaaS taught us the hard way that a public API is a promise — and we had been casually editing the promise for months. This post covers the REST API design mistakes we made on that project and every SaaS since — the ones teams make when they are moving fast and not thinking about the contract they are building. If you are building a SaaS API today, these are the traps to step around instead of into.

How to avoid REST API design mistakes in SaaS — programming code on a screen representing clean API design

Mistake 1: Verbs in URLs

Every new SaaS team makes this one. It feels natural: you need to create a user, so you write /api/createUser. You need to fetch users, so you write /api/getUsers. Before long your API looks like a menu of RPC calls dressed up in HTTP clothing.

TypeScript
1// Before
2POST /api/createUser
3GET  /api/getUsers
4PUT  /api/updateUser/5
5POST /api/deleteUser/5
TypeScript
1// After
2POST   /api/users         // create
3GET    /api/users          // list
4GET    /api/users/5        // get one
5PUT    /api/users/5        // update
6DELETE /api/users/5        // delete

The HTTP method already tells you the action. POST creates, GET reads, PUT replaces, DELETE removes. The URL should describe the resource — the noun — not the operation. This is not stylistic preference; it is the foundation of REST. When URLs are resource-based, any developer who knows /api/users can guess that /api/orders and /api/subscriptions follow the same pattern. That predictability is the entire point.

For non-CRUD operations — "send invoice," "cancel subscription," "approve document" — model the action as a sub-resource creation rather than a verb in the URL.

TypeScript
1// Instead of:
2POST /api/invoices/5/sendEmail
3
4// Model as a sub-resource:
5POST /api/invoices/5/deliveries

Mistake 2: Inconsistent Naming Conventions

An API that uses blog-posts in one endpoint, user_profiles in another, orderItems in a third, and ProductList in a fourth is not an API — it is a guessing game. We had exactly this problem because four different engineers built four different endpoint groups without agreeing on a convention.

TypeScript
1// Before: every endpoint uses a different convention
2/api/blog-posts          // kebab-case
3/api/user_profiles       // snake_case
4/api/orderItems          // camelCase
5/api/ProductList         // PascalCase
TypeScript
1// After: consistent kebab-case, plural nouns, no verbs
2/api/blog-posts
3/api/user-profiles
4/api/order-items
5/api/products

Pick one convention and enforce it across every endpoint. Kebab-case is the industry standard for URLs because it is readable and URL-safe. Always use plural nouns for collections — /api/users, not /api/user — even when you are accessing a single element: /api/users/5. Consistency is the single highest-impact design decision you can make for developer experience, just as consistent data access patterns (like our PostgreSQL RLS pattern for multi-tenant isolation) eliminate entire categories of bugs.

Common REST API design mistakes in SaaS — error message representing poor API error handling

Mistake 3: Wrong HTTP Status Codes

A mistake SaaS teams always seem to make is returning HTTP 200 for everything and putting the real status in the response body. It is the most frustrating pattern for frontend developers. It breaks every HTTP client, every proxy, every monitoring tool, and every error-handling middleware that relies on status codes to do its job.

TypeScript
1// Before: 200 OK with error in body
2HTTP 200
3{
4  "success": false,
5  "error": "User not found"
6}
TypeScript
1// After: proper status codes
2HTTP 404
3{
4  "type": "https://api.example.com/errors/resource-not-found",
5  "title": "Resource Not Found",
6  "status": 404,
7  "detail": "No user found with ID 4523."
8}

The status codes you need to know cold:

CodeMeaningWhen
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST (include Location header)
204No ContentSuccessful DELETE
400Bad RequestValidation errors, malformed input
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not permitted
404Not FoundResource does not exist
409ConflictDuplicate resource, version conflict
422Unprocessable EntitySemantically wrong request body
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server failure

Pay special attention to the 401 vs 403 distinction. Getting this wrong means every frontend developer who integrates with your API will build the wrong error-handling logic, and neither of you will realise it until something breaks in production.

Mistake 4: Poor Error Response Format

Inconsistent error responses were the number one source of support tickets on our first SaaS. One endpoint returned { "error": "User not found" }. Another returned { "success": false, "message": "something went wrong" }. A third returned a plain string. Our customers' developers had to write custom parsing logic for every endpoint, which is the opposite of a good developer experience.

TypeScript
1// Before: three different error shapes from three endpoints
2{ "error": "User not found" }
3{ "success": false, "message": "bad request" }
4"validation failed"
TypeScript
1// After: consistent RFC 7807 Problem Details
2HTTP 422
3{
4  "type": "https://api.example.com/errors/validation-error",
5  "title": "Validation Error",
6  "status": 422,
7  "detail": "One or more fields failed validation.",
8  "errors": [
9    {
10      "field": "email",
11      "message": "Must be a valid email address.",
12      "rejectedValue": "not-an-email"
13    },
14    {
15      "field": "age",
16      "message": "Must be a positive integer.",
17      "rejectedValue": -5
18    }
19  ]
20}

Adopt the RFC 7807 Problem Details standard across your entire API. Every error returns the same shape: a type URI pointing to documentation, a short title, the HTTP status, a human-readable detail, and an errors array for field-level validation failures. This makes error handling predictable for every consumer — write one piece of client-side error logic and it works everywhere.

Mistake 5: No Pagination

One classic mistake SaaS teams discover too late is skipping pagination. This one bit us exactly when you expect it to: the day a customer with 50,000 records hit our list endpoint for the first time. The response took 12 seconds, the JSON payload was 8MB, and the developer on the other end was not happy.

TypeScript
1// Before: returns everything
2GET /api/orders
3// → 50,000 orders in one response, 12 seconds, 8MB
TypeScript
1// After: cursor-based pagination
2GET /api/orders?cursor=eyJpZCI6MTAwfQ==&limit=25
3
4// Response:
5{
6  "data": [ ... ],
7  "pagination": {
8    "nextCursor": "eyJpZCI6MTI1fQ==",
9    "hasMore": true
10  }
11}

Add pagination on day one, even if your database has 15 test records. Removing it later is trivial. Adding it later is a breaking change for every consumer. Use cursor-based pagination over offset-based for production APIs — offset pagination gets slower as your dataset grows because the database must skip rows, while cursor pagination maintains consistent performance regardless of position.

Set a sensible default page size (20-25) and enforce a hard maximum (100). Return pagination metadata in every list response so consumers always know how to get the next page.

Mistake 6: No API Versioning

The field rename that broke a client's integration at 2am happened because we had no versioning strategy. Every endpoint was at the root: /api/users, no prefix, no version, no contract. Any change was a potential breaking change, and we had no way to communicate that to consumers.

TypeScript
1// Before: no versioning
2GET /api/users
3// → "we'll rename a field and hope nobody notices"
4
5// After: URL-based versioning
6GET /api/v1/users
7GET /api/v2/users

URL-based versioning is the most practical choice for SaaS APIs. It is visible, testable in a browser, easy to route, and understood by every developer. Reserve new versions for breaking changes only — adding fields is not a breaking change, removing or renaming them is. Document what changed between versions in a changelog, and give consumers a deprecation timeline (90 days minimum) before retiring old versions.

If you are starting a new SaaS today, begin every endpoint with /api/v1/. You might not need v2 for a year, but when you do, you will be glad the structure is already there.

Mistake 7: Not Handling Idempotency

We discovered this one when a network timeout caused a customer's payment webhook to fire the same POST request twice, and our API happily created two subscriptions and charged the customer twice. The refund was painful. The trust loss was worse.

TypeScript
1// Before: no idempotency, duplicate POST creates duplicate resources
2POST /api/v1/subscriptions
3{ "plan": "pro", "customerId": "cust_123" }
4// → first request: 201 Created
5// → second request (retry): 201 Created (duplicate!)
TypeScript
1// After: Idempotency-Key header prevents duplicates
2POST /api/v1/subscriptions
3Idempotency-Key: 8a3b91c4-f7e2-4d1a-b5c8-9e0f12345678
4Content-Type: application/json
5
6{ "plan": "pro", "customerId": "cust_123" }
TypeScript
1// Server-side handler
2app.post('/api/v1/subscriptions', async (req, res) => {
3  const idempotencyKey = req.headers['idempotency-key'];
4
5  if (!idempotencyKey) {
6    return res.status(400).json({
7      title: 'Missing Idempotency Key',
8      detail: 'Idempotency-Key header is required for this endpoint.'
9    });
10  }
11
12  const existing = await db.findByIdempotencyKey(idempotencyKey);
13  if (existing) {
14    return res.status(200).json(existing);
15  }
16
17  const subscription = await db.createSubscription(req.body, idempotencyKey);
18  return res.status(201).json(subscription);
19});

GET, PUT, and DELETE are naturally idempotent — calling them multiple times produces the same result. POST is the one that needs explicit protection. Require an Idempotency-Key header on every POST endpoint that creates resources or triggers side effects, and store the key-response pair so retries return the original result instead of creating duplicates. Stripe's API is the canonical example of this done right.

Mistake 8: No Rate Limiting

An API without rate limiting is an invitation. A single misbehaving client — intentional or accidental — can saturate your database connections, exhaust your worker pool, and degrade the experience for every other customer. On our first SaaS, a beta customer's polling script that ran without a delay taught us this lesson at 3pm on a Tuesday.

TypeScript
1// Response headers for rate limiting
2HTTP 200
3X-RateLimit-Limit: 1000
4X-RateLimit-Remaining: 847
5X-RateLimit-Reset: 1743696000
6
7// When limit is exceeded:
8HTTP 429 Too Many Requests
9Retry-After: 60
10
11{
12  "type": "https://api.example.com/errors/rate-limit-exceeded",
13  "title": "Rate Limit Exceeded",
14  "status": 429,
15  "detail": "You have exceeded 1000 requests per hour. Retry after 60 seconds."
16}

Set rate limits per API key or per customer, not globally. Return clear headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) so consumers can self-regulate. Return a Retry-After header and a structured 429 response when the limit is hit. Most frameworks and API gateways have built-in rate limiting middleware — use it on day one, not after a customer takes your API down. We covered a full NestJS rate limiting implementation in a previous post if you need a framework-specific guide.

Mistake 9: Exposing Internal Implementation Details

This common mistake is both a design error and a security risk. Returning database column names, auto-increment IDs, password hashes, or ORM metadata in your API responses couples consumers to your internal implementation and leaks information you should never share.

TypeScript
1// Before: exposes database internals
2{
3  "user_tbl_id": 4523,
4  "pwd_hash": "$2b$10$abcdef...",
5  "mysql_created_ts": "2026-03-15 10:30:00",
6  "_hibernate_version": 3
7}
TypeScript
1// After: clean, intentional response contract
2{
3  "id": "usr_a1b2c3d4",
4  "name": "Jane Doe",
5  "email": "jane@example.com",
6  "createdAt": "2026-03-15T10:30:00Z"
7}

Treat your API response as a contract that is independent of your database schema. Use a mapping layer — DTOs, serializers, or presenters — between your internal models and your API responses. Never expose password hashes, internal IDs, stack traces in production, or any field whose name includes an implementation detail.

For resource identifiers, use prefixed UUIDs (usr_, ord_, sub_) instead of auto-increment integers. Sequential IDs reveal your user count and growth rate, and make enumeration trivial for anyone who wants to scrape your data.

Mistake 10: Treating Authentication and Authorization as an Afterthought

Most REST API design guides focus on URLs and status codes and skip the auth layer entirely. That is a mistake. We have seen production APIs that accept JWT tokens without validating the signature, or check authentication but not authorization — the server knows who you are but never checks whether you should be doing what you are doing.

TypeScript
1// Before: checks auth but not authorization
2async function getUser(req, res) {
3  const user = await authenticate(req); // validates JWT signature
4  const resource = await db.users.findById(req.params.id);
5  return res.json(resource); // never checks if user owns this resource
6}
TypeScript
1// After: checks both auth and authorization
2async function getUser(req, res) {
3  const user = await authenticate(req);
4  const resource = await db.users.findById(req.params.id);
5
6  if (resource.tenantId !== user.tenantId) {
7    return res.status(403).json({
8      title: 'Forbidden',
9      detail: 'You do not have access to this resource.'
10    });
11  }
12
13  return res.json(resource);
14}

Implement authorization checks at both the endpoint level (can this user access this API at all?) and the object level (can this user access this specific record?). Use scoped API keys or granular OAuth scopes so consumers can request only the permissions they need. The average data breach now costs $4.88 million (IBM, 2024), and stolen credentials account for 24% of breaches (Verizon DBIR 2024). Auth mistakes at the API layer are where that money goes. For a deeper look at API key authentication specifically, see our post on NestJS API key authentication for SaaS.

Fixing REST API design mistakes in SaaS — developer workspace with code and API documentation

Mistake 11: No Deprecation Strategy

We renamed a field and called it housekeeping. It was not housekeeping — it was a breaking change we did not have the vocabulary to name. Every API eventually needs to retire endpoints, rename fields, or change behaviour. Without a deprecation strategy, every change becomes a crisis.

TypeScript
1// Deprecating an endpoint: add Sunset header 90+ days before removal
2GET /api/v1/users
3Sunset: Sat, 20 Sep 2026 00:00:00 GMT
4Deprecation: true
5Link: </api/v2/users>; rel="successor"
6
7// Response body also includes migration guidance
8{
9  "data": [ ... ],
10  "deprecation": {
11    "sunset": "2026-09-20T00:00:00Z",
12    "migrationGuide": "/docs/migrate-v1-to-v2",
13    "suggestedApiVersion": "v2"
14  }
15}

Add Sunset and Deprecation HTTP headers to endpoints you plan to remove. Include a Link header pointing to the replacement endpoint and a response body field with a migration guide URL. Monitor usage of deprecated endpoints so you can proactively contact heavy consumers before the cutoff date. Give customers a minimum of 90 days between announcing a deprecation and removing the endpoint.

The GitHub API deprecation policy is a good reference — they communicate timelines clearly, provide migration guides, and support old versions long enough that integrations can transition without emergency deploys.

Mistake 12: Over-Complicated Responses

The temptation to return everything about a resource in every response creates bloated payloads, slower integrations, and more surface area for mistakes. We had an endpoint that returned 47 fields for a user object, including nested order history, address details, and preference objects that most callers never used.

TypeScript
1// Before: kitchen sink response with everything
2{
3  "id": "usr_123",
4  "username": "jsmith",
5  "email": "jsmith@example.com",
6  "firstName": "John",
7  "lastName": "Smith",
8  "address": { "street": "123 Main St", "city": "Boston", ... },
9  "phoneNumbers": [ { "type": "home", "number": "555-1234" }, ... ],
10  "orders": [ { "id": "ord_1001", "items": [ ... ], ... } ],
11  "preferences": { "theme": "dark", "notifications": true, ... },
12  "lastLoginIp": "192.168.1.1",
13  "internalNotes": "VIP customer"
14  // 35 more fields...
15}
TypeScript
1// After: focused response with only what most callers need
2{
3  "id": "usr_123",
4  "name": "John Smith",
5  "email": "jsmith@example.com"
6}

Return only the fields most consumers need by default. Support field selection via query parameter for callers that need more: GET /api/v1/users/123?fields=id,name,email,preferences.theme. Provide separate endpoints for related collections (GET /api/v1/users/123/orders) rather than nesting them. Every field you include is a field you must maintain, document, and support indefinitely.

Pre-Launch API Checklist

Before shipping your next REST API endpoint, run through this:

  • URLs use nouns, not verbs
  • Consistent kebab-case naming across all endpoints
  • Proper HTTP methods (GET, POST, PUT, DELETE, PATCH)
  • Correct HTTP status codes for every response
  • Consistent RFC 7807 error response format
  • Pagination on all list endpoints (with sensible defaults)
  • API versioning from day one (/api/v1/)
  • Idempotency keys on POST endpoints
  • Rate limiting with clear response headers
  • No internal implementation details in responses
  • Authentication and authorization at both endpoint and object level
  • Deprecation strategy documented (Sunset headers, migration guides)
  • Focused responses — no kitchen-sink payloads
  • CORS configured correctly
  • API documentation published (OpenAPI/Swagger)

The Bottom Line on REST API Design Mistakes

Good REST API design is not about academic purity. It is about reducing friction for every developer who builds on top of your SaaS. The REST API design mistakes SaaS teams make are almost always the same ones — and they are almost always fixable once you know what to look for. Every mistake on this list created real costs for us: wasted engineering hours, support tickets, broken integrations, and at least one 2am incident call that none of us want to repeat.

The encouraging part is that every one of these REST API design mistakes is avoidable with a little upfront structure — consistent naming, proper status codes, pagination, versioning, idempotency, rate limiting, and auth that actually checks both who you are and what you can do. These are not hard problems to solve. They are just easy to defer until after they have already cost you something.

We wrote that checklist above because we still use it before every deployment. If you take nothing else from this post, run that list against your current API surface. Chances are at least three items will apply — and fixing them now is cheaper than fixing them after a customer's integration breaks at 2am.

Frequently Asked Questions

Inconsistent naming and structure is the most common mistake. Mixing verb-based URLs, different casing conventions (snake_case, camelCase, kebab-case) across endpoints, and using singular instead of plural resource names all create confusion for developers integrating with your API. A consistent naming convention from day one eliminates an entire category of bugs and support questions.

URI path versioning (/api/v1/users) is the most practical choice for most SaaS teams. It is simple, visible in logs, easy to test in a browser, and well understood by developers. Header-based versioning keeps URLs cleaner but adds friction during development and testing because it is not immediately visible.

Use a consistent JSON structure for every error response. Follow RFC 7807 Problem Details format with a type URI, title, status code, detail message, and an errors array for validation failures. Always use proper HTTP status codes — never return 200 OK with an error in the body.

Idempotency means making the same request multiple times produces the same result. GET, PUT, and DELETE are naturally idempotent. POST is not. Without idempotency keys on POST endpoints, network retries can create duplicate resources, double charges, or repeated notifications. Implement Idempotency-Key headers for all mutation endpoints.

Add a Sunset or Deprecation HTTP header to the old endpoint at least 90 days before removal. Include a link in the header or response body pointing to the migration guide for the new endpoint. Monitor usage of deprecated endpoints and contact heavy users before the cutoff date.

Add pagination on day one, even if your dataset is small today. Adding pagination later is a breaking change for consumers. Start with cursor-based pagination for better performance at scale, or offset-based for simplicity. Set a reasonable default page size (20-25) and a hard maximum (100).

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