All lessons
Web basicsIntermediate14 min readIn my stack

REST APIs

Design APIs that are predictable, safe to retry and pleasant to integrate.

  • Resources
  • Status codes
  • Pagination
  • Versioning
  • Idempotency
Step 1/6
An API is like a waiter. GET /menu — “bring me the menu” — 200 OK, here it is.

In 30 seconds

A REST API exposes your data as resources at URLs, which clients read and change with standard HTTP methods. Good APIs are predictable: consistent names, correct status codes, clear errors, pagination, versioning and documentation. Done well, web, mobile and partner apps can integrate quickly and safely.

Why I use it

  • I design and build REST APIs that serve websites, admin panels and mobile apps from one backend.
  • A predictable API saves more time than any framework: frontend and mobile developers stop guessing.
  • Payments and orders need idempotency and clear errors; I have built both into production APIs.
  • Partners integrate faster when the API is versioned, rate-limited fairly and documented with OpenAPI.

How it works

01Resources and URLs

REST (Representational State Transfer) models your domain as resources: nouns such as products, orders and customers. Each resource has a URL; the method says what to do with it. Use plural nouns and IDs: /orders, /orders/1001, /orders/1001/items. Avoid verbs in paths such as /getOrders or /createOrder; the method already says that. Keep nesting shallow (one level is usually enough). For actions that do not map cleanly to CRUD, model them as sub-resources or state changes: POST /orders/1001/cancellation or PATCH /orders/1001 with a status field. Use consistent naming (camelCase or snake_case, pick one), ISO 8601 dates in UTC, and IDs that do not leak business volume where that matters. Analogy: resources are rooms in a building, URLs are their addresses, and methods are what you do in the room.

http
GET    /v1/orders?status=pending&page=2   # list
POST   /v1/orders                         # create
GET    /v1/orders/1001                    # read one
PATCH  /v1/orders/1001                    # partial update
DELETE /v1/orders/1001                    # delete
GET    /v1/customers/77/orders            # related collection
POST   /v1/orders/1001/refunds            # action as sub-resource

02Methods, status codes and error format

Map operations to methods and results to status codes. Creating returns 201 Created with a Location header and the new resource. Success with no content returns 204. Validation errors return 422 (or 400 for malformed JSON) with field-level details. Not authenticated is 401, not allowed is 403, missing is 404, a version or state conflict is 409, too many requests is 429, and server bugs are 500. Pick one error format and use it everywhere. RFC 9457 Problem Details (application/problem+json) is a good standard: type, title, status, detail, plus your own fields such as errors per field and a request ID for support. Never leak stack traces or SQL in production errors.

http
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
 
{
  "type": "https://api.example.com/problems/validation",
  "title": "The request has invalid fields.",
  "status": 422,
  "errors": { "quantity": ["Must be at least 1."] },
  "requestId": "req_7f3a9c"
}

03Pagination, filtering, sorting

Never return unbounded lists. Offset pagination (?page=3&per_page=20) is simple and lets users jump to a page, but gets slow on large tables and can skip or repeat items when data changes. Cursor pagination (?cursor=eyJpZCI6MTAwMH0&limit=20) uses an opaque pointer such as the last seen ID and created_at; it is fast and stable for feeds, infinite scroll and syncs, but cannot jump to page 50. Return metadata or links so clients do not build URLs themselves. For filtering use clear query parameters (?status=paid&created_from=2026-01-01), for sorting ?sort=-created_at,total, and for search ?q=. Cap the maximum page size and only allow filtering and sorting on indexed fields.

http
GET /v1/orders?status=paid&sort=-created_at&limit=20&cursor=eyJpZCI6MTAyMH0
 
{
  "data": [ { "id": 1019, "total": 12500, "status": "paid" } ],
  "meta": { "limit": 20, "hasMore": true },
  "links": { "next": "/v1/orders?status=paid&sort=-created_at&limit=20&cursor=eyJpZCI6MTAwMH0" }
}

04Versioning and evolving safely

Clients, especially mobile apps that users do not update, depend on your API for years. Additive changes are safe: new endpoints, new optional fields, new enum values if clients were told to tolerate unknown ones. Breaking changes include removing or renaming fields, changing types or meaning, and making optional input required. For breaking changes, introduce a new version. URL versioning (/v1, /v2) is the most visible and the easiest to route, cache and debug; header or media-type versioning keeps URLs clean but is harder to test. Some APIs use dated versions in a header. Whatever you choose, announce deprecations with dates, send Deprecation and Sunset headers, and track which clients still call old versions before turning them off. Pair this with a forced-update mechanism in mobile apps.

