Executive Talents · API Gateway — Getting Started

Rendered from this repo's docs/gateway/APP_INTEGRATION_GUIDE.md.

Connecting an application to the API Gateway

Audience: any Executive Talents engineer plugging their app into the gateway — either as a provider (other apps/users reach your app through it), a consumer (your app calls other apps through it), or both. Read CONTRACT.md for the authoritative wire contract; this doc is the practical "what do I actually build" walkthrough. It's written to be stack-agnostic — every code sample below is illustrative pseudocode-with-real-syntax for a specific language, not a claim that this repo ships that language. Adapt the pattern (constant-time compare, headers in, headers out) to whatever your app is actually written in.

Two separate questions

Before writing any code, answer these for each route your app exposes:

  1. Who calls this route: a logged-in human (via your own frontend/session), or another application acting on its own behalf?
  2. If it's a human — is that traffic going through the gateway at all, or hitting your app directly? (Both are valid; the gateway doesn't require every request to pass through it.)

As of ADR-GW-2 (CONTRACT.md §5), every route you declare lives at the same URL for every kind of caller — /<your-slug>/<path>, namespaced, whether it's your own frontend's session traffic or a granted external application. What used to be two different route classes with two different public URLs is now one URL with a per-request auth decision:

scope empty (passthrough) scope set (machine-preferred)
Who it's for Your own frontend / a browser session Another registered application, with an explicit grant — but composes with human traffic on the same route, see below
Auth checked by the edge? No — forwards Authorization unchanged Tries to verify a Bearer token as an EdDSA JWT, offline, via JWKS
No/invalid machine token presented N/A — never attempted Falls through to the same passthrough behavior as the left column — not rejected
Valid machine token, insufficient scope N/A Rejected outright (403 GW1003) — this is the one case that's still a hard reject
What your app receives The original Authorization header, untouched X-Gateway-Client-Id / X-Gateway-Scopes only if verified; otherwise the same as the left column
Namespaced under your app's slug? Yes — /<your-slug>/<path>, always Yes — /<your-slug>/<path>, always

A route with scope set now automatically serves both a granted machine caller and an ordinary human session at the same URL — the edge decides which, per request, by whether the presented credential actually verifies. You don't need to hand-write a "compose both checks" guard for this case anymore (see "Trust the gateway's forwarded identity" below) — just check whether X-Gateway-Client-Id is present.

The one thing you can't express anymore: "this route must only ever be reachable by a specific granted machine caller, reject anything else outright." A scope-bearing route always has a passthrough fallback now. If you genuinely need that stronger guarantee for a specific route, enforce it yourself downstream — reject if X-Gateway-Client-Id is absent — the same "compose, don't replace" pattern below, just opted into rather than automatic.

Provider side — letting other apps/gateway traffic reach you

1. Expose a manifest

GET /.well-known/gateway-manifest — a JSON document declaring your app's identity, scopes, and routes (CONTRACT.md §2):

{
  "appId": "your-app-slug",
  "name": "Your App",
  "description": "One line describing what this app does",
  "version": "1.0.0",
  "baseUrl": "https://your-app.internal",
  "documentation": "https://your-app.internal/.well-known/openapi.json",
  "owner": "Your Team",
  "contact": "your-team@company.com",
  "scopes": [
    { "key": "employee.read", "description": "Read employee records" }
  ],
  "routes": [
    { "path": "/employees", "method": "GET", "scope": "employee.read" },
    { "path": "/status" }
  ]
}

Either way: mark this endpoint (and your health check) as unauthenticated/public in your own framework — a gateway can't fetch the manifest that tells it how to authenticate if the manifest endpoint itself requires authentication first.

2. Decide, per route, whether it needs a scope

Default assumption: anything a human calls today via your own frontend, unmodified, needs no scope at all (leave scope empty) — or isn't registered with the gateway at all, if that traffic never goes through the gateway. Only declare a scope once you have an actual reason another application — not your own frontend — needs to call it on its own behalf, with its own granted scope.

