API Design Practices for Long-Lived, Multi-Consumer Systems
A durable API models resources as nouns rather than actions, commits to a versioning strategy before the first external consumer integrates, and treats every field, status code, and error shape as a contract that cannot change without notice. In practice that means plural noun-based paths, a deliberate choice between URL-path and header-based versioning, authentication matched to the caller (API keys for services, OAuth2 for delegated user access), cursor-based pagination once collections can grow or mutate, structured machine-readable error responses, and idempotency keys on every state-changing endpoint. The organizations that avoid painful breaking changes years later are the ones that made these calls deliberately at the start, not the ones that got lucky.
Key takeaways
- Model resources as plural nouns with consistent nesting depth - the URL structure is part of the contract, not an implementation detail.
- Decide on a versioning strategy (URL path vs. header-based) before your first external integration, because retrofitting versioning later is far more disruptive than choosing conservatively upfront.
- Cursor-based pagination holds up under concurrent writes and growing datasets in ways offset-based pagination structurally cannot.
- Structured error responses with machine-readable codes let client applications branch on logic instead of parsing human sentences.
- Backward compatibility is a discipline, not an accident - additive-only changes and explicit deprecation windows are what let consumers upgrade on their own schedule.
An API is a promise made to code you don't control and often can't see. Once a mobile app, a partner's backend, or an internal service starts calling an endpoint, that endpoint's shape - its URL, its fields, its status codes, its error format - becomes a contract, whether or not anyone wrote it down as one. Internal implementation details can be refactored freely because the people who depend on them work down the hall. A public or partner-facing API doesn't have that luxury: every consumer who integrated against version one is still calling it, in production, long after the team that built it has moved on to other work.
This is what separates API design from ordinary software design. A misjudged internal function signature costs a refactor. A misjudged public endpoint costs a support burden or a deprecation project - or, worse, a decision to never fix it because too many consumers now depend on the mistake. The practices below are written for that reality: an API meant to be integrated against by multiple, independent consumers over years, not a prototype endpoint that will be rewritten next sprint. They're opinionated on purpose, because vague guidance ("be consistent," "think about the future") doesn't survive contact with a real design review.
Model Resources as Nouns, Not Actions
The most common structural mistake in API design is letting endpoints describe actions instead of resources. POST /createUser, GET /getUserOrders, and POST /cancelOrder all leak the verb of a specific operation into the URL, which forces every new capability to invent a new verb-shaped path. A resource-oriented design instead exposes nouns - users, orders - and lets HTTP methods carry the verb: POST /users creates, GET /orders/{id} retrieves, DELETE /orders/{id} removes. The method and the resource combine to express the action, so the URL space grows by adding resources and sub-resources, not by adding an unbounded vocabulary of action names that every client has to memorize individually.
This isn't a stylistic preference - it has concrete downstream effects. A noun-based structure is predictable enough that a new consumer can often guess the shape of an endpoint they haven't used yet, because it follows the same pattern as the ones they have. It also maps cleanly onto HTTP semantics that infrastructure already understands: caching layers, gateways, and monitoring tools reason about GET, POST, PUT, PATCH, and DELETE in ways they can't reason about an arbitrary verb embedded in a path. Not every operation reduces cleanly to CRUD - actions like "approve," "cancel," or "refund" represent a state transition more than a resource - and for those it's reasonable to model the action as a sub-resource (POST /orders/{id}/cancellations), rather than forcing every operation into a strict CRUD shape at the cost of clarity.
Pluralization and Naming Consistency
Pick plural nouns for collections (/users, /orders, /invoices) and stay with that choice for the entire API's lifetime. This sounds trivial until an API has grown to sixty endpoints built by a rotating cast of engineers over several years, at which point a mix of /user and /orders and /getInvoiceList becomes a genuine source of integration bugs - a client library generated from inconsistent naming produces inconsistent method names, and a developer reading unfamiliar documentation has to check rather than infer. The specific convention matters less than the discipline of never deviating from it once chosen, and it's worth writing the convention down in a short internal style guide the moment a second person starts building endpoints, because "we'll stay consistent" without a written rule rarely survives more than a few pull requests.
The same discipline applies to field naming inside payloads: pick one casing convention (commonly camelCase for JSON bodies), one convention for booleans (isActive rather than a mix of active, enabled, and status: "active"), one timestamp format (ISO 8601 in UTC, applied everywhere), and one identifier strategy (numeric, UUID, or a prefixed string like usr_1a2b3c) applied consistently across resources. None of these choices is inherently correct - what matters is that a consumer who has integrated with one resource can predict the shape of the next one without re-reading documentation line by line.
Nesting Depth and Compound Resources
Nested paths are useful for expressing genuine ownership: /customers/{customerId}/orders makes it explicit that orders belong to a customer, and it lets you scope authorization and query filtering naturally at the URL level. The trap is nesting too deeply. /customers/{customerId}/orders/{orderId}/items/{itemId}/discounts/{discountId} is technically descriptive, but it forces every client to carry four IDs to reach a single resource, makes the URL brittle to reorganization, and usually indicates the resource would be better addressed directly by its own identifier once it has one - /discounts/{discountId} - with the parent relationship expressed in the response body or as a query parameter rather than the path.
A practical rule that holds up well in review: nest one level deep to express a genuine parent-child relationship where the child cannot exist without the parent and is almost always accessed through it, and address every resource with its own stable ID beyond that, cross-referencing parents by ID in the payload rather than the path. This keeps every resource individually addressable - which matters for caching, for webhooks that need to reference a single object, and for clients that fetched a resource once and want to look it up again later without reconstructing an entire ownership chain.
Choosing a Versioning Strategy Before You Need One
Every API accumulates changes that aren't backward compatible eventually - a field that needs to change type, a response shape that needs restructuring, an authentication mechanism that needs replacing. Versioning is how you make that possible without breaking every existing consumer simultaneously. The mistake isn't choosing the "wrong" versioning scheme; multiple schemes work fine in production. The mistake is not choosing one before the first external consumer integrates, because retrofitting versioning onto an API that's already live means either breaking that consumer or permanently treating the unversioned endpoint as an implicit version one you can never change.
The two dominant approaches are versioning in the URL path (/v1/orders, /v2/orders) and versioning through a request header (Accept: application/vnd.company.v2+json, or a custom header like X-API-Version: 2). Both are legitimate; they carry different long-term tradeoffs that are worth understanding before committing.
| Consideration | URL path versioning | Header-based versioning |
|---|---|---|
| Visibility | Version is visible in every log line, browser address bar, and curl command - easy to debug | Version is invisible unless someone inspects request headers, which complicates support and log analysis |
| Client implementation | Trivial - no special client configuration needed beyond the base URL | Requires every client to set and correctly forward the header, including through proxies and gateways |
| Caching | Works cleanly with URL-based HTTP and CDN caching, since each version is a distinct cacheable URL | Requires cache keys to account for the header (Vary behavior), which is easy to misconfigure |
| Conceptual purity | Arguably "wrong" in strict REST terms, since a resource's identity shouldn't change with its representation | Arguably more correct, since the resource is the same and only its representation version differs |
| Operational simplicity | Simple to route at the infrastructure layer (load balancer or gateway rules by path prefix) | Requires routing logic that inspects headers, which is a more complex and error-prone configuration surface |
| Real-world adoption | The dominant approach among public APIs consumed by a wide range of client sophistication levels | More common for internal or partner APIs with sophisticated, well-documented clients |
The Real Cost of Each Approach to Long-Term Maintainability
The purist argument for header-based versioning is sound in theory: a resource's URL should identify the thing, not the version of the schema describing it. But long-term maintainability isn't decided by theoretical purity, it's decided by what actually goes wrong two years in, and what goes wrong is almost always operational rather than conceptual. Header-based versioning quietly fails when a corporate proxy strips non-standard headers, when a client library defaults to omitting an optional header and silently gets routed to whatever version is marked default, or when a support engineer can't tell which version a failing request used without asking the customer to share their request headers. URL-path versioning fails in a more visible, more debuggable way: a client requests the wrong path and gets an explicit 404, not a silently mismatched contract.
The practical recommendation for a broadly distributed API - one where consumers range from disciplined partner engineering teams to a solo developer copying a curl example from documentation - is URL-path versioning, specifically because it fails loudly and is debuggable without any tooling beyond looking at a log line. Header-based versioning is a reasonable choice for internal or tightly governed partner APIs where every consumer is known, documented, and capable of maintaining header configuration correctly. Whichever is chosen, the actual long-term cost driver isn't the mechanism - it's whether the team commits to keeping old versions running through a real deprecation window rather than a rushed switch, which the next section covers directly.
Authentication and Authorization Patterns
Authentication answers "who is calling," and authorization answers "what are they allowed to do" - conflating the two is a common source of both security gaps and integration friction. The right authentication mechanism depends heavily on who the caller actually is, and choosing one that doesn't match the caller is a frequent source of either weak security or unnecessary integration overhead.
API keys are a static, long-lived credential passed with each request (commonly as a header, e.g. Authorization: Bearer <key> or a custom X-API-Key header). They fit server-to-server integrations well: a partner's backend calling your API on its own behalf, with no individual end user in the loop. Their weakness is exactly their simplicity - a leaked key grants full access until manually rotated, there's no built-in expiry or scope negotiation, and if the same key is used for multiple purposes, revoking it for one breaks all of them. API keys should always be scoped as narrowly as the use case allows, support rotation without downtime (issuing a new key before revoking the old one), and never be accepted over an unencrypted connection.
OAuth2 fits the case an API key structurally can't: delegated access, where an end user authorizes a third-party application to act on their behalf without sharing their actual credentials with that application. The authorization-code flow, token expiry, and refresh-token mechanism exist specifically to bound how long and how broadly a given piece of access lasts, and to let a user revoke one application's access without affecting others. The cost is real implementation complexity - token issuance, refresh, scope management, and often a consent screen - which is why OAuth2 is the right tool when there's a genuine third-party-acting-for-a-user scenario, and needless overhead when there isn't one.
Signed tokens (commonly JWTs) are less an alternative to the above than a format choice that often sits underneath them: a token containing claims (user ID, scopes, expiry) cryptographically signed so a service can verify it without a database round-trip. They're a strong fit for stateless verification across distributed services - a downstream service can validate a token's signature and expiry locally rather than calling back to an auth service for every request - but they come with a real tradeoff: a signed token generally can't be revoked before its expiry without maintaining a revocation list, which reintroduces the state you were trying to avoid. Short expiry windows paired with a refresh mechanism are the usual mitigation.
Matching the Mechanism to the Caller
| Caller type | Typical fit | Why |
|---|---|---|
| Another backend service, no end user involved | API keys or service-to-service signed tokens | No consent or delegation needed; simplicity and low latency matter more than user-level scoping |
| Third-party app acting on behalf of an end user | OAuth2 | Delegated, revocable, scoped access is the actual requirement, not just verifying a caller's identity |
| Your own first-party frontend (web or mobile app) | Signed tokens (often issued via your own login flow) | No third-party consent step needed, but stateless verification across services is still valuable |
| Public, unauthenticated read access to non-sensitive data | No auth, or a lightweight API key purely for rate-limit attribution | Full authentication overhead isn't justified when there's nothing sensitive to protect |
Authorization - what an authenticated caller can actually do - should be modeled independently of which authentication mechanism is in use, typically through scopes attached to a token or key rather than by branching application logic on the caller's identity. A design that hardcodes "if this is partner X, allow this" instead of "if this token has scope orders:write, allow this" doesn't scale past a handful of consumers and turns every new integration into a code change rather than a configuration one.
Pagination That Survives Scale and Change
Any endpoint that returns a collection eventually needs pagination, and the pagination strategy chosen at the start is difficult to change later because it's baked into how clients construct requests. Two approaches dominate: offset-based and cursor-based.
Offset-based pagination (GET /orders?limit=20&offset=40) is simple to implement and simple to reason about - it maps directly onto a LIMIT/OFFSET database query, and it lets a client jump directly to an arbitrary page. Its weakness shows up specifically in systems with concurrent writes: if a row is inserted or deleted ahead of the current offset while a client is paging through results, every subsequent page shifts, causing the client to skip records it should have seen or see a record twice. It also degrades in performance on large tables, since a high offset still requires the database to scan and discard every row before it.
Cursor-based pagination (GET /orders?after=ord_9f8e7d&limit=20) identifies a page by a stable reference point - typically an opaque encoded value derived from the last record's sort key - rather than a numeric position. Because the cursor points at an actual record rather than a shifting position, the sequence of results stays correct even as rows are inserted or deleted elsewhere in the table. It also performs better at scale, since the database can seek directly to the cursor's position with an indexed lookup instead of scanning every preceding row.
| Dimension | Offset-based | Cursor-based |
|---|---|---|
| Correctness under concurrent writes | Breaks silently - records can be skipped or duplicated as data changes mid-pagination | Stays correct - each page anchors to a real record, unaffected by inserts/deletes elsewhere |
| Performance on large datasets | Degrades as offset grows, since preceding rows must still be scanned | Stays roughly constant, since the database seeks directly via an index |
| Arbitrary page jumping ("go to page 8") | Supported directly | Not naturally supported - cursors are sequential by design |
| Client implementation simplicity | Simple - just a number | Slightly more work - client stores and passes back an opaque cursor rather than computing a number |
| Typical fit | Small, mostly static datasets, or UIs that need direct page-number jumping | Growing datasets, real-time or frequently-changing data, infinite-scroll style consumption |
Why Cursor-Based Pagination Holds Up Better Over Time
The case for cursor-based pagination as the default for a long-lived API isn't about which one is more "modern" - it's that offset-based pagination's failure mode is silent. A client doesn't get an error when it skips a record because of a concurrent insert; it just quietly never sees that record, and nobody notices until a customer reports a missing order weeks later. An API expected to be integrated against for years will accumulate exactly the conditions that expose this - more data, more concurrent writes, more consumers polling for changes - so a design decision that looks equivalent at low volume in a demo environment diverges sharply once real production traffic and real data mutation enter the picture.
The practical middle ground many APIs land on is exposing cursor-based pagination as the primary mechanism while returning a total count (when it's not prohibitively expensive to compute) so clients can still render "showing 40 of 1,240" style UI without relying on offsets for the actual page-fetching mechanics. What matters is that the field used to fetch the next page is a cursor, not a position - the position can be provided as supplementary display information, never as the retrieval key.
Consistent, Structured Error Handling
An API's error responses are used more often than its happy-path responses, because every integration spends significant development time handling what happens when a request fails - and yet error handling is frequently the least designed part of an API, bolted on inconsistently endpoint by endpoint. A durable design treats the error response shape as seriously as the success response shape, because client code needs to parse both.
Three things every error response should carry, consistently, across every endpoint:
- A meaningful HTTP status code. Not every error is a
400or a generic500. Use401for missing or invalid authentication,403for an authenticated caller lacking permission,404for a resource that doesn't exist,409for a conflict (e.g., a duplicate resource or a version mismatch),422for a request that's well-formed but semantically invalid, and429for rate limiting. Reserve5xxcodes for genuine server-side failures, not validation errors - conflating the two makes it impossible for a client to distinguish "fix your request" from "retry later," which is exactly the distinction a client's error-handling logic needs to make. - A machine-readable error code, distinct from the HTTP status code and distinct from the human-readable message - something like
insufficient_fundsorresource_lockedin a dedicatedcodefield. Status codes are too coarse for application logic (many different problems all return422), and human messages are unstable - a client that branches on the literal text of an error message breaks the moment someone edits that sentence for clarity. A stable machine-readable code is the actual contract; the message is a convenience for humans reading logs, not something client code should ever parse. - A human-readable message, meant for developers debugging an integration or for surfacing (carefully) in a UI, kept separate from the machine-readable code precisely so it can be rewritten for clarity without touching the contract client code depends on.
A minimal, consistent shape - applied to every error response regardless of endpoint - looks roughly like this:
{
"error": {
"code": "validation_failed",
"message": "The 'email' field must be a valid email address.",
"details": [{ "field": "email", "issue": "invalid_format" }]
}
}
The specific field names matter less than applying the same shape everywhere. An API where some endpoints return { "error": "..." } as a plain string, others return { "message": "..." }, and others return a raw framework stack trace forces every consumer to write bespoke error-handling per endpoint instead of one shared error-handling path - which is precisely the kind of integration friction that erodes trust in an API over time. In a Node.js service, this is usually enforced most reliably with a single global exception filter or error-handling middleware (a NestJS exception filter, for example) that normalizes every thrown error - validation library errors, database errors, explicit application errors - into the same response shape before it reaches the client, rather than relying on every route handler to format its own errors correctly.
Idempotency for State-Changing Operations
Networks fail, clients time out and retry, and mobile connections drop mid-request - all of which mean a state-changing request (creating an order, charging a payment, sending a message) can arrive at the server more than once even though the client only intended to send it once. Without protection against this, a retried POST /payments can charge a customer twice, and there is no way for the client to safely retry a timed-out request without risking a duplicate.
The standard solution is an idempotency key: the client generates a unique value (typically a UUID) for a given logical operation and sends it with the request, commonly as an Idempotency-Key header. The server records which idempotency keys it has already processed and, on a repeated request with the same key, returns the original response instead of executing the operation again. This turns an inherently risky retry into a safe one - the client can retry as aggressively as it needs to for reliability, without the server-side risk of duplicate effect.
A few details determine whether an idempotency implementation actually holds up in production rather than just looking correct in a design doc:
- The key must be scoped to the operation, not just unique globally - reusing the same key for a genuinely different operation (a different payment amount, say) should be treated as an error rather than silently returning a stale response for the wrong request.
- Idempotency records need a retention window and expiry, not indefinite storage - typically 24 hours to a few days is enough to cover realistic retry scenarios without accumulating unbounded state.
- The stored result must be the actual response, not just a "processed" flag - a retried request should get back exactly what the original request would have returned, so client logic doesn't need special-case handling for the retry path.
- Idempotency keys are the client's responsibility to generate correctly - documentation should be explicit that a new key means a new operation and a reused key means "this might be a retry," since an ambiguous contract here defeats the entire mechanism.
This matters most for POST operations, since PUT and DELETE are idempotent by HTTP definition already (calling them repeatedly with the same input should produce the same end state) - though it's worth confirming your implementation actually honors that definition rather than merely following the naming convention.
Rate Limiting and Backoff Signaling
Rate limiting protects an API's infrastructure from being overwhelmed by a single misbehaving or overly aggressive consumer, but its design has a second, equally important purpose in a long-lived multi-consumer API: telling well-behaved clients exactly how to behave when they hit a limit, rather than leaving them to guess.
A rate-limited response should always include, at minimum:
- A
429 Too Many Requestsstatus code, not a generic403or400that forces the client to infer the actual cause from a message string. - A
Retry-Afterheader (or an equivalent field in the response body) telling the client exactly how long to wait before retrying, rather than leaving it to guess and either retry too aggressively or back off far more than necessary. - Rate-limit status headers on every response, not just the one that exceeds the limit - commonly
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset- so well-behaved clients can self-throttle proactively and never hit the limit at all, rather than discovering it reactively through failed requests.
The choice of limiting strategy (fixed window, sliding window, or token bucket) is largely an infrastructure decision, but it's worth exposing consistently across every endpoint rather than applying different limiting behavior to different parts of the API without documentation - a consumer that has tuned its request rate against one endpoint shouldn't be surprised by a stricter, undocumented limit on another. For multi-tenant APIs, rate limits are typically scoped per API key or per authenticated caller rather than per IP address, since IP-based limiting either under-protects (many callers can share one IP behind a corporate NAT) or over-protects (a single caller distributing requests across IPs bypasses it entirely) compared to limiting the actual authenticated identity making the requests.
Backward Compatibility as a Design Discipline
Every other practice in this article exists in service of one underlying goal: letting an API evolve without forcing every consumer to change their code on your schedule. Backward compatibility isn't a single technique - it's a discipline applied to every change, and the rule that makes it tractable is simple to state and consistently hard to follow under deadline pressure: changes to a public contract should be additive, not destructive.
Concretely, additive and generally safe:
- Adding a new optional field to a response.
- Adding a new endpoint.
- Adding a new optional request parameter with a sensible default.
- Adding a new value to an enum, provided consumers are documented to handle unknown values gracefully (this is itself worth stating explicitly in documentation, since an exhaustive switch statement without a default case will still break on a new value even though the API technically only added something).
Destructive and never safe without a version bump and a deprecation window:
- Removing or renaming an existing field.
- Changing a field's type or its meaning (a
statusfield that used to mean "order status" now meaning "shipping status" is a breaking change even if the field name and type are unchanged). - Changing the status code returned for an existing, already-documented scenario.
- Making a previously optional request field required.
- Tightening validation on a field that used to accept a broader range of values.
The discipline that's easy to state and hard to practice is resisting the temptation to make a destructive change "just this once" because a version bump feels like overhead for a change that seems small. Small changes are exactly the ones that erode trust fastest, because they're the ones a team is tempted to ship without the version bump and communication that a larger change would obviously require - and a consumer whose integration silently breaks because of a "small" undocumented change has no way to distinguish it from carelessness.
Deprecation Windows and Communicating Change
When a breaking change is genuinely necessary, the mechanism is a new version running alongside the old one for a defined window, not an in-place replacement. The old version keeps working exactly as documented for the entire window; the new version is available for consumers to migrate to on their own schedule. What separates a deprecation window that actually protects consumers from one that's a formality:
- The window length matches the migration effort required, not the team's internal roadmap pressure. A trivial field rename might reasonably need a few months of overlap; a restructured authentication flow or response shape that requires real client-side changes usually needs closer to a year.
- Deprecation is communicated actively, not just documented passively. A note in a changelog that nobody subscribes to isn't communication. Deprecated response headers (
DeprecationandSunsetheaders are a standard, machine-readable way to signal this on every response from the old version), direct outreach to known consumers, and reminders at the start, midpoint, and approach of the sunset date are what actually get integrations migrated in time. - The deprecated version keeps working, without degraded reliability, for the entire stated window. A deprecation window that's technically honored but comes with reduced monitoring or slower bug fixes on the old version isn't a real transition period - it's a pressure tactic, and sophisticated consumers notice.
- Removal happens on the announced date, not earlier and not indefinitely later. Removing early breaks consumers who were told they had more time. Never removing anything, on the other hand, means every deprecation notice going forward is read as non-binding, which undermines the entire mechanism for the next change.
Documentation as Part of the Contract, Not an Afterthought
Documentation that's hand-maintained separately from the actual API implementation drifts, reliably and quickly, because there's no mechanism forcing the two to stay in sync - a developer changes a field type in code and simply forgets, or doesn't know, to update a separate markdown page describing it. For an API meant to be integrated against for years by consumers who weren't in the room when it was built, drifted documentation isn't a minor inconvenience - it's actively worse than no documentation, because it actively misleads.
The structural fix is schema-first design: define the API's contract in a machine-readable schema - an OpenAPI specification is the dominant standard - and generate or validate everything else from that single source of truth, rather than writing the schema and the implementation independently and hoping they stay aligned:
- Generate the human-readable documentation site from the schema, so a change to the schema is a change to the documentation automatically, with no separate manual step to remember.
- Validate incoming requests and outgoing responses against the schema in tests, so a code change that silently diverges from the documented contract fails the build rather than reaching a consumer as an undocumented surprise. In a Node.js/NestJS codebase, this is commonly wired up with the framework's built-in OpenAPI decorators paired with a schema-validation library, so the same type definitions drive request validation, response typing, and the generated specification, instead of maintaining three versions of the same information by hand.
- Generate client SDKs or type definitions from the schema where practical, so consumers integrate against generated, type-checked code rather than hand-transcribing field names from a documentation page that may be out of date.
- Treat the schema itself as under the same backward-compatibility discipline as the API - a schema-validation step in continuous integration that flags breaking changes to the schema (a field removed, a type narrowed, a required field added) catches accidental contract breaks before they reach a review, let alone production.
The underlying principle connects back to everything earlier in this article: an API meant to last years can't rely on any single person remembering every decision made about it. Resource naming conventions, versioning rules, error shapes, and the schema describing all of it need to be encoded somewhere more durable than institutional memory - in automated validation, in generated documentation, and in a contract that a new engineer joining the team years later can read and trust without having to ask the person who wrote the original endpoint whether the documentation is still accurate.
Frequently asked questions
Should we version our API from day one, even before we have external consumers?
Yes. The cost of adding a version segment or header to an API that doesn't need it yet is nearly zero - it's a naming convention. The cost of introducing versioning after consumers are already calling an unversioned endpoint is high, because you either break them or you maintain an implicit "version zero" forever while trying to layer a scheme on top of it. Deciding on a versioning approach at the start costs an afternoon of discussion; deciding on it later costs a migration project.
Is header-based versioning ever the right choice for a public API?
It can be, particularly for APIs consumed primarily by sophisticated internal or partner clients who read documentation and configure headers deliberately - internal service-to-service APIs are a common fit. For a broadly distributed public API, URL-path versioning usually wins on practical grounds: it's visible in logs, works with basic tooling and browser testing without extra configuration, and doesn't rely on every client correctly setting and forwarding a header through proxies and gateways that sometimes strip non-standard ones.
Why is cursor-based pagination considered more reliable than offset-based pagination?
Offset-based pagination identifies a page by position ("skip 40, take 20"), and that position shifts silently whenever rows are inserted or deleted ahead of it - a client paging through results while new records arrive will skip or duplicate rows without any error being raised. Cursor-based pagination identifies a page by a stable reference point (an ID or sort key from the last record seen), so the sequence stays correct regardless of concurrent writes. It also tends to perform better on large tables, since the database can seek directly to the cursor's position instead of scanning and discarding all preceding rows.
What actually counts as a breaking change to an API contract?
Anything a correctly-written existing client could reasonably fail on: removing a field, renaming a field, changing a field's type or meaning, changing a status code for an existing scenario, making a previously optional request field required, or tightening validation that used to accept a value. Adding a new optional field, adding a new endpoint, or adding a new enum value that clients are expected to ignore gracefully are additive changes and generally safe - though even additive enum values can break clients that used an exhaustive switch statement without a default case, which is worth calling out in documentation.
How long should a deprecation window be before removing an old API version?
There's no universal number, but the window should be sized to how hard the migration is for your actual consumers, not how convenient it is for your team. A field rename with a drop-in replacement might warrant a few months; a change to an authentication mechanism or a restructured response shape that requires real client-side code changes usually warrants closer to a year, with clear communication at the start, midpoint, and end of the window rather than a single announcement that gets missed.
Related reading
- API Development & Systems IntegrationAPI design, partner integrations, and systems-integration engineering connecting ERP, MES, WMS, and third-party platforms into a reliable data flow.
- Node.jsHow North Tech Labs uses Node.js for backend systems and APIs — its real strengths, its trade-offs, and where another runtime fits better.
- NestJSHow North Tech Labs uses NestJS for structured Node.js backends — its real strengths, its trade-offs, and when plain Node.js fits better.
Have a specific question about your project?
This article covers the general pattern — the fastest way to get a specific answer is to ask us directly.