05Authentication, idempotency and rate limits

Pick auth by client type. Browser apps on the same site: secure HttpOnly session cookies plus CSRF protection. Mobile and SPA clients: short-lived access tokens (often JWT) with refresh tokens, via OAuth 2 flows with PKCE for third-party login. Server-to-server partners: scoped API keys or OAuth client credentials, sent in headers, never in URLs. Networks fail, so make writes safe to retry: clients send an Idempotency-Key header with a unique value; the server stores the key with the result and returns the same response if the key is repeated, instead of charging or ordering twice. Protect the API with rate limits per key, user or IP, answer 429 with Retry-After, and expose limits in headers so clients can slow down.

http
POST /v1/payments HTTP/1.1
Authorization: Bearer <access_token>
Idempotency-Key: 6f1c2d4e-8a7b-4c3d-9e2f-1a2b3c4d5e6f
Content-Type: application/json
 
{"orderId": 1001, "amount": 12500, "currency": "AMD"}
 
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Policy: "default";q=100;w=60

06Documentation with OpenAPI

OpenAPI (version 3.1 is current and aligned with JSON Schema) describes your endpoints, parameters, request and response bodies, errors and auth in a YAML or JSON file. From that one source you get interactive docs (Swagger UI, Redoc, Scalar), generated typed clients for TypeScript, Kotlin or Swift, mock servers and contract tests. Generate it from code annotations or decorators (NestJS has built-in support; Laravel has packages such as Scramble) so it never drifts from reality. Add examples for every request and error, describe pagination and rate limits, and publish a changelog. Good docs are a feature: they cut integration time from weeks to days.

yaml
paths:
  /v1/orders/{id}:
    get:
      summary: Get one order
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "404": { $ref: "#/components/responses/NotFound" }

07REST vs GraphQL vs gRPC vs WebSockets vs webhooks

These solve different problems and often live side by side. REST is the default for public and mobile APIs: simple, cacheable, understood by every tool. GraphQL lets clients ask for exactly the fields they need from one endpoint, which helps when many different screens combine data from many sources; the cost is complex caching, authorization per field and query-cost limits. gRPC uses Protocol Buffers over HTTP/2 with generated clients and streaming; great for fast, typed service-to-service calls, awkward in browsers. WebSockets keep a two-way connection open for chat, live order status and dashboards. Webhooks are reverse APIs: your server calls the partner's URL when something happens, such as a payment succeeded; sign them with HMAC, retry with backoff and make receivers idempotent.

  • Public, partner or mobile API: REST + OpenAPI.
  • Many clients with very different data needs: consider GraphQL.
  • Internal high-throughput services: gRPC.
  • Live updates to users: WebSockets or SSE. Events to other systems: webhooks.

Use it when

  • Public, partner and mobile APIs that must be easy to understand and integrate.
  • CRUD-heavy back offices and storefronts.
  • When HTTP caching, CDNs and standard tooling matter.
  • As the stable contract between a backend and several frontends.

Skip it when

  • Very chatty internal service calls where latency and strict schemas matter more: consider gRPC.
  • Real-time push to clients: use WebSockets, SSE or push notifications instead of polling.
  • Screens that need many nested resources in one request from many sources: GraphQL or a dedicated aggregate endpoint may be simpler.

Alternatives

  • GraphQL

    Client-chosen fields over one endpoint; flexible, but caching and authorization are harder.

  • gRPC

    Typed, fast RPC over HTTP/2 for internal services.

  • tRPC

    End-to-end TypeScript types between a Node backend and a TS frontend, no schema file.

  • WebSockets / webhooks

    Push-style communication for live updates and cross-system events.

Best practices

  1. Use plural nouns, consistent naming and correct status codes everywhere.
  2. Return one error format (Problem Details) with field errors and a request ID.
  3. Paginate every list, cap page size and filter only on indexed fields.
  4. Version from day one and announce deprecations with dates.
  5. Require Idempotency-Key on payments and order creation; retry only idempotent calls automatically.
  6. Rate-limit per key or user and return 429 with Retry-After.
  7. Generate OpenAPI docs from code and publish examples and a changelog.

