Look back at what we wrote in module 4: a rate limiter with Redis, JWT and OAuth token validation via JWKS, an origin allowlist for CORS, caching headers with ETag and 304, compression, metrics at /metrics. Six pieces of infrastructure, with their code, their tests and their maintenance.

Now the uncomfortable part: almost all of them already exist, solved, one layer above. An API gateway does all six, with configuration instead of code, and it does them for all your APIs at once. The obvious question is whether we wasted our time. The answer is no —understanding a mechanism is what lets you decide whether to delegate it and debug it when it fails— but the next question is genuinely hard, and it takes up half this lesson: what gets delegated to the gateway and what must stay in the application.

And there is a second half. We have an excellent API, documented and deployed. But CataBox, the third-party application integrating over OAuth, has no idea how to start: where to sign up, how to get credentials, where to test without breaking anything, what its limits are, when something will change. That is a developer portal, and it determines whether your API gets used or abandoned within the first hour.

This lesson closes module 5 with the two layers that surround the API: the one that protects it and the one that explains it.

Contents

  1. What an API gateway is and what problem it solves
  2. The functions it takes on, compared with module 4
  3. What to delegate and what never to delegate
  4. The complete architecture: CDN, gateway, services
  5. The backend for frontend pattern
  6. A service mesh is a different thing
  7. The products and how they compare
  8. Kong configuration for Aroma Store
  9. The NGINX alternative
  10. Which middleware we could retire and which we could not
  11. The risks of a gateway
  12. What a developer portal is
  13. What a good portal contains
  14. Application registration and OAuth credentials
  15. Time to first successful call
  16. API lifecycle and inventory
  17. Monetisation, as a footnote
  18. Taking stock of module 5

  1. What an API gateway is and what problem it solves

An API gateway is a server that sits in front of one or more APIs and acts as the single door: it receives all external traffic, applies cross-cutting policies and forwards requests to whichever service is appropriate.

The problem it solves is best seen in the scenario that gives it meaning. Imagine Aroma Store grows:

Without a gateway                    With a gateway
─────────────────────────────        ─────────────────────────────
api.aromastore.example               api.aromastore.example
  → store API                          → GATEWAY
     · its own rate limiting                 · rate limiting (once)
     · its own CORS                          · CORS (once)
     · its own JWT                           · JWT (once)
                                             · metrics (once)
inventory.internal                           ↓
  → inventory API                     /v1/coffees     → store API
     · its own rate limiting          /v1/orders      → store API
     · its own CORS                   /v1/stock       → inventory API
     · its own JWT (the same?)        /v1/suggestions → recommendations
recommendations.internal
  → recommendations API
     · its own rate limiting (or not?)

With one API, the gateway adds little: you are moving code that already works from one place to another. With five, the difference is enormous: without it, each team reimplements rate limiting its own way, three of the five get it wrong, the CORS policy diverges, and there is nowhere to answer "how many requests do we receive in total?".

The precise formulation: a gateway centralises the cross-cutting concerns of traffic. And like everything centralised, it brings consistency and creates a single point of failure. Both at the same time.

  1. The functions it takes on, compared with module 4

Function How we did it What the gateway does Delegable?
Rate limiting express-rate-limit + Redis (04-04) Plugin with limits per consumer, route and tier Yes, entirely
TLS termination Delegated to the proxy Certificates, renewal, TLS versions Yes
JWT validation middleware/authentication.js (03-06) Verifies signature, exp, iss, aud and rejects before reaching you Yes, the validation
OAuth and JWKS oauth-authentication.js + jose (04-03) Downloads and caches the JWKS, validates scopes Yes
CORS cors(corsOptions) (04-05) Declarative allowlist, preflight Yes
Response caching ETag + Redis cache-aside (04-06) Response cache by URI and Vary Partly
Compression compression gzip and brotli, usually better implemented Yes
Routing and versioning app.use('/v1', v1Routes) Path to service; /v1 and /v2 to different targets Yes
Transformation Mappers (03-03) Adding, removing or renaming headers and fields Yes, carefully
Aggregation We do not do it Combining several calls into one response With great care
Per-client quotas We do not do it 10,000 calls a month per plan Yes
Metrics and logs prom-client + pino (04-07) Metrics per consumer and route, without touching code Yes, as a complement
mTLS We do not do it Client certificates for partners Yes
Blocklists We do not do them Blocking by IP, country, pattern or reputation Yes
Retries and circuit breaker Partly (04-04) Retries, timeouts and circuit breaking per target Yes
Schema validation Zod (03-04) Validation against the contract's JSON Schema Partly
Resource-level authorisation requireRole, ownership checks It cannot know NEVER
Business logic Services It cannot NEVER
Semantic validation Services It cannot NEVER

The last three rows are the subject of the next section.

  1. What to delegate and what never to delegate

The criterion, in one sentence: the gateway knows who is calling and where to; only the application knows what is inside and what it means.

Everything else follows from that:

Delegates well: anything that depends only on the request and on identity: rate limiting, TLS termination, cryptographic verification of the token, CORS, compression, routing. These are decisions that do not require querying your database.

Never delegate, and it is worth being blunt:

1. Resource-level authorisation. The gateway can check that the token is valid and that it carries the orders.read scope. It cannot check that ord_5001 belongs to cus_842, because that requires querying the database. And that check is precisely the one that prevents the number one flaw in the OWASP API Top 10 (04-02), BOLA. If you stop doing it in the application because "the gateway already looks at that", you have a critical vulnerability.

// src/services/orders.js — this stays in the application, ALWAYS.
export async function getOrder(orderId, user) {
  const order = await orderRepository.findById(orderId);
  if (!order) throw errors.orderNotFound(orderId);

  // The gateway already validated the token and the scope. What it CANNOT know
  // is whose order this is: only the database knows.
  const isTheirs = order.customerId === user.id;
  const isStaff = ['employee', 'administrator'].includes(user.role);
  if (!isTheirs && !isStaff) throw errors.insufficientPermissions();

  return order;
}

2. Business logic. That a paid order cannot go back to pending_payment, that no more stock is sold than is available, that the return window is 14 days. Putting business rules into the gateway's configuration creates a second place where the domain lives, with no tests, no types and no code review. It is the most expensive mistake made with these tools.

3. Semantic validation. The gateway can reject a body that does not satisfy the JSON Schema —types, ranges, required fields— and that is a useful defence. It cannot validate that coffeeId exists, that priceMin is less than priceMax, or that the customer is allowed to buy that coffee.

4. Defence in depth. Even though the gateway validates the token, the application must keep validating it. The reason: if somebody reaches your service bypassing the gateway —a misconfigured network rule, a new deployment, an attacker inside the network— your API would be wide open. The rule is zero trust: the service does not trust that traffic comes from where it appears to.

A table that summarises the assignment:

