Skip to content

Web Services & APIs — How-to Guides

Task-oriented recipes for describing, securing, versioning, testing, documenting, and operating web APIs. Each section answers "how do I do X?" with commands and config you can adapt. For concepts and trade-offs, see Explanation. For versions, status codes, headers, and checklists, see Reference.

Placeholders

Hostnames such as api.example.com, IDs such as 01HXYZ, and variables such as $TOKEN are placeholders. Commands were checked against the tool versions listed in Reference — Tooling Versions.


API Specification Formats

Specifications are machine-readable contracts for APIs. They enable codegen, mock servers, linting, and documentation.

OpenAPI 3.1 and 3.2 (REST)

OpenAPI is the industry standard for describing HTTP APIs. The latest release is 3.2.1 (2026-09-10, patch of 3.2.0 from 2025-09-19). Version 3.1 aligned the Schema Object with JSON Schema 2020-12. Version 3.2 adds streaming, the QUERY method, and hierarchical tags. Tool support for 3.2 is still catching up in 2026, so check your generator, linter, and docs renderer before you switch the openapi field. Tools ignore the patch number (3.1.0 and 3.1.2 mean the same feature set).

openapi: 3.1.1
info:
  title: Orders API
  version: 2.4.0
  contact:
    email: api@example.com
  license:
    name: Apache 2.0
servers:
  - url: https://api.example.com/v2
    description: Production
  - url: https://sandbox.api.example.com/v2
    description: Sandbox

paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      summary: Retrieve a single order
      tags: [Orders]
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Order found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "404":
          $ref: "#/components/responses/NotFound"
      security:
        - bearerAuth: []

components:
  schemas:
    Order:
      type: object
      required: [id, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, confirmed, shipped, delivered, cancelled]
        createdAt:
          type: string
          format: date-time
  responses:
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

