APIs are architectural commitments with long lifetimes. The API shape you publish in month one is the shape your clients will build against. Changing it breaks them. The consequence is that early API design decisions constrain the product roadmap in ways that aren’t obvious until years later, when the engineering team is paying the cost of decisions made under different assumptions.
The patterns I’m describing here aren’t about getting an API working quickly — they’re about keeping it maintainable and evolvable as the product changes around it. The discipline required is different from the discipline of shipping features fast, and the tradeoffs are different. Both matter; don’t confuse them.
Stability Requires Versioning From Day One
The most predictable API mistake: shipping v1 with a plan to “version it when we need to.” By the time you need to version the API, you have clients that are hard to migrate, business relationships that depend on the current behavior, and an engineering team that wants to spend its time building new features rather than managing API migrations.
Version the API from the first public release. The URI approach (/v1/users, /v2/users) is simple and obvious. The header approach (Accept: application/vnd.yourapp.v2+json) is more RESTfully pure but less transparent and harder to debug. Either is fine — consistency matters more than the specific scheme.
The versioning contract: once a version is published, its behavior is stable. Adding fields to responses is allowed (clients should ignore unknown fields). Removing fields, changing field semantics, or changing required inputs is a breaking change that requires a new version. Deprecate old versions on a communicated timeline — 12-18 months is a reasonable deprecation window for external APIs.
The operational discipline: monitor version usage metrics. When a deprecated version is down to near-zero usage, you can retire it. Without usage metrics, deprecation timelines become indefinite and old API versions accumulate as maintenance burden.
Naming Conventions Are Load-Bearing
Inconsistent naming in an API is a tax that clients pay on every integration. If some resources use camelCase, some use snake_case, some pluralize resource names and some don’t, some use created_at and some use createdAt and some use creation_date — every client developer has to look up the specific convention for each field, and bugs occur where the convention is assumed incorrectly.
The convention doesn’t matter as much as consistency. Choose one:
snake_casefor fields (the JSON convention in many languages)- Plural resource names in URLs (
/users,/orders, not/user,/order) - ISO 8601 for all dates (
2026-01-15T14:30:00Z, notJanuary 15, 2026or1736951400) - Boolean fields with
is_,has_, orshould_prefixes (is_active,has_subscription,should_retry)
Apply the convention to the entire API without exceptions. When the convention feels awkward for a specific case, that usually signals something about the data model — investigate why the naming feels wrong before overriding the convention.
The Resource vs. Action Tension
REST’s pure resource model (GET /users/{id}, POST /users, PATCH /users/{id}, DELETE /users/{id}) works cleanly for CRUD operations. It works less cleanly for actions that don’t map to resource state changes — sending a notification, processing a payment, initiating a workflow.
The paths that are commonly taken:
Resource-ified actions: POST /emails (creates and sends an email resource), POST /payments (creates a payment, which processes it). Works when the action produces a meaningful resource. Awkward when the action has side effects that are more significant than the resource created.
Sub-resource actions: POST /users/{id}/verify, POST /orders/{id}/cancel. Clear about what the action applies to. Can become a long list of verbs on a resource, which starts to look like RPC.
Explicit action endpoint: POST /actions/send-welcome-email. Honest about being an action rather than a resource operation. Useful for operations that cross resource boundaries or don’t naturally fit the resource model.
My preference: start with resource-ified actions, use sub-resource actions for lifecycle operations on a specific resource, and use explicit action endpoints sparingly for genuinely cross-resource operations. The exact choice matters less than consistency — don’t mix all three styles randomly throughout the API.
Error Response Design
Error responses are the part of API design that most documentation and tutorials underemphasize. Error cases are not exceptional — in production, error rates for any significant API are non-trivial, and clients need to handle errors programmatically.
A well-designed error response structure:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "One or more fields failed validation",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email address format is not valid"
}
],
"request_id": "req_8f3d2a1b"
}
}
The elements that matter:
A machine-readable error code (VALIDATION_FAILED, RESOURCE_NOT_FOUND, RATE_LIMIT_EXCEEDED) that clients can switch on. The HTTP status code alone isn’t enough — a 400 could mean many things, and clients need to distinguish between them programmatically.
A human-readable message that developers can read during integration. This is not the user-facing message — it’s for the developer integrating the API.
Structured validation details when the error relates to specific input fields. Clients need to know which field failed and why to surface useful errors to end users.
A request ID that can be provided in support requests to correlate the client’s experience with server-side logs. This is disproportionately useful in debugging integrations.
Rate Limiting and Backoff Design
Any public or partner API needs rate limiting. Rate limiting design decisions that surface as integration problems:
Surface rate limit status in response headers. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (timestamp when the window resets). Clients that can see their rate limit status can implement adaptive request scheduling without hammering the API to discover they’re limited.
Use 429 (Too Many Requests) consistently for rate limit errors, with a Retry-After header indicating when the client can retry. Don’t return 503 for rate limit exhaustion — 503 implies service unavailability, not client overuse.
Make rate limit scope explicit in documentation. Is the limit per API key? Per user? Per IP? Per endpoint? Undocumented rate limit scope leads to clients making incorrect assumptions about their headroom.
Include jitter guidance for retry logic. If a rate limit causes many clients to back off for exactly 60 seconds, they’ll all retry simultaneously and produce a thundering herd. Document (or implement server-side) jitter: retry after base_delay + random(0, jitter).
Idempotency for State-Changing Operations
State-changing operations — creating an order, processing a payment, sending a message — must be idempotent to handle network failures correctly. If a payment request succeeds on the server but the network drops before the response returns, the client will retry. Without idempotency, the payment processes twice.
The standard pattern: require clients to send an idempotency key with state-changing requests (a client-generated UUID that’s unique per intended operation). The server stores processed idempotency keys and returns the cached response for duplicate requests within a window. Stripe’s API popularized this pattern; it’s become the standard for payment and transaction APIs.
For internal APIs, database-level idempotency using ON CONFLICT DO NOTHING or similar constructs handles most cases. The important thing is that idempotency is designed in, not retrofitted after the first payment-charged-twice incident.
Our software development practice treats API design as a first-class deliverable, not a byproduct of implementation. Related: if your API feeds data engineering pipelines or downstream analytics, the API design decisions (particularly around field naming, event schemas, and versioning) directly affect the data layer architecture.