Question Who answers
Is this token signed by who it claims and not expired? Gateway (and the app too)
Has this consumer exceeded its quota? Gateway
Can this origin make requests from the browser? Gateway
Does this token carry the orders.write scope? Gateway (and the app too)
Can this user view this order? Only the application
Is there enough stock for this item? Only the application
Is the order in a state that allows payment? Only the application

  1. The complete architecture: CDN, gateway, services

graph LR
    SPA[SPA<br/>aromastore.example] --> CDN
    MOB[Aroma Mobile] --> CDN
    PAN[Internal panel] --> CDN
    CAT[CataBox<br/>OAuth] --> CDN
    SS[SwiftShip<br/>mTLS] --> CDN
    CDN[CDN and WAF<br/>TLS, DDoS, static files, edge cache] --> GW
    GW[API Gateway<br/>rate limiting, JWT, CORS,<br/>routing, quotas, metrics]
    GW --> API[Aroma Store API<br/>routes /v1/coffees and /v1/orders]
    GW --> INV[Inventory service]
    GW --> REC[Recommendations service]
    API -.gRPC.-> INV
    API -.gRPC.-> REC
    API --> DB[(SQLite or PostgreSQL)]
    API --> R[(Redis)]

What each layer does and why it is where it is:

Layer Responsibility Why there
CDN / WAF TLS, DDoS mitigation, static files, edge cache, geo-blocking As close as possible to the user and as far as possible from your infrastructure. Malicious traffic is filtered before it consumes your resources.
Gateway Authentication, rate limiting, CORS, routing, quotas, per-consumer metrics A single point that knows every consumer and every service
Application Business logic, resource-level authorisation, semantic validation, persistence It is the only thing that knows the domain
East-west (gRPC) Communication between internal services It does not go through the gateway: that would be an unnecessary detour and a bottleneck

The dotted arrows in the diagram matter: north-south traffic (from outside in) goes through the gateway; east-west traffic (between internal services) does not. Making internal calls go out and back in through the gateway multiplies latency and turns the gateway into the bottleneck for the whole system.

  1. The backend for frontend pattern

Aroma Mobile has a problem the SPA does not: to paint the home screen it needs the featured catalogue, the customer's recent orders and their preferences. With the API as it is, that is three requests over a mobile network with 150 ms of latency and a battery that drains.

A BFF is an intermediate API dedicated to one particular consumer, which aggregates and adapts:

// mobile-bff/src/routes/home.js
// Aroma Mobile's BFF: ONE call from the phone, three on the internal network (fast).
router.get('/home', authenticate, asyncHandler(async (req, res) => {
  const [featured, orders, preferences] = await Promise.all([
    storeApi.getCoffees({ featured: true, limit: 6 }),
    storeApi.getCustomerOrders(req.user.id, { limit: 3 }),
    storeApi.getPreferences(req.user.id),
  ]);

  // As well as aggregating, it SLIMS DOWN: the phone does not need tastingNotes
  // or _links on the home screen, and every byte counts on a mobile network.
  res.json({
    featured: featured.data.map((c) => ({
      id: c.id, name: c.name, priceEuros: c.priceEuros, image: c._links.image.href,
    })),
    recentOrders: orders.data.map((o) => ({
      id: o.id, status: o.status, totalEuros: o.totalEuros, date: o.createdAt,
    })),
    preferredRoast: preferences.roast,
  });
}));
General API BFF
Consumers All of them One
Who maintains it The platform team The team of the client it serves
Can change With a deprecation cycle (02-07) Whenever it likes, together with its client
Risk Duplicating business logic in every BFF

The political advantage of a BFF is as important as the technical one: the mobile team can change its BFF without negotiating with anyone or waiting for a versioning cycle, because it is the only consumer. The trap is a BFF ending up with business rules of its own that diverge from the API. Rule: the BFF aggregates, filters and adapts formats; it decides nothing about the domain.

Some gateways offer aggregation through configuration, without writing a BFF. It works for trivial cases and becomes unmanageable as soon as there is conditional logic. If you need an if, write a BFF.

  1. A service mesh is a different thing

They are constantly confused, so it is worth separating them:

API gateway Service mesh
Traffic North-south: from outside in East-west: between internal services
Where it lives A centralised entry point A sidecar next to each service
What it solves External authentication, quotas, public exposure Internal mTLS, retries, traffic splitting, observability
Examples Kong, Apigee, AWS API Gateway Istio, Linkerd, Consul
When it is needed As soon as you expose an API to third parties With many internal services and teams operating them

They are not alternatives: they are complementary, and many mature architectures have both. For Aroma Store today, with one API and two internal services, a service mesh is clearly premature: its operational cost only pays off with dozens of services.

  1. The products and how they compare

Product Model Extensibility Cost Portal included Fit with Aroma Store
Kong Self-managed (OSS) or managed High: plugins in Lua, JS, Python, Go Free (OSS) / paid Yes, in the paid edition A good option: powerful and with a path towards the managed version
NGINX Self-managed Medium: modules, Lua with OpenResty Free / NGINX Plus No If you already run it as a proxy and need little more
Traefik Self-managed Medium Free / paid No Excellent on Docker and Kubernetes: discovers services by itself
AWS API Gateway Managed Medium: Lambda as authoriser Per request Yes, basic Natural if you are already on AWS; the cost scales with traffic
Apigee (Google) Managed Very high High Yes, very complete A large enterprise with monetisation and many partners
Azure API Management Managed High: XML policies Medium-high Yes, very complete The Microsoft ecosystem
Tyk Both High Free (OSS) / paid Yes A solid alternative to Kong, portal included in OSS
Cloudflare / Fastly Managed, at the edge Medium: Workers Low-medium Partial You already have it as a CDN; many gateway functions at the edge

Selection criteria, which look a lot like those in 05-03:

  • Who is going to operate it? A self-managed Kong in high availability is one more distributed system to maintain, patch and monitor. If the team is small, the managed option almost always wins.
  • Where is everything else? If you are already on AWS with ECS, AWS API Gateway saves integration work. If you already use Cloudflare, a good part of the job can be done there.
  • Do you need a portal? If you are going to have third parties like CataBox, the portal is half the product. Kong OSS does not include one; Tyk does; Apigee and Azure APIM have the most complete ones.
  • How much does it cost per request? The managed ones charge per million requests. At high volumes, the bill can exceed the cost of operating it yourself.
  • What configuration model? Declarative YAML configuration versioned in Git (Kong with decK, Traefik, the Kubernetes Gateway API) is far superior to configuring through a graphical interface: it is reviewed in a pull request and deployed with the pipeline from 05-05.

  1. Kong configuration for Aroma Store

A complete declarative configuration, versionable in the repository as gateway/kong.yaml:

# gateway/kong.yaml — declarative configuration of the Aroma Store gateway.
# Applied with: deck gateway sync gateway/kong.yaml
# It lives in the repository and is deployed from the CI pipeline (05-05).
_format_version: "3.0"