Key OpenAPI 3.1 changes over 3.0: - Full JSON Schema 2020-12 alignment (replaces OpenAPI's extended subset). nullable: true is gone; use type: [string, "null"] - webhooks top-level field for describing webhooks your API sends - const, examples, and per-schema $schema; jsonSchemaDialect at the root - exclusiveMinimum/exclusiveMaximum are now numbers (not booleans) - paths is optional, so a document can hold only components or webhooks

Describe an SSE stream and a QUERY operation (OpenAPI 3.2):

openapi: 3.2.0
info:
  title: Orders API
  version: 2.5.0
paths:
  /orders/events:
    get:
      summary: Stream order events
      responses:
        "200":
          description: Server-Sent Events stream
          content:
            text/event-stream:
              itemSchema:            # applies to each event, not the whole stream
                type: object
                properties:
                  event: { type: string }
                  data: { type: string }
  /orders/search:
    query:                           # HTTP QUERY method (RFC 10008)
      summary: Search orders with a JSON filter body
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderFilter"
      responses:
        "200":
          description: Matching orders

The full 3.0 / 3.1 / 3.2 feature comparison is in Reference — OpenAPI 3.x Feature Matrix.

AsyncAPI 3.x (Event-Driven APIs)

AsyncAPI is the OpenAPI equivalent for WebSocket, MQTT, Kafka, AMQP, and SNS/SQS APIs. The latest release is 3.1.0 (2026-01-31), a non-breaking minor that adds the ROS 2 binding. Bumping asyncapi: 3.0.0 to 3.1.0 requires no other change. Version 3.0 separated channels from operations (send/receive), unlike 2.x's publish/subscribe.

asyncapi: 3.1.0
info:
  title: Order Events API
  version: 1.0.0
channels:
  orderCreated:
    address: orders.created
    messages:
      OrderCreated:
        payload:
          type: object
          properties:
            orderId:
              type: string
            customerId:
              type: string
operations:
  onOrderCreated:
    action: receive
    channel:
      $ref: "#/channels/orderCreated"

Protocol Buffers IDL (gRPC)

See Explanation — Protocol Buffers for the full .proto format. The .proto file IS the API spec for gRPC services.

Validate an AsyncAPI document with the AsyncAPI CLI:

npx @asyncapi/cli validate asyncapi.yaml

Codegen, mock, lint, and breaking-change tools per format are listed in Reference — Specification Tooling Matrix.


API Gateways

An API gateway is the single entry point for all client traffic. It handles routing, auth enforcement, rate limiting, observability, and protocol translation.

flowchart LR
    C1[Mobile client] --> GW["API gateway<br/>Kong / Envoy"]
    C2[Browser] --> GW
    C3[Partner API] --> GW
    GW -->|"/v2/orders"| OS[Orders service]
    GW -->|"/v2/users"| US[User service]
    GW -->|"/v2/products"| PS[Product service]
    GW -->|"JWT validation, JWKS"| Auth["OAuth / OIDC server"]
    GW -->|"counters"| RL["Rate limit store<br/>Redis"]
    GW -->|"OTLP traces, metrics"| Log["Observability<br/>OpenTelemetry Collector"]

Kong Gateway

Kong Gateway is built on NGINX + OpenResty (Lua). The open-source edition is Apache-2.0. The public Kong/kong repository's latest changelog entry is 3.9.3 (checked 2026-09). Kong Gateway Enterprise and the Konnect SaaS control plane add RBAC, the Dev Portal, analytics, and advanced plugins such as rate-limiting-advanced.

# Kong declarative config (deck format)
services:
  - name: orders-service
    url: http://orders-service:8080
    plugins:
      - name: rate-limiting
        config:
          minute: 1000
          policy: redis
          redis:
            host: redis        # config.redis_host is deprecated since Kong 3.6
      - name: jwt
        config:
          claims_to_verify: [exp]
    routes:
      - name: orders-route
        paths: [/v2/orders]
        strip_path: false
        methods: [GET, POST, PUT, PATCH, DELETE]
# Sync the declarative file to a running gateway
deck gateway sync kong.yaml

# Kong Admin API — add the correlation-id plugin (generates X-Request-ID per request)
curl -X POST http://kong:8001/routes/orders-route/plugins \
  --data name=correlation-id \
  --data config.header_name=X-Request-ID \
  --data config.generator=uuid

Envoy Proxy

Envoy is a high-performance C++ proxy created at Lyft and a CNCF graduated project (2018). It is the data plane of Istio and of Envoy Gateway (the Kubernetes Gateway API implementation). You configure it via xDS APIs (dynamic) or static YAML. The filter below sends rate-limit decisions to an external rate limit service (for example envoyproxy/ratelimit).

# Envoy static config — HTTP rate limit filter
http_filters:
  - name: envoy.filters.http.ratelimit
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
      domain: orders_api
      rate_limit_service:
        grpc_service:
          envoy_grpc:
            cluster_name: rate_limit_service
        transport_api_version: V3

AWS API Gateway

AWS API Gateway is a managed gateway for REST, HTTP, and WebSocket APIs. It integrates natively with Lambda, ALB, and VPC Link.

# Create HTTP API (simpler, lower cost than REST API)
aws apigatewayv2 create-api \
  --name orders-api \
  --protocol-type HTTP \
  --target arn:aws:lambda:us-east-1:123456789:function:orders-handler

# Add JWT authorizer
aws apigatewayv2 create-authorizer \
  --api-id abc123 \
  --authorizer-type JWT \
  --identity-source '$request.header.Authorization' \
  --jwt-configuration Audience=orders-api,Issuer=https://auth.example.com \
  --name JwtAuthorizer

A side-by-side gateway comparison (deployment, config model, rate-limit algorithm) is in Reference — API Gateway Comparison.


Authentication and Authorization

API Keys

API keys are the simplest scheme. They suit server-to-server or developer access where OAuth overhead is unneeded.

GET /v2/orders HTTP/1.1
X-API-Key: sk_live_a1b2c3d4e5f6

Best practices: - Prefix keys by environment: sk_live_, sk_test_ - Store only the hash (SHA-256) in database — never plaintext - Rotate on compromise. Provide a 30-day grace period during planned rotations - Associate keys with scopes: orders:read, orders:write

JWT (JSON Web Tokens)

JWTs are stateless bearer tokens. They have three base64url-encoded parts: header, payload, signature.

# Decode the JWT payload without verification (debugging only).
# JWTs use unpadded base64url, so translate the alphabet and re-add padding first.
TOKEN="eyJhbGci..."
p=$(echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+'); while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
echo "$p" | base64 -d | jq
// Payload claims
{
  "sub": "user_01HXYZ",
  "iss": "https://auth.example.com",
  "aud": "orders-api",
  "exp": 1745600000,
  "iat": 1745596400,
  "scope": "orders:read orders:write",
  "jti": "01HXYZ-unique-token-id"
}

JWT security checklist: - Use an asymmetric algorithm (ES256, EdDSA, or RS256) so resource servers only need the public key from the JWKS endpoint. Keep HS256 for single-party setups - Allowlist the expected algorithm on the verifier; reject alg: none (RFC 8725) - Short expiry: 5–15 minutes for access tokens. Browser apps should keep refresh tokens out of JavaScript (httpOnly cookie via a BFF) - Validate iss, aud, exp, nbf on every request - Include jti (JWT ID) for revocation lookup in Redis blocklist - Never store sensitive data in payload — JWTs are encoded, not encrypted (use JWE for confidentiality)

OAuth 2.0 / OAuth 2.1

Authorization Code + PKCE (browser and mobile clients):

sequenceDiagram
    participant U as User
    participant C as Client App
    participant AS as Auth Server
    participant RS as Resource Server

    C->>C: Generate code_verifier, code_challenge = SHA256(verifier)
    C->>AS: GET /authorize?response_type=code&client_id=...&code_challenge=...
    AS->>U: Login + Consent screen
    U->>AS: Approve
    AS->>C: Redirect with ?code=AUTH_CODE
    C->>AS: POST /token {code, code_verifier, client_id}
    AS->>C: {access_token, refresh_token, expires_in}
    C->>RS: GET /orders Authorization: Bearer ACCESS_TOKEN
    RS->>C: 200 {orders: [...]}

Client Credentials (machine-to-machine):

curl -X POST https://auth.example.com/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=service-account \
  -d client_secret=secret \
  -d scope="orders:read inventory:write"

OAuth 2.1 key changes (still an IETF Internet-Draft; draft-ietf-oauth-v2-1-16, 2026-09-03): - PKCE required for all clients using the authorization code grant (confidential clients too) - Implicit grant removed - Resource Owner Password Credentials (ROPC) grant removed - Redirect URIs must match exactly (no wildcards) - Bearer tokens must not be sent in query strings - Refresh tokens for public clients must be sender-constrained or one-time use (rotation)

You do not need to wait for the RFC: RFC 9700 (OAuth 2.0 Security Best Current Practice, January 2025) already recommends the same profile for OAuth 2.0 deployments.

Sender-constrained tokens with DPoP (RFC 9449) bind an access token to a client key pair, so a stolen token cannot be replayed from another machine:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&code_verifier=...

GET /v2/orders HTTP/1.1
Authorization: DPoP <access_token>
DPoP: <new proof JWT signed for this method and URL>

mTLS (Mutual TLS)

Both client and server present certificates. This removes shared secrets for service-to-service auth.

# Generate client cert signed by your CA
openssl req -new -key client.key -out client.csr \
  -subj "/CN=orders-service/O=internal"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out client.crt -days 365

# Call API with client cert
curl --cert client.crt --key client.key \
  --cacert ca.crt \
  https://internal-api.example.com/v2/orders

In Kubernetes: use SPIFFE/SPIRE for automatic workload identity, or let Istio inject mTLS transparently via sidecar.


API Versioning

Versioning Strategies

Strategy Example Pros Cons
URI path /v2/orders Most visible, easy routing Breaks resource identity
Query param /orders?version=2 Non-breaking URL Easily forgotten, cache unfriendly
Header API-Version: 2024-01-01 Clean URLs Less discoverable
Content negotiation Accept: application/vnd.api+json;version=2 RFC-compliant Complex client setup

URI versioning is the most common choice for public APIs (used by Stripe, Twilio, and others). Stripe combines a coarse URI version (/v1, /v2) with a fine-grained dated header (Stripe-Version: 2026-08-26.dahlia). GitHub's REST API uses a dated header, X-GitHub-Api-Version: 2022-11-28.

Calendar-Based Versioning (Stripe Pattern)

Instead of major version bumps, every breaking change ships in a dated version:

GET /v1/charges HTTP/1.1
Stripe-Version: 2026-08-26.dahlia

Since late 2024 Stripe names versions YYYY-MM-DD.<release>: a named major release (for example acacia, basil, clover, dahlia) carries breaking changes about twice a year, and monthly versions within a release are backward-compatible. A Stripe account is pinned to the version current at its first API request, and requests can override it per call with the header. Server-side, a chain of version "transformers" converts responses from the latest internal shape back to each pinned version.

Deprecation and Sunset Headers (RFC 9745, RFC 8594)

HTTP/1.1 200 OK
Deprecation: @1767225600
Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Link: <https://docs.example.com/deprecations/v2>; rel="deprecation"; type="text/html"
Link: <https://docs.example.com/migration/v3>; rel="successor-version"
  • Deprecation (RFC 9745, March 2025): a Structured Field Date — @ plus Unix seconds (@1767225600 = 2026-01-01T00:00:00Z). A date in the past means "already deprecated"; a future date announces it
  • Sunset (RFC 8594): an HTTP-date after which the resource may stop responding. It must not be earlier than the Deprecation date
  • Link with rel="deprecation" (RFC 9745) points to human-readable deprecation notes. rel="successor-version" points to the replacement
  • After the sunset date, return 410 Gone with a Problem Details body that links to the migration guide

Non-Breaking vs Breaking Changes

Non-breaking (safe to ship): - Adding optional request fields - Adding new response fields - Adding new endpoints - New enum values (unless clients use exhaustive matching)

Breaking (require new version): - Removing or renaming fields - Changing field types - Changing HTTP method for an operation - Altering authentication requirements - Removing enum values


Rate Limiting

Rate limiting protects services from abuse. It enforces fair usage and supports monetization tiers.

Algorithms

Token Bucket (allow bursting):

capacity = 100 tokens
refill_rate = 10 tokens/second

on request:
  if tokens >= cost:
    tokens -= cost
    return ALLOW
  else:
    return 429 Too Many Requests

AWS API Gateway throttles with a token bucket (rate = refill, burst = bucket size). Kong's open-source rate-limiting plugin uses fixed-window counters per second/minute/hour/day. The Enterprise rate-limiting-advanced plugin adds sliding windows. NGINX limit_req is a leaky bucket.

Sliding Window Log (most precise):

This algorithm stores the timestamp of each request. It counts requests within [now - window, now]. Memory cost is high at scale.

Sliding Window Counter (approximation, low memory):

rate = (prev_count × (1 - elapsed/window)) + curr_count

Redis-based implementation: two counters (current window, previous window) per key.

Fixed Window (simplest, boundary spike risk):

This algorithm resets the counter at fixed intervals. A burst at 11:59:59 and 12:00:01 yields 2× the allowed rate.

Response Headers

Most APIs today send the de facto X-RateLimit-* headers. The IETF RateLimit-Policy / RateLimit header fields are still an Internet-Draft (draft-ietf-httpapi-ratelimit-headers-11, May 2026) and their syntax has changed between drafts, so treat them as optional extras, not a replacement. Retry-After (RFC 9110) is the one standard header every client should honor.

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1745600000
Retry-After: 30

On 429:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1745600000
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/rate-limit-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "You have exceeded 1000 requests per minute."
}

Rate Limit Keys

Choose the right granularity:

Key Use Case
IP address Unauthenticated public APIs, DDoS protection
API key Developer tier enforcement
User ID Per-account limits after auth
Endpoint Expensive operations (for example, /search)
Tenant ID SaaS multi-tenant isolation

CORS (Cross-Origin Resource Sharing)

CORS restricts which browser origins can call your API. It does NOT protect server-to-server calls.

# Preflight request (browser auto-sends for non-simple requests)
OPTIONS /v2/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type

# Server response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true

Critical rules: - Never set Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true — browsers reject the response. Echo the specific allowed origin and add Vary: Origin - Never reflect the request Origin header without checking it against the allowlist; that is equivalent to * with credentials - Maintain an allowlist of trusted origins. Validate dynamically against it - Cache preflight with Access-Control-Max-Age to reduce OPTIONS overhead


API Design Best Practices

Resource Naming

# Good — noun-based, plural, lowercase
GET    /v2/orders
POST   /v2/orders
GET    /v2/orders/{orderId}
PUT    /v2/orders/{orderId}
PATCH  /v2/orders/{orderId}
DELETE /v2/orders/{orderId}

# Nested resources — use sparingly; max 2 levels deep
GET /v2/orders/{orderId}/items
POST /v2/orders/{orderId}/items

# Actions (verbs) — use only for operations that don't map to CRUD
POST /v2/orders/{orderId}/cancel
POST /v2/orders/{orderId}/refund
POST /v2/payments/{paymentId}/capture

Idempotency Keys

When clients retry on network failure, prevent duplicate processing.

POST /v2/orders HTTP/1.1
Idempotency-Key: 01HXYZ-unique-request-id
Content-Type: application/json

{"productId": "prod_123", "quantity": 2}
Server logic:
1. Hash Idempotency-Key → look up in idempotency store (Redis/DB)
2. If found and result cached → return cached response immediately
3. If found and in-flight → return 409 Conflict or wait
4. If not found → process, store result keyed to hash, return result

TTL: 24–48 hours (Stripe prunes keys after at least 24 hours)

Also store a fingerprint of the request body with the key. If a client reuses a key with a different body, return 422 instead of replaying the old response. The Idempotency-Key header is being standardized in draft-ietf-httpapi-idempotency-key-header (Internet-Draft, latest -07, 2025-10).

Pagination

Cursor-based (recommended for large/real-time datasets):

// Request: GET /v2/orders?limit=20&after=01HXYZ
{
  "data": [...],
  "pagination": {
    "limit": 20,
    "hasNextPage": true,
    "nextCursor": "01HABC",
    "hasPrevPage": true,
    "prevCursor": "01HWXY"
  }
}

Offset-based (simpler, do not use it for real-time data — page drift on writes):

// Request: GET /v2/orders?limit=20&offset=40
{
  "data": [...],
  "pagination": {
    "total": 1847,
    "limit": 20,
    "offset": 40,
    "pages": 93
  }
}

Standardized Error Responses (RFC 9457 / Problem Details)

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "Request body contains invalid fields.",
  "instance": "/v2/orders/01HXYZ",
  "errors": [
    {
      "field": "quantity",
      "message": "Must be a positive integer",
      "code": "INVALID_VALUE"
    },
    {
      "field": "productId",
      "message": "Product not found",
      "code": "RESOURCE_NOT_FOUND"
    }
  ],
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

Always include traceId or requestId for support/debugging correlation.

Long-Running Operations (202 Async Pattern)

# 1. Client submits job
POST /v2/reports HTTP/1.1
{"type": "monthly-revenue", "month": "2026-03"}

# 2. Server accepts immediately
HTTP/1.1 202 Accepted
Location: /v2/reports/jobs/job_01HXYZ
Retry-After: 30

# 3. Client polls
GET /v2/reports/jobs/job_01HXYZ

# 4a. Still processing
HTTP/1.1 200 OK
{"status": "processing", "progress": 42, "estimatedCompletion": "2026-04-25T10:15:00Z"}

# 4b. Complete
HTTP/1.1 200 OK
{"status": "complete", "resultUrl": "/v2/reports/rpt_01HABC", "expiresAt": "2026-04-26T10:00:00Z"}

# 5. Retrieve result
GET /v2/reports/rpt_01HABC

Alternative: use webhook callback instead of polling — POST /v2/reports body includes callbackUrl.

Filtering, Sorting, Searching

# Filtering — use query params
GET /v2/orders?status=pending&customerId=cust_123&createdAfter=2026-01-01

# Sorting — field and direction
GET /v2/orders?sort=-createdAt,+status   # minus = desc, plus = asc

# Sparse fieldsets — reduce payload size
GET /v2/orders?fields=id,status,total

# Full-text search
GET /v2/products?q=wireless+headphones&category=electronics

API First Design

Design the API contract before writing implementation code.

Workflow: 1. Write OpenAPI spec in YAML (use Spectral to lint against rules) 2. Generate mock server with Prism: prism mock openapi.yaml 3. Share mock URL with frontend team — both sides develop in parallel 4. Generate server stubs with oapi-codegen (Go), openapi-generator (Java, Python, and more) 5. Write implementation against generated interfaces 6. Run contract tests against live server to verify spec compliance

# Prism mock server (read OpenAPI spec, serve mock responses)
npx @stoplight/prism-cli mock openapi.yaml --port 4010

# Call mock
curl http://localhost:4010/v2/orders/01HXYZ \
  -H "Authorization: Bearer test-token"

# Prism validation proxy (forward to real server, validate request/response against spec)
npx @stoplight/prism-cli proxy openapi.yaml http://localhost:8080

Testing

REST API Testing (curl)

# GET with auth header and pretty JSON
curl -s -X GET https://api.example.com/v2/orders/01HXYZ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" | jq

# POST with JSON body
curl -s -X POST https://api.example.com/v2/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"productId": "prod_123", "quantity": 2}' | jq

# Test rate limiting — fire 10 requests rapidly
for i in {1..10}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" \
    https://api.example.com/v2/orders
done

# Inspect headers only
curl -sI https://api.example.com/v2/orders

# Follow redirects, show timing breakdown
curl -s -o /dev/null -L \
  -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
  https://api.example.com/v2/orders

# Force HTTP/3 (needs a curl build with HTTP/3 support; check `curl -V` for HTTP3)
curl -sI --http3-only https://api.example.com/v2/orders

gRPC Testing (grpcurl)

# Install (or: go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest)
brew install grpcurl

# List services (server reflection must be enabled)
grpcurl -plaintext localhost:50051 list

# Describe a service
grpcurl -plaintext localhost:50051 describe orders.OrderService

# Unary call
grpcurl -plaintext \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"order_id": "01HXYZ"}' \
  localhost:50051 orders.OrderService/GetOrder

