Designing a Public API for Your SaaS: Keys, Versioning and Limits
Founder & Lead Engineer, RAITHub
A public SaaS API is a contract you cannot easily take back, so design six things before the first customer integrates: prefixed API keys stored only as hashes, scoped to one tenant; a versioning rule that never breaks old clients; cursor pagination; one error format; idempotency keys on writes; and published rate limits. Everything else can change later. These cannot.
If you would rather have the API built and documented for you, see how RAITHub would build this below.
When does a SaaS actually need a public API?
When customers ask to move data in or out without a person clicking, usually to sync with their CRM, data warehouse or internal tools. In B2B sales, "do you have an API?" often appears on the security or procurement questionnaire before anyone has used the product.
A public API is different from the private API your own front end calls. Your front end ships with your back end, so you can change both together. A customer's integration was written once, by someone who may have left, and it will keep calling the same URL with the same fields for years. That is why the decisions below matter more than the framework you use.
| Decision | Recommended default | Why it is hard to change later |
|---|---|---|
| Authentication | Tenant-scoped API keys with scopes; OAuth 2.0 when third-party apps act for your users | Every integration stores the credential format |
| Versioning | Additive changes only within a version; breaking changes behind a new version | Old clients cannot be upgraded by you |
| Identifiers | Opaque, prefixed string IDs (inv_..., cus_...) | Clients store them in their own databases |
| Pagination | Cursor-based with a limit | Offset paging breaks under inserts and gets slow on big tables |
| Errors | One JSON error shape with a stable code | Clients branch on your error codes |
| Rate limits | Per key, published, with headers and 429 responses | Tightening limits later breaks working integrations |
How should API keys be generated and stored?
Generate at least 256 bits of randomness, add a readable prefix, show the full key once, and store only a hash. If your database leaks, the hashes are useless to an attacker.
The prefix is not decoration. GitHub explained in its post on new authentication token formats that identifiable prefixes such as ghp_ make leaked tokens easy to detect, and chose an underscore because it is not a Base64 character. A prefix like acme_live_ or acme_test_ also tells your support team at a glance which environment a key belongs to.
import { randomBytes, createHash } from 'node:crypto'
const PREFIX = { live: 'acme_live_', test: 'acme_test_' } as const
export function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
// Returns the full key ONCE, for display. Persist only hash and hint.
export function generateApiKey(env: 'live' | 'test') {
const secret = randomBytes(32).toString('base64url') // 256 bits of entropy
const key = PREFIX[env] + secret
return { key, hash: sha256(key), hint: key.slice(-4) }
}
// Look up by hash; a unique index makes this one indexed read.
export async function authenticate(
header: string | undefined,
findByHash: (hash: string) => Promise<{ tenantId: string; scopes: string[]; revokedAt: Date | null } | null>,
) {
const key = header?.startsWith('Bearer ') ? header.slice(7) : undefined
if (!key || !(key.startsWith(PREFIX.live) || key.startsWith(PREFIX.test))) return null
const row = await findByHash(sha256(key))
if (!row || row.revokedAt) return null
return row // tenantId and scopes now drive every query in this request
}
A plain SHA-256 hash is enough here, unlike passwords, because the key is long and random; there is nothing to guess from a dictionary. The table:
CREATE TABLE api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL, -- 'Zapier sync', chosen by the customer
key_hash text NOT NULL UNIQUE, -- sha256 of the full key
hint text NOT NULL, -- last 4 characters, for the UI
scopes text[] NOT NULL, -- e.g. {invoices:read, customers:write}
created_by uuid NOT NULL,
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz
);
Let customers create several named keys, each with its own scopes, so they can rotate one integration without breaking the others. Record last_used_at so they can find and revoke keys nobody uses. Never put API keys in URLs, where they end up in logs.
API keys or OAuth: which should a SaaS offer?
Start with API keys for customers integrating their own systems. Add OAuth 2.0 when third-party developers build apps that act on behalf of many of your customers, for example a marketplace listing. Keys are simpler for a customer's developer; OAuth lets a user grant and revoke an app's access without handing over a secret.
Either way, the credential must resolve to exactly one tenant, and every query in the request must be filtered by that tenant. The most common API flaw is still the first one on the OWASP API Security Top 10 (2023): API1, Broken Object Level Authorization, where changing an ID in the URL returns another customer's record. The fix and the tests are in users can see other tenants' data and the API security checklist.
How should you version a public API?
Make additive changes freely, and put every breaking change behind a new version that clients opt into. Then keep old versions running for a published period.
Additive means: a new endpoint, a new optional parameter, a new field in a response, a new event type. Breaking means: removing or renaming a field, changing a type, making an optional parameter required, or changing what an error code means. Tell clients in your docs to ignore unknown fields, so additive changes are safe.
There are two common styles. A major version in the path (/v1/invoices) is easy to understand and to route. Date-based versions pinned per account are finer-grained: Stripe pins each account to an API version and lets a request override it with a Stripe-Version header. Its versioning docs say that since 2024 it releases monthly versions with no breaking changes, and twice a year a major release that can contain breaking changes. That works well at scale but needs a translation layer for every old version. For most SaaS products, a path version plus strict additive rules is enough for years.
How should a public API paginate, report errors and handle retries?
Pagination. Use cursors: return a page of results ordered by a stable key, plus a cursor for the next page. Stripe's pagination is a familiar model: a limit, a starting_after object ID and a has_more flag. Offset paging skips or repeats rows when records are inserted mid-scan, and gets slower as the offset grows.
Errors. Pick one shape and use it everywhere. RFC 9457, Problem Details for HTTP APIs, defines a standard JSON body with type, title, status, detail and instance, served as application/problem+json, and allows extension members. Add a stable machine-readable code and a request ID that support can search for.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/limit_reached",
"title": "Plan limit reached",
"status": 403,
"detail": "This workspace has used 50 of 50 projects.",
"code": "limit_reached",
"request_id": "req_8f2c1d"
}
Retries. Networks fail after the server has done the work, so clients retry and create duplicates. Accept an Idempotency-Key header on POST requests: store the first response for that key and tenant, and return it for repeats. Stripe's idempotent requests page describes the same design: keys up to 255 characters, results saved for the first request, an error if the parameters differ, and keys pruned after at least 24 hours.
How should rate limits work on a public API?
Limit per API key (and per tenant across keys), return 429 Too Many Requests with a Retry-After header when the limit is hit, and tell clients their remaining quota in every response. Publish the limits in your docs and tie higher limits to plan entitlements.
The IETF HTTP API working group's RateLimit header fields draft (draft 11, May 2026) defines a RateLimit-Policy header that advertises quotas and a RateLimit header that reports what is left, for example RateLimit: "default";r=50;t=30. It is still a draft, so many APIs also send the older X-RateLimit-* headers. Algorithms and storage choices are in API rate limiting explained and rate limiting without Redis. Higher limits per plan come from the same entitlement check described in SaaS entitlements and plan limits.
What should public API documentation include?
An OpenAPI description generated from, or tested against, the real code; a getting-started page that reaches a successful call in five minutes; authentication, pagination, errors, idempotency and rate limits each explained once; a changelog; and a deprecation policy with dates. A test environment with test keys lets customers build without touching live data.
Most customers also want to be told when something changes in your system rather than polling for it. That is the job of outgoing webhooks, covered in sending webhooks to your SaaS customers.
Do-it-yourself estimate: 2–4 weeks to put a first set of 10–20 endpoints behind keys, scopes, versioning, pagination, errors, idempotency, rate limits and generated docs, if your back end is already tenant-safe. The main risk is shipping a field name or ID format you later regret, because every customer integration now depends on it.
Buy, build or hire?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| API gateway or management product | Hosted gateways from your cloud provider, or a dedicated API management platform | You need key issuance, quotas and a developer portal quickly, in front of an API you already have | The gateway handles keys and limits, not tenant-level authorization inside your data |
| Framework scaffolding or a template | OpenAPI-first generators and framework plugins for keys and rate limits | You have back-end developers and a clear, small resource model | Templates rarely cover idempotency, versioning policy or tenant isolation tests |
| Custom build in your back end | The design in this guide | The API is part of the product you sell and must match your entitlements and data model | You own the docs, the deprecations and the support load |
| Hire a team to build it | RAITHub or another studio | Customers are asking now and your team is busy on the core product | Get the OpenAPI file, the tests and the versioning policy in the handover |
How do you test a public API before customers depend on it?
- Tenant isolation: for every endpoint, call it with tenant B's key and tenant A's IDs, and expect 404.
- Scopes: a read-only key must fail on every write.
- Contract tests: validate responses against the OpenAPI file in CI, so an accidental rename fails the build.
- Idempotency: send the same POST twice with one key and assert one record; send different bodies with one key and assert an error.
- Rate limits: exceed the limit and check the 429, the Retry-After value and the recovery.
More on tools and contract testing is in the API testing guide.
Why RAITHub for this
- APIs at scale in RAITHub's own work. TheSkinProof, the founder's own venture rather than a client, has 217 API endpoints and 750+ tests across 5 portals.
- Tenant and permission safety. Sundor Skin enforces 88 permission codes across 12 staff roles on 146 PostgreSQL tables with row-level security; BlockEstate is a multi-tenant platform built to MVP in 6 weeks.
- Rate limiting in production. This site runs rate limiting without Redis, written up in the post linked above.
When you don't need us
- Customers only need a CSV export or a Zapier-style connector. Ship that first and see whether anyone asks for more.
- You need a gateway in front of an existing, well-built API. A managed gateway may be all that is missing.
- You want developers placed in your team. RAITHub does fixed-scope builds and dedicated teams, not staff augmentation.
How RAITHub would build this
- API contract first: resource model, IDs, error codes, versioning and deprecation policy agreed in an OpenAPI file before code.
- Key management: prefixed, hashed, scoped keys with a customer-facing screen to create, name, rotate and revoke them.
- Request pipeline: tenant-scoped authorization, idempotency storage, cursor pagination and per-key rate limits tied to plan entitlements.
- Docs and test environment: generated reference docs, a getting-started guide and test-mode keys.
Timeline: a public API on an existing product sits in the 6–12 week backend and API range; inside a new SaaS build, a first API ships within the 4–6 week fixed scope if it is part of the agreed MVP.
You receive: contract and isolation tests in CI, the OpenAPI file, handover docs and runbooks, and full IP assigned to you under NDA.
Next step: a free 15-minute technical audit, then a written fixed quote. See the API and backend development service, or book the audit.
Frequently asked questions
How should I store API keys for my SaaS?
Store only a SHA-256 hash of each key plus a short hint for display, and show the full key to the customer once. Because the key is long and random, a fast hash is enough; a leaked database then reveals no usable keys.
Should API versions go in the URL or a header?
Both work. A major version in the path is simpler for most products. Date-based versions in a header, pinned per account, give finer control but need a translation layer for each old version.
What counts as a breaking change in an API?
Removing or renaming a field, changing its type, making an optional input required, or changing the meaning of a status or error code. Adding endpoints, optional parameters and response fields is not breaking if clients ignore unknown fields.
Do I need idempotency keys on my API?
On any POST that creates something or moves money, yes. Clients retry after timeouts, and without idempotency each retry can create a duplicate record or charge.
What rate limit should a SaaS API have?
There is no universal number. Start from real usage, set per-key limits comfortably above it, return 429 with Retry-After when exceeded, and raise limits by plan rather than by exception.
Is an API gateway enough to secure a public API?
No. A gateway can check keys and enforce quotas, but only your application can check that a record belongs to the caller's tenant. Broken object-level authorization is a code problem, not a gateway setting.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.