# ---------------------------------------------------------------------------
# SERVICES: the internal targets. The gateway does not expose them directly.
# ---------------------------------------------------------------------------
services:
  - name: aroma-store-api
    url: http://aroma-store-api.internal:3000
    retries: 2                    # retries on connection failure
    connect_timeout: 2000
    write_timeout: 10000
    read_timeout: 10000

    routes:
      # Main route: everything under /v1 goes to the API. Versioning in the path (02-07)
      # means that tomorrow /v2 can point at another service without touching anything else.
      - name: v1
        paths: ["/v1"]
        strip_path: false         # the API expects to receive /v1: we do NOT strip it
        protocols: ["https"]      # HTTPS only; HTTP is redirected earlier
        methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]

      # Login has its own route so a different limit can be applied to it.
      - name: v1-sessions
        paths: ["/v1/sessions"]
        strip_path: false
        protocols: ["https"]
        methods: ["POST"]

      # The documentation (05-02) is public and carries no authentication.
      - name: documentation
        paths: ["/docs"]
        strip_path: false
        protocols: ["https"]
        methods: ["GET", "HEAD"]

# ---------------------------------------------------------------------------
# CONSUMERS: who calls. This enables per-client limits and quotas.
# ---------------------------------------------------------------------------
consumers:
  - username: aromastore-spa
    tags: ["internal", "first-party"]
  - username: aroma-mobile
    tags: ["internal", "first-party"]
  - username: internal-panel
    tags: ["internal"]
  - username: swiftship
    tags: ["partner"]
  - username: catabox
    tags: ["third-party", "free-plan"]

# ---------------------------------------------------------------------------
# GLOBAL PLUGINS: applied to all traffic.
# ---------------------------------------------------------------------------
plugins:
  # --- Rate limiting: the same limits as 04-04, now declarative -------------
  - name: rate-limiting
    config:
      minute: 600                 # the global limit from 04-04
      hour: 20000
      policy: redis               # state shared between gateway instances
      redis:
        host: redis.internal
        port: 6379
        database: 1               # a different database from the application's
      fault_tolerant: true        # if Redis goes down, LET TRAFFIC THROUGH instead of blocking
      hide_client_headers: false
      limit_by: consumer          # per authenticated consumer, not per IP
      error_message: '{"error":{"code":"rate_limit_exceeded","message":"You have exceeded the request limit.","details":[]}}'

  # --- Trace correlation: the header from 04-07 -----------------------------
  - name: correlation-id
    config:
      header_name: Aroma-Trace-Id
      generator: uuid
      echo_downstream: true       # it is also returned to the client

  # --- Observability: metrics per service, route and consumer ---------------
  - name: prometheus
    config:
      per_consumer: true          # metrics segmented by consumer
      status_code_metrics: true
      latency_metrics: true
      # Mind the cardinality (04-07): per_consumer is fine because
      # consumers number in the tens; NEVER label by end user.

  - name: http-log
    config:
      http_endpoint: http://log-collector.internal:9880/kong
      custom_fields_by_lua:
        trace_id: "return kong.request.get_header('Aroma-Trace-Id')"

  # --- Compression and response headers -------------------------------------
  - name: response-transformer
    config:
      remove:
        headers: ["Server", "X-Powered-By"]   # do not reveal the stack (04-02)
      add:
        headers:
          - "Strict-Transport-Security: max-age=31536000; includeSubDomains"
          - "X-Content-Type-Options: nosniff"

# ---------------------------------------------------------------------------
# PER-ROUTE PLUGINS
# ---------------------------------------------------------------------------
  # --- CORS: the allowlist from 04-05, now in the gateway -------------------
  - name: cors
    route: v1
    config:
      origins:
        - https://aromastore.example
        - https://panel.aromastore.example
        # NEVER "*" together with credentials: true. It is the golden rule of 04-05.
      methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
      headers:
        - Authorization
        - Content-Type
        - Idempotency-Key
        - If-Match
        - If-None-Match
      exposed_headers:            # without this, the browser cannot see them
        - ETag
        - Link
        - Location
        - Retry-After
        - Aroma-Trace-Id
        - Aroma-RateLimit-Limit
        - Aroma-RateLimit-Remaining
        - Aroma-RateLimit-Reset
      credentials: true
      max_age: 3600               # caches the preflight for an hour
      preflight_continue: false   # the gateway answers the OPTIONS: the app never sees it

  # --- JWT and OAuth validation (03-06 and 04-03) ---------------------------
  - name: jwt-signer               # validates against the OIDC provider's JWKS
    route: v1
    config:
      access_token_issuer: https://auth.aromastore.example
      access_token_jwks_uri: https://auth.aromastore.example/.well-known/jwks.json
      access_token_leeway: 5      # clock tolerance, in seconds
      verify_access_token_signature: true
      verify_access_token_expiry: true
      verify_access_token_issuer: true
      # Propagates the already verified identity upstream to the API, in a header of its own.
      # The application STILL validates the token: defence in depth.
      upstream_access_token_header: Authorization

  # --- Login-specific limit: far stricter (04-04) ---------------------------
  - name: rate-limiting
    route: v1-sessions
    config:
      minute: 5                   # five attempts a minute against brute force
      policy: redis
      redis: { host: redis.internal, port: 6379, database: 1 }
      limit_by: ip                # here by IP: there is no authenticated consumer yet
      fault_tolerant: false       # if Redis goes down, BETTER TO BLOCK than to let through
      error_message: '{"error":{"code":"rate_limit_exceeded","message":"Too many login attempts.","details":[]}}'

  # --- The documentation carries no authentication --------------------------
  - name: request-termination
    route: documentation
    enabled: false                # a marker: jwt-signer is NOT applied here

  # --- Monthly quota for third parties (CataBox) ----------------------------
  - name: rate-limiting-advanced
    consumer: catabox
    config:
      limit: [100, 10000]
      window_size: [60, 2592000]  # 100/minute and 10,000/month: the free plan
      identifier: consumer
      strategy: redis
      sync_rate: 1

  # --- Logistics partner: generous limits and mTLS --------------------------
  - name: mtls-auth
    consumer: swiftship
    config:
      ca_certificates: ["<partner-ca-id>"]
      skip_consumer_lookup: false
      revocation_check_mode: STRICT

Four points in that configuration deserve comment:

  • A different fault_tolerant per route. On the global limit it is true: if Redis goes down, we would rather let traffic through than bring the whole API down. On login it is false: if Redis goes down, we would rather block than be left with no brute-force protection. It is an availability-versus-security decision that must be taken consciously for each case, and the gateway forces you to spell it out.
  • limit_by: consumer versus limit_by: ip. Limiting by IP punishes every user behind a corporate NAT and does not protect against an attacker with many IPs. Per authenticated consumer is the right approach… except on login, where there is no consumer yet.
  • exposed_headers. The most frustrating CORS error from 04-05: the browser receives the ETag but JavaScript cannot read it unless it is in Access-Control-Expose-Headers. Here it is declared once for the whole API.
  • preflight_continue: false. The gateway answers the OPTIONS and the application never sees them. It saves latency and a chunk of traffic, but it means your CORS middleware stops running for the preflight: if one day you remove the gateway, remember that piece was there.