# Server streaming call
grpcurl -plaintext \
  -d '{"customer_id": "cust_123"}' \
  localhost:50051 orders.OrderService/WatchOrders

# Call with mTLS (flags must come before the address)
grpcurl \
  -cert client.crt -key client.key -cacert ca.crt \
  -d '{"order_id": "01HXYZ"}' \
  api.example.com:443 orders.OrderService/GetOrder

# Health check (grpc.health.v1): overall server, then one service
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
grpcurl -plaintext -d '{"service": "orders.OrderService"}' \
  localhost:50051 grpc.health.v1.Health/Check

# Auth checks: no token, invalid token, valid token
grpcurl -plaintext localhost:50051 orders.OrderService/GetOrder
grpcurl -plaintext -H "authorization: Bearer invalid_token" \
  localhost:50051 orders.OrderService/GetOrder
grpcurl -plaintext -H "authorization: Bearer $TOKEN" \
  -d '{"order_id": "01HXYZ"}' localhost:50051 orders.OrderService/GetOrder

Expect Unauthenticated (code 16) for the first two calls. Status code meanings are in Reference — gRPC Status Codes.

Kubernetes gRPC probe (stable since Kubernetes v1.27; the container must serve grpc.health.v1.Health):

livenessProbe:
  grpc:
    port: 50051
    service: ""          # empty = overall server health
  initialDelaySeconds: 10
  periodSeconds: 10