Common mistakes

  • Returning 200 for every response and hiding errors in the body.
  • Exposing database tables one-to-one, leaking internal fields and coupling clients to the schema.
  • Breaking mobile clients by renaming fields without a new version.
  • Unbounded list endpoints that time out as data grows.
  • Checking authentication but not authorization, so users can read other users' resources by changing an ID.

Deep dive

08Authorization is the number one API risk

The OWASP API Security Top 10 (2023) puts Broken Object Level Authorization first: the API checks that a user is logged in, but not that the order, invoice or file they ask for belongs to them, so changing /orders/1001 to /orders/1002 leaks someone else's data. Close behind are broken function-level authorization (a regular user calling an admin endpoint) and broken object property level authorization (mass assignment, where a client sets role or price in a PATCH body). The robust pattern is to scope every query by the caller, not to check afterwards: fetch through the user's or tenant's relationship, so a foreign ID simply finds nothing and returns 404. Whitelist writable fields per endpoint, and use policies or guards that run on every route. Random or prefixed IDs reduce guessing but never replace authorization.

php
// Laravel: scope by owner, then authorize with a policy
public function show(Request $request, int $id)
{
    $order = $request->user()->orders()->findOrFail($id); // 404 for others
    $this->authorize('view', $order);
    return new OrderResource($order);
}
 
// Only allow known fields to be written
$data = $request->validate([
    'note'     => ['nullable', 'string', 'max:500'],
    'quantity' => ['required', 'integer', 'min:1'],
]);

09Long-running operations and reliable events

Some requests cannot finish within a normal timeout: exports, imports, video processing, bulk updates. Instead of holding the connection open, accept the work and return 202 Accepted with a Location header pointing to a status resource such as /v1/jobs/77. The client polls that resource, or subscribes to a webhook, and finally follows a link to the result. A related problem is publishing events reliably. If the code saves an order and then sends a webhook, a crash between the two steps loses the event; sending first and then failing to save creates a ghost event. The transactional outbox pattern fixes this: write the order and an outbox row in the same database transaction, and let a worker deliver outbox rows with retries. Consumers must still deduplicate by event ID, because delivery is at least once.

http
POST /v1/exports HTTP/1.1
Content-Type: application/json
 
{"type": "orders", "from": "2026-10-01", "to": "2026-10-31"}
 
HTTP/1.1 202 Accepted
Location: /v1/jobs/77
 
GET /v1/jobs/77  ->  {"status": "running", "progress": 40}
GET /v1/jobs/77  ->  {"status": "done", "resultUrl": "/v1/exports/77/file"}

10Rate limiting algorithms

A fixed window counts requests per calendar minute; it is simple but allows double bursts at the boundary (100 at 12:00:59 and 100 at 12:01:00). A sliding window weighs the previous window's count to smooth that out. A token bucket gives each client a bucket that refills at a steady rate up to a maximum, so short bursts are allowed while the long-term rate stays fixed; it matches how real clients behave. Store counters in Redis with atomic operations so all app servers share them. Choose keys carefully: per API key for partners, per user for logged-in traffic, per IP only as a last resort, since many users can share one IP behind a NAT or mobile carrier. Return 429 with Retry-After, and use separate, stricter limits for expensive or sensitive endpoints like search, login and exports.

php
// Laravel: named limiters, applied with ->middleware('throttle:api')
RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by('user:'.$request->user()->id)
        : Limit::perMinute(30)->by('ip:'.$request->ip());
});
 
RateLimiter::for('login', fn (Request $request) =>
    Limit::perMinute(5)->by($request->input('email').'|'.$request->ip())
);

11Performance: payload shape, N+1 and caching

Most slow APIs are slow in the database. A list endpoint that loads 50 orders and then queries the customer for each one makes 51 queries: the N+1 problem. Eager-load relations, select only needed columns and add indexes that match filters and sort order; log query counts per request in development. Shape payloads for the client: sparse fieldsets (?fields=id,total,status) or include parameters (?include=customer) let one endpoint serve both a compact list and a detailed view. Compress JSON with gzip or Brotli. For read-heavy resources, send ETag or Last-Modified so clients get 304 responses, and cache computed responses in Redis with explicit invalidation on writes. Public, non-personal endpoints such as a product catalogue can be cached at a CDN with s-maxage. Measure p95 latency per endpoint, not averages.

Real-world scenarios

01

A user can read other customers' invoices

Problem

A security researcher reports that changing the invoice ID in GET /v1/invoices/{id} returns other customers' PDFs. The endpoint requires a valid token.

How to solve it