The configuration is applied from the pipeline in 05-05:

# Validate before applying (in the pull request)
deck gateway validate gateway/kong.yaml

# See what would change (in the pull request: the diff is posted as a comment)
deck gateway diff gateway/kong.yaml

# Apply (after approval, on deployment)
deck gateway sync gateway/kong.yaml

This is infrastructure as code applied to the gateway, and it is what prevents the classic problem: somebody edits the configuration in the graphical interface one Tuesday, nobody remembers, and at the next rebuild of the environment the policy is gone.

  1. The NGINX alternative

If the team already runs NGINX and needs neither consumer management nor a portal, a good part of the above can be achieved with direct configuration:

# gateway/nginx.conf — a minimal version with NGINX
# Shared memory zones for the limiters. 10 MB ≈ 160,000 IPs.
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

upstream aroma_store_api {
    server api-1.internal:3000 max_fails=3 fail_timeout=10s;
    server api-2.internal:3000 max_fails=3 fail_timeout=10s;
    keepalive 32;                     # persistent connections: less latency
}

server {
    listen 443 ssl http2;
    server_name api.aromastore.example;

    ssl_certificate     /etc/ssl/certs/aromastore.crt;
    ssl_certificate_key /etc/ssl/private/aromastore.key;
    ssl_protocols       TLSv1.2 TLSv1.3;   # nothing below 1.2 (04-02)

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    server_tokens off;                # do not reveal the NGINX version

    client_max_body_size 100k;        # the same limit as express.json (04-02)

    # Correlation trace: generated if absent, and propagated (04-07)
    set $trace_id $http_aroma_trace_id;
    if ($trace_id = "") { set $trace_id $request_id; }

    location /v1/sessions {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://aroma_store_api;
        include /etc/nginx/proxy_common.conf;
    }

    location /v1/ {
        limit_req zone=general burst=20 nodelay;
        limit_req_status 429;

        gzip on;
        gzip_types application/json;
        gzip_min_length 1024;

        proxy_pass http://aroma_store_api;
        include /etc/nginx/proxy_common.conf;
    }

    location /docs {
        proxy_pass http://aroma_store_api;
        include /etc/nginx/proxy_common.conf;
    }
}
# /etc/nginx/proxy_common.conf — headers that must ALWAYS be propagated
proxy_http_version 1.1;
proxy_set_header Connection "";                       # enables keepalive
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header Aroma-Trace-Id    $trace_id;
proxy_read_timeout 10s;
proxy_connect_timeout 2s;

The X-Forwarded-For header connects directly with the trust proxy 1 at position 1 of src/app.js. Without X-Forwarded-For, Express sees the gateway's IP on every request and per-IP rate limiting limits the entire gateway. And with trust proxy misconfigured —trusting more hops than there are— an attacker can spoof their IP by adding the header themselves. The number must match exactly the number of trusted proxies in front.

What NGINX does not give and Kong does: consumer management with credentials, monthly quotas, JWT validation without resorting to Lua, a developer portal, and configuration per API instead of per file. For an API with partners and third parties, those absences weigh heavily.

  1. Which middleware we could retire and which we could not

The practical question: with the gateway above in front, what is left in src/app.js?

Position in src/app.js Middleware Retire it? Reason
1 disable('x-powered-by') + trust proxy No, and trust proxy is more necessary Without it, every IP is the gateway's
2 assignTraceId No, adapt it It must respect the incoming Aroma-Trace-Id and only generate one if it is missing
3 securityHeaders (helmet) No Defence in depth; the cost is zero
4 cors(corsOptions) Yes, with conditions The gateway does it and answers the preflight. Keep it if the app is also exposed without a gateway (local development)
5 logRequests (pino) No The gateway's logs have no business context: user, order, query
6 metricsMiddleware No The gateway's are traffic metrics; yours are business metrics (aroma_orders_created_total)
7 globalLimit It can be simplified Delegate the global limit; keep a looser safety limit in case somebody bypasses the gateway
8 compression Yes The gateway compresses better and frees the application's CPU
9 express.json({limit}) No The body limit applies at both layers: defence in depth
10-11 /health and /health/ready No They are used by the orchestrator (05-05), not the gateway
12 protected /metrics No Prometheus scrapes the instance, not the gateway
13 conditionalEtag No The ETag depends on the content: only the app knows how to generate it
14 authenticate on the routes No, never Zero trust: if somebody bypasses the gateway, this is all that is left
14 requireRole / requireScope No, never Authorisation: never delegated
14 requireIdempotencyKey No It requires business state (03-05)
14 validate(schema) No Semantic validation belongs to the application
15-16 notFoundHandler, errorHandler No The catalogue's format (02-04) is yours

Honest balance: one or two middleware out of sixteen are retired. That is the real result and it deserves saying plainly, because it contradicts these tools' marketing promise.

The gateway's value is not in slimming down your application. It is in three different things:

  1. Consistency across several services. With five APIs, the policies are written once instead of five times.
  2. Consumer management. Quotas per plan, credentials, mTLS with partners, blocklists. That did not exist in our project and building it would be costly.
  3. Changing policies without deploying. Adjusting a limit or blocking an abusive client is one line of YAML and thirty seconds, instead of a full deployment.

And one last important observation: the gateway's configuration has to be tested just like code. The Newman journey from 05-01 must run against the gateway, not against the API directly. Otherwise a misconfigured CORS policy or a wrong strip_path is discovered in production. With deck gateway diff in the pull request, the policy change is reviewed like any other.

  1. The risks of a gateway

1. A single point of failure. If the gateway goes down, all your APIs go down at once, even though they are perfectly healthy. Mitigation: several instances, health checks, and —what almost nobody does— a documented plan for temporarily exposing the services without it.

2. A bottleneck and added latency. Every request crosses one more network hop and additional processing: typically between 1 and 10 ms. With a latency budget of 200 ms (04-06) that is acceptable; if your p99 target is 20 ms, it is 25 % of the budget and you have to measure it, not assume.

3. Duplicated, diverging configuration. The most pernicious case: CORS configured in the gateway and in the application with different lists. An origin works in development and fails in production, or the other way round, and debugging it is hell because each layer says it is fine. Rule: one policy, one owner, documented in an ADR (04-01).

4. Business logic in the gateway. The most expensive risk. It starts with an innocent transformation and ends with pricing rules in a Lua script with no tests, no effective version control and nobody who knows they are there. The rule in section 3 admits no exceptions.

