← Back to homeapis

API Design in 2026: REST, GraphQL, gRPC — and Making Your API Agent-Ready

← All writing

Every API we've shipped in the last two years has picked up a consumer nobody put in the original requirements doc: an AI agent, holding an API key, reading our error messages as if they were instructions. That single fact has changed how we think about API design more than any protocol debate of the previous decade. The interesting question is no longer "REST or GraphQL?" — that argument has mostly settled — but "can a machine that has never seen your docs figure out your API from its responses alone?" This post is our current playbook: an honest read of the protocol landscape, REST decisions that age well, contracts and auth and webhooks done properly, and the new work of making an API agent-ready.

Illustration: a robot traveler reading a clear wayfinding signpost in a train station

The protocol landscape, honestly

REST + JSON is still the default, and that's fine

REST over HTTP with JSON bodies remains the default for public and partner APIs, and the reasons are boring in the best way: every language has an HTTP client, every proxy and CDN and WAF understands it, caching works at the infrastructure layer via plain HTTP semantics, and debugging is curl. When a client asks us why we didn't reach for something fancier, the answer is usually that the fancy option solves a problem they don't have and creates several they will.

REST's real advantage in the current era is legibility. A resource-oriented API with predictable URLs and standard status codes is self-describing in a way RPC-over-POST never is — and legibility, as we'll get to, is exactly what the newest class of API consumers needs most.

Where GraphQL earns its complexity — and where it became regret

GraphQL solves a real problem: many heterogeneous clients with divergent data needs hitting the same backend, where the alternative is either chronic over-fetching or an explosion of bespoke endpoints. If you're Shopify — which has gone all-in, making GraphQL the primary interface for its Admin API and steering new app development away from REST — the investment pays for itself. GitHub has maintained a GraphQL API alongside its REST API for years, and both are genuinely used.

But the last few years also produced a visible retreat. A wave of widely read "why we're moving off GraphQL" retrospectives captured a common arc: a small team adopts GraphQL for one web client, then spends its innovation budget on persisted queries, query-depth limiting, N+1 dataloaders, response caching that HTTP would have given them for free, and per-field (rather than per-endpoint) authorization. Every query is a bespoke workload the server must cost-analyze before executing.

Our rule of thumb: GraphQL earns its keep when you have multiple client teams who ship independently of the backend team, or a genuine data-graph domain. One web app talking to one backend does not need it. A BFF (backend-for-frontend) endpoint that returns exactly the shape one screen needs is 90% of GraphQL's benefit at 10% of its cost.

gRPC for service-to-service

gRPC with Protocol Buffers is the right call for internal service-to-service traffic: binary encoding, HTTP/2 multiplexing, first-class streaming, and — most importantly — codegen from a .proto contract that makes cross-team drift a compile error instead of a 3 a.m. incident. We don't expose it publicly: browsers still need gRPC-Web and a proxy, human debuggability is worse, and public-API tooling (gateways, docs, agent frameworks) assumes HTTP+JSON. gRPC inside the trust boundary, REST at the edge, is a boring and correct architecture.

ProtocolBest fitAvoid when
REST + JSONPublic APIs, partner APIs, anything agents consumeHigh-throughput internal RPC with strict latency budgets
GraphQLMany independent client teams, true graph domainsOne client, one backend, small team
gRPCService-to-service, streaming, polyglot microservicesPublic edge, browser-first consumers

REST design that ages well

Model resources, not procedures

The APIs that survive five years of feature growth are the ones that modeled nouns. POST /projects/{id}/archive reads nicely today, but a year later you have forty verb endpoints and no consistent way to list, inspect, or undo any of them. Prefer state transitions on resources (PATCH /projects/{id} with {"status": "archived"}) or, for genuinely long-running work, an explicit operation resource:

POST /v1/exports HTTP/1.1
Content-Type: application/json

{"project_id": "prj_8f2k", "format": "csv"}
HTTP/1.1 202 Accepted
Location: /v1/exports/exp_01j9
Content-Type: application/json

{"id": "exp_01j9", "status": "processing", "created_at": "2026-05-30T09:14:00Z"}

Now long-running work is pollable, listable, and cancelable with zero new concepts.

Pagination: cursors, almost always

Offset pagination (?offset=200&limit=50) is easy to implement and easy to regret: it degrades on large tables (the database still walks the skipped rows) and returns duplicated or skipped items when rows change mid-pagination — which is always, in a live system. Cursor pagination encodes the position of the last item in an opaque token:

