API Marketplace is a full-stack platform where providers publish APIs, developers discover, subscribe to, and consume them, and admins govern the marketplace. The backend additionally runs a gateway that proxies runtime traffic to provider-owned upstream APIs — enforcing per-plan quotas and recording usage telemetry for billing and analytics.
- Overview
- Architecture
- Technology Stack
- Repository Layout
- Quick Start
- Core Concepts
- Domain Model
- REST API Reference
- Gateway
- Configuration
- Security
- Background Jobs & Caching
- Testing & Quality Gates
- Operational Runbook
- Troubleshooting
- Documentation Index
- License
The platform solves the problem of distributing, metering and monetising third-party APIs behind a single, consistent control plane. It has three audiences:
| Audience | Role | What they do |
|---|---|---|
| Publishers | PROVIDER |
Author APIs, define pricing plans, provision a pool of credentials, observe consumption |
| Consumers | DEVELOPER |
Discover APIs, subscribe to a plan, receive a delegated key, call through the gateway |
| Operators | ADMIN |
Govern users, categories, the entire catalog, quotas, and platform-wide telemetry |
- JWT authentication (register / login) with role-based authorization and method-level guards.
- API catalog with categories, pricing plans, reviews and average-rating aggregation.
- Subscriptions that delegate a credential from a provider-managed key pool.
- Runtime gateway that forwards traffic to upstream APIs and logs per-request usage telemetry.
- Redis-backed rate limiting at the gateway, enforced per API key against the plan quota.
- Redis-backed caching for hot public reads (catalog, categories, plans, key validation).
- JobRunr background jobs (retention pruning, expiry sweeps, rating recomputation).
- OpenAPI/Swagger docs, Actuator health + Prometheus metrics, HikariCP pooling, a single centralised error contract, and a frontend test suite with enforced coverage thresholds.
┌───────────────┐ /api/* ┌──────────────────────────────┐
│ React SPA │────────────▶│ Spring Boot backend :8080 │
│ :5173 │ │ JWT · RBAC · Gateway │
└───────────────┘ └───────┬──────────────┬───────┘
│ │
PostgreSQL 16 Redis 7
(catalog) (cache, rate limits, jobs)
│
▼
upstream provider APIs
The frontend is a thin, stateless SPA. The backend owns all authority: authentication,
authorization, quota enforcement, credential hashing and telemetry. Redis is a shared dependency for
three distinct concerns — cache, rate-limit counters, and JobRunr storage — which is why
the bundled Redis configuration mandates noeviction (see Background Jobs & Caching).
Client ──X-API-Key──▶ GatewayController
1. Validate the key against the API in the path (cached 10s)
2. Consume the plan quota via a Redis Lua counter ── 429 + Retry-After on exceed
3. Forward to the upstream API (SSRF-validated)
4. Persist telemetry for the attempt (own REQUIRES_NEW transaction)
5. Return the upstream response verbatim
| Concern | Choice |
|---|---|
| Framework | Spring Boot 3.4.1 (parent POM), Java 17 |
| Web | spring-boot-starter-web |
| Persistence | spring-boot-starter-data-jpa + PostgreSQL driver (H2 for tests) |
| Cache / Rate limit | spring-boot-starter-data-redis (Lettuce) + spring-boot-starter-cache |
| Background jobs | jobrunr-spring-boot-3-starter 7.3.2 (Redis-backed) |
| Security | spring-boot-starter-security + spring-security-test |
| Validation | spring-boot-starter-validation |
| Ops | spring-boot-starter-actuator + micrometer-registry-prometheus |
| JWT | jjwt 0.12.6 (jjwt-api / jjwt-impl / jjwt-jackson) |
| API docs | springdoc-openapi 2.7.0 |
| Coverage | JaCoCo 0.8.12 (prepare-agent + report bound to verify) |
Intentionally not used: Flyway/Liquibase, Resilience4j, OpenTelemetry tracing. Schema changes are applied out-of-band in production and validated at boot with
DB_DDL_AUTO=validate.
| Concern | Choice |
|---|---|
| Language | JavaScript (ES2022+) — no TypeScript |
| UI | React 18 + React DOM |
| Routing | react-router-dom 7 |
| State | Redux Toolkit 2 + react-redux 9 |
| Styling | Tailwind CSS 3 via PostCSS + autoprefixer |
| Build | Vite 6 + @vitejs/plugin-react |
| HTTP | Native fetch, wrapped in src/api/client.js |
| Testing | Vitest 2 (jsdom) + React Testing Library + jest-dom + user-event |
| Coverage | @vitest/coverage-v8 |
| Lint | ESLint 9 + react-hooks + react-refresh |
.
├── README.md # This document
├── .env.example # Environment template — copy to .env
├── docker-compose.yml # One-command local stack
├── docs/
│ ├── RUNBOOK.md # Deploy, rollback, key rotation, incidents
│ └── System_Blueprint.md # Full system design reference
├── backend/ # Spring Boot application
│ ├── mvnw / mvnw.cmd # Maven wrapper
│ ├── pom.xml
│ └── src/
│ ├── main/java/com/am/backend/
│ │ ├── BackendApplication.java
│ │ ├── cache/ # CacheNames, RedisCacheConfig
│ │ ├── config/ # OpenApiConfig, PaginationConfig
│ │ ├── controller/ # REST controllers
│ │ ├── dto/ # Request/response records
│ │ ├── exception/ # Domain exceptions + GlobalExceptionHandler
│ │ ├── job/ # JobRunr job handlers
│ │ ├── model/ # JPA entities + enums
│ │ ├── ratelimit/ # Redis fixed-window limiter
│ │ ├── repository/ # Spring Data JPA repositories
│ │ ├── security/ # SecurityConfig, JWT, hasher, SSRF guard
│ │ └── service/ # Business logic
│ ├── main/resources/application.properties
│ └── test/ # Unit + context tests
└── frontend/ # React SPA
├── package.json · vite.config.js · vitest.config.js
├── tailwind.config.js · postcss.config.js · eslint.config.js
├── src/
│ ├── main.jsx · App.jsx · index.css
│ ├── api/client.js # Single HTTP boundary
│ ├── store/ # Redux Toolkit slices
│ ├── hooks/ # useAppDispatch / useAppSelector
│ ├── components/ # Layout, guards, modals, UI kit
│ └── pages/ # One file per route view
└── tests/ # setup.js · unit/ · component/
| Tool | Version |
|---|---|
| Docker + Docker Compose | any current release (Option A) |
| Java (JDK) | 17 |
| Node.js | 20 |
| PostgreSQL | 16 |
| Redis | 7 |
cp .env.example .env
# Edit .env and set a real JWT_SECRET (>= 32 bytes)
docker compose up --build| Service | URL |
|---|---|
| SPA | http://localhost:5173 |
| API | http://localhost:8080 |
| Swagger UI | http://localhost:8080/swagger-ui.html |
| OpenAPI JSON | http://localhost:8080/v3/api-docs |
| JobRunr dashboard | http://localhost:8000/dashboard |
| Health | http://localhost:8080/actuator/health |
nginx serves the SPA and proxies /api/* to the backend on the same origin, so no CORS
configuration is involved in this topology. The compose stack also starts Postgres and Redis with
health checks, and the backend waits for both to be healthy before it boots.
# 1. Infrastructure
docker run -d --name am-db -e POSTGRES_DB=api_marketplace \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 postgres:16-alpine
docker run -d --name am-redis -p 6379:6379 redis:7-alpine
# 2. Backend (:8080) — the dev server proxies /api to it
cd backend
export JWT_SECRET="a-sufficiently-long-random-secret-value-32chars+"
./mvnw spring-boot:run
# 3. Frontend (:5173, proxies /api → :8080)
cd frontend
npm install
npm run devGenerate a safe secret with:
openssl rand -base64 48Self-service registration always creates a DEVELOPER (or PROVIDER on request); the ADMIN
role is never grantable through the public API. Promote your first account directly:
UPDATE users SET role = 'ADMIN' WHERE email = 'you@example.com';Once one administrator exists, further roles can be changed through PUT /api/users/{id}.
Roles. ADMIN governs everything. PROVIDER publishes APIs, defines plans and owns a key pool —
but never consumes APIs, so a publisher cannot be its own customer for quota purposes. DEVELOPER
discovers, subscribes, reviews and consumes.
API keys are hashed, never stored. A key is minted as am_<uuid>; only its SHA-256 digest is
persisted (ApiKey.keyHash), alongside a short display prefix (keyPrefix, e.g. am_1a2b3c4d). The
plaintext is returned exactly once — at creation or rotation — and cannot be retrieved afterwards, so
a database disclosure cannot be replayed against the gateway.
The key pool. Providers provision unassigned keys (user = null). A subscription delegates one
from that pool; cancelling returns it. If the pool is empty, subscribing fails with 409 rather than
handing out an unusable credential.
Visibility masking. An API that is not publicly visible and not owned by the caller returns
404, not 403, so status codes cannot be used to enumerate draft or private APIs. An API is publicly
listable iff status == PUBLISHED and visibility != PRIVATE.
Quota. Plan.rateLimit + ratePeriod define a fixed window enforced by an atomic Redis counter
at the gateway. Exceeding it returns 429 with Retry-After and X-RateLimit-* headers. When Redis
is unreachable the service either fails open or closed, per RATE_LIMIT_FAIL_OPEN.
Gateway SSRF controls. A provider's baseUrl is untrusted input. The gateway allows only
http/https, optionally restricts hosts via GATEWAY_ALLOWED_HOSTS, resolves DNS and rejects any
address in a private, loopback, link-local, carrier-grade-NAT or unique-local range, strips hop-by-hop
headers, and caps response body size.
All entities extend BaseEntity (@MappedSuperclass): a UUID id (@UuidGenerator,
updatable = false) plus createdAt / updatedAt maintained by @PrePersist / @PreUpdate.
| Entity | Table | Key fields | Relationships |
|---|---|---|---|
| User | users |
email (unique), password (BCrypt), firstName, lastName, role, bio, avatarUrl |
implements UserDetails |
| Api | apis |
name, slug (unique), description, baseUrl, documentationUrl, status, visibility, version, averageRating, reviewCount |
category (M:1, optional), owner (M:1 → User, required) |
| Category | categories |
name (unique), description |
— |
| Plan | plans |
name, description, price, rateLimit, ratePeriod, supportLevel |
api (M:1, required); unique (api_id, name) |
| Review | reviews |
rating (1–5), title, comment |
api (M:1), user (M:1); unique (api_id, user_id) |
| Subscription | subscriptions |
status, startedAt, expiresAt, cancelledAt |
user (M:1), api (M:1), plan (M:1, optional) |
| ApiKey | api_keys |
keyHash (unique, SHA-256 hex), keyPrefix, name, status, expiresAt, lastUsedAt |
user (M:1, nullable = pool), api (M:1), plan (M:1, optional) |
| UsageLog | usage_logs |
endpoint, method, statusCode, latencyMs, ipAddress, userAgent |
apiKey (M:1, required) |
| Enum | Values | Default |
|---|---|---|
Role |
ADMIN, PROVIDER, DEVELOPER |
DEVELOPER |
ApiStatus |
DRAFT, PUBLISHED, ARCHIVED |
DRAFT |
ApiVisibility |
PUBLIC, PRIVATE |
PRIVATE |
ApiKeyStatus |
ACTIVE, REVOKED, EXPIRED |
ACTIVE |
SubscriptionStatus |
ACTIVE, CANCELLED, EXPIRED |
ACTIVE |
RatePeriod |
SECOND, MINUTE, HOUR, DAY, MONTH |
— |
All enums persist as strings (@Enumerated(EnumType.STRING)).
Base path /api. Request and response bodies are JSON. Authenticated calls send
Authorization: Bearer <jwt>; gateway calls send X-API-Key. List endpoints return a
PageResponse<T> envelope (content, page, size, totalElements, totalPages) and accept Spring
Pageable parameters (page, size, sort), capped by app.pagination.max-page-size.
| Method | Path | Access | Returns |
|---|---|---|---|
| POST | /auth/register |
public | 201 AuthResponse |
| POST | /auth/login |
public | 200 AuthResponse |
AuthResponse: accessToken, tokenType, id, email, firstName, lastName, role, createdAt.
| Method | Path | Access |
|---|---|---|
| GET | /apis, /apis/published |
public (supports categoryId, search) |
| GET | /apis/all |
ADMIN |
| GET | /apis/my |
PROVIDER, ADMIN |
| GET | /apis/{id}, /apis/slug/{slug} |
public (returns 404 when not viewable) |
| POST | /apis |
PROVIDER — new APIs default to DRAFT + PRIVATE |
| PUT | /apis/{id} |
PROVIDER (own), ADMIN |
| DELETE | /apis/{id} |
PROVIDER (own), ADMIN — cascades to plans, keys, subscriptions, reviews |
| Method | Path | Access |
|---|---|---|
| GET | /apis/{apiId}/keys |
PROVIDER (own), ADMIN |
| GET | /apis/{apiId}/keys/{keyId} |
PROVIDER (own), ADMIN |
| GET | /apis/{apiId}/keys/my |
DEVELOPER, ADMIN |
| GET | /keys/my |
DEVELOPER, ADMIN |
| GET | /keys/all |
ADMIN |
| POST | /apis/{apiId}/keys |
PROVIDER (own) — returns plaintextKey once |
| PUT | /apis/{apiId}/keys/{keyId} |
PROVIDER (own), ADMIN |
| POST | /apis/{apiId}/keys/{keyId}/rotate |
PROVIDER (own), ADMIN — returns a new secret once |
| POST | /apis/{apiId}/keys/{keyId}/revoke |
PROVIDER (own), ADMIN |
| DELETE | /apis/{apiId}/keys/{keyId} |
PROVIDER (own), ADMIN |
| GET | /apis/{apiId}/keys/{keyId}/usage |
PROVIDER (own), ADMIN — usage telemetry |
| Method | Path | Access |
|---|---|---|
| GET | /apis/{apiId}/plans, /apis/{apiId}/plans/{planId} |
public |
| GET | /plans/all |
ADMIN |
| POST | /apis/{apiId}/plans |
PROVIDER (own) — rateLimit + ratePeriod define the quota |
| PUT | /apis/{apiId}/plans/{planId} |
PROVIDER (own), ADMIN |
| DELETE | /apis/{apiId}/plans/{planId} |
PROVIDER (own), ADMIN — 409 while referenced |
| Method | Path | Access |
|---|---|---|
| GET | /apis/{apiId}/reviews |
public |
| GET | /reviews/all |
ADMIN |
| POST | /apis/{apiId}/reviews |
DEVELOPER, ADMIN — one review per account per API (409 on duplicate) |
| PUT | /apis/{apiId}/reviews/{reviewId} |
author, ADMIN |
| DELETE | /apis/{apiId}/reviews/{reviewId} |
author, ADMIN |
| Method | Path | Access |
|---|---|---|
| GET | /apis/{apiId}/subscriptions/my |
DEVELOPER, ADMIN |
| GET | /me/subscriptions |
DEVELOPER, ADMIN |
| GET | /apis/{apiId}/subscriptions |
PROVIDER (own), ADMIN |
| GET | /subscriptions |
ADMIN |
| POST | /apis/{apiId}/subscriptions?planId= |
DEVELOPER, ADMIN — delegates a key; 409 if pool empty |
| PUT | /apis/{apiId}/subscriptions/{subscriptionId} |
PROVIDER (own), ADMIN |
| PUT | /subscriptions/{subscriptionId} |
ADMIN |
| DELETE | /apis/{apiId}/subscriptions/{subscriptionId} |
DEVELOPER (own), ADMIN — returns the key |
| DELETE | /subscriptions/{subscriptionId} |
ADMIN |
| Method | Path | Access |
|---|---|---|
| GET | /users/me |
authenticated |
| PUT | /users/me |
authenticated (partial update) |
| GET | /users |
ADMIN |
| GET | /users/developers |
PROVIDER, ADMIN |
| GET | /users/{id} |
ADMIN |
| PUT | /users/{id} |
ADMIN — a blank password leaves the credential unchanged |
| DELETE | /users/{id} |
ADMIN — 409 while still owning records |
| Method | Path | Access |
|---|---|---|
| GET | /categories, /categories/{id} |
public |
| POST | /categories |
ADMIN |
| PUT | /categories/{id} |
ADMIN |
| DELETE | /categories/{id} |
ADMIN — 409 if assigned |
| Capability | ADMIN | PROVIDER | DEVELOPER | Anonymous |
|---|---|---|---|---|
| Register / login | ✅ | ✅ | ✅ | ✅ |
| Browse published APIs / categories / plans / reviews | ✅ | ✅ | ✅ | ✅ |
| Create / update / delete an API | ✅ (any) | ✅ (own) | ❌ | ❌ |
| Manage plans of an API | ✅ (any) | ✅ (own) | ❌ | ❌ |
| Manage API keys of an API | ✅ (any) | ✅ (own) | ❌ | ❌ |
| Subscribe to an API (receive a key) | ✅ | ❌ | ✅ | ❌ |
| Write reviews | ✅ | ❌ | ✅ | ❌ |
| View own subscriptions / keys | ✅ | ✅* | ✅ | ❌ |
Gateway forward (X-API-Key auth) |
✅ | ✅ | ✅ | ✅ (valid key) |
| User administration | ✅ | ❌ | ❌ | ❌ |
/actuator/** (except health) |
✅ | ❌ | ❌ | ❌ |
* Providers are CRUD-only and do not consume APIs; the frontend encodes this via
canConsume = isAuthenticated && !isProvider.
{ "timestamp": "2026-01-01T12:00:00", "status": 404, "message": "API not found", "errors": null }| Exception | HTTP |
|---|---|
NotFoundException |
404 |
ConflictException / DataIntegrityViolationException |
409 |
BadRequestException + malformed-request exceptions |
400 (+ field errors for validation) |
AuthenticationException |
401 |
AccessDeniedException — anonymous caller |
401 |
AccessDeniedException — authenticated caller |
403 |
RateLimitExceededException |
429 (+ Retry-After, X-RateLimit-*) |
HttpRequestMethodNotSupportedException |
405 |
NoResourceFoundException |
404 |
Exception (fallback) |
500 |
GlobalExceptionHandler inspects the authentication state so an anonymous caller denied by
@PreAuthorize receives 401 rather than 403 — returning 403 would leak the endpoint's existence.
ANY /api/gateway/forward/{apiId}[/{path}]
Headers: X-API-Key: <key> (required)
Returns: GatewayResponse { status, statusText, contentType, body, latencyMs, truncated }
The trailing path segment is optional: /forward/{apiId} targets the base URL itself while
/forward/{apiId}/v1/weather appends a sub-path.
Pipeline: validate key → check the Redis rate limit (429 on exceed) → forward (SSRF-validated) →
persist telemetry → return the upstream response verbatim.
Hardening controls
- Only
http/httpsschemes; relative and protocol-relative URLs rejected. - DNS resolution followed by rejection of any address in a private, loopback, link-local,
carrier-grade-NAT or unique-local range (
OutboundUrlValidator). - Optional per-deployment upstream host allow-list via
app.gateway.allowed-hosts. - Hop-by-hop headers stripped in both directions; caller identity headers
(
X-Forwarded-For/-Host/-Proto) replaced with the platform's own. - Response body capped at
app.gateway.max-response-body-bytes. Accept-Encodingis stripped inbound and forced toidentityoutbound, becauseGatewayResponse.bodyis aString.gzip/deflateresponses are decompressed as a bounded fallback; unsupported encodings (br,zstd) are logged and passed through undecoded.- Request-path traversal segments (
..) rejected before forwarding.
Known limitation:
GatewayResponse.bodyis text, so binary upstream responses (images, PDFs, protobuf) cannot be relayed faithfully. Seedocs/RUNBOOK.md§7.
cd backend && ./mvnw verify # unit + context tests, JaCoCo report
cd frontend && npm run lint # ESLint
cd frontend && npm run test # Vitest + React Testing Library
cd frontend && npm run test:coverage # enforces ≥70% lines / ≥60% branches
cd frontend && npm run build # production bundleThe backend suite runs with no external infrastructure (H2 in-memory database) and includes a
@SpringBootTest context-load check. The frontend suite mocks only src/api/client.js — the single
network boundary — and otherwise exercises real reducers, real routing and real DOM events.
CI/CD quality gates
- Backend:
./mvnw verify(unit + context tests) with JaCoCo reporting; dependency scanning. - Frontend:
npm run lint,npm run test:coverage,npm run build,npm audit. - Container: image scanning and SBOM generation.
Everything is environment-driven. Copy .env.example to .env and adjust; see that file for the full
list. The settings that matter most in production:
| Variable | Why |
|---|---|
JWT_SECRET |
≥ 32 bytes (HS256 requires a 256-bit key). The app refuses to start otherwise. |
CORS_ALLOWED_ORIGINS |
Explicit allow-list. * is development-only and never combined with credentials. |
GATEWAY_ALLOWED_HOSTS |
Restricts which upstream hosts the gateway may reach. |
RATE_LIMIT_FAIL_OPEN |
false for paid tiers so a Redis outage cannot become a quota bypass. |
DB_DDL_AUTO |
Set to validate in production to fail fast on schema drift. |
JOBRUNR_DASHBOARD_ENABLED |
false closes the JobRunr dashboard port in production. |
SPRINGDOC_API_DOCS_ENABLED / SPRINGDOC_SWAGGER_UI_ENABLED |
false disables Swagger in production. |
ACTUATOR_EXPOSURE |
health,info,prometheus to expose metrics in production. |
There is no
application-prod.properties. A singleapplication.propertiesgoverns every environment, and production behaviour is selected entirely through environment variables. Because development defaults exist, a misconfigured deployment will silently start with them.
| Property | Default | Env override |
|---|---|---|
server.port |
8080 |
SERVER_PORT |
spring.datasource.url |
jdbc:postgresql://localhost:5432/api_marketplace |
DB_URL |
spring.datasource.username / .password |
postgres / mysecretpassword |
DB_USERNAME, DB_PASSWORD |
| Hikari pool | max 10, min-idle 2, 30s connect, 600s idle, 1800s lifetime | DB_POOL_* |
spring.jpa.hibernate.ddl-auto |
update |
DB_DDL_AUTO (set validate in prod) |
spring.jpa.open-in-view |
false |
— |
spring.data.redis.host / .port |
localhost / 6379 |
REDIS_HOST, REDIS_PORT |
spring.cache.type |
redis |
CACHE_TYPE |
app.jwt.secret |
dev default | JWT_SECRET (≥ 32 bytes) |
app.jwt.expiration-ms |
86400000 |
JWT_EXPIRATION_MS |
app.cors.allowed-origins |
http://localhost:5173,http://localhost:4173 |
CORS_ALLOWED_ORIGINS |
app.ratelimit.default-limit / default-period-seconds |
60 / 60 |
RATE_LIMIT_DEFAULT, RATE_LIMIT_PERIOD_SECONDS |
app.ratelimit.fail-open |
true |
RATE_LIMIT_FAIL_OPEN (set false in prod) |
app.gateway.connect-timeout-ms / read-timeout-ms |
10000 / 30000 |
GATEWAY_* |
app.gateway.max-response-body-bytes |
1048576 |
GATEWAY_MAX_RESPONSE_BYTES |
app.gateway.allowed-hosts |
(empty) | GATEWAY_ALLOWED_HOSTS |
app.pagination.max-page-size |
100 |
MAX_PAGE_SIZE |
app.usage.retention-days |
90 |
USAGE_RETENTION_DAYS |
jobrunr.background-job-server.enabled |
true |
JOBRUNR_SERVER_ENABLED |
jobrunr.dashboard.enabled |
true |
JOBRUNR_DASHBOARD_ENABLED (set false in prod) |
| Actuator exposure | health,info |
ACTUATOR_EXPOSURE |
The Vite dev server runs on 5173 and proxies /api/ → localhost:8080. BASE_URL = '/api' in
src/api/client.js.
- Authentication: stateless JWT (HS256),
roleclaim; passwords hashed with BCrypt. - Authorization: method-level
@PreAuthorizerole checks plus ownership assertions inside the service layer. - Credential storage: API keys persist only as SHA-256 hashes; the plaintext is shown once.
- Information hiding: 404 masking for non-viewable APIs; 401 vs 403 disambiguated; no internal
error or stack-trace leakage (
server.error.*disabled). - Public endpoint allow-list: auth routes, OpenAPI/Swagger,
/actuator/health, public catalog reads (GET /api/apis/{uuid}is UUID-constrained so it cannot swallow/apis/allor/apis/my), categories, and/api/gateway/**(key-authenticated). Everything else requires authentication. - Gateway: SSRF hardening, header stripping, request/response size caps (10 MB).
- Data lifecycle: 90-day raw telemetry retention;
ipAddressanduserAgenttruncated on write.
For the full security design see docs/System_Blueprint.md §6.
| Cache | Key | TTL | Invalidated on |
|---|---|---|---|
apiCatalog |
published:{cat}:{search}:{page}:{size} / id:{id} / slug:{slug} |
60s | API CUD |
categories |
all / id:{id} |
5 min | Category CUD |
plans |
api:{apiId} / plan:{planId} |
60s | Plan CUD |
keyValidation |
key:{sha256digest}:{apiId} |
10s | revoke / rotate / delete / plan edit / subscription change / expiry sweep |
usageStats |
usage:{apiKeyId} |
60s | subscription change / API delete |
- Key prefix
am:cache:v3:— the version segment is bumped whenever the serialised form changes. - Cache failures are non-fatal: the error handler logs and falls through to the database.
Critical invariant: the catalog cache is principal-independent.
id:{id}/slug:{slug}carry no caller dimension, so only publicly visible APIs are ever cached —PublicApiCacheReaderre-checksisPubliclyVisible()on every populate. Adding a write path that caches anApiResponsewithout that guard would let one caller's authorisation result leak to another.
| Job | Schedule | Purpose |
|---|---|---|
| Usage-log persistence | per gateway request | Records telemetry and stamps ApiKey.lastUsedAt |
| Rating recomputation | after review changes | Recomputes averageRating / reviewCount |
| API-key expiry sweep | hourly | Marks keys EXPIRED; evicts keyValidation |
| Subscription expiry sweep | hourly | Marks subscriptions EXPIRED; releases delegated keys |
| Usage-log retention | daily | Prunes rows older than app.usage.retention-days |
The JobRunr dashboard listens on its own port (default 8000) and must be disabled in production
(JOBRUNR_DASHBOARD_ENABLED=false). Jobs are idempotent and retried with exponential backoff. Redis
must use a noeviction policy so job records are not silently dropped — the bundled compose file sets
this deliberately.
Day-to-day operations are documented in docs/RUNBOOK.md:
- Deploy — pre-flight checks, rollout, verification
- Rollback — including the schema caveat
- API key rotation — customer keys and platform secrets
- Rate limiting — diagnosing 429s and Redis outages
- Background jobs — JobRunr operations
- Database — backup and restore
- Gateway — diagnosing upstream failures
- Common incidents
- Health and observability
| Endpoint | Purpose |
|---|---|
/actuator/health |
Liveness/readiness, including db and redis components |
/actuator/info |
Build metadata |
/actuator/prometheus |
Metrics (production profile only) |
Key log lines to alert on:
| Pattern | Meaning |
|---|---|
Failed to enqueue usage-log job |
JobRunr or Redis problem; telemetry is being lost |
Rate limiter unavailable |
Redis unreachable; check the fail-open policy's effect |
Blocked SSRF attempt |
Security event — investigate the source API |
409 data integrity violation |
A unique constraint fired; usually a race on a duplicate |
500 on |
Unhandled exception; the full stack trace follows in the same log entry |
| Symptom | Likely cause & fix |
|---|---|
| Backend refuses to start | JWT_SECRET is missing or shorter than 32 bytes — generate one with openssl rand -base64 48. |
409 No API keys are currently available on subscribe |
The provider's key pool is empty. The provider must POST /apis/{apiId}/keys. |
Legitimate customer receiving 429 |
Inspect redis-cli --scan --pattern 'am:rl:{apiKeyId}:*' and raise the plan's rateLimit via PUT /apis/{apiId}/plans/{planId}. |
| Everyone signed out at once | JWT_SECRET changed or differs between instances — expected after a rotation. |
| Stale catalog data beyond a minute | A write path is missing its @CacheEvict. redis-cli FLUSHDB clears the cache immediately. |
400 The upstream API could not be reached |
The upstream is unreachable or blocked by SSRF screening; check GATEWAY_ALLOWED_HOSTS and the provider's baseUrl. |
docs/RUNBOOK.md— deploy, rollback, key rotation, incident procedures.docs/System_Blueprint.md— full system design reference: architecture, domain model, security, API reference, frontend, testing and operations.
Proprietary. All rights reserved. See the owning organisation for licensing terms.