5. Vendor lock-in. Apigee or Azure APIM policies do not migrate to Kong. The more logic you put in the gateway, the more expensive it is to change it. Another argument for keeping it thin.

6. A false sense of security. "The gateway validates the tokens, so the app can trust them." No. Zero trust: the application always validates.

7. Harder debugging. A 403 can come from the gateway or from the application, and sometimes it is not obvious which. Mitigation: have the gateway mark its own errors —a header of its own, or a field in details— and have the Aroma-Trace-Id cross both layers, which is exactly what the correlation-id plugin is for.

  1. What a developer portal is

We change subject and audience. A developer portal is the website where whoever wants to integrate with your API learns how to do it, signs up, obtains credentials, tests and finds out about changes.

The question it answers: CataBox wants to integrate with Aroma Store on a Tuesday morning. What happens?

Without a portal With a portal
They search Google and find a PDF from 2024 They find developers.aromastore.example
They email support asking for the documentation They read the reference generated from openapi.yaml
They wait two days for somebody to answer They sign up and create an application in five minutes
They ask for credentials by email They get a client_id and a test environment instantly
Somebody creates the OAuth client by hand They try it in the interactive console without writing code
They discover the limits when they get a 429 They read them on the plans page
They find out about a deprecation when something breaks They are subscribed to the changelog

Who needs one: any API with consumers outside your team. With a strictly internal API and two teams, /docs with Swagger UI and a chat channel can be enough. As soon as there are third parties, partners or more than four or five internal teams, the portal stops being a luxury.

  1. What a good portal contains

Section Content Where it comes from
Home What the API does, who it is for, an example in 30 seconds Hand-written
Getting started From zero to the first successful call Hand-written — the most important section
Reference Every endpoint, parameter, schema and error Generated from openapi.yaml (05-02)
Authentication JWT versus OAuth, flows, scopes, renewal Written, with the securitySchemes as the basis
Topic guides Pagination, idempotency, webhooks, errors, retries Hand-written
Interactive console "Try it now" against the test environment Swagger UI, Scalar or Stoplight Elements
My applications Registration, credentials, scopes, redirect URLs Gateway or portal
Test environment Fictional data, test credentials, test cards Infrastructure
Limits and plans Quotas, prices, how to ask for more Written
Changelog What changed and when; deprecation notices From the cycle in 02-07
Service status Incidents and maintenance windows Monitoring (04-07)
Support How to ask for help and what information to provide Written
Terms of use What may be done with the data; GDPR Legal

Three sections are usually missing and are the ones most appreciated:

  • Topic guides, not just reference. The reference says that POST /v1/orders accepts Idempotency-Key. A guide explains why, what happens on a retry, how long the key is kept and how to generate it. The reference answers "what"; the guide answers "how and why", and it is what prevents badly built integrations.
  • The errors page. The full catalogue from 02-04 with what each code means, whether it is worth retrying and what to do. With 429 and Retry-After, with the idempotency 409, with the If-Match 412. It is the most visited page of a mature portal, and its absence translates directly into support tickets.
  • Runnable examples. Copyable curl, and the Postman collection from 05-01 with an import button. Somebody being able to paste a command and see a real response in thirty seconds is worth more than ten pages of prose.

And a getting-started guide that works has this shape, with no frills:

# First steps with the Aroma Store API

## 1. Create your account and your application (2 minutes)
Sign up and create an application under "My applications".
You will get a `client_id` and, if it is a confidential application, a `client_secret`.

## 2. Get a test token (1 minute)
    curl -X POST https://auth-test.aromastore.example/oauth/token \
      -d "grant_type=client_credentials" \
      -d "client_id=YOUR_CLIENT_ID" \
      -d "client_secret=YOUR_CLIENT_SECRET" \
      -d "scope=coffees.read"

## 3. Your first call (30 seconds)
    curl https://api-test.aromastore.example/v1/coffees?limit=3 \
      -H "Authorization: Bearer YOUR_TOKEN"

You should see three coffees from the test catalogue. **That is it.**

## 4. Next steps
- [Filtering, sorting and pagination](/guides/queries)
- [Creating orders with idempotency](/guides/orders)
- [Receiving signed webhooks](/guides/webhooks)
- [Handling errors and retries](/guides/errors)

Four steps, three minutes, zero ambiguity. That is the standard.

  1. Application registration and OAuth credentials

The flow by which a third party becomes a consumer:

sequenceDiagram
    participant D as CataBox developer
    participant P as Portal
    participant G as Gateway
    participant A as Authorisation server
    D->>P: Signs up and creates the CataBox application
    D->>P: Declares redirect URLs and requested scopes
    P->>A: Creates the OAuth client with those details
    A-->>P: client_id and client_secret if it is confidential
    P->>G: Registers the consumer with its plan and quota
    P-->>D: Shows the credentials, the secret only once
    D->>A: Requests a token with Client Credentials or PKCE
    A-->>D: access_token with the granted scopes
    D->>G: GET /v1/coffees with the token
    G->>G: Validates signature, scope and the consumer's quota
    G-->>D: 200 with the catalogue

Design decisions in this flow, all with security consequences (04-02 and 04-03):

  • The client_secret is shown only once. It is stored as a hash, just like a password. If it is lost, it is rotated; it is not recovered.
  • Public versus confidential applications. A SPA or a mobile app cannot keep a secret: they are public and must use Authorization Code with PKCE. The portal must ask which type it is and not offer a secret to public ones.
  • Minimum scopes. CataBox should ask for coffees.read and reviews.write, not everything. The portal must explain what each scope allows in plain language, because that text is what the end customer will see on the consent screen.
  • Sensitive scopes with manual review. reviews.moderate or shipments.write are not granted automatically: they are requested and somebody approves them.
  • Separate approval for production. The test environment is instant; production access requires reviewing the application. That is what stops a curious developer reaching real data.
  • Credential rotation with no downtime: being able to have two valid secrets at once during the rotation. Without that, rotating means downtime, and the consequence is that nobody ever rotates.

  1. Time to first successful call

There is one metric that sums up the quality of the integration experience: TTFHW (time to first hello world), the time from somebody arriving at your portal to receiving their first 200.

Why it matters so much: in those first minutes, whoever is evaluating your API decides whether to carry on or look elsewhere. A TTFHW of thirty minutes with three emails to support along the way is a real commercial barrier, not a user-experience detail.

Time Rating What it implies
< 5 min Excellent Automatic sign-up, instant credentials, a copyable example
5-15 min Good Some manual step or slightly scattered documentation
15-60 min Needs work Confusing documentation or high friction in the sign-up
> 1 day Bad Manual approval, credentials by email, tickets

How to measure it properly: sit down with somebody who does not know the API, give them the portal link and time them without helping. Note every point where they hesitate, go wrong or stop. Those points are your task list, ordered by impact. It is a test that costs an hour and produces better information than any survey.