This is broken object level authorization. Fix the endpoint first: load the invoice through the caller's account, for example the user's or tenant's invoices relation, and return 404 when it is not found. Then search the codebase for every route that takes an ID and apply the same pattern or a policy check, including nested resources and file downloads. Add automated tests that call each endpoint with a second user's token and expect 404. Check access logs for sequential ID access to see whether data was exposed, and follow your incident and disclosure process.

02

Customers are charged twice on flaky networks

Problem

Support sees duplicate payments from mobile users. Logs show two POST /payments requests a few seconds apart with identical bodies after a client timeout.

How to solve it

Make the endpoint idempotent. The mobile client generates a UUID per payment attempt and sends it as Idempotency-Key, reusing it on retries. The server stores the key, a hash of the body and the response in a table with a unique constraint on client and key. A repeat with the same body returns the stored response; a different body with the same key returns 422; a repeat while the first is still running returns 409. Also pass the key to the payment provider if it supports idempotency, so both layers are protected.

03

An old mobile version breaks after a release

Problem

After renaming the field total to totalAmount in the orders response, users on an app version from last year see empty totals and cannot check out.

How to solve it

Restore the old field immediately, and send both fields for now, since adding a field is non-breaking. Introduce a rule: field removals and renames happen only in a new API version or after a deprecation period. Log the app version from a header on each request, so you know who still uses old fields before removing anything. Add contract tests generated from the OpenAPI spec that fail when a response loses a documented field. For truly unsupported old versions, use a forced-update screen instead of breaking them silently.

Cheat sheet

Create
POST /v1/orders -> 201 Created + Location: /v1/orders/1001
Partial update with concurrency check
PATCH /v1/orders/1001 + If-Match: "v7" -> 200 or 412
Accepted for background work
202 Accepted + Location: /v1/jobs/77
Validation error format
Content-Type: application/problem+json (RFC 9457) with type, title, status, errors
Cursor pagination
GET /v1/orders?limit=20&cursor=eyJpZCI6MTAwMH0
Sparse fieldsets and includes
GET /v1/orders?fields=id,total,status&include=customer
Safe retries for writes
Idempotency-Key: 6f1c2d4e-8a7b-4c3d-9e2f-1a2b3c4d5e6f
Rate limit exceeded
429 Too Many Requests + Retry-After: 30
Deprecation signals
Deprecation: @1798761600 + Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Lint an OpenAPI file
npx @redocly/cli lint openapi.yaml

In my real projects

  • The restaurant ordering platform runs on one Laravel REST API with separate web and mobile channels and controllers split into Shared, Ui, Mobile and Admin layers, about 330 endpoints in total.
  • In the multi-tenant POS SaaS (NestJS, OpenAPI 3.1) I worked on a versioned guest API for mobile apps and a partner public API with scoped API keys, rate limits and key rotation.
  • That platform uses idempotency keys for partner orders, HMAC-signed webhooks delivered through a queue, and returns 404 for rows from another tenant.
  • In the pet-store app the HTTP client retries only idempotent methods, so network errors never duplicate orders.

Questions & answers

Q1What makes an API RESTful?

REST is an architectural style: resources identified by URLs, manipulated through a uniform interface (standard HTTP methods), stateless requests that carry all the context they need, cacheable responses, and representations such as JSON. In practice a good REST API uses nouns for resources, the method to express the action, correct status codes, standard headers for caching and auth, and consistent error formats. Strict REST also includes hypermedia links that guide clients, which few APIs fully implement. I focus on predictability and correct HTTP semantics rather than purity, because that is what makes integration fast.

Q2How do you handle pagination for large datasets?

For admin tables where users jump between pages, offset pagination is fine with an indexed sort and a capped page size. For large or fast-changing data, feeds, infinite scroll and sync endpoints, I use cursor pagination: the cursor encodes the last item's sort key and ID, and the query uses a WHERE clause on those columns, which stays fast at any depth and never skips or duplicates rows when new data arrives. I return the next cursor and a hasMore flag or links, so clients never build URLs. Total counts can be expensive on big tables, so I make them optional.

Q3What is an idempotency key and how do you implement it?

It is a unique value the client generates for one logical operation, such as a payment, and sends in an Idempotency-Key header. The server stores the key with the request fingerprint and the response, scoped to the client, in a table with a unique constraint or in Redis with a TTL. If the same key arrives again, the server returns the stored response instead of processing it twice; if the same key arrives with a different body, it returns 422 or 409. While the first request is still running, a duplicate gets 409 so it retries later. This makes POST safe to retry after timeouts.

