Software & DevOps · API contract design

API Design Decision Guide

Choose REST, GraphQL, gRPC, or webhooks with a contract that survives caching, retries, versioning, auth, and the next review.

Read mode: architecture review on desktop, code review on laptop Primary query: REST vs GraphQL vs gRPC vs webhooks
REST GraphQL gRPC Webhooks OpenAPI protobuf OAuth / OIDC
Query data REST for stable resources; GraphQL when the client must choose the shape.
Client-shaped reads GraphQL when one endpoint should answer many nested read patterns.
API router
Command system gRPC for low-latency internal calls, streaming, and strict contracts.
Notify external consumer Webhooks when your system must push events out and retry delivery.
Quick reference The fastest decision points and the contract artifact that should exist before implementation starts.

Use this as the "cheat within the cheat". The design choice should be obvious after one scan.

Situation Default choice Why Contract artifact
CRUD on stable resources REST HTTP methods, caching, proxies, and tooling all fit naturally. OpenAPI document plus resource model
Client needs nested, shaped reads GraphQL One request can fetch the exact fields and related objects needed. GraphQL schema and field-level auth policy
Internal service-to-service RPC gRPC Binary transport, generated stubs, deadlines, and streaming are first-class. .proto service and message definitions
Outbound event notifications Webhooks The producer controls retries; the consumer controls idempotency. Event schema, signature scheme, replay policy
Browser push from server to client SSE Simple one-way stream over HTTP; good for live dashboards and status. Stream endpoint and reconnect policy
Two-way low-latency interaction WebSockets Bidirectional control or chat when HTTP request/response is too coarse. Message envelope, heartbeat, backpressure policy
Need a machine-readable HTTP contract OpenAPI Design-first docs, client generation, validation, and review gates. OpenAPI 3.2.0 document
Need a versioned callback contract Webhook callback object Callbacks belong in the contract, not in the integration folklore. OpenAPI callback object or provider webhook spec
API style decision matrix Compare the four primary styles across transport, caching, schema, browser fit, streaming, and failure modes.
Style Best fit Strength Weakness Contract / tooling When not to use
REST Public or semi-public resource APIs HTTP semantics, cacheability, broad tooling Overfetching, endpoint sprawl, version pressure OpenAPI, HTTP status codes, ETags When the client must freely shape deeply nested reads
GraphQL Client-driven aggregation and mobile/web backends Single endpoint, exact field selection, schema introspection Caching and authorization are harder; query planning can hurt GraphQL schema, operation docs, complexity limits When the API is simple CRUD or you need proxy-friendly caching
gRPC Service-to-service RPC and streaming Binary efficiency, stubs, deadlines, streaming Browsers need a gateway; humans do not handcraft it easily .proto, generated code, reflection/health checks When you need broad browser compatibility without a proxy
Webhooks Asynchronous notifications to other systems Push-based integration, decoupled timing, event fan-out Delivery is retried, duplicated, and occasionally out of order Event schema, signature verification, replay log When the consumer expects request/response semantics
Decision rule: if the client should choose data shape, favor GraphQL; if the transport must be generic and cacheable, favor REST; if the caller is another service and streaming matters, favor gRPC; if your system is telling someone else that something happened, use a webhook.
Core design primitives The nouns you should decide before debating style: resource, command, event, subscription, callback, and retry identity.

Resource

A stable thing with an identity, such as an order, invoice, or shipment.

  • Example: /orders/12345 for the order record.
  • Gotcha: if the URL names an action, it is probably not a pure resource.

Command

A request to cause a state change, such as capture payment or cancel order.

  • Example: a cancel request for order 12345.
  • Gotcha: commands need idempotency if callers may retry.

Event

A fact that already happened, such as invoice.paid.

  • Example: event payment_intent.succeeded with a durable event ID.
  • Gotcha: events are immutable history, not editable state.

Subscription

A long-lived read that receives future data or events over time.

  • Example: GraphQL subscription for order status changes.
  • Gotcha: subscriptions are not a replacement for durable delivery logs.

Callback

A server-initiated request to a consumer endpoint, usually a webhook.

  • Example: POST https://hooks.example.com/stripe.
  • Gotcha: the receiver must expect retries and duplicate deliveries.

Idempotency token