As of ADR-GW-2, getting this "backwards" (declaring a scope on a route your own frontend calls) is no longer the integration-breaking mistake it used to be: the edge tries to verify the caller as a machine token, fails (a session JWT doesn't verify as one), and falls through to the exact same passthrough handling your frontend traffic would get anyway (CONTRACT.md §5.2). It costs one wasted verification attempt per request, not a broken app. The reason to still get this right isn't avoiding breakage — it's correctness of intent: a scope you declare is a real claim ("another application can be granted this"), and an unused one is just noise in your manifest.

3. Trust the gateway's forwarded identity on machine routes — don't try to verify a JWT yourself

The edge already did the hard cryptographic work (EdDSA/JWKS verification) before forwarding a verified machine request — and it only stamps X-Gateway-Client-Id/X-Gateway-Scopes when that verification actually succeeded (CONTRACT.md §5.2/§7). Your app's job is simple: check for that header's presence, and when it's there, verify the accompanying shared secret before trusting it.

X-Gateway-Client-Id: <the calling application's id>   — present ONLY on a verified machine caller;
                                                          this is what you check for "is this machine"
X-Gateway-Scopes: <space-joined scopes the caller was actually granted>
X-Gateway-Auth: <GATEWAY_SHARED_HEADER_SECRET>         — stamped on EVERY gateway-forwarded request,
                                                          both machine and passthrough — verify this
                                                          whenever X-Gateway-Client-Id is present, but
                                                          its presence alone does NOT mean "machine"

Do not key your check off X-Gateway-Auth presence — the edge stamps it on ordinary passthrough traffic too (CONTRACT.md §7), so a check gated on "is X-Gateway-Auth present" would try to treat every gateway-forwarded request as a machine call, including your own frontend's session traffic. The header that's exclusive to a verified machine caller is X-Gateway-Client-Id.

Every stack follows the same shape: a piece of middleware that runs before your route handlers, checks whether X-Gateway-Client-Id is present — if absent, it's a no-op, your normal session/Bearer auth runs unmodified; if present, it does a constant-time comparison of X-Gateway-Auth against an env var holding the same secret the gateway edge has, and — only on a match — reads X-Gateway-Client-Id/X-Gateway-Scopes into whatever per-request context your framework uses. A present-but-wrong X-Gateway-Auth is always a reject, never a fall-through to "trust the headers anyway" or "treat as unauthenticated."

Node/Express:

const crypto = require('crypto');

function gatewayAuth(req, res, next) {
  const clientId = req.header('X-Gateway-Client-Id');
  if (clientId == null) {
    return next(); // no machine-caller claim — normal session/Bearer auth handles this request
  }
  const provided = req.header('X-Gateway-Auth') || '';
  const expected = process.env.GATEWAY_SHARED_HEADER_SECRET || '';
  const ok = expected.length > 0 &&
    Buffer.byteLength(provided) === Buffer.byteLength(expected) &&
    crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
  if (!ok) {
    return res.status(401).json({ error: { code: 'APP1001', message: 'invalid gateway auth' } });
  }
  req.gateway = {
    clientId,
    scopes: (req.header('X-Gateway-Scopes') || '').split(' ').filter(Boolean),
  };
  next();
}

Python (Flask/Django-style middleware):

import hmac
import os

def gateway_auth_middleware(get_response):
    def middleware(request):
        client_id = request.headers.get("X-Gateway-Client-Id")
        if client_id is None:
            return get_response(request)  # no machine-caller claim — normal session auth handles it
        provided = request.headers.get("X-Gateway-Auth", "")
        expected = os.environ.get("GATEWAY_SHARED_HEADER_SECRET", "")
        if not expected or not hmac.compare_digest(provided, expected):
            return json_response({"error": {"code": "APP1001", "message": "invalid gateway auth"}}, status=401)
        request.gateway_client_id = client_id
        request.gateway_scopes = request.headers.get("X-Gateway-Scopes", "").split()
        return get_response(request)
    return middleware

Go (net/http):

func gatewayAuth(secret string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		clientID := r.Header.Get("X-Gateway-Client-Id")
		if clientID == "" {
			next.ServeHTTP(w, r) // no machine-caller claim — normal session auth handles this request
			return
		}
		provided := r.Header.Get("X-Gateway-Auth")
		if secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
			writeError(w, http.StatusUnauthorized, "APP1001", "invalid gateway auth")
			return
		}
		ctx := context.WithValue(r.Context(), gatewayClientIDKey, clientID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Ruby (Rails before_action):

before_action do
  client_id = request.headers["X-Gateway-Client-Id"]
  next if client_id.blank? # no machine-caller claim — normal session auth handles this request

  provided = request.headers["X-Gateway-Auth"].to_s
  expected = ENV.fetch("GATEWAY_SHARED_HEADER_SECRET", "")
  unless expected.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
    render json: { error: { code: "APP1001", message: "invalid gateway auth" } }, status: :unauthorized
    return
  end
  @gateway_client_id = client_id
  @gateway_scopes = request.headers["X-Gateway-Scopes"].to_s.split
end

Whatever your stack, the same rules apply:

4. Register + get granted

None of the above makes you reachable by itself — an admin still has to:

  1. POST /applications {"manifestUrl": "https://your-app/.well-known/gateway-manifest"} against the control plane.
  2. POST /applications/{consumerId}/grants {"providerAppSlug": "your-slug", "scopeKey": "..."} for every other application that should be allowed to call you, and for which scope. Registration never implies a grant — this is a separate, explicit step per consuming application.

Debugging: "did my request even reach the gateway?"

Before assuming your app's own code is at fault for an unexpected error (a GW1xxx error code from the edge, or a request you expected to arrive that never did), check GET /applications/{your-app-id}/requests against the control plane — every request the edge classified for your app, machine and passthrough both, with method/status/duration/timestamp (CONTRACT.md §14). GET /requests/summary gives the same thing aggregated across every app, if you're not sure which application's traffic is actually the problem. This tells you, before you go looking anywhere else, whether the request reached the edge at all and what it decided — a 401 GW1001 there means the edge itself rejected the token (expired, malformed, wrong iss/aud), not something your own app's code did.

5. Network exposure

The shared-secret check in step 3 only protects you if your app isn't reachable by anything that bypasses the gateway — otherwise anyone who can reach your app directly could set X-Gateway-Client-Id themselves without ever knowing the secret... except they'd still need the secret to pass step 3, so the real risk is a leaked secret, not a bypassed network path. Still: prefer keeping your app off any publicly routable address and reachable only via the gateway/shared internal network, so a leaked secret isn't your only line of defense.

Consumer side — calling another app through the gateway

Never hardcode another application's real internal address. Call the gateway's edge instead:

  1. Get a credential. An admin runs POST /applications/{yourAppId}/credentials against the control plane. The response is {"clientId", "clientSecret", "sharedHeaderSecret"?} — the clientId/clientSecret are yours alone and shown once (store them in your secrets manager, never in code); sharedHeaderSecret, if present, is the same gateway-wide value from "Trust the gateway's forwarded identity" above, only worth saving if you're also a provider.
  2. Get grants. An admin runs POST /applications/{yourAppId}/grants {"providerAppSlug": "...", "scopeKey": "..."} for every app + scope you actually need to call. Having a credential doesn't grant you anything by itself.
  3. Mint a token. POST <control-plane>/oauth/token with grant_type=client_credentials&client_id=...&client_secret=... returns a short-lived JWT (~15 min) carrying exactly the scopes you currently hold. Cache it; re-mint shortly before (or on a 401 from) expiry — don't mint a fresh token on every call.
  4. Call the edge, not the target app. <gateway-edge>/<provider-slug>/<path> with Authorization: Bearer <token>.

The token-caching step looks the same regardless of stack — cache the token and its expiry in-process, re-mint a little before it actually expires (a fixed safety margin, e.g. 30s, is enough), and on an unexpected 401 from the edge, mint once more and retry the call exactly once before giving up:

function callProvider(providerSlug, path, opts):
  token = getCachedTokenOrMint()          # mint() calls POST /oauth/token, caches token + expiry
  response = httpCall(edgeUrl + "/" + providerSlug + path, bearer=token, ...opts)
  if response.status == 401:
    token = mint()                        # force a fresh mint, bypass the cache once
    response = httpCall(edgeUrl + "/" + providerSlug + path, bearer=token, ...opts)
  return response

A frontend/SPA is a special case of "consumer" — and usually isn't one

A browser-based frontend calling its own backend with a user's login session is not a machine client, and should not be registered, credentialed, or granted a scope:

Reference implementation

Everything above is deliberately generic — if you want to see a fully worked, real (not pseudocode) integration to cross-check your own implementation against, talentscholarplus-backend-staging (NestJS) has one. Predates ADR-GW-2 as of this writing — its gateway-or-jwt-auth.guard.ts may still key off X-Gateway-Auth presence rather than X-Gateway-Client-Id (§3's corrected pattern above); check it against the current code before copying, don't assume it's already migrated:

Nothing about the gateway's contract is NestJS- or TypeScript-specific — this is one example among however many stacks end up integrating, not the reference stack.

Checklist

Using Claude Code and not on the Executive-Talents workspace?

Fetch the org's gateway integration rule + checklist directly — no repo clone needed:

mkdir -p .claude/rules .claude/skills/gateway-integration-check
curl -fsSL https://gateway.etdevops.io/claude/rules/api-gateway.md -o .claude/rules/api-gateway.md
curl -fsSL https://gateway.etdevops.io/claude/skills/gateway-integration-check.md \
  -o .claude/skills/gateway-integration-check/SKILL.md