The usual blockers, which repeat across almost every API:

  1. The sign-up asks for unnecessary details on the first step (company name, phone, tax address).
  2. Credentials require human approval even for the test environment.
  3. The documentation example does not work when copied and pasted (a header is missing, the URL is out of date).
  4. The test environment has no data: the first GET returns an empty list and it looks as if something is broken.
  5. The errors do not explain the problem: a generic 401 that does not say whether the token is invalid, expired or missing a scope.

Point 4 is especially treacherous. The test environment must be seeded with rich fictional data: our npm run seed with cof_001, cof_002, cus_842 and ord_5001 does exactly that job, which is why the portal's first calls return something interesting instead of {"data": [], "total": 0}.

  1. API lifecycle and inventory

In 04-02, the ninth item in the OWASP API Top 10 was improper inventory management: forgotten APIs, old versions still alive, test endpoints exposed. This is where it gets solved.

The gateway is the source of truth for the inventory, because everything exposed goes through it. If something receives external traffic and is not in the gateway's configuration, it is a shadow API and there is a problem.

Each API or version has an explicit lifecycle:

State Meaning Who may use it Support
Design The contract is under review; a mock is available (05-04) Nobody, or internal testing
Beta It works, it may change with no deprecation cycle Consumers who accept the risk No guarantees
Stable In production, with compatibility guarantees Everyone Full
Deprecated It works but will be retired; Deprecation and Sunset (02-07) Existing consumers; no new sign-ups Security fixes
Retired Returns 410 api_version_retired Nobody None
Zombie Nobody knows it exists and it is still alive Anybody None

The last row is the real problem. Zombie APIs appear because nobody has the complete list, and they survive because nobody dares switch anything off just in case.

The practices that prevent it, all resting on pieces we already have:

  • Automatic inventory from the gateway's configuration, versioned in Git.
  • Usage metrics per route, version and consumer (04-07 and the prometheus plugin with per_consumer). Before retiring /v1, the question "who still uses it?" has an answer with names and volumes, not a guess.
  • One owner per API, with a name and a team. With no owner, there is nobody to decide.
  • A review date. Each API is reviewed at least once a year: is it still needed? is it documented? does it have consumers?
  • Non-production environments, locked down. Staging with no authentication, reachable from the internet, is a classic and appears in breach reports with depressing frequency.
  • Retirement in two phases. First the test blackout: returning 410 for a few hours on an announced date. Any remaining consumers appear immediately. Then the final retirement. It is far more effective than any warning email.

  1. Monetisation, as a footnote

When the API is itself a product, the gateway and the portal provide the billing infrastructure. The usual models:

Model How it works Example
Free with a limit A generous quota, free 1,000 calls a month
Tiered Plans with increasing quotas and features Free / Pro / Enterprise
Usage-based You pay per call or per unit processed €0.001 per call
Feature-based Certain endpoints only on higher plans Webhooks on Enterprise only
Revenue share The partner earns a commission on generated sales CataBox per referred order

The technical pieces are all in this lesson: quotas in the gateway per consumer, metering through the per-consumer metrics, plans in the portal, and billing integrated with the payment gateway.

A warning about metering, which is where the expensive mistakes are made: you have to decide explicitly whether failed calls are charged. Charging for your own 500 is indefensible; not charging for a 429 may encourage abuse. And the metering must be auditable: a customer is entitled to see their consumption itemised and reconcile it with their invoice.

For Aroma Store, the API is not the product: it is the channel. Revenue sharing with CataBox on referred orders would make more sense than charging per call.

Common Mistakes and Tips

  • Putting a gateway in front of a single API. You add a point of failure, latency and one more tool to operate, in exchange for almost nothing. A gateway is justified by several services or by third parties to manage.
  • Removing authentication from the application because the gateway does it. If somebody reaches the service bypassing the gateway, your API is completely open. Zero trust, always.
  • Delegating resource-level authorisation. The gateway cannot know that ord_5001 belongs to cus_842. It is the number one flaw in the OWASP API Top 10 and it has no solution outside the application.
  • Putting business logic in the gateway. Domain rules in a Lua script with no tests, no types and no review. It is the most expensive mistake to undo.
  • Configuring CORS in the gateway and in the application with different lists. An origin works in one environment and fails in another, and debugging it takes hours because each layer looks correct. One policy, one owner.
  • Forgetting exposed_headers in the gateway. The browser receives ETag and Link but JavaScript cannot read them. It is the most frustrating CORS error from 04-05.
  • A misconfigured trust proxy. With fewer hops than there are, the real IP is lost; with more, an attacker can spoof it. The number must match exactly.
  • Configuring the gateway through a graphical interface. Nobody remembers what was changed or why, and it is lost at the next rebuild. Declarative configuration, versioned and applied from the pipeline.
  • Not testing against the gateway. The Newman journey from 05-01 must point at the gateway. Otherwise a wrong strip_path or a bad CORS setting is discovered in production.
  • A portal that is just Swagger UI. The reference with no getting-started guide, no errors page and no runnable examples leaves whoever is integrating hunting for where to start.
  • Manual approval for the test environment. It multiplies the TTFHW by a hundred and sends people off to try the competitor's API while they wait.
  • An empty test environment. The first call returns {"data": [], "total": 0} and looks broken. Seed it with rich fictional data.
  • Tip: measure the TTFHW with a real person and a stopwatch. An hour of observation produces better information than any survey.
  • Tip: before retiring a version, run a test blackout. A few hours of 410 on an announced date flushes out every remaining consumer, which no warning email achieves.
  • Tip: document in an ADR (04-01) which policy lives in the gateway and which in the application. It is the information that is missing on the day of the incident.

Exercises

Exercise 1: dividing up the responsibilities

Aroma Store is going to put Kong in front of the API. For each requirement, decide whether it is implemented in the gateway, in the application or in both, and justify it:

  1. Rejecting requests with no valid token.
  2. Checking that a customer only sees their own orders.
  3. Limiting CataBox to 10,000 calls a month.
  4. Rejecting a priceMin greater than priceMax.
  5. Blocking an IP range that is attacking the login.
  6. Returning 409 order_already_paid if the order has already been paid.
  7. Allowing requests from https://panel.aromastore.example and no other origin.
  8. Requiring the Idempotency-Key header on POST /v1/orders.
  9. Rejecting bodies larger than 100 KB.
  10. Returning 304 when the ETag matches If-None-Match.

Exercise 2: the /v2 route with coexistence

Aroma Store publishes /v2, which lives in a separate service (api-v2.internal:3000), while /v1 stays on the current service with six months of coexistence. Write the Kong declarative configuration required: the services and routes, how the same rate limiting and CORS plugins are applied to both versions without duplicating configuration, and how the Deprecation, Sunset and Link headers with the successor are added only to /v1 responses. Also state what happens on the Sunset day.

Exercise 3: auditing a developer portal

