Public API design for first-time SaaS builders: basics
Public API design made practical for first-time SaaS builders: choose versioning, pagination, rate limits, docs, and a small SDK you can ship fast.

The real problem: shipping an API you can maintain
A public API isn't just an endpoint your app exposes. It's a promise to people outside your team that the contract will keep working, even as you change the product.
The hard part isn't writing v1. It's keeping it stable while you fix bugs, add features, and learn what customers actually need.
Early choices show up later as support tickets. If responses change shape without warning, if naming is inconsistent, or if clients can't tell whether a request succeeded, you create friction. That friction turns into mistrust, and mistrust makes people stop building on you.
Speed matters too. Most first-time SaaS builders need to ship something useful fast, then improve it. The tradeoff is simple: the faster you ship without rules, the more time you'll spend undoing those decisions when real users arrive.
Good enough for v1 usually means a small set of endpoints that map to real user actions, consistent naming and response shapes, a clear change strategy (even if it's just v1), predictable pagination and sane rate limits, and docs that show exactly what to send and what you'll get back.
A concrete example: imagine a customer builds an integration that creates invoices nightly. If you later rename a field, change date formats, or silently start returning partial results, their job fails at 2 a.m. They'll blame your API, not their code.
If you build with a chat-driven tool like Koder.ai, it's tempting to generate many endpoints quickly. That's fine, but keep the public surface small. You can keep internal endpoints private while you learn what should be part of the long-term contract.
Start with a small, clear API surface
Good public API design starts by choosing a small set of nouns (resources) that match how customers talk about your product. Keep resource names stable even if your internal database changes. When you add features, prefer adding fields or new endpoints over renaming core resources.
A practical starting set for many SaaS products looks like: users, organizations, projects, and events. If you can't explain a resource in one sentence, it's probably not ready to be public.
Keep HTTP usage boring and predictable:
- GET reads data (no side effects)
- POST creates something (or starts an action)
- PATCH updates a few fields
- DELETE removes or disables something
Auth doesn't need to be fancy on day one. If your API is mainly server to server (customers calling from their backend), API keys are often enough. If customers need to act as individual end users, or you expect third-party integrations where users grant access, OAuth is usually a better fit. Write the decision in plain language: who is the caller, and whose data are they allowed to touch?
Set expectations early. Be explicit about what's supported vs best effort. For example: list endpoints are stable and backwards-compatible, but search filters may expand and aren't guaranteed to be exhaustive. This reduces support tickets and keeps you free to improve.
If you're building on a vibe-coding platform like Koder.ai, treat the API as a product contract: keep the contract small first, then grow it based on real usage, not guesses.
Versioning without painting yourself into a corner
Versioning is mostly about expectations. Clients want to know: will my integration break next week? You want room to improve things without fear.
URL versioning vs header versioning
Header-based versioning can look clean, but it's easy to hide from logs, caches, and support screenshots. URL versioning is usually the simplest choice: /v1/.... When a customer sends you a failing request, you can see the version immediately. It also makes it easy to run v1 and v2 side by side.
What is a breaking change, really?
A change is breaking if a well-behaved client could stop working without changing their code. Common examples:
- Renaming a field (for example
customer_idtocustomerId) - Changing a field type (string to number) or its meaning
- Removing an endpoint or a response field clients may rely on
- Tightening validation rules (previously optional, now required)
- Changing auth requirements or default permissions
A safe change is one that old clients can ignore. Adding a new optional field is usually safe. For instance, adding plan_name to a GET /v1/subscriptions response won't break clients that only read status.
A practical rule: don't remove or repurpose fields inside the same major version. Add new fields, keep the old ones, and retire them only when you're ready to deprecate the entire version.
A deprecation policy you can follow
Keep it simple: announce deprecations early, return a clear warning message in responses, and set an end date. For a first API, a 90-day window is often realistic. During that time, keep v1 working, publish a short migration note, and make sure support can point to one sentence: v1 works until this date; here's what changed in v2.
If you build on a platform like Koder.ai, treat API versions like snapshots: ship improvements in a new version, keep the old one stable, and only cut it off after you've given customers time to move.
Pagination patterns that stay predictable
Pagination is where trust is won or lost. If results jump around between requests, people stop trusting your API.
Use page/limit when the data set is small, the query is simple, and users often want page 3 of 20. Use cursor-based pagination when lists can grow large, new items arrive often, or the user can sort and filter a lot. Cursor-based pagination keeps the sequence stable even when new records are added.
A few rules keep pagination reliable:
- Always define a default sort (for example: created_at desc).
- Add a tie-breaker (for example: id) so order is deterministic.
- Treat pagination as part of the contract: changing sort later is a breaking change.
- Return the minimum needed to continue: items plus the next cursor (or next page).
Totals are tricky. A total_count can be expensive on big tables, especially with filters. If you can provide it cheaply, include it. If you can't, omit it or make it optional via a query flag.
Here are simple request/response shapes.
// Page/limit
GET /v1/invoices?page=2&limit=25&sort=created_at_desc
{
"items": [{"id":"inv_1"},{"id":"inv_2"}],
"page": 2,
"limit": 25,
"total_count": 142
}
// Cursor-based
GET /v1/invoices?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0wOVQxMDozMDowMFoiLCJpZCI6Imludl8xMDAifQ==
{
"items": [{"id":"inv_101"},{"id":"inv_102"}],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0wOVQxMDoyNTowMFoiLCJpZCI6Imludl8xMjUifQ=="
}
Rate limits and friendly retries
Rate limits are less about being strict and more about staying online. They protect your app from traffic spikes, your database from expensive queries done too often, and your wallet from surprise infrastructure bills. A limit is also a contract: clients know what normal usage looks like.
Start simple and tune later. Choose something that covers typical usage with room for short bursts, then watch real traffic. If you have no data yet, a safe default is a per-API-key limit like 60 requests per minute plus a small burst allowance. If one endpoint is much heavier (like search or exports), give it a stricter limit or a separate cost rule instead of punishing every request.
When you enforce limits, make it easy for clients to do the right thing. Return a 429 Too Many Requests response and include a few standard headers:
X-RateLimit-Limit: the max allowed in the windowX-RateLimit-Remaining: how many are leftX-RateLimit-Reset: when the window resets (timestamp or seconds)Retry-After: how long to wait before retrying
Clients should treat 429 as a normal condition, not an error to fight. A polite retry pattern keeps both sides happy:
- Wait for
Retry-Afterwhen it's present - Otherwise use exponential backoff (for example 1s, 2s, 4s)
- Add a little randomness (jitter) so many clients don't retry at once
- Cap the wait time (for example 30-60s)
Example: if a customer runs a nightly sync that hits your API hard, their job can spread requests across a minute and automatically slow down on 429s instead of failing the whole run.
Errors, status codes, and safe writes
If your API errors are hard to read, support tickets pile up fast. Pick one error shape and stick to it everywhere, including 500s. A simple standard is: code, message, details, and a request_id the user can paste into a support chat.
Here is a small, predictable format:
{
"error": {
"code": "validation_error",
"message": "Some fields are invalid.",
"details": {
"fields": [
{"name": "email", "issue": "must be a valid email"},
{"name": "plan", "issue": "must be one of: free, pro, business"}
]
},
"request_id": "req_01HT..."
}
}
Use HTTP status codes the same way every time: 400 for bad input, 401 when auth is missing or invalid, 403 when the user is authenticated but not allowed, 404 when a resource is not found, 409 for conflicts (like a duplicate unique value or wrong state), 429 for rate limits, and 500 for server errors. Consistency beats cleverness.
Make validation errors easy to fix. Field-level hints should point to the exact parameter name your docs use, not an internal database column. If there's a format requirement (date, currency, enum), say what you accept and show an example.
Retries are where many APIs accidentally create duplicate data. For important POST actions (payments, invoice creation, sending emails), support idempotency keys so clients can safely retry.
- Accept an
Idempotency-Keyheader on selected POST endpoints. - Store the key with the result for a short window (for example, 24 hours).
- On a repeated key, return the same response as the first request.
- If the first request is still running, return a clear try again response instead of creating a second resource.
That one header prevents a lot of painful edge cases when networks are flaky or clients hit timeouts.
Example scenario: a small SaaS billing API in practice
Imagine you run a simple SaaS with three main objects: projects, users, and invoices. A project has many users, and each project gets monthly invoices. Clients want to sync invoices into their accounting tool and show basic billing inside their own app.
A clean v1 might look like this:
GET /v1/projects/{project_id}
GET /v1/projects/{project_id}/invoices
POST /v1/projects/{project_id}/invoices
Now a breaking change happens. In v1, you store invoice amounts as an integer in cents: amount_cents: 1299. Later, you need multi-currency and decimals, so you want amount: "12.99" and currency: "USD". If you overwrite the old field, every existing integration breaks. Versioning avoids the panic: keep v1 stable, ship /v2/... with the new fields, and support both until clients migrate.
For listing invoices, use a predictable pagination shape. For example:
GET /v1/projects/p_123/invoices?limit=50&cursor=eyJpZCI6Imludl85OTkifQ==
200 OK
{
"data": [ {"id":"inv_1001"}, {"id":"inv_1000"} ],
"next_cursor": "eyJpZCI6Imludl8xMDAwIn0="
}
One day a customer imports invoices in a loop and hits your rate limit. Instead of random failures, they get a clear response:
429 Too Many RequestsRetry-After: 20- a small error body like
{ "error": { "code": "rate_limited" } }
On their side, the client can pause for 20 seconds, then continue from the same cursor without re-downloading everything or creating duplicate invoices.
Step-by-step: a simple release plan for your v1 API
A v1 launch goes better when you treat it like a small product release, not a pile of endpoints. The goal is simple: people can build on it, and you can keep improving it without surprises.
A practical 1-week plan (even for a tiny team)
Start by writing one page that explains what your API is for and what it is not. Keep the surface area small enough that you can explain it out loud in a minute.
Use this sequence and don't move on until each step is good enough:
- Draft a single-page spec that names your core resources and the first handful of endpoints you'll support (think 5-10). Include the base URL pattern, auth method, and required headers.
- For every endpoint, write one realistic request and one realistic response. Use actual field names you plan to ship. These examples become your first docs and your first tests.
- Add a short rules section: how errors look, how pagination works (if any list endpoint exists), what rate limits are, and which fields are stable vs may change.
- Do an internal test with a fake client. Pretend you're a customer building an integration in a new repo. Time how long it takes to get the first successful call, and note where you got confused.
- Publish your v1 contract: what changes are safe (additive fields), what changes require a new version, and how much notice you'll give for breaking changes.
If you build with a code-generating workflow (for example, using Koder.ai to scaffold endpoints and responses), still do the fake-client test. Generated code can look correct while still being awkward to use.
The payoff is fewer support emails, fewer hotfix releases, and a v1 you can actually maintain.
Shipping a small SDK without over-engineering
A first SDK isn't a second product. Think of it as a thin, friendly wrapper around your HTTP API. It should make the common calls easy, but it shouldn't hide how the API works. If someone needs a feature you haven't wrapped yet, they should still be able to drop down to raw requests.
Pick one language to start, based on what your customers actually use. For many B2B SaaS APIs that's often JavaScript/TypeScript or Python. Shipping one solid SDK beats shipping three half-finished ones.
What a small SDK should include
A good starter set is:
- Auth handling (API key or OAuth token) in one place
- Sensible timeouts and automatic retries for safe requests (GET), with backoff
- Pagination helpers that return an iterator or next page function
- Clear request/response models (even if it's basic types)
- An escape hatch for custom headers and raw HTTP calls
You can build this by hand or generate it from an OpenAPI spec. Generation is great when your spec is accurate and you want consistent typing, but it often produces a lot of code. Early on, a hand-written minimal client plus an OpenAPI file for docs is usually enough. You can switch to generated clients later without breaking users, as long as the public SDK interface stays stable.
Version the SDK separately from the API
Your API version should follow your compatibility rules. Your SDK version should follow packaging rules.
If you add new optional parameters or new endpoints, that's usually a minor SDK bump. Reserve major SDK releases for breaking changes in the SDK itself (renamed methods, changed defaults), even if the API stayed the same. That separation keeps upgrades calm and support tickets low.
Common mistakes that cause support headaches
Most API support tickets aren't about bugs. They're about surprises. Public API design is mostly about being boring and predictable so client code keeps working month after month.
The fastest way to lose trust is changing responses without telling anyone. If you rename a field, change a type, or start returning null where you used to return a value, you'll break clients in ways that are hard for them to diagnose. If you truly must change behavior, version it, or add a new field and keep the old one for a while with a clear sunset plan.
Pagination is another repeat offender. Problems show up when one endpoint uses page/pageSize, another uses offset/limit, and a third uses cursors, all with different defaults. Pick one pattern for v1 and stick to it everywhere. Keep sorting stable too, so next page doesn't skip or repeat items when new records arrive.
Errors create a lot of back-and-forth when they're inconsistent. A common failure mode is one service returning { "error":"..." } and another returning { "message":"..." }, with different HTTP status codes for the same issue. Clients then build messy, endpoint-specific handlers.
Here are five mistakes that generate the longest email threads:
- Silent field changes (names, types, meaning) without a version bump or deprecation window
- Pagination rules that vary by endpoint or change defaults over time
- Different error formats, status codes, or validation messages across endpoints
- Missing request IDs, so neither side can quickly find the exact failing call in logs
- Rate limits that trigger suddenly, with no headers, no clear retry guidance, and no example backoff
A simple habit helps: every response should include a request_id, and every 429 should explain when to retry.
Quick checklist and next steps
Before you publish anything, do a final pass focused on consistency. Most support tickets happen because small details don't match across endpoints, docs, and examples.
Quick checks that catch the most issues:
- Naming: consistent nouns, plural resources, and field casing (pick one and stick to it).
- Examples: every endpoint has one realistic request and response, including pagination.
- Errors: a clear error shape, stable error codes, and helpful messages for common failures.
- Limits: rate limit behavior is documented and responses include the headers you expect.
- Safety: idempotency for retries on creates, and predictable handling of timeouts.
After launch, watch what people actually hit, not what you hoped they'd use. A small dashboard and a weekly review is enough early on.
Monitor these signals first:
- 429 spikes (who is getting limited and why).
- p95 latency per endpoint (slow endpoints often hide N+1 queries).
- Top endpoints and parameters (your real API surface).
- Error rates by status code (400 vs 401 vs 500).
- Timeouts and retries (clients may be looping without realizing it).
Collect feedback without rewriting everything. Add a short report an issue path in your docs, and tag each report with the endpoint, request id, and client version. When you fix something, prefer additive changes: new fields, new optional params, or a new endpoint, instead of breaking existing behavior.
Next steps: write a one-page API spec with your resources, versioning plan, pagination rules, and error format. Then produce docs and a tiny starter SDK that covers authentication plus 2 to 3 core endpoints. If you want to move faster, you can draft the spec, docs, and a starter SDK from a chat-based plan using tools like Koder.ai (its planning mode is a handy way to map endpoints and examples before you generate code).
FAQ
How many endpoints should I expose in my first public API?
Start with 5–10 endpoints that map to real customer actions.
A good rule: if you can’t explain a resource in one sentence (what it is, who owns it, how it’s used), keep it private until you learn more from usage.
What resources should I start with for a SaaS API?
Pick a small set of stable nouns (resources) customers already use in conversation, and keep those names stable even if your database changes.
Common starters for SaaS are users, organizations, projects, and events—then add more only when there’s clear demand.
Which HTTP methods should I use, and why does it matter?
Use the standard meanings and be consistent:
GET= read (no side effects)POST= create or start an actionPATCH= partial updateDELETE= remove or disable
The main win is predictability: clients shouldn’t guess what a method does.
Should I version my API in the URL or in headers?
Default to URL versioning like /v1/....
It’s easier to see in logs and screenshots, easier to debug with customers, and simpler to run v1 and v2 side by side when you need a breaking change.
What counts as a breaking change in an API?
A change is breaking if a correct client can fail without changing their code. Common examples:
- Renaming fields
- Changing field types or meaning
- Removing fields or endpoints
- Making previously-optional input required
- Changing auth rules or default permissions
Adding a new optional field is usually safe.
What’s a realistic deprecation policy for a v1 API?
Keep it simple:
- Announce it early
- Include a clear warning in responses (and/or docs)
- Set a firm end date
A practical default is a 90-day window for a first API, so customers have time to migrate without panic.
Should I use page-based or cursor-based pagination?
Pick one pattern and stick to it across all list endpoints.
- Use page/limit when datasets are small and people jump to page 3.
- Use cursor when lists grow large or change often.
Always define a default sort and a tie-breaker (like created_at + id) so results don’t jump around.
What rate limit should I set, and what should I return on 429?
Start with a clear per-key limit (for example 60 requests/minute plus a small burst), then adjust based on real traffic.
When limiting, return 429 and include:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After
This makes retries predictable and reduces support tickets.
What should a good API error response look like?
Use one error format everywhere (including 500s). A practical shape is:
code(stable identifier)message(human-readable)details(field-level issues)request_id(for support)
Also keep status codes consistent (400/401/403/404/409/429/500) so clients can handle errors cleanly.
How do I ship docs and a small SDK without over-engineering it?
If you generate lots of endpoints quickly (for example with Koder.ai), keep the public surface small and treat it as a long-term contract.
Do this before launch:
- Write one realistic request + response example per endpoint
- Lock in pagination and error shapes
- Add idempotency keys for important
POSTactions - Build a “fake client” internally to see where it’s confusing
Then publish a tiny SDK that helps with auth, timeouts, retries for safe requests, and pagination—without hiding how the HTTP API works.