Q4How do you version an API, and when do you need a new version?

I need a new version only for breaking changes: removing or renaming fields, changing types or meaning, or adding required inputs. Additive changes stay in the current version, and clients are told to ignore unknown fields. I prefer URL versioning such as /v1 because it is explicit, easy to route, log and cache. When a new version ships, the old one gets a deprecation notice with a sunset date, Deprecation and Sunset headers, and monitoring of who still calls it. For mobile apps, which users do not always update, I keep old versions longer and use a forced-update mechanism for truly unsupported releases.

Q5When would you choose GraphQL or gRPC over REST?

GraphQL fits when many clients need different shapes of data assembled from many sources, such as a large product with web, mobile and partner frontends, and over-fetching or many round trips become real problems. It costs harder HTTP caching, per-field authorization and query-complexity limits. gRPC fits internal service-to-service calls where low latency, strict typed contracts and streaming matter, and browsers are not the main client. REST remains my default for public, partner and mobile APIs because every tool, CDN and developer understands it. Often a system combines them: REST outside, gRPC or queues inside.

Q6How do you secure webhooks you send or receive?

When sending, I sign each payload with HMAC-SHA256 using a per-subscriber secret and include a timestamp in the signed content and headers. Delivery goes through a queue with retries and exponential backoff, and each event has a unique ID. When receiving, I verify the signature with a constant-time comparison, reject old timestamps to block replays, and process idempotently by storing event IDs, because providers deliver at least once and may resend. I respond 2xx quickly and do heavy work in a background job. Finally, I never trust the payload blindly for money: I confirm critical state with the provider's API.

Senior-level questions

S1How would you design a public API for third-party partners?

I start with the contract: an OpenAPI 3.1 spec, URL versioning, Problem Details errors, cursor pagination and ISO dates, reviewed before implementation. Authentication uses scoped API keys or OAuth client credentials, with keys hashed at rest, shown once, rotatable and revocable. Every request is scoped to the partner's tenant, and rate limits apply per key with headers that show remaining quota. Writes accept Idempotency-Key. Events go out as HMAC-signed webhooks with retries, a delivery log and a replay option. A sandbox environment with test data lets partners integrate safely. The trade-off is up-front design time and a long support commitment, because once partners depend on a version, changing it costs them real money.

S2How do you keep an API consistent when it spans several services or a queue?

Within one service I use database transactions. Across services, distributed transactions are rarely worth their cost, so I design for eventual consistency. Each service owns its data; changes are published as events through a transactional outbox, and consumers process them idempotently by event ID. Multi-step business flows, such as order, payment and stock reservation, become a saga: a sequence of local steps with compensating actions if a later step fails, like releasing stock when payment is declined. The API exposes this honestly with states such as pending, confirmed and failed, plus a status endpoint or webhook, instead of pretending everything happened synchronously. The trade-off is more states to handle in clients and more monitoring of stuck flows.

S3What would you monitor for an API in production?

Per endpoint and per client: request rate, error rate split into 4xx and 5xx, and latency at p50, p95 and p99. A rising 4xx rate from one client often means a broken integration, while 5xx means a problem on our side. I watch 429s to see whether limits are tuned well, and timeouts and retries against dependencies. Each request gets a request ID returned in responses and written to structured logs, so support can trace a customer report to exact log lines, and distributed tracing shows where time goes across services. For business-critical flows I add synthetic checks that place a test order. Usage of deprecated versions is tracked, so sunsets are based on data. Alerts fire on symptoms users feel, not on every CPU spike.

Glossary

Resource
A thing your API exposes, such as an order or customer, identified by a URL.
Representation
The format a resource is sent in, usually JSON, describing its current state.
BOLA / IDOR
Broken object level authorization: users can access objects that are not theirs by changing an ID.
Mass assignment
When an API copies all client-sent fields into a model, letting users set fields like role or price.
Idempotency key
A unique client-generated value that lets the server recognise and safely ignore a repeated request.
Cursor
An opaque pointer to a position in a sorted list, used to fetch the next page reliably.
Problem Details
RFC 9457 standard JSON format for API errors, served as application/problem+json.
Transactional outbox
Saving events in the same database transaction as data, then delivering them from a worker.
Token bucket
A rate-limiting algorithm that refills allowance at a steady rate and permits short bursts.
OpenAPI
A machine-readable description of an HTTP API used for docs, client generation and testing.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.