GET /v1/invoices?limit=50&cursor=eyJpZCI6Imludl8wMWo4In0 HTTP/1.1
{
  "data": [ ... ],
  "next_cursor": "eyJpZCI6Imludl8wMWo5In0",
  "has_more": true
}

Keep the cursor opaque (base64 of the sort key is fine) so you can change the underlying implementation without breaking clients. Offset is acceptable only for small, admin-facing lists where "jump to page 7" is a real requirement.

Idempotency keys for unsafe operations

Networks fail after the server commits but before the client hears back. Without idempotency, the client's retry double-charges the card. The pattern popularized by Stripe — and now working its way through the IETF HTTP APIs working group as a draft standard — is an Idempotency-Key header on POST:

POST /v1/payments HTTP/1.1
Idempotency-Key: 3f0c8e1a-77b2-4e0f-9c1d-2ab4f0a1d9e4
Content-Type: application/json

{"amount": 45000, "currency": "LKR", "customer": "cus_92kd"}

The server stores the key with the first response and replays that stored response for any retry with the same key. Scope keys per endpoint, expire them after 24 hours or so, and return a 409 if the same key arrives with a different request body — that's a client bug worth surfacing loudly.

Partial updates: JSON Merge Patch

For updates, PATCH (RFC 5789) with JSON Merge Patch semantics (RFC 7396) is the pragmatic choice: send only the fields you're changing, use null to clear a field. JSON Patch (RFC 6902) is more expressive — it can address array elements — but in practice clients find it fiddly and servers rarely need it. Whatever you choose, document it and be consistent; the worst outcome is PUT endpoints that secretly behave like PATCH.

Errors: RFC 9457 problem+json

RFC 9457 (Problem Details for HTTP APIs, the revision of RFC 7807) gives you a standard, extensible error envelope. Use it everywhere:

{
  "type": "https://api.example.com/problems/insufficient-credits",
  "title": "Insufficient credits",
  "status": 402,
  "detail": "This export requires 120 credits but the workspace has 45. Purchase credits at POST /v1/credit-purchases or reduce the export date range.",
  "instance": "/v1/exports/exp_01j9",
  "balance": 45,
  "required": 120
}

Served with Content-Type: application/problem+json. The type URI is a stable identifier clients can branch on; the extension members (balance, required) carry machine-readable specifics. Note what the detail field does above: it names the problem, quantifies it, and states two concrete remedies. Hold that thought — it matters more than ever in the final section.

Versioning: URL versioning won

The purist position is media-type versioning (Accept: application/vnd.example.v2+json); the pragmatic position is /v1/ in the path — and pragmatism won. Path versioning is visible in logs, trivially routable, cacheable without Vary gymnastics, copy-pasteable in a bug report, and impossible to get wrong by omission. Stripe's refinement — a coarse path version plus dated versions pinned per key — is the gold standard, but it's an investment. For most teams: /v1/ in the URL, additive changes only within a version, and a written deprecation policy (announce, dual-run, sunset with Deprecation and Sunset headers) beats any clever content negotiation.

OpenAPI is the contract, not the documentation

The OpenAPI document should be the artifact everything else derives from — not a description generated after the fact and drifting from reality.

Spec-first vs code-first is less a religious war than a question of where you tolerate drift. Code-first (decorators or route introspection generating the spec) keeps the spec accurate but lets API design happen accidentally, one handler at a time. Spec-first forces the design conversation up front and makes the spec reviewable in a PR before any code exists. We land spec-first for public surfaces and code-first with strict CI diffing for internal ones. Either way, the non-negotiable is that CI fails when the spec and the implementation disagree.

paths:
  /v1/invoices/{id}:
    get:
      operationId: getInvoice
      summary: Retrieve a single invoice by ID.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^inv_[a-z0-9]{8,}$' }
      responses:
        '200':
          description: The invoice.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404':
          description: No invoice with this ID exists in this workspace.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

Small details in that fragment do heavy lifting: a stable operationId becomes the generated client's method name (and, later, an agent tool name); the pattern on the ID documents your identifier scheme; the 404 documents its problem+json shape instead of hand-waving.

From this one file we generate TypeScript clients and server types (openapi-typescript and friends), request validation, and mock servers. Contract testing closes the loop: replay recorded traffic or run schema-driven property tests against the implementation, and run your generated client against a mock derived from the same spec. When the contract is enforced, "the docs are wrong" stops being a category of bug.

Auth: keys, client credentials, and JWTs