readinessProbe:
  grpc:
    port: 50051
    service: orders.OrderService

WebSocket Testing (wscat)

# Install
npm install -g wscat

# Connect to WebSocket server
wscat -c wss://api.example.com/ws \
  --header "Authorization: Bearer $TOKEN"

# Send a message (after connecting)
> {"type": "subscribe", "channel": "orders", "customerId": "cust_123"}
< {"type": "subscribed", "channel": "orders"}
< {"type": "order.updated", "orderId": "01HXYZ", "status": "shipped"}

# Connect with subprotocol
wscat -c wss://api.example.com/ws --subprotocol "v2.orders"

GraphQL Testing (curl + jq)

# Introspection query
curl -s -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ __schema { types { name } } }"}' | jq '.data.__schema.types[].name'

# Query with variables
curl -s -X POST https://api.example.com/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetOrder($id: ID!) { order(id: $id) { status total } }",
    "variables": {"id": "01HXYZ"}
  }' | jq

# Mutation
curl -s -X POST https://api.example.com/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CancelOrder($id: ID!) { cancelOrder(id: $id) { success } }",
    "variables": {"id": "01HXYZ"}
  }' | jq

Load Testing (k6)

// k6 load test script — orders API
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate } from "k6/metrics";

const errorRate = new Rate("errors");