A key that makes a retried write safe to repeat.

  • Example: Idempotency-Key: 3b4f1f4c-8c2b-4f4b-b6ee-4b1fbe4e6f8c.
  • Gotcha: use one token per intended operation, not per HTTP attempt.

Correlation / trace ID

A shared identifier that ties a request chain together across services.

  • Example: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00.
  • Gotcha: do not overload it as a business identifier.

Canonical representation

The authoritative payload shape for a resource or event at a point in time.

  • Example: the serialized JSON for an order resource.
  • Gotcha: internal DB shape is not the canonical API shape.
REST done well Resource modeling, method semantics, conditional requests, pagination, and versioning that do not fight HTTP.

Fundamentals

Use nouns for resources and HTTP methods for intent: GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes.

  • Example: GET /orders/12345 or POST /orders.
  • Gotcha: if every endpoint is /doThing, you are doing RPC over HTTP.

Working knowledge

Design the response contract before coding: status codes, pagination, filtering, sorting, and headers.

  • Example: GET /orders?customer_id=123&limit=50&cursor=eyJpZCI6IjQyIn0.
  • Gotcha: without ETag and If-Match, update races become silent overwrites.

Edge & advanced

Use 202 for async jobs, 204 for empty success, and conditional requests for concurrency control.

  • Example: PUT /orders/12345 with If-Match: "v7".
  • Gotcha: bulk and command-like endpoints need explicit failure semantics or they become unrecoverable.
Method / code Definition Example Pitfall
GET Safe read of a resource representation. GET /customers/91 Do not make it mutate server state.
HEAD GET metadata without the body. HEAD /assets/invoice.pdf Useful for existence and cache checks; often overlooked.
POST Create a subordinate resource or trigger a non-idempotent action. POST /orders Use idempotency keys when clients may retry.
PUT Replace the full representation at a known URI. PUT /orders/12345 Do not send partial state unless the API explicitly defines merge semantics.
PATCH Apply a partial modification. PATCH /orders/12345 Define the patch media type and conflict behavior.
DELETE Remove or tombstone a resource. DELETE /sessions/abc123 Deletion may be asynchronous and return 202, not always 204.
201 Created A write created a resource, usually with a Location header. POST /orders returns 201 and Location: /orders/12345 Returning 200 for creation hides the newly created URI.
202 Accepted The server accepted work but is not done yet. Batch invoice generation accepted for background processing. Never imply completion when the job can still fail.
204 No Content Success with no body. DELETE /sessions/abc123 returns 204. Do not include a body and call it 204.
409 Conflict The request conflicts with current state. Updating an order that another writer already closed. Useful for concurrency or business-rule conflicts.
412 Precondition Failed A conditional request did not match current state. If-Match etag mismatch on update. Use this for lost-update prevention, not generic validation.
415 Unsupported Media Type The payload format is not acceptable. Client sends XML to a JSON-only endpoint. Separate format errors from semantic errors.
422 Unprocessable Content The syntax is valid but the content fails domain validation. Postal code fails country-specific rules. Use only when the schema is valid and the business rule is not.
429 Too Many Requests Rate limit or abuse control. Retry after Retry-After: 60. If you return 429, tell the client when to retry.
PUT /orders/12345 HTTP/1.1
If-Match: "v7"
Content-Type: application/json

{
  "status": "confirmed",
  "shipping_address": {
    "city": "Austin",
    "region": "TX",
    "postal_code": "78701"
  }
}

OpenAPI should describe the endpoints, schemas, response codes, headers, and security schemes. If the contract cannot be generated or validated, the spec is too vague.

GraphQL done well Schema-first reads, explicit mutations, cursor pagination, and field-level security that does not collapse under real traffic.

Fundamentals

GraphQL is a typed schema where clients ask for exactly the fields they need from the object graph.

  • Example: query { order(id: "12345") { id total customer { name } } }
  • Gotcha: GraphQL is not "REST but with JSON"; it has its own execution and error model.

Working knowledge

Serve GraphQL over HTTP with JSON bodies, use POST by default, and allow GET for query operations when appropriate.

  • Example: response contains data and maybe errors.
  • Gotcha: auth and rate limits must happen before expensive execution where possible.

Edge & advanced

