An API defines what a client can request, how it expresses its intent, and what a response means. Its design connects the consumer’s experience to the system’s rules: identity, resource access, consistency, and recovery when a request fails.
The contract should be explainable before choosing a protocol. Creating a hold, browsing events, and editing an event are different operations; each needs clear inputs, results, and error conditions.
Start with the contract
Identify consumers and their main operations. A browser may need small lists and responses that are easy to inspect; an internal service may require typed calls or a continuous stream of messages. Frequency, size, latency, and independent evolution of clients and servers also matter.
For each operation, define which resource it affects, who may execute it, which fields it accepts, and what its result confirms. Successfully receiving work does not necessarily mean completing it. Likewise, a timeout does not prove that an operation never happened.
Business invariants complete the contract. A hold must belong to the authenticated user, cover available inventory, and have a server-defined expiration. The HTTP method helps communicate intent, but the application and its storage protect those rules.
Interface styles
A resource-oriented HTTP API is often a practical starting point. It can use familiar methods, status codes, tools, and caching mechanisms. The choice still depends on the contract and its consumers.
| Approach | When it can help | Costs to consider |
|---|---|---|
| REST over HTTP | Resources and operations that fit clear routes and representations. | Response granularity, evolution, and coordination across multiple calls. |
| GraphQL | Consumers that need to select fields and combine related data. | Query cost, resolvers, object- and field-level authorization, and caching strategy. |
| gRPC | Typed RPC between services, shared contracts, and streaming needs. | Client and intermediary compatibility, schema evolution, and operational tools. |
GraphQL can reduce over-fetching and under-fetching: returning unnecessary fields or requiring additional calls to complete a view. This does not guarantee a cheap query. Depth, object counts, and resolver work need limits and measurement; authorization must continue to protect the selected data. Caching also needs a strategy compatible with the queries and the consumer’s identity.
gRPC commonly combines Protobuf-defined messages with HTTP/2 transport. Its binary representation can reduce size and serialization cost compared with certain alternatives; performance depends on the payloads, clients, and measured workload. It can fit internal communication, but it is not a requirement for every microservice. Client and environment support matter; a browser may need a suitable integration or gateway. None of these options removes the need to design errors, permissions, or retries.
RPC expresses operations as calls to remote procedures: a name, parameters, and a result. gRPC is a form of RPC; the JSON-RPC 2.0 specification defines a JSON format for messages, responses, and errors, without imposing a single transport or the application’s methods. Methods and their guarantees belong to the specific contract, rather than the format alone. The id correlates the response with the request; it does not act as an idempotency key.
In Ethereum, wallets and dApps can use JSON-RPC as a bridge to an accessible node, whether local or remote, operated directly or by a provider. They can access its API without maintaining their own full node. eth_getBalance queries a balance with respect to a block; eth_call executes a call without publishing a transaction and does not guarantee its later success; eth_sendRawTransaction submits a signed transaction whose hash does not confirm inclusion or finality. A remote call adds latency, timeouts, and errors: after a submission with an uncertain outcome, its status must be recovered rather than assuming it never arrived. The node’s view and block reference determine which state is queried. This access abstracts the node’s interface, rather than consensus or trust in its responses; privacy, trust, and verification depend on the connection and the use case. The example does not make JSON-RPC or these methods a rule for every chain. Their inputs and results are described in the Ethereum JSON-RPC reference.
Resources and input locations
Routes commonly use plural resource names: /events, /holds, and /orders. An identifier in the path selects a specific resource; query parameters describe filters or how to retrieve a collection.
| Location | Example | Responsibility |
|---|---|---|
| Path | /events/{eventId} | Identify the resource being acted on. |
| Query | /events?city=CDMX&limit=20 | Filter, sort, and paginate results. |
| Headers | Authorization, Idempotency-Key, If-Match | Carry credentials or request conditions. |
| Body | eventId and seatIds when requesting a hold | Express the data or action accepted by the contract. |
The contract defines types, required fields, limits, and how unknown fields are handled. It also distinguishes syntactic validation from business rules: a seat list can be valid JSON while containing occupied inventory or seats outside the specified event.
--- config: look: handDrawn --- flowchart TD client["Client"] --> request["Request<br/>Method, route, and inputs"] request --> auth["Authentication when applicable<br/>Authorization"] auth --> contract["Validate contract<br/>and conditions"] contract --> service["Application<br/>Business rules"] service --> store[("Authoritative state")] service --> response["Result or error<br/>Response contract"] response --> client
The diagram summarizes responsibilities, rather than a rigid execution order: some authorization and condition checks need to read the resource and coordinate with its update.
HTTP methods: POST and PUT are not equivalent
The method describes request semantics. Idempotency means that repeating the same request has the same intended effect as executing it once; it does not require returning the same status code or body every time.
| Method | Usual intent | Method idempotency |
|---|---|---|
| GET | Retrieve a representation without requesting a resource modification. | Yes; it is safe, although it may generate logs or metrics. |
| POST | Ask the resource to process the submitted representation; it may create a resource or perform an operation. | Not guaranteed; the contract can add it with a key. |
| PUT | Create or replace the resource’s representation at a known URI. | Yes, for the intended effect of repeating the same request. |
| PATCH | Apply a partial modification. | Not guaranteed; it depends on the defined operation. |
| DELETE | Remove the resource’s association with its URI. | Yes; a repeated request may report that it no longer exists. |
With POST /holds, the server can assign an identifier to a new hold. The client sends its selection and a key to recover the same attempt on retry:
POST /holds HTTP/1.1
Authorization: Bearer <access-token>
Content-Type: application/json
Idempotency-Key: hold-attempt-123
{"eventId":"event-123","seatIds":["seat-A1"]}A completed creation can return 201 Created, with Location: /holds/hold-456 and a representation containing holdId, status, and expiration. The server derives ownership from the authenticated identity and validates the selection.
In contrast, an authorized organizer can use PUT /events/event-123 to submit the complete representation of the known event’s writable fields. The contract defines what omitting a field means and which fields the server manages. If PUT creates a resource, it can return 201; if it replaces an existing one, 200 or 204, depending on the contract.
Repeating that PUT must not create another event. However, idempotency does not prevent overwriting another client’s concurrent change. A version or an ETag with If-Match, checked together with the write, can reject an update based on an earlier version with 412 Precondition Failed.
PATCH needs the same precision: assigning a value may be idempotent, while incrementing a counter or appending an item may repeat the effect. The method’s name does not make those operations safe to retry.
Pagination and ordering
A collection should limit its size and document an order. For events sorted by date, the pair (starts_at, id) provides a stable tie-breaker. The cursor represents a position and must preserve the filter and ordering context; it can be opaque to the consumer.
A request such as GET /events?limit=20&after=<cursor> retrieves the next page under that contract. A maximum page size prevents arbitrarily large responses. An opaque cursor is not a credential either: the server validates its inputs and permissions for each query.
Cursor and offset pagination offer different tradeoffs. Offset is simple for small lists or page jumps, but may require more work as the offset grows and shift results when data changes. A cursor can use access aligned with the ordering; the data model and its indexes must support that query.
The tie-breaker does not create a snapshot. Inserts, deletes, or changes to the ordering field can alter what appears between pages. If a fixed view is required, the contract needs an explicit version or snapshot strategy. A total count also has a cost and does not need to be part of every response.
Authentication and authorization
Authentication identifies the consumer; authorization determines what it can do to a resource. The catalog can allow GET /events without a session, while POST /holds requires identity and permissions, and PUT on an event requires organizer authorization. A JWT requires validation of its signature, allowed algorithms, issuer, audience, and expiration under the contract. Reading its claims without verifying them does not establish a trusted identity.
A cookie-based session is also valid. It needs HTTPS, suitable cookie attributes such as Secure and HttpOnly, and CSRF protections for operations that change state. The mechanism should fit the client and its threat model.
An orderId or userId supplied by the client can identify an operation’s target, provided the server authorizes access. An ownerId in the body must not be accepted as proof of identity. Authorization covers the object, tenant, role, and writable fields; it also protects collection operations and GraphQL resolvers.
Validation limits sizes, types, and accepted fields, and avoids directly assigning every received field to an internal model. Credentials should not travel in the query. Authorization and validation are complemented by traffic limits that protect capacity and control abuse.
Responses, retries, and evolution
Errors should offer a stable contract: an application code, a useful message, and a request identifier for diagnosis, without sensitive internal details. Codes such as 400, 401, 403, 404, 409, and 429 distinguish invalid inputs, authentication, permissions, absence, conflicts, and limits; the disclosure policy may avoid revealing whether a private resource exists.
An idempotency key is bound to the user, operation, and accepted payload, with a defined retention policy. Reusing it with incompatible data should produce a conflict. Recording the decision and coordinating concurrent requests prevents executing the same attempt twice within that contract. If an external dependency participates, its guarantees also matter: the ticketing case shows how to recover purchases and payments with uncertain outcomes.
Retries should be bounded and respect backoff and signals such as Retry-After. A timeout requires checking status or retrying idempotently; changing the key may create another operation. If work is accepted asynchronously, 202 Accepted can include a URL for checking its status, but does not confirm completion. Work queues complete that contract with explicit delivery and recovery.
Evolution protects existing consumers: changes to fields, validation, and semantics need a compatibility policy and tests against real contracts. Designing an API makes these decisions observable and sustainable, alongside the models and mechanisms in the System Design path.