export const options = {
  stages: [
    { duration: "30s", target: 50 },   // ramp up to 50 VUs
    { duration: "2m", target: 50 },    // hold
    { duration: "30s", target: 200 },  // spike to 200 VUs
    { duration: "1m", target: 200 },   // hold spike
    { duration: "30s", target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ["p(95)<500"],  // 95th percentile < 500ms
    errors: ["rate<0.01"],             // error rate < 1%
  },
};

export default function () {
  const res = http.get("https://api.example.com/v2/orders", {
    headers: { Authorization: `Bearer ${__ENV.API_TOKEN}` },
  });
  const ok = check(res, {
    "status is 200": (r) => r.status === 200,
    "response time < 500ms": (r) => r.timings.duration < 500,
  });
  errorRate.add(!ok);
  sleep(1);
}
k6 run --env API_TOKEN=$TOKEN load-test.js

Contract Testing (Pact)

Consumer-driven contract tests verify that API providers honour the contracts that consumers expect.

# Consumer tests (Pact JS, pytest-pact, ...) write pact files to ./pacts
# CLI tools ship in @pact-foundation/pact-cli (pact-broker, pact-verifier, ...)
npm install -D @pact-foundation/pact-cli

# Publish consumer pacts to the Pact Broker / PactFlow
npx pact-broker publish ./pacts \
  --broker-base-url https://your-pact-broker.example.com \
  --consumer-app-version "$(git rev-parse HEAD)" \
  --branch "$(git rev-parse --abbrev-ref HEAD)"

# Provider side: verify all pacts for this provider against a running service
npx pact-verifier \
  --hostname localhost --port 8080 \
  --broker-url https://your-pact-broker.example.com \
  --provider-name orders-service \
  --publish --provider-version "$(git rev-parse HEAD)"

Monitoring and Observability

Key Metrics (RED Method)

Metric Description Alert Threshold (example)
Rate Requests per second Traffic drop > 50% vs baseline
Errors 5xx error rate > 1% over 5 minutes
Duration p50, p95, p99 latency p99 > 1000ms

Additional API-specific metrics: - 4xx rate (client errors) — a spike can indicate a breaking change or client bug - Auth failure rate — spike indicates credential attack or misconfiguration - Rate limit hit rate (429 responses) — indicate capacity planning needs - Payload size distribution — detect runaway requests

Distributed Tracing (OpenTelemetry)

# Node.js — auto-instrumentation with OTLP export
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node

# Inject trace context headers
GET /v2/orders HTTP/1.1
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: rend=congo

Propagate traceparent across all service boundaries. Every response must include X-Request-ID or X-Trace-ID tied to the trace.

Structured Logging

{
  "level": "info",
  "timestamp": "2026-04-25T10:00:00.123Z",
  "service": "orders-api",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "method": "GET",
  "path": "/v2/orders/01HXYZ",
  "statusCode": 200,
  "durationMs": 47,
  "customerId": "cust_123",
  "region": "us-east-1"
}

Health Endpoints

# Liveness — is the process alive?
GET /health/live
HTTP/1.1 200 OK
{"status": "ok"}

# Readiness — is the service ready to receive traffic?
GET /health/ready
HTTP/1.1 200 OK
{
  "status": "ok",
  "checks": {
    "database": "ok",
    "cache": "ok",
    "dependencyServiceA": "ok"
  }
}

# Degraded state
HTTP/1.1 503 Service Unavailable
{
  "status": "degraded",
  "checks": {
    "database": "ok",
    "cache": "error",
    "dependencyServiceA": "ok"
  }
}

Circuit Breaker Pattern

The circuit breaker prevents cascading failures when a downstream dependency is degraded.

The state machine below uses example thresholds. Tune them per dependency.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: failure rate over 50% in last 10 calls
    Open --> HalfOpen: cooldown elapsed (for example 30 s)
    HalfOpen --> Closed: 3 consecutive probe successes
    HalfOpen --> Open: any probe failure
    note right of Open
        Calls fail fast (503 or cached fallback)
        without touching the dependency
    end note

Libraries: Resilience4j (Java), Polly (.NET), opossum (Node.js), gobreaker (Go). Service meshes provide a coarser version without code changes: Envoy/Istio outlier detection ejects failing endpoints from the load-balancing pool.


Webhooks as a Product

For APIs that offer webhooks, treat the delivery system as a first-class product. Why delivery is at-least-once and how signatures prevent forgery is explained in Explanation — Webhooks. The Standard Webhooks spec (headers webhook-id, webhook-timestamp, webhook-signature) is a good default wire format; the Python below uses the similar Stripe-style t=...,v1=... header.

Delivery Architecture

sequenceDiagram
    participant ES as Event Source
    participant Q as Message Queue
    participant WD as Webhook Dispatcher
    participant C as Customer Server

    ES->>Q: Publish event
    Q->>WD: Consume event
    WD->>C: POST /webhook (signed payload)
    alt Success (2xx)
        C->>WD: 200 OK (within 5s)
        WD->>Q: Ack message
    else Failure / Timeout
        WD->>Q: Nack / retry
        WD->>WD: Exponential backoff (5s, 25s, 125s, ...)
        WD->>WD: After 72h: mark dead, alert
    end

Payload Signing (HMAC-SHA256)

import hashlib, hmac, time

def sign_payload(secret: str, payload: bytes) -> str:
    timestamp = str(int(time.time()))
    message = f"{timestamp}.{payload.decode()}".encode()
    signature = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return f"t={timestamp},v1={signature}"

def verify_signature(secret: str, payload: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > tolerance:
        return False  # replay attack
    message = f"{timestamp}.{payload.decode()}".encode()
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Reliability Patterns

Pattern Implementation
Idempotency keys Include webhookId in payload. The consumer deduplicates
Immediate 200 Return 200 before processing. Use a queue for async work
Retry with backoff 5s → 25s → 125s → 625s. Max 72h delivery window
Dead letter queue After max retries, route to DLQ. Alert the operator
Event ordering Include sequence counter. The consumer handles out-of-order delivery
Event replay Let consumers re-request past events by ID or time range after an outage
CloudEvents format Standardize payload envelope (specversion, type, source, id)

Webhook Management Portal (product features)

  • Endpoint registration with per-event-type subscription
  • Delivery attempt log with request/response bodies (last 30 days)
  • Manual replay of failed deliveries
  • HMAC secret rotation (grace period supporting both old + new key)
  • 200 OK webhook test endpoint for validation

API Caching Strategies

Caching is the single most impactful API performance optimization. Multiple layers can cache independently.

Caching Layers

flowchart LR
    C[Client] -->|1| BC["Browser cache<br/>Cache-Control"]
    BC -->|2| CDN["CDN edge<br/>Cloudflare / CloudFront / Fastly"]
    CDN -->|3| GW["Gateway / reverse proxy<br/>Varnish / NGINX"]
    GW -->|4| APP["Application cache<br/>Redis / Memcached"]
    APP -->|5| DB[("Database<br/>buffer cache")]

Cache-Control Patterns

# Immutable asset (hashed filename — never changes)
Cache-Control: public, max-age=31536000, immutable

# Frequently changing API resource
Cache-Control: private, max-age=0, must-revalidate
ETag: "a1b2c3"

# Shared resource (CDN-cacheable)
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
Vary: Accept-Encoding, Authorization

# No caching (sensitive data)
Cache-Control: no-store

stale-while-revalidate — the CDN/proxy serves the stale cached response immediately while fetching a fresh copy in the background. The client gets a fast response. The cache updates asynchronously. This matters for APIs where slight staleness is permitted (product catalogs, search results).

stale-if-error — serve stale content if the origin returns a 5xx error. This gives graceful degradation when the backend is down.

Cache Invalidation Patterns

Pattern How It Works Best For
TTL expiry Cache expires after max-age seconds Simple and predictable. Permitted staleness
Event-driven purge Backend publishes event → CDN/cache purge API called Real-time consistency. More infrastructure
Surrogate keys (tags) Tag cached responses. Purge all responses with a tag Purge all /products/* when inventory changes
Conditional revalidation If-None-Match / If-Modified-Since → 304 or fresh Bandwidth savings. The origin is still hit
# Fastly — purge by surrogate key
curl -X POST https://api.fastly.com/service/SERVICE_ID/purge/product-42 \
  -H "Fastly-Key: $FASTLY_TOKEN"

# CloudFront — invalidation
aws cloudfront create-invalidation \
  --distribution-id E1234 \
  --paths "/v2/products/42" "/v2/products?category=electronics"

GraphQL Caching

The GraphQL POST /graphql endpoint breaks traditional HTTP caching — see apis/web-services/explanation#caching for normalized client cache, APQ, and @cacheControl directive approaches.


Retry Patterns

Retries are essential for resilient API consumers, but naive retries cause retry storms that amplify failures.

Exponential Backoff with Jitter

Attempt 1: wait 0ms (immediate)
Attempt 2: wait random(0, 1000ms)          → e.g., 487ms
Attempt 3: wait random(0, 2000ms)          → e.g., 1,342ms
Attempt 4: wait random(0, 4000ms)          → e.g., 2,891ms
Attempt 5: wait random(0, 8000ms)          → e.g., 5,203ms
Give up after attempt 5

Full jitter (recommended by AWS) prevents thundering herd — all retrying clients spread randomly across the backoff window instead of hitting the server at the same instant.

import random, time

def retry_with_backoff(func, max_retries=5, base_delay=1.0, max_delay=30.0):
    for attempt in range(max_retries):
        try:
            return func()
        except RetryableError:
            if attempt == max_retries - 1:
                raise
            delay = min(base_delay * (2 ** attempt), max_delay)
            jittered = random.uniform(0, delay)  # full jitter
            time.sleep(jittered)

Retry Budgets

Instead of per-request retry limits, set a budget: "retry at most 10% of total requests." This prevents cascading retry storms where every client retries simultaneously during an outage.

If 1000 req/s normally, allow at most 100 retries/s total
When budget exhausted → fail fast instead of retrying

Envoy supports this natively (circuit_breakers.thresholds.retry_budget on a cluster, with budget_percent and min_retry_concurrency). Istio exposes it as trafficPolicy.retryBudget in a DestinationRule (default 20% of active requests, minimum 3 concurrent retries). Linkerd has retry budgets on its retry configuration as well.

Which Errors to Retry

Status Code Retry? Reason
408 Request Timeout Yes Transient timeout
429 Too Many Requests Yes (respect Retry-After) Rate limited. Wait and retry
500 Internal Server Error Yes (cautiously) Can be transient. Limit retries
502 Bad Gateway Yes Upstream briefly unavailable
503 Service Unavailable Yes (respect Retry-After) Server overloaded. Back off
504 Gateway Timeout Yes Upstream timeout
400 Bad Request No Client error. Retry does not help
401/403 No Auth issue. A retry with the same credentials does not help
404 No Resource does not exist
409 Conflict Maybe Re-read state, then maybe retry with updated data
422 No Validation error. Fix the input first

Idempotency Requirement

Only retry non-idempotent operations (POST) if the API supports idempotency keys. Otherwise, a retried POST can create duplicate resources.


SDK and Client Code Generation

Generating typed client SDKs from API specifications eliminates hand-written HTTP calls and catches breaking changes at compile time.

REST — openapi-generator

# Install
npm install -g @openapitools/openapi-generator-cli

# Generate TypeScript client from OpenAPI spec
openapi-generator-cli generate \
  -i https://api.example.com/v2/openapi.yaml \
  -g typescript-fetch \
  -o ./generated/api-client \
  --additional-properties=supportsES6=true,npmName=@example/api-client

# Generate Go server stubs
openapi-generator-cli generate \
  -i openapi.yaml \
  -g go-server \
  -o ./internal/api

TypeScript-only alternatives that generate lighter output: openapi-typescript (types only, pairs with openapi-fetch) and @hey-api/openapi-ts (types + SDK).

npx openapi-typescript openapi.yaml -o src/api/schema.d.ts

oapi-codegen (Go-specific, lighter weight):

# Generate Go types + Echo server from spec
oapi-codegen -package api -generate types,server openapi.yaml > api/api.gen.go

Generated client usage (TypeScript):

import { OrdersApi, Configuration } from '@example/api-client';

const api = new OrdersApi(new Configuration({
  basePath: 'https://api.example.com/v2',
  accessToken: token,
}));

// Fully typed — input and output types from OpenAPI spec
const order = await api.getOrder({ orderId: '01HXYZ' });
// order is typed as Order, not `any`

GraphQL — graphql-codegen

npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'https://api.example.com/graphql',
  documents: 'src/**/*.graphql',
  generates: {
    './src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typed-document-node',
      ],
    },
  },
};
export default config;
npx graphql-codegen