Use cursor pagination, complexity limits, persisted queries, and per-field authorization to keep it stable.

  • Example: orders(first: 20, after: "YXJyYXljb25uZWN0aW9uOjE5").
  • Gotcha: ignoring N+1 makes GraphQL look fast in demos and slow in production.
Topic Rule Example Gotcha
Operation types query reads, mutation writes, subscription streams live updates. mutation PlaceOrder($input: PlaceOrderInput!) Do not use mutations as a generic RPC escape hatch without a typed input contract.
HTTP transport Use POST for most operations; GET is allowed for query operations. Content-Type: application/json Do not assume every intermediary will cache GraphQL the same way it caches REST.
Response shape The response can include data, errors, and extensions. Partial data plus field errors in one response. Partial success is normal; code must inspect both data and errors.
Pagination Prefer cursor-based connections for lists that can grow. first: 20, after: "cursor" Offset pagination becomes unstable on changing datasets.
Authorization Enforce access by field/object boundary, not only at the root query. A user can read their own invoice but not another tenant's email field. Field-level leaks are easy when resolvers reuse generic loaders.
Complexity control Limit depth, breadth, and cost of one query. Cap list sizes and nested object expansions. Unlimited introspection plus no limits becomes a denial-of-service path.
Schema evolution Add fields; deprecate before removal; keep additive compatibility. Mark oldField deprecated and add newField. Removing a field without a deprecation window breaks clients silently.
query OrderSummary($id: ID!) {
  order(id: $id) {
    id
    status
    total
    customer {
      id
      name
    }
  }
}
gRPC done well Strong contracts, generated clients, streaming, and deadline-aware service-to-service calls.

Fundamentals

gRPC starts with a .proto file and generates client and server bindings from a typed service definition.

  • Example: service Orders { rpc GetOrder(GetOrderRequest) returns (Order); }
  • Gotcha: gRPC is not hand-written JSON over HTTP; the wire format and semantics are different.

Working knowledge

Use unary, server streaming, client streaming, or bidirectional streaming depending on flow direction.

  • Example: server-streaming telemetry updates for a live job.
  • Gotcha: the browser usually needs gRPC-Web or a gateway proxy.

Edge & advanced

Define deadlines, retries, and status handling so the caller can fail fast instead of hanging.

  • Example: a 3 second deadline for a read path that normally finishes in 100 ms.
  • Gotcha: retries without deadlines can amplify load during partial outages.
RPC shape Definition Example Use case
Unary One request, one response. GetOrder Simple reads and commands.
Server streaming One request, many responses. WatchInventory Live progress, telemetry, subscriptions.
Client streaming Many requests, one response. UploadTelemetry Batching a flow of client events.
Bidirectional streaming Many requests, many responses. Interactive control loop or sync feed. Live collaboration, chat, or agent control.
Deadlines Client-stated limit for how long the RPC may run. deadline = now + 3s Prevent zombie calls and make retries bounded.
Status codes gRPC returns canonical codes like OK, INVALID_ARGUMENT, UNAVAILABLE, and DEADLINE_EXCEEDED. Retry only if the status is retryable and the deadline allows it. Always map business failures to a specific code, not "unknown".
Proto compatibility Add fields, do not reuse field numbers, and avoid changing field types. Add string shipping_method = 8; Backward compatibility depends on stable numeric tags.
syntax = "proto3";

service Orders {
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc StreamOrderEvents (OrderEventRequest) returns (stream OrderEvent);
}

message GetOrderRequest {
  string order_id = 1;
}

Use gRPC-Web or a gateway when the client is a browser; plain browser JavaScript cannot speak native gRPC over HTTP/2 frames directly.

Webhooks done well Push events out reliably, verify them cryptographically, and assume duplicates, retries, and redelivery windows.

Fundamentals

A webhook is an HTTP POST to a consumer endpoint when something happens in the producer system.

  • Example: payment_intent.succeeded sent to https://hooks.example.com/payments.
  • Gotcha: the producer cannot assume the consumer is online or fast.

Working knowledge

Verify signatures, respond with a 2xx quickly, then process the event asynchronously.

  • Example: Stripe Stripe-Signature or GitHub X-Hub-Signature-256.
  • Gotcha: if verification happens after processing, forged traffic can waste work or trigger side effects.

Edge & advanced

Store delivery IDs, support replay, and make the consumer idempotent because retries are normal.

  • Example: ignore a duplicate event by event ID after the first successful application.
  • Gotcha: ordering is usually best-effort, not a guarantee.
Delivery rule Definition Example Why it matters
Fast 2xx ACK Acknowledge as soon as the payload is safely accepted. Return 200 or 204 before long processing. Prevents producer retry storms and keeps latency bounded.
Signature verification Prove the event came from the expected producer and was not altered. Verify an HMAC over the raw request body. Protects against spoofed or tampered deliveries.
Idempotent consumer A repeated delivery has no extra side effects. Record event evt_123 before applying the state change. Retries and redelivery are normal; duplication must be harmless.
Retry policy The producer decides how often to retry a failed delivery. Exponential backoff over several days. The consumer should never rely on exactly-once transport.
Replay / redelivery A way to resend past events from a UI or CLI. GitHub delivery redelivery or Stripe CLI resend. Critical for recovery from downtime or bugs in the handler.
Event filtering Subscribe only to the event types you actually handle. Listen only to invoice.paid and invoice.payment_failed. Reduces noise, cost, and handler complexity.
Dead-letter / backlog Keep deliveries that repeatedly fail and surface them to operators. A failed event queue or replay dashboard. Without a backlog, you lose failures in the dark.
POST /webhooks/stripe HTTP/1.1
Stripe-Signature: t=1720384800,v1=...
Content-Type: application/json

{
  "id": "evt_123",
  "type": "payment_intent.succeeded",
  "data": { "object": { "id": "pi_456" } }
}

Stripe documents automatic retries for up to three days in live mode; GitHub documents signature validation and manual redelivery. Treat those as the pattern, not the whole implementation.

Security, auth, and governance Bearer tokens, OpenID Connect, mTLS, rate limits, and contract review keep API style from becoming API drift.

OAuth / OIDC

OAuth is for delegated access; OpenID Connect is the identity layer on top when you need login and user claims.

  • Example: Authorization: Bearer eyJ... for a protected API.
  • Gotcha: bearer tokens must be protected in storage and in transit.

mTLS / sender-constrained tokens

Mutual TLS can bind a client certificate to the token and help prevent token replay.

  • Example: service-to-service calls on a private mesh.
  • Gotcha: operational complexity is higher than bearer tokens alone.

Rate limits

Rate limits are a contract, not just an error response.

  • Example: 429 Too Many Requests with Retry-After: 60.
  • Gotcha: tell the client how to back off or you are just forcing guesswork.

Governance

Every public API should have a review path for schema changes, compatibility, and deprecation windows.

  • Example: review new enum values and new webhook event types before release.
  • Gotcha: additive changes are safer, but only if consumers ignore unknown data gracefully.
Practical rule: if the contract cannot answer how auth, retries, pagination, and deprecation work, the API is not ready for production, even if the endpoints compile.
Common mistakes and anti-patterns The failures that make API reviews expensive later.
RPC over REST
Symptoms: /doThing, /runJob, and non-HTTP semantics everywhere.
Better default: model a resource or switch to a true command-style contract if that is what you need.
GraphQL for simple CRUD
Symptoms: a one-screen form uses a graph query with no aggregation benefit.
Better default: plain REST is simpler to cache, document, and secure.
gRPC with no browser gateway
Symptoms: the frontend team cannot call the API without awkward workarounds.
Better default: add gRPC-Web / Envoy or choose REST for browser-facing traffic.
Non-idempotent retries
Symptoms: duplicate payment charges or duplicate webhook side effects after a network blip.
Better default: add idempotency keys and dedupe storage before the first side effect.
Webhook handler does too much work inline
Symptoms: the producer retries because the handler takes too long or times out.
Better default: verify, persist, ACK, then do the real work asynchronously.
Leaking DB tables as API shapes
Symptoms: schema names and nullable quirks appear in the public contract.
Better default: design the contract from the consumer's point of view, not the storage engine's.
Unversioned breaking changes
Symptoms: renamed fields, reused enum values, or removed event types break lagging clients.
Better default: additive changes first, deprecate loudly, remove only after a real window.
Ambiguous error semantics
Symptoms: every failure is 400 or "unknown", so client behavior becomes guesswork.
Better default: define validation, auth, conflict, and retryable errors separately.