Three mechanisms cover nearly every API we build, and they compose rather than compete:

  • API keys — right for server-to-server access where the caller is the account. Prefix them (sk_live_, sk_test_) so they're greppable in leaks and secret scanners can catch them; store only a hash; support multiple concurrent keys per account so rotation doesn't require downtime.
  • OAuth 2.0 client credentials (RFC 6749, and its consolidation in the OAuth 2.1 effort) — right for machine-to-machine access when you need issued, expiring, scoped tokens rather than long-lived secrets, or when third parties act on behalf of your customers.
  • JWTs (RFC 7519) — a token format, not an auth strategy. Great as short-lived access tokens because resource servers can validate them without a database hit. The classic mistake is treating them as sessions: a 24-hour JWT with no revocation story means a leaked token is valid for 24 hours, full stop. Keep lifetimes to minutes and pair with refresh.

Whatever the mechanism, scope from day one. invoices:read and invoices:write as separate grants costs almost nothing at design time and is miserable to retrofit. This is about to matter more, not less: when your customers hand credentials to semi-autonomous agents, coarse-grained keys become the difference between "the agent read some data it didn't need" and "the agent deleted production records while being helpful."

Rate limiting and abuse protection

Rate limit everything, including — especially — unauthenticated endpoints. (Our own site's public AI routes are on the pre-launch list for exactly this reason; unauthenticated POST endpoints that call an LLM are a cost-amplification attack waiting to happen.) The mechanics:

  • Token bucket per key (allows short bursts) with a secondary per-IP limit for unauthenticated traffic.
  • Return 429 with Retry-After (RFC 9110), and advertise your policy in RateLimit headers — the IETF draft standardizing these (RateLimit-Policy plus current-window state) is worth adopting now, since client libraries and agent frameworks increasingly parse them for automatic backoff.
  • Make the 429 body a problem+json document that states the limit and the reset time in prose, not just headers.
  • Separate limits by cost class: a search endpoint and a bulk-export endpoint should not share a bucket.

Beyond rate limits: cap request body sizes, cap pagination limit parameters, set aggressive upstream timeouts, and put a concurrency ceiling on anything expensive. Abuse protection is mostly about making the worst-case request boring.

Webhooks done right

Webhooks are the API you push, and they fail in ways request/response APIs don't. Three rules:

Sign every delivery. HMAC-SHA256 over a timestamp plus the raw body, timestamp included in the signed payload to kill replay attacks. Verification on the consumer side:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(
  secret: string,
  signatureHeader: string, // "t=1717315200,v1=5257a869e7..."
  rawBody: string,
  toleranceSeconds = 300
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=", 2) as [string, string])
  );
  const timestamp = Number(parts["t"]);
  const received = parts["v1"];
  if (!timestamp || !received) return false;

  // Reject stale deliveries (replay protection)
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Note the details that bite in production: verify against the raw body (any JSON re-serialization breaks the HMAC), use a constant-time comparison, and enforce a timestamp tolerance. The Standard Webhooks specification codifies this scheme if you'd rather adopt a convention than invent one.

Retry with backoff, and expect it to fail. Exponential backoff over hours-to-days, a dead-letter state visible in a dashboard, and a manual redelivery button. Consumers must return 2xx fast — accept, enqueue, process async.

Design for idempotent consumers. Every event carries a unique event_id; consumers dedupe on it, because at-least-once delivery means duplicates are a certainty, not an edge case. Deliver events as thin notifications (invoice.paid + ID) and let consumers fetch the current resource — it avoids processing stale payloads out of order.

The new consumer: AI agents

Here's the shift that motivated this whole post. The fastest-growing consumer of the APIs we ship is not a mobile app or a partner integration — it's an LLM-driven agent, calling tools in a loop, deciding its next request based on the text of your last response. Agents don't read your beautiful docs site. They read whatever is in their context window: a tool schema, and your responses.

This changes the economics of API quality in a way that's easy to state: your error messages are now prompts. When a human hits {"error": "invalid request"}, they sigh and open the docs. When an agent hits it, it guesses — and retries, and guesses again, burning tokens and rate limit against a wall your API built. When an agent instead hits the problem+json body from earlier — "requires 120 credits, workspace has 45, purchase at POST /v1/credit-purchases or reduce the date range" — it can often self-correct in one step. Every design practice in this post compounds here: resource-oriented URLs are guessable, RFC 9457 errors are recoverable, RateLimit headers are machine-parseable backoff instructions, and idempotency keys make agent retry loops safe instead of terrifying.

MCP as a wrapper over your API

The Model Context Protocol — introduced by Anthropic in late 2024 and since adopted across the major agent ecosystems — has become the standard way to expose capabilities to agents. The practical guidance: MCP is a presentation layer, not a replacement API. Build your REST API well, then wrap it in an MCP server that exposes a curated set of tools. Don't machine-translate all 200 endpoints into 200 tools; agents choose poorly from huge flat tool lists, and every tool description consumes context. Ten well-described tools mapping to real user intents ("find unpaid invoices for a customer") outperform an exhaustive mirror of your endpoint list. Because MCP tools are just described functions, a clean OpenAPI document with good operationIds, summary fields, and real request/response examples gets you most of the way there — another return on the contract-first investment.

Self-describing and forgiving

Two properties define an agent-ready endpoint:

Self-describing: the response teaches the next step. Include stable IDs the agent can use in follow-up calls, echo back interpreted parameters ("period resolved to 2026-05-01..2026-05-31"), and make list responses state their own pagination (has_more, next_cursor) rather than relying on out-of-band knowledge.

Forgiving: accept reasonable variation where it's unambiguous — but say so in the response, and stay strict wherever money moves or data is destroyed. A validation error should name the field, show the received value, state the expected format, and give a valid example. That's not agent-special design; it's what good errors always looked like. Agents just made the ROI undeniable.

llms.txt and machine-readable docs

The llms.txt convention — proposed by Jeremy Howard in 2024 — is a markdown file at your site root that gives LLMs a curated map of your documentation, with links to plain-markdown versions of each page. Adoption across developer-tools companies has been steadily growing, and it's cheap to do well: a one-paragraph description of your API, links to the OpenAPI spec, auth setup, and the five pages that answer 90% of questions. Serve your docs as clean markdown at stable URLs (many docs platforms now expose a .md variant per page), keep your OpenAPI document publicly fetchable, and put runnable curl examples in it. The measure of success: an agent that has never seen your product, given only your base URL and a key, completes a real task.

The design checklist

Before an API of ours ships, it answers yes to these:

  • Resources are nouns; long-running work is an operation resource with 202 Accepted + Location.
  • Cursor pagination with opaque tokens; has_more in every list response.
  • Idempotency-Key supported on every unsafe POST; stored responses replayed on retry.
  • PATCH with JSON Merge Patch semantics, documented.
  • Every error is RFC 9457 problem+json with a stable type, quantified detail, and a stated remedy.
  • /v1/ in the path; additive-only changes within a version; written deprecation policy with Sunset headers.
  • OpenAPI document is CI-enforced against the implementation; clients and types are generated, not handwritten.
  • Auth is scoped from day one; keys are prefixed, hashed at rest, and rotatable without downtime.
  • Every endpoint is rate limited; 429s carry Retry-After and a problem+json body; costly endpoints have their own buckets.
  • Webhooks are HMAC-signed with timestamps, retried with backoff, and consumers are documented as dedupe-on-event_id.
  • An llms.txt exists; docs are fetchable as markdown; the OpenAPI spec is public.
  • The agent test passes: a capable agent with only the base URL, a key, and the spec can complete a core task unassisted.

Takeaways

  • REST+JSON won the public-API default on legibility and infrastructure fit; that legibility is now doubly valuable because agents consume it.
  • GraphQL is a tool for many-client organizations, not a default; most single-client teams that adopted it spent their complexity budget re-implementing what HTTP gave them for free.
  • gRPC belongs inside the trust boundary; keep the public edge HTTP+JSON.
  • Cursor pagination, idempotency keys, and RFC 9457 problem+json errors are the three cheapest investments with the longest payoff.
  • URL path versioning won because it's visible, routable, and hard to get wrong — pragmatism beat purity.
  • Treat OpenAPI as the enforced contract: spec-first for public surfaces, CI-diffed always, everything generated from it.
  • Scope auth from day one; agents holding coarse-grained credentials are an incident report in waiting.
  • Sign webhooks over timestamp + raw body, retry with backoff, and require consumers to dedupe on event ID.
  • Agents are the fastest-growing API consumer, and they read responses, not docs — your error messages are now prompts, so make them name the problem and the remedy.
  • MCP is a curated presentation layer over a good REST API, not a substitute for one; llms.txt plus markdown docs plus a public spec makes your API discoverable by machines.

Enjoyed the read? We build this stuff for clients too.

Start a project