An API is a contract between your app and everything that calls it — other services, your own frontend, third-party developers. A well-designed API is predictable and consistent: a developer who has used one endpoint should be able to guess how the others work. If Cartara flagged this in your diff, you likely added or changed an endpoint in a way that could confuse callers or break existing integrations.
What makes an API "good"
Good API design comes down to one word: consistency. The cost of getting it wrong is high because APIs are contracts — once someone depends on an endpoint, changing it breaks their code. You live with early decisions for a long time, so it's worth thinking deliberately before you ship.
How to design RESTful endpoints
REST is organized around resources (nouns), acted on by HTTP methods (verbs). The method already supplies the verb, so your URL should be a noun:
- Use nouns, not verbs in paths:
GET /users/42, notGET /getUser?id=42 - Use plural nouns:
/users,/orders,/products - Nest to show relationships, but not too deep:
/users/42/ordersis clear; anything past two levels is usually a sign to flatten - Let HTTP methods carry meaning: GET is safe (no side effects), PUT and DELETE are idempotent, POST creates or triggers
- Keep status codes honest: 2xx success, 4xx client error, 5xx server error. Never return
200 OKwith an error in the body
Versioning your API
Because an API is a contract, you need a way to evolve it without breaking callers. The pragmatic default is URL versioning: /v1/users. It's visible, easy to test, and easy to route.
The deeper principle is backward compatibility: prefer additive changes — new optional fields, new endpoints — that don't break existing clients. Adding a field is safe. Removing one, renaming one, or changing its type is a breaking change that needs a version bump and a deprecation window.
Pagination: never return an unbounded list
GET /orders on a large dataset will time out or blow up memory. You need pagination. Two approaches:
- Offset/limit (
?limit=20&offset=40) — simple, allows jumping to any page, but slow on large offsets and can skip rows if data changes mid-browse - Cursor-based (
?limit=20&after=<cursor>) — the client passes a pointer to the last item seen. Fast, stable, and used by most large APIs (Stripe, GitHub)
Cursor pagination is the better default for anything that scales. Return the next cursor and a "has more" flag in a consistent place in every response.
Designing good error responses
Error responses are part of your API surface and deserve as much care as success responses. A good error is machine-readable and human-helpful:
```json
{
"error": {
"code": "insufficient_funds",
"message": "The card has insufficient funds.",
"field": "payment_method",
"request_id": "req_a1b2c3"
}
}
```
Key points:
- Use a stable
codefield that clients can branch on — don't make them string-match your human message - Include a
request_idso developers can find the failure in your logs - Keep the shape consistent across every endpoint
- Never expose stack traces or SQL in production error bodies
Idempotency: handling retries safely
An operation is idempotent if doing it twice has the same effect as doing it once. GET, PUT, and DELETE are naturally idempotent. POST is not — submitting "create payment" twice creates two payments.
Networks are unreliable, so clients retry. The standard solution is an idempotency key: the client sends a unique key with the request, and the server returns the original result instead of acting again on a duplicate. This is essential for anything involving money or side effects.
Rate limiting: protect your API and communicate clearly
Without rate limiting, a single bad actor can exhaust your resources. Good rate limiting is communicative, not just protective:
- Return
429 Too Many Requestswhen a client exceeds their limit - Include headers showing their limit, remaining quota, and reset time
- Return a
Retry-Afterheader so clients know how long to wait
What You'll See in Your Code
A common pattern Cartara flags: an endpoint that returns all records without pagination.
```javascript
// Before: will blow up on large datasets
app.get('/api/orders', async (req, res) => {
const orders = await db.orders.findMany()
res.json(orders)
})
// After: cursor-based pagination
app.get('/api/orders', async (req, res) => {
const { after, limit = 20 } = req.query
const orders = await db.orders.findMany({
take: parseInt(limit) + 1,
cursor: after ? { id: after } : undefined,
orderBy: { createdAt: 'desc' }
})
const hasMore = orders.length > limit
res.json({
data: orders.slice(0, limit),
nextCursor: hasMore ? orders[limit - 1].id : null,
hasMore
})
})
```
Another common flag: inconsistent error shapes.
```javascript
// Before: different error formats across endpoints
res.status(400).json({ message: 'Email required' }) // endpoint A
res.status(400).json({ error: 'Invalid input', field: 'email' }) // endpoint B
// After: consistent shape everywhere
res.status(400).json({
error: {
code: 'validation_error',
message: 'Email is required.',
field: 'email',
request_id: req.id
}
})
```
REST vs GraphQL vs gRPC vs tRPC
| Style | Best for | Trade-off |
|---|---|---|
| REST | Public APIs, broad compatibility | Can over/under-fetch |
| GraphQL | Complex data graphs, many frontend teams | Server complexity, harder caching |
| gRPC | Internal service-to-service, high-volume | Not browser-friendly |
| tRPC | Full-stack TypeScript monorepos | TypeScript-only |
A sensible default: REST (or tRPC in a TypeScript monorepo) for your app. Don't adopt GraphQL's complexity for a simple CRUD app.