A competitor's portal has: a home page with the value proposition, a complete reference generated from OpenAPI with a "try it now" console, a contact form to request access —answer within 2-3 working days—, a PDF with the plans and prices, and a service status page.

Identify at least five serious gaps, estimate the resulting TTFHW justifying the estimate, and propose a prioritised improvement plan with the first three actions, stating which metric you would expect each one to move.

Solutions

Solution 1

# Requirement Where Justification
1 Rejecting requests with no valid token Both The gateway rejects early and saves the application traffic. The application repeats it because if somebody reaches it bypassing the gateway —a misconfigured network rule, a new deployment, an internal attacker— it would be the only defence. It is the canonical example of defence in depth.
2 A customer only sees their own orders Only the application The gateway cannot query the database to know that ord_5001 belongs to cus_842. Delegating it is impossible, and attempting it with gateway rules would produce incomplete, dangerous authorisation (BOLA, 04-02).
3 10,000 calls a month for CataBox Only the gateway It is consumer management: a quota per plan and per time window. The application does not have, and should not have, the concept of "CataBox's plan". Besides, changing the quota should not require a deployment.
4 priceMin > priceMax Only the application It is semantic validation: the relationship between two fields. The gateway can check that both are positive numbers with the JSON Schema; the relationship between them is domain logic and its error (invalid_parameter with details) belongs to your catalogue.
5 Blocking an attacking IP range Gateway (and the CDN/WAF, better still) The sooner malicious traffic is cut off, the fewer resources it consumes. Ideally the CDN's WAF, even before the gateway. The application should never see that traffic.
6 409 order_already_paid Only the application A pure business rule, dependent on persisted state. It is not even tempting to delegate.
7 CORS from the panel only Gateway, and in the application with nuances The gateway is the natural place and it answers the preflight. It is worth keeping cors() in the application with the same list for local development without a gateway, but documenting in an ADR that the gateway is the source of truth to avoid the divergence in section 11.
8 Idempotency-Key mandatory Both, with a split The gateway can reject the request if the header is missing (428), which is cheap and early. But the real logic —storing the key, detecting the retry, returning the original response, detecting reuse with a different body (409)— requires business state and stays in the application.
9 Bodies larger than 100 KB Both The gateway cuts it off before the body reaches your process, which is the efficient thing. The application keeps express.json({limit: '100kb'}) as a safety net, with its body_too_large error.
10 304 with If-None-Match Only the application The ETag is computed from the resource's content and its version: only the application can generate it. A gateway with a cache can serve already-cached responses, but the conditional validation that closes the loop with If-Match and the optimistic concurrency 412 (04-06) belongs to the application.

The pattern that emerges: everything that depends on content or on persisted state stays in the application; everything that depends on who calls and how much they call goes to the gateway; and whatever is cheap to check twice is done in both.

Solution 2

# gateway/kong.yaml — coexistence of /v1 and /v2
_format_version: "3.0"

services:
  # ---- v1 service: the current one, in the deprecation phase --------------
  - name: api-v1
    url: http://aroma-store-api.internal:3000
    tags: ["public-api", "deprecated"]
    routes:
      - name: route-v1
        paths: ["/v1"]
        strip_path: false
        protocols: ["https"]
    plugins:
      # Deprecation headers ONLY on v1 (02-07).
      # Deprecation: the date it became deprecated, as a Unix mark (RFC 9745).
      # Sunset: the retirement date, in HTTP format (RFC 8594).
      # Link with rel="successor-version": the alternative, discoverable.
      - name: response-transformer
        config:
          add:
            headers:
              - "Deprecation: @1767225600"
              - "Sunset: Wed, 30 Jun 2027 23:59:59 GMT"
              - 'Link: <https://api.aromastore.example/v2>; rel="successor-version"'
              - 'Warning: 299 - "Version v1 will be retired on 2027-06-30. Migrate to /v2: https://developers.aromastore.example/migration/v2"'

  # ---- v2 service: the new one, in another deployment ---------------------
  - name: api-v2
    url: http://api-v2.internal:3000
    tags: ["public-api", "stable"]
    routes:
      - name: route-v2
        paths: ["/v2"]
        strip_path: false
        protocols: ["https"]

# ---------------------------------------------------------------------------
# GLOBAL PLUGINS: applied to BOTH versions without duplicating configuration.
# This is the answer to "how not to duplicate": plugins with no `service` and no
# `route` are global; only those that differ are declared per route.
# ---------------------------------------------------------------------------
plugins:
  - name: rate-limiting
    config:
      minute: 600
      hour: 20000
      policy: redis
      redis: { host: redis.internal, port: 6379, database: 1 }
      limit_by: consumer
      fault_tolerant: true
      # The quota is SHARED between v1 and v2 on purpose: a client migrating
      # gradually must not get double the quota for using both.

  - name: cors
    config:
      origins:
        - https://aromastore.example
        - https://panel.aromastore.example
      methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
      headers: [Authorization, Content-Type, Idempotency-Key, If-Match, If-None-Match]
      exposed_headers:
        - ETag
        - Link
        - Location
        - Retry-After
        - Deprecation          # essential: without it the SPA cannot see the notice
        - Sunset
        - Aroma-Trace-Id
        - Aroma-RateLimit-Remaining
      credentials: true
      max_age: 3600

  - name: jwt-signer
    config:
      access_token_issuer: https://auth.aromastore.example
      access_token_jwks_uri: https://auth.aromastore.example/.well-known/jwks.json
      verify_access_token_signature: true
      verify_access_token_expiry: true

  - name: correlation-id
    config:
      header_name: Aroma-Trace-Id
      generator: uuid
      echo_downstream: true

  - name: prometheus
    config:
      per_consumer: true
      status_code_metrics: true
      latency_metrics: true

How duplication is avoided: plugins declared at the top level (with no service and no route) are global and apply to every route. Only what differs is declared per service or per route, which here is only the response-transformer with the deprecation headers. Changing the global limit is one line affecting both versions.

The detail everyone forgets: Deprecation and Sunset must be in the CORS plugin's exposed_headers. Otherwise the browser receives them but the SPA cannot read them, and the instrumentation you wanted —the front end warning in the console that it is using a deprecated version— does not work.

What happens on the Sunset day (30 June 2027):

  # Phase 1 — Test blackout: a few hours, on a date announced well in advance.
  # It is what flushes out the remaining consumers, which emails never achieve.
  - name: request-termination
    route: route-v1
    config:
      status_code: 410
      content_type: "application/json"
      body: '{"error":{"code":"api_version_retired","message":"Version v1 was retired on 2027-06-30. Use /v2: https://developers.aromastore.example/migration/v2","details":[]}}'