Result: every .graphql query/mutation file produces a fully typed TypedDocumentNode — input variables and response shape are both type-checked at compile time. For React and other frontends, the GraphQL Code Generator docs recommend @graphql-codegen/client-preset, which generates a typed graphql() function for operations written inline in components.

gRPC — buf generate

# Install buf CLI (or: npm install -D @bufbuild/buf)
brew install bufbuild/buf/buf
# buf.gen.yaml — code generation config
version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/connectrpc/go
    out: gen/go
    opt: paths=source_relative
# Generate
buf generate proto/

buf advantages over raw protoc: - Dependency management (BSR — Buf Schema Registry) - buf lint — enforces proto style guide - buf breaking — detects breaking changes between proto versions in CI - buf generate — replaces complex protoc plugin chains


API Documentation Generation

Swagger UI

Swagger UI is interactive documentation from an OpenAPI spec. Users can try API calls directly in the browser.

# Docker — serve Swagger UI for your spec
docker run -p 8080:8080 \
  -e SWAGGER_JSON=/spec/openapi.yaml \
  -v $(pwd):/spec \
  swaggerapi/swagger-ui

# Or embed in Express:
npm install swagger-ui-express
const swaggerUi = require('swagger-ui-express');
const spec = require('./openapi.json');
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));

Redoc

Redoc produces static, clean, three-panel documentation. It is better for public-facing API docs than Swagger UI.

# CLI rendering
npx @redocly/cli build-docs openapi.yaml -o docs/index.html

# Or CDN-hosted single HTML
# <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
# <redoc spec-url="openapi.yaml"></redoc>

Scalar

Scalar is a modern, customizable API reference with a built-in API client. It is a growing alternative to Swagger UI.

npm install @scalar/express-api-reference
// Express integration
import { apiReference } from '@scalar/express-api-reference';

app.use('/reference', apiReference({
  url: '/openapi.json',   // older releases used spec: { url }
  theme: 'purple',
}));

GraphQL Documentation

  • GraphiQL — official in-browser IDE with docs explorer, query autocomplete, variable pane
  • Apollo Studio / Apollo Sandbox — schema explorer, operation history, field usage analytics
  • Postman — supports GraphQL schema import and introspection

Most servers embed an explorer, so there is nothing extra to install: GraphQL Yoga serves GraphiQL on its /graphql endpoint, and Apollo Server serves the Apollo Sandbox landing page in development. To host GraphiQL yourself, use the graphiql React package (v5.x, 2026-09) in a small web app; it has no CLI.


API Governance and Linting

Spectral (OpenAPI / AsyncAPI Linting)

Spectral enforces API design standards via configurable rules. Run in CI to prevent non-compliant changes.

# Install
npm install -g @stoplight/spectral-cli

# Lint against built-in OpenAPI rules
spectral lint openapi.yaml

# Lint against custom ruleset
spectral lint openapi.yaml --ruleset .spectral.yaml
# .spectral.yaml — custom API governance rules
extends:
  - spectral:oas

rules:
  # Require operationId on every endpoint
  operation-operationId:
    severity: error
    given: "$.paths[*][*]"
    then:
      field: operationId
      function: truthy

  # Enforce kebab-case paths (the ~ selects each path key, not its value)
  paths-kebab-case:
    severity: error
    given: "$.paths[*]~"
    then:
      function: pattern
      functionOptions:
        match: "^(/([a-z0-9-]+|\\{[a-zA-Z0-9_]+\\}))+$"

  # Require description on all parameters
  parameter-description:
    severity: warn
    given: "$.paths[*][*].parameters[*]"
    then:
      field: description
      function: truthy

  # Ban query string versioning
  no-query-version:
    severity: error
    given: "$.paths[*][*].parameters[?(@.name == 'version' && @.in == 'query')]"
    then:
      function: falsy

  # Require error response schemas
  require-error-responses:
    severity: warn
    given: "$.paths[*][*].responses"
    then:
      - field: "400"
        function: truthy
      - field: "500"
        function: truthy

Breaking Change Detection

# oasdiff — breaking-change report between two OpenAPI documents (files or URLs)
# install: brew install oasdiff  (or: go install github.com/oasdiff/oasdiff@latest)
oasdiff breaking old-spec.yaml new-spec.yaml
oasdiff breaking old-spec.yaml new-spec.yaml --fail-on ERR   # exit 1 on breaking changes

# buf breaking — detect protobuf breaking changes
buf breaking proto/ --against '.git#branch=main'

# optic — track API changes in CI (low release activity since 2025)
npx @useoptic/optic diff openapi.yaml --base main --check

CI integration example (GitHub Actions):

- name: Lint API spec
  run: npx @stoplight/spectral-cli lint openapi.yaml --fail-severity warn

- name: Check for breaking changes
  run: |
    git show origin/main:openapi.yaml > "$RUNNER_TEMP/base.yaml"
    oasdiff breaking "$RUNNER_TEMP/base.yaml" openapi.yaml --fail-on ERR

Proto Linting with buf

# buf.yaml — proto lint configuration
version: v2
lint:
  use:
    - STANDARD         # Google's protobuf style guide
    - COMMENTS         # Require comments on all public types
  except:
    - PACKAGE_VERSION_SUFFIX
# Run lint
buf lint proto/

# Check for breaking changes against main branch
buf breaking proto/ --against '.git#branch=main'

buf breaking catches: field number reuse, type changes, field removal, service method signature changes — all before merge.


API Security Testing

Run these checks against a staging environment with two test accounts (user A and user B) and one admin account. Every command should be rejected; a 2xx response is a finding. Why each attack works is covered in Explanation — OWASP API Security Top 10. Scanner options are in Reference — Security Testing Tools.

Security Testing Checklist

# 1. Test BOLA — access another user's resource with your token (expect 403/404)
curl -H "Authorization: Bearer USER_A_TOKEN" \
  https://api.example.com/v2/orders/USER_B_ORDER_ID

# 2. Test BFLA — call admin endpoint with regular user token (expect 403)
curl -X DELETE -H "Authorization: Bearer REGULAR_TOKEN" \
  https://api.example.com/v2/admin/users/user_456

# 3. Test mass assignment — send privileged fields (expect 400/422, or fields ignored)
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "admin", "isAdmin": true}' \
  https://api.example.com/v2/users/me

# 4. Test rate limiting — burst requests (expect 429 after the limit)
for i in {1..100}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Content-Type: application/json" \
    -d '{"email":"test@test.com","password":"wrong"}' \
    https://api.example.com/v2/auth/login
done | sort | uniq -c

# 5. Test SSRF — webhook URL pointing to cloud metadata (expect 400/422)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "http://169.254.169.254/latest/meta-data/"}' \
  https://api.example.com/v2/webhooks

# 6. Test excessive data — request huge page (expect capped page size or 400)
curl -H "Authorization: Bearer $TOKEN" "https://api.example.com/v2/users?limit=999999"

# 7. Test GraphQL introspection in production (expect an error)
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ __schema { types { name } } }"}'

# 8. Test JWT alg:none — unsigned token (expect 401)
b64url() { base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'; }
JWT="$(printf '{"alg":"none","typ":"JWT"}' | b64url).$(printf '{"sub":"admin","role":"admin"}' | b64url)."
curl -H "Authorization: Bearer $JWT" https://api.example.com/v2/users/me

Automate the same checks in CI with a schema-driven fuzzer:

# Schemathesis: property-based tests generated from the OpenAPI document
uvx schemathesis run https://staging.api.example.com/openapi.json \
  --header "Authorization: Bearer $TOKEN"

Transport Security

TLS Configuration

A baseline NGINX server block based on the Mozilla "intermediate" profile. Regenerate it with the Mozilla SSL Configuration Generator for your NGINX and OpenSSL versions. The full checklist is in Reference — TLS Hardening Checklist.

# NGINX — TLS 1.2 + 1.3 (Mozilla intermediate)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

# OCSP stapling only helps with CAs that still run OCSP (Let's Encrypt stopped in Aug 2025)
# ssl_stapling on;
# ssl_stapling_verify on;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

Verify the result:

# Protocol and cipher negotiated
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>/dev/null | grep -E "Protocol|Cipher"

# Legacy protocols must fail
openssl s_client -connect api.example.com:443 -tls1_1 </dev/null

Get the SPKI pin hash for certificate pinning (see Explanation — Certificate Pinning for the trade-offs):

openssl x509 -in server.crt -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | base64

API Tooling Ecosystem

The categorized tool list (editors, linters, mocks, clients, load and contract testing, documentation, gateways, meshes, codegen, monitoring) is in Reference — API Tooling Ecosystem.


Sources

OpenAPI & Specification

Authentication & Security

API Design

Rate Limiting & Gateways

Webhooks

Testing & Monitoring

Caching & Performance

Code Generation & Governance