An API is a contract. It is a promise you make to every developer who integrates with your system about how your service will behave. Breaking that contract has consequences: broken integrations, angry partners, costly migrations, and lost trust. Designing the contract well from the start is one of the highest-leverage activities in software engineering.
At Pepla, APIs are central to everything we build. Our services communicate through APIs, our clients integrate through APIs, and Pepla Voice exposes APIs for configuration and analytics. This article distils what we have learned about designing APIs that developers enjoy using and that stand the test of time.
REST Conventions: The Foundation
REST is not a standard; it is an architectural style. But the industry has converged on a set of conventions that, when followed consistently, produce APIs that are intuitive and predictable.
Resources are nouns, HTTP methods are verbs -- this distinction is fundamental but frequently violated.
Resource Naming
Resources should be nouns, not verbs. The HTTP method provides the verb. This distinction is fundamental but frequently violated:
- Good:
GET /orders/123(retrieve order 123) - Bad:
GET /getOrder?id=123(verb in the URL) - Good:
POST /orders(create a new order) - Bad:
POST /createOrder(redundant verb)
Use plural nouns for collections (/orders) and identifiers for individual resources (/orders/123). Nest sub-resources where there is a clear parent-child relationship: /orders/123/line-items. But avoid nesting more than two levels deep. If you find yourself writing /customers/456/orders/123/line-items/7/notes, the resource model needs rethinking.
HTTP Methods and Status Codes
Use HTTP methods for their intended semantics:
GETretrieves a resource. It must be safe (no side effects) and idempotent.POSTcreates a new resource. It is neither safe nor idempotent.PUTreplaces a resource entirely. It is idempotent.PATCHpartially updates a resource. Use JSON Patch or JSON Merge Patch format.DELETEremoves a resource. It is idempotent.
Status codes communicate the result unambiguously. Do not return 200 for everything with an error flag in the body. Use the codes as intended:
200 OKfor successful GET, PUT, PATCH, DELETE201 Createdfor successful POST (include a Location header)204 No Contentfor successful operations with no response body400 Bad Requestfor validation failures401 Unauthorizedfor missing or invalid authentication403 Forbiddenfor authenticated but not authorised404 Not Foundfor missing resources409 Conflictfor state conflicts (duplicate entries, version mismatches)429 Too Many Requestsfor rate limiting500 Internal Server Errorfor unexpected failures
Pagination: Handle Large Collections
Any endpoint that returns a collection must support pagination. Returning 50,000 records in a single response is a denial-of-service attack on your own API. There are three common approaches:
Offset-Based Pagination
GET /orders?offset=20&limit=10 returns items 21-30. Simple and intuitive, but problematic for large datasets: the database still has to skip the first N rows, and concurrent inserts or deletes can cause items to be skipped or duplicated between pages.
Cursor-Based Pagination
GET /orders?after=abc123&limit=10 uses an opaque cursor (typically an encoded primary key or timestamp) to mark the position. This is performant at any depth and handles concurrent modifications gracefully. It is the approach we recommend for most APIs.
Page-Based Pagination
GET /orders?page=3&pageSize=10 is the most user-friendly but has the same underlying issues as offset-based. It works well for human-facing UIs where the total page count is displayed.
Always include pagination metadata in the response: total count (when feasible), current page or cursor position, and links to the next and previous pages. The response should contain everything the client needs to navigate the collection without constructing URLs manually.
Use nouns for resources, HTTP verbs for actions, and consistent naming across every endpoint.
Filtering and Sorting
Use query parameters for filtering: GET /orders?status=pending&createdAfter=2026-01-01. For complex filters, consider a structured query parameter: GET /orders?filter[status]=pending&filter[total][gte]=1000.
Sorting follows a similar pattern: GET /orders?sort=-createdAt,+total where the prefix indicates direction. Document your sorting and filtering capabilities explicitly; do not make consumers guess which fields are filterable.
Versioning: URL vs Header
APIs evolve. Breaking changes are inevitable. The question is how to manage them.
URL Versioning
GET /v2/orders/123 is the most common approach. It is explicit, easy to route, and immediately visible in logs and documentation. The downside is that it pollutes URLs and encourages large, infrequent version bumps rather than incremental evolution.
Header Versioning
Accept: application/vnd.pepla.v2+json keeps URLs clean and supports content negotiation. It is harder to test with a browser and less visible in access logs. It works well for internal APIs where consumers are sophisticated.
Our Recommendation
For public APIs: URL versioning. The clarity and simplicity outweigh the aesthetic cost. For internal APIs: consider header versioning or no explicit versioning at all, using additive changes and feature flags instead. At Pepla, our public APIs use URL versioning with a commitment to supporting each major version for at least 18 months after the next version launches.
Error Responses: Be Helpful
Error responses are where most APIs fail their consumers. A bare 400 Bad Request with no body is useless. A well-structured error response should include:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"issue": "Must be a valid email address.",
"value": "not-an-email"
},
{
"field": "quantity",
"issue": "Must be a positive integer.",
"value": -5
}
],
"traceId": "abc-123-def-456",
"documentation": "https://docs.pepla.co.za/errors/VALIDATION_FAILED"
}
}
Every error should have a machine-readable code (for programmatic handling), a human-readable message (for developers debugging), field-level details (for form validation), a trace ID (for support escalation), and a documentation link (for self-service resolution).
Error responses should help developers fix problems -- include codes, messages, and guidance.
Rate Limiting
Every public API needs rate limiting. Without it, a single misbehaving client can degrade the service for everyone. Implement rate limiting using the standard headers:
X-RateLimit-Limit: The maximum number of requests allowed in the windowX-RateLimit-Remaining: The number of requests remainingX-RateLimit-Reset: When the window resets (Unix timestamp)Retry-After: How many seconds to wait before retrying (on 429 responses)
Rate limits should be per API key or per authenticated user, not per IP address. IP-based limiting breaks for clients behind NATs and corporate proxies. Use a sliding window algorithm (like token bucket) rather than fixed windows to prevent burst issues at window boundaries.
Documentation: OpenAPI and Beyond
An undocumented API is an unusable API. OpenAPI (formerly Swagger) has become the standard for API documentation, and for good reason: it is machine-readable, supports code generation, and integrates with testing tools.
Good API documentation includes:
- A getting started guide that gets a developer to their first successful request in under 5 minutes
- Authentication instructions with example requests
- Complete endpoint reference with request/response schemas, status codes, and example payloads
- Error code catalogue with explanations and remediation steps
- Rate limit documentation with upgrade paths for higher limits
- Changelog documenting every change to the API
- SDKs or code examples in the languages your consumers use
The best API documentation is generated from the code itself. Use annotations or decorators to keep your OpenAPI spec in sync with your implementation. Documentation that drifts from reality is worse than no documentation at all.
The best API documentation is generated from code itself -- specs that drift from reality are worse than none.
GraphQL: When and Why
GraphQL is not a replacement for REST. It solves a specific problem: when the consumer needs to fetch data from multiple related resources in a single request, and the shape of that data varies significantly between consumers.
GraphQL excels when:
- You have multiple consumers (web, mobile, third-party) that need different subsets of the same data
- Your data model is highly interconnected with deep relationships
- Over-fetching and under-fetching are measurable problems causing performance issues
REST is typically better when:
- Your resources are well-defined and consumers mostly need them in their entirety
- Caching is important (REST's URL-based caching is simpler than GraphQL's query-based caching)
- File uploads, webhooks, or streaming are required (these are more natural in REST)
- Your team does not have GraphQL experience (the learning curve is real)
For most applications we build at Pepla, REST with well-designed resource endpoints, field selection (?fields=id,name,status), and compound documents (embedding related resources) provides 90% of GraphQL's benefits with significantly less operational complexity.
At Pepla, every API we build follows these conventions from day one. Our .NET and Spring Boot teams maintain shared API design guidelines that ensure consistency across all client projects.
APIs are products. They have users (developers), user experience (documentation and consistency), and lifecycles (versioning and deprecation). Treat them with the same design rigour you would apply to any user-facing product, and your integrations will be smoother, your partners happier, and your maintenance burden lighter.