Recommended sequence:

  1. Months before: the Deprecation and Sunset headers active (they already are), notices in the portal changelog, and emails to the consumers identified by the gateway's metrics, which say exactly who is still calling /v1 and at what volume.
  2. A month before: a two-hour test blackout, announced. The stragglers appear.
  3. On the Sunset day: the request-termination with 410 is switched on. 410 Gone and not 404, because 410 means "it existed and was deliberately retired", which is useful information for anyone debugging.
  4. Weeks later: the route is removed from the configuration and the api-v1 service is switched off. The 410 gives way to the generic 404.

The explanatory 410 is kept for weeks instead of deleting the route immediately, because a bare 404 leaves the consumer with no idea what happened, whereas the 410 with a link to the migration guide is self-explanatory.

Solution 3

Five serious gaps:

  1. Access with 2-3 days of manual approval. It is by far the most serious gap. It turns a five-minute evaluation into a week-long project, and anyone evaluating alternatives will try the competitor's while they wait. Many never come back.
  2. There is no test environment with instant credentials. A consequence of the first: nothing can be touched without permission, so the "try it now" console is decorative. Nobody can evaluate the API without committing first.
  3. There is only a reference, no guides. The reference says which fields POST /orders accepts; it does not explain idempotency, pagination, how to retry after a 429 or how to verify a webhook signature. Without that, every integration is built badly in the same way and the cost is passed on to support.
  4. Prices in a PDF. A PDF cannot be linked to a specific section, it goes out of date without anyone noticing, it is not accessible and it signals that the information is static. Besides, if the plans are in a PDF, the technical quotas are probably not documented anywhere.
  5. There is no changelog and no deprecation policy. It is the gap that most frightens anyone about to build a business on top: there is no way to know whether the API will change, or with how much notice. Without a public compatibility commitment, integrating means taking on an undefined risk.

Additional gaps: there is no errors page with the code catalogue; there is no Postman collection or runnable examples; nothing states the technical usage limits; and there are no generated clients or SDKs (05-02).

TTFHW estimate: 2-3 working days. The breakdown justifies the figure: discovery and reading, 10 minutes; filling in the form, 5 minutes; waiting for approval, 2-3 days; configuring authentication with incomplete documentation, 30-60 minutes; first successful call, 10 minutes. Active time is about 90 minutes; elapsed time is three days. And what counts commercially is the elapsed time, because that is what determines whether the person is still interested.

Prioritised plan — the first three actions:

Action 1 (very high impact, medium cost): automatic sign-up for the test environment. Anybody signs up with an email address, creates an application and gets a test client_id and client_secret instantly. Manual approval is kept only for production, which is where it is genuinely needed. The sandbox must be seeded with rich fictional data so that the first call returns something interesting. Expected metric: TTFHW from 2-3 days to under 15 minutes. It is an order-of-magnitude change and, on its own, it justifies the project.

Action 2 (high impact, low cost): a four-step getting-started guide and an errors page. A page with sign-up → token → first call → next steps, with copyable curl commands tested in CI so they never go stale (05-04 already taught us to test that the contract's examples work). And a page with the complete error catalogue: what each code means, whether it is worth retrying and what to do. Expected metric: active time down from 90 to 20 minutes, and a drop in first-line support tickets, which are usually 60-70 % of the total and are almost always "I do not understand this error".

Action 3 (medium-high impact, low cost): a public changelog and a compatibility commitment. A page with the change history, an explicit commitment —"we do not remove fields without six months' notice; breaking changes go in a major version of the path"— and subscription by email or RSS. It is the piece that turns an API people try into an API somebody dares to build a product on. Expected metric: conversion from trial accounts to production integrations. And, in the medium term, fewer incidents on each deployment, because consumers learn about changes before suffering them.

After that: publish the prices in HTML, offer the Postman collection with an import button, generate SDKs with OpenAPI Generator (05-02) and document the technical limits alongside the plans.

Conclusion

You have seen the layer that surrounds the API from the outside. An API gateway centralises the cross-cutting concerns of traffic —rate limiting, TLS termination, JWT and OAuth validation via JWKS, CORS, compression, version routing, per-consumer quotas, metrics, mTLS with partners, blocklists— and turns them into declarative, versionable configuration instead of code repeated in every service. You have its real configuration for Aroma Store in gateway/kong.yaml, with the same limits as 04-04 and its fault_tolerant decided route by route, the allowlist from 04-05 with the exposed_headers that almost everyone forgets, the correlation-id that propagates the Aroma-Trace-Id from 04-07 and the Prometheus plugin segmented by consumer; and the equivalent alternative in gateway/nginx.conf, with the X-Forwarded-For that gives meaning to the trust proxy 1 at position 1 of src/app.js.

And you have the criterion, which is worth more than the configuration: the gateway knows who is calling and where to; only the application knows what is inside and what it means. That is why rate limiting, TLS, cryptographic token validation and CORS delegate well, while resource-level authorisation, business logic and semantic validation are never delegated —and why, with the gateway in front, only one or two of the sixteen middleware in src/app.js are retired. The value is not in slimming down your application, but in consistency across several services, in the consumer management that did not exist before, and in being able to change policies without deploying. All of it while accepting its risks with eyes open: a single point of failure, added latency, diverging configuration and the ever-present temptation to put business rules where there are no tests and no review.

The second half of the lesson was the other side: a developer portal with a four-step getting-started guide, a reference generated from the openapi.yaml of 05-02, an interactive console, application registration with instant OAuth credentials for the test environment, an errors page with the catalogue from 02-04, a changelog with the deprecation notices from 02-07, limits, service status and support. With one metric that sums it all up, the time to first successful call, measured with a real person and a stopwatch, which decides whether your API is adopted or abandoned in the first hour. And with lifecycle and inventory management, where the gateway becomes the source of truth about what is exposed and the per-consumer metrics answer the question "who is still using /v1?" with names and volumes before you retire it —with a test blackout of a few hours, which flushes out stragglers as no email ever does.

That closes module 5. The Aroma Store API is no longer alone: it has a Postman collection runnable in CI, a complete OpenAPI contract that serves as documentation, mock, validator and client generator, the perspective to know what would have changed with another framework and what would not, contract tests that stop the code and the contract drifting apart together with a gate that detects breaking changes, a pipeline that builds, tests and deploys with no downtime with backwards-compatible migrations and rollback, and a gateway and portal layer that protects and explains it. It is an operable and consumable API, not merely a correct one.

What is missing is no longer a loose piece: it is seeing all of this together, applied end to end and sustained over time. In module 6, Case Studies and Projects, we leave the tools behind and return to design with everything we have learned on board: a complete case study of an online store's API, walking through the decisions from the resources to the deployment (06-01); a second case, a social network, where the problems are different —relationship graphs, timelines, cursor pagination at scale, user-generated content and moderation— and force us to rethink several of the decisions we took as settled here (06-02); the evolution and maintenance of an API in production, that is, what happens in the three years after launch: contract debt, version migrations, real incidents and the retirement of features (06-03); and the final project, in which you will design and develop your own RESTful API applying all six modules (06-04).

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved