Every comic universe, one API. One unified, typed, documented REST API over four free comic-metadata providers - Comic Vine, Metron Cloud, Marvel, and SuperHero API.
Search comics, characters, creators, publishers, and series - or fetch a single canonical record - without caring which upstream the data came from. Every provider is normalized into one schema, every result carries per-source provenance, and a single upstream being down, rate-limited, or unconfigured never fails the whole request.
- Why this exists
- Features
- Providers
- Architecture
- Quickstart
- Configuration
- API reference
- Caching & rate limiting
- Running with Docker
- Testing
- Project structure
- Roadmap
- Contributing
- License
- Disclaimer
Comic metadata is scattered across several free APIs, each with its own auth scheme, rate limits, response shape, and gaps in coverage. Building anything on top of them means writing the same fan-out, normalization, caching, and failure-handling glue over and over. This project does that work once: it puts a single, key-gated, OpenAPI-documented surface in front of all four, returns one consistent schema with provenance, and degrades gracefully when an upstream is unavailable. You bring your own provider keys; the API does the aggregation.
- Unified schema - every provider response is mapped into one set of Zod-validated canonical models (
Comic,Character,Creator,Publisher,Series) with inferred TypeScript types. - Four providers with graceful degradation - results fan out in parallel via
Promise.allSettled; an upstream that is down, rate-limited, or unconfigured is reported inmeta.sources, never thrown. Partial data still returns200. - Cross-reference dedup - records that share a deterministic cross-reference (Metron's
cv_id/gcd_idmatched against Comic Vine) are collapsed into one record, with a heuristic title/issue-number/year fallback. Every contributing source is preserved insources{}. - Pluggable cache - in-memory TTL + LRU by default, Redis-backed with
CACHE_BACKEND=redis. Per-resource TTLs (short for search, long for detail). - Per-provider upstream rate limiting - a token bucket per provider honors each one's documented limits (Comic Vine per hour, Metron per minute, Marvel per day, SuperHero per minute) so a burst can never get your key banned.
- Consumer auth + per-IP rate limit - every
/v1route requires anX-API-Key(compared in constant time);@fastify/rate-limitenforces a per-IP cap. - Auto-generated docs - OpenAPI 3 served at
/docs(Swagger UI) and/docs/json, derived directly from the Zod route schemas. - Fully typed and tested - 134 tests across mappers, dedup, cache, rate limiter, auth, env validation, and route integration (upstreams mocked with
undiciMockAgent).
A provider is enabled only when its credentials are present in the environment; otherwise it is reported as enabled: false and skipped at query time. The capability matrix below mirrors src/providers/registry.ts.
| Provider | Comics | Characters | Creators | Publishers | Series | Auth | Notes / documented limit |
|---|---|---|---|---|---|---|---|
| Comic Vine | yes | yes | yes | yes | yes | api_key query param + unique User-Agent |
Non-commercial use only. ~200 requests / resource / hour. |
| Metron Cloud | yes | yes | yes | yes | yes | HTTP Basic (username + password) | Exposes cv_id + gcd_id cross-refs; primary dedup source. ~30 / minute. |
| Marvel | yes | yes | yes | no | yes | ts + md5(ts + private + public) hash |
No publishers. Attribution required: "Data provided by Marvel. (c) Marvel". ~3000 / day. |
| SuperHero API | no | yes | no | no | no | access token in path | Characters only (powerstats, biography, appearances). |
See DISCLAIMER.md for each provider's data-use terms. You must obtain your own keys and comply with them.
flowchart TD
Client["Client"] -- "X-API-Key" --> Fastify["Fastify (routes + Zod validation, auth, per-IP rate limit)"]
Fastify --> Search["SearchService"]
Search -- "Promise.allSettled fan-out" --> Registry["ProviderRegistry (enabled + capable only)"]
Registry --> CV["ComicVineProvider"]
Registry --> MET["MetronProvider"]
Registry --> MAR["MarvelProvider"]
Registry --> SH["SuperHeroProvider"]
CV --> Mappers["client -> mapper -> canonical model"]
MET --> Mappers
MAR --> Mappers
SH --> Mappers
Mappers --> Infra["per-provider cache + token-bucket rate limiter"]
Infra --> Merge["merge + dedup (cv_id/gcd_id + title/number/year heuristics)"]
Merge --> Envelope["unified envelope: results + meta.sources + warnings"]
Envelope --> Client
Core principle: a single upstream being down, rate-limited, or unconfigured never fails the whole request. Per-provider failures are surfaced in a meta.sources block while every other provider's results still return (HTTP 200 with partial data).
Requires Node.js >=20.
# 1. Clone
git clone https://github.com/DanielChahine0/comicverse-api.git
cd comicverse-api
# 2. Configure - at minimum set API_KEYS (the example already ships a dev key)
cp .env.example .env
# 3. Install
npm install
# 4. Run in watch mode
npm run devThe server boots on http://localhost:3000. It runs out of the box with no provider keys - every provider simply reports not-configured. Add provider credentials to .env to start getting real data.
Check liveness (no key required):
curl http://localhost:3000/health{ "status": "ok", "uptime": 1.53, "version": "0.1.0" }List providers (requires the key):
curl -H "X-API-Key: dev-key-123" http://localhost:3000/v1/providers{
"providers": [
{ "provider": "comicvine", "enabled": false, "capabilities": ["comics", "characters", "creators", "publishers", "series"] },
{ "provider": "metron", "enabled": false, "capabilities": ["comics", "characters", "creators", "publishers", "series"] },
{ "provider": "marvel", "enabled": false, "capabilities": ["comics", "characters", "creators", "series"] },
{ "provider": "superhero", "enabled": false, "capabilities": ["characters"] }
]
}Search (requires the key). With no providers configured you get an empty result set plus a transparent per-source status:
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search/comics?q=saga"{
"results": [],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "total": 0, "hasMore": false },
"sources": [
{ "provider": "comicvine", "queried": false, "ok": false, "skippedReason": "not-configured" },
{ "provider": "metron", "queried": false, "ok": false, "skippedReason": "not-configured" },
{ "provider": "marvel", "queried": false, "ok": false, "skippedReason": "not-configured" }
]
}
}Browse the interactive docs at http://localhost:3000/docs, or fetch the raw OpenAPI document from http://localhost:3000/docs/json.
All configuration is read from the environment and validated with Zod at boot (src/config/env.ts); an invalid value fails fast. The table below documents every variable. See .env.example for a copy-paste starting point.
| Variable | Description | Default | Required |
|---|---|---|---|
NODE_ENV |
Environment: development | test | production |
development |
no |
PORT |
HTTP listen port | 3000 |
no |
HOST |
Bind address | 0.0.0.0 |
no |
LOG_LEVEL |
fatal | error | warn | info | debug | trace |
info |
no |
API_KEYS |
Comma-separated consumer keys accepted in the X-API-Key header |
- | yes |
RATE_LIMIT_MAX |
Per-IP request cap for the consumer rate limiter | 120 |
no |
RATE_LIMIT_WINDOW |
Window for the per-IP rate limiter | 1 minute |
no |
CACHE_BACKEND |
Cache backend: memory | redis |
memory |
no |
CACHE_TTL_SEARCH |
TTL (seconds) for search responses | 300 |
no |
CACHE_TTL_DETAIL |
TTL (seconds) for detail records | 3600 |
no |
CACHE_MAX_ITEMS |
Max entries in the in-memory cache before LRU eviction | 1000 |
no |
REDIS_URL |
Redis connection string (used when CACHE_BACKEND=redis) |
- | no |
COMICVINE_API_KEY |
Comic Vine API key. Setting it enables the Comic Vine provider. | - | no |
COMICVINE_USER_AGENT |
Unique User-Agent Comic Vine requires on every request | comicverse-api/0.1 (+https://github.com/DanielChahine0/comicverse-api) |
no |
COMICVINE_RATE_PER_HOUR |
Comic Vine token-bucket limit (requests/hour) | 200 |
no |
METRON_USERNAME |
Metron username. Enables Metron when set together with the password. | - | no |
METRON_PASSWORD |
Metron password (HTTP Basic) | - | no |
METRON_RATE_PER_MINUTE |
Metron token-bucket limit (requests/minute) | 30 |
no |
MARVEL_PUBLIC_KEY |
Marvel public key. Enables Marvel when set together with the private key. | - | no |
MARVEL_PRIVATE_KEY |
Marvel private key (used in the ts+hash signature) |
- | no |
MARVEL_RATE_PER_DAY |
Marvel token-bucket limit (requests/day) | 3000 |
no |
SUPERHERO_TOKEN |
SuperHero API token. Setting it enables the SuperHero provider. | - | no |
HTTP_TIMEOUT_MS |
Per-request timeout for upstream calls (ms) | 10000 |
no |
HTTP_RETRIES |
Retries (with backoff) for transient upstream failures | 2 |
no |
A provider is enabled only when all of its credentials are present:
- Comic Vine -
COMICVINE_API_KEY - Metron -
METRON_USERNAMEandMETRON_PASSWORD - Marvel -
MARVEL_PUBLIC_KEYandMARVEL_PRIVATE_KEY - SuperHero -
SUPERHERO_TOKEN
Base URL: http://localhost:3000. Every endpoint under /v1 requires authentication; /health and /docs are open.
List endpoints (search and series issues) return a uniform envelope:
meta.sources is the transparency layer - one entry per relevant provider:
| Field | Meaning |
|---|---|
provider |
comicvine | metron | marvel | superhero |
queried |
whether the provider was actually called |
ok |
whether it returned successfully |
count |
number of records it contributed (when ok) |
skippedReason |
why it was skipped or failed (see below) |
error |
optional error detail string |
skippedReason is one of:
| Value | Meaning |
|---|---|
not-configured |
the provider has no credentials in the environment |
not-capable |
the provider does not support this resource type (e.g. SuperHero for comics) |
rate-limited |
the per-provider token bucket would have exceeded the documented limit |
error |
the upstream call failed |
Detail endpoints return a single canonical record (no envelope). Every canonical record carries a sources map keyed by provider name, recording the native id, optional URL, and any cross-reference ids (cvId, gcdId).
Send your key in the X-API-Key header on every /v1 request. The value must match one of the comma-separated entries in API_KEYS; comparison is constant-time.
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/providers"A missing or invalid key returns 401:
{ "error": { "code": "UNAUTHORIZED", "message": "Missing or invalid API key" } }Liveness probe. No auth.
curl http://localhost:3000/health{ "status": "ok", "uptime": 1.53, "version": "0.1.0" }Swagger UI and the raw OpenAPI 3 document. No auth.
Status and capabilities of all four upstreams. Auth required.
curl -H "X-API-Key: dev-key-123" http://localhost:3000/v1/providers{
"providers": [
{ "provider": "comicvine", "enabled": false, "capabilities": ["comics", "characters", "creators", "publishers", "series"] },
{ "provider": "metron", "enabled": false, "capabilities": ["comics", "characters", "creators", "publishers", "series"] },
{ "provider": "marvel", "enabled": false, "capabilities": ["comics", "characters", "creators", "series"] },
{ "provider": "superhero", "enabled": false, "capabilities": ["characters"] }
]
}All search endpoints require auth and a non-empty q query parameter, and return the list envelope. Shared optional parameters:
| Param | Type | Description |
|---|---|---|
q |
string (required) | search query |
page |
int >= 1 | page number (default 1) |
pageSize |
int 1-100 | results per page (default 20) |
sources |
string | comma-separated provider names to restrict the query, e.g. metron,comicvine |
Unified multi-type search. Returns results grouped by resource type (not the flat list envelope):
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search?q=spider-man"{
"results": {
"comics": [ /* Comic[] */ ],
"characters": [ /* Character[] */ ],
"creators": [ /* Creator[] */ ],
"publishers": [ /* Publisher[] */ ],
"series": [ /* Series[] */ ]
},
"meta": { "sources": [ /* one merged SourceStatus per provider */ ] }
}Comic search. In addition to the shared params, accepts comic-specific filters:
| Param | Type | Description |
|---|---|---|
publisher |
string | filter by publisher name |
year |
int | filter by cover/publication year |
series |
string | filter by series/volume name |
creator |
string | filter by creator name |
curl -H "X-API-Key: dev-key-123" \
"http://localhost:3000/v1/search/comics?q=saga&publisher=Image&year=2012&page=1&pageSize=20"Representative populated result (canonical Comic, consistent with src/models/comic.ts):
{
"results": [
{
"id": "metron:31415",
"title": "Saga #1",
"issueNumber": "1",
"description": "When two soldiers from opposite sides of a galactic war fall in love...",
"coverDate": { "year": 2012, "month": 3, "day": 14, "iso": "2012-03-14" },
"publisher": "Image Comics",
"series": { "id": "8472", "name": "Saga" },
"creators": [
{ "name": "Brian K. Vaughan", "roles": ["writer"] },
{ "name": "Fiona Staples", "roles": ["artist", "cover"] }
],
"characters": [{ "name": "Alana" }, { "name": "Marko" }],
"images": [{ "kind": "cover", "url": "https://example.com/saga-1.jpg" }],
"price": { "amount": 2.99, "currency": "USD" },
"pageCount": 44,
"sources": {
"metron": { "provider": "metron", "id": 31415, "cvId": 360940, "url": "https://metron.cloud/issue/saga-2012-1/" },
"comicvine": { "provider": "comicvine", "id": "4000-360940", "url": "https://comicvine.gamespot.com/saga-1/4000-360940/" }
}
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "total": 1, "hasMore": false },
"sources": [
{ "provider": "comicvine", "queried": true, "ok": true, "count": 1 },
{ "provider": "metron", "queried": true, "ok": true, "count": 1 }
]
}
}The two raw records above (
metron:31415and the Comic Vine issue4000-360940) were collapsed into a single canonical comic because Metron'scvIdmatches the Comic Vine id - both are retained undersources.
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search/characters?q=spider-man"Representative populated Character (consistent with src/models/character.ts; powerstats is contributed only by SuperHero):
{
"results": [
{
"id": "superhero:620",
"name": "Spider-Man",
"realName": "Peter Parker",
"aliases": ["Spidey", "Web-Slinger"],
"publisher": "Marvel Comics",
"powerstats": { "intelligence": 90, "strength": 55, "speed": 67, "durability": 75, "power": 74, "combat": 85 },
"firstAppearance": "Amazing Fantasy #15",
"images": [{ "kind": "thumbnail", "url": "https://example.com/spider-man.jpg" }],
"sources": {
"superhero": { "provider": "superhero", "id": 620 },
"marvel": { "provider": "marvel", "id": 1009610, "url": "https://www.marvel.com/characters/spider-man-peter-parker" }
}
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "total": 1, "hasMore": false },
"sources": [
{ "provider": "comicvine", "queried": false, "ok": false, "skippedReason": "not-configured" },
{ "provider": "marvel", "queried": true, "ok": true, "count": 1 },
{ "provider": "superhero", "queried": true, "ok": true, "count": 1 }
]
}
}Search creators / authors. Shared params only.
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search/creators?q=brian%20k%20vaughan"Search publishers. Shared params only. (Marvel and SuperHero do not contribute publishers and are reported with skippedReason: "not-capable" when explicitly requested via sources.)
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search/publishers?q=image"Search series / volumes. Shared params (plus publisher and year are accepted as filters).
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/search/series?q=saga"Detail endpoints fetch a single canonical record from one named source. The path is /{type}/:source/:id, where :source is one of comicvine | metron | marvel | superhero and :id is that provider's native id. They require auth and return the bare record (no list envelope).
A request returns 404 when the source is unknown or not configured, when that provider does not support the resource type, or when no record matches the id.
| Endpoint | Description | Notes |
|---|---|---|
GET /v1/comics/:source/:id |
Canonical comic detail | |
GET /v1/characters/:source/:id |
Canonical character detail | |
GET /v1/creators/:source/:id |
Canonical creator detail | |
GET /v1/publishers/:source/:id |
Canonical publisher detail | marvel and superhero always 404 (not supported) |
GET /v1/series/:source/:id |
Canonical series / volume detail | |
GET /v1/series/:source/:id/issues |
Issues within a series / volume | accepts ?page= and ?pageSize=; returns the list envelope |
curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/comics/metron/31415"curl -H "X-API-Key: dev-key-123" "http://localhost:3000/v1/series/comicvine/8472/issues?page=1&pageSize=20"The series-issues endpoint returns the list envelope with a single-source meta.sources:
{
"results": [ /* Comic[] */ ],
"meta": {
"sources": [{ "provider": "comicvine", "queried": true, "ok": true, "count": 20 }]
}
}A 404 from a detail endpoint:
{ "error": { "code": "NOT_FOUND", "message": "comics '999999' not found in metron" } }Every error response uses the same envelope: { "error": { "code", "message", "details?" } }.
| Code | HTTP | When |
|---|---|---|
VALIDATION_ERROR |
400 | request failed Zod validation (e.g. missing q) |
UNAUTHORIZED |
401 | missing or invalid X-API-Key |
NOT_FOUND |
404 | unknown source, unsupported resource, or no matching record |
RATE_LIMITED |
429 | per-IP consumer rate limit exceeded |
PROVIDER_ERROR |
502 | an upstream provider failed in a way that could not be degraded |
INTERNAL_ERROR |
500 | unexpected server error |
A validation failure (e.g. GET /v1/search/comics without q):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": { "issues": [{ "path": "/q", "message": "Required" }] }
}
}Caching. Every provider call is cached under a namespaced key (provider:method:hash(params)). Search responses use a short TTL (CACHE_TTL_SEARCH, default 300s) because they are volatile; detail records use a longer TTL (CACHE_TTL_DETAIL, default 3600s) because they are stable. The default backend is an in-process TTL + LRU store bounded by CACHE_MAX_ITEMS. Set CACHE_BACKEND=redis and REDIS_URL to share the cache across instances.
Upstream rate limiting. Each provider gets its own token bucket sized to that provider's documented limit (COMICVINE_RATE_PER_HOUR, METRON_RATE_PER_MINUTE, MARVEL_RATE_PER_DAY, and a fixed 60/minute for SuperHero). When a call would exceed the bucket it fails fast and is reported with skippedReason: "rate-limited" rather than risking a key ban.
Consumer rate limiting. @fastify/rate-limit enforces a per-IP cap of RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW. Exceeding it returns 429 with the standard error envelope.
The project ships a multi-stage Dockerfile and a docker-compose.yml (API plus an optional Redis service behind a Compose profile).
# Build and run the API alone (in-memory cache)
docker compose up --buildTo use the Redis-backed cache, enable the redis profile and point the cache at it:
CACHE_BACKEND=redis REDIS_URL=redis://redis:6379 \
docker compose --profile redis up --buildConfiguration is supplied through the same environment variables as local development - mount or pass your .env, or set them in the Compose environment. The container exposes PORT (default 3000).
npm test # run the full suite once (vitest run)
npm run test:watch # watch modeThe suite has 134 tests: unit tests for every provider mapper (raw fixture to canonical model), the dedup logic, both cache backends, the token-bucket rate limiter, the auth comparison, and env validation; plus integration tests that exercise every route with upstreams mocked via undici MockAgent and captured fixtures, asserting partial-failure behavior, auth gating, validation errors, and dedup output.
Other quality gates:
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run format # prettier --write .src/
server.ts # boot: read env, build app, listen
app.ts # buildApp(deps) -> Fastify instance (testable, no listen)
config/
env.ts # Zod-validated env config, fail-fast at boot
core/
httpClient.ts # undici-based: timeouts, retries + backoff, auth injection
cache/ # Cache interface + memory and redis backends
rateLimiter.ts # per-provider token bucket (perHour/perMinute/perDay)
errors.ts # typed errors -> consistent JSON envelope
logger.ts # pino config
models/ # canonical Zod schemas + inferred TS types
common.ts comic.ts character.ts creator.ts publisher.ts series.ts
providers/
types.ts base.ts registry.ts
comicvine/ metron/ marvel/ superhero/ # each: client + mapper + index
services/
aggregate.ts # parallel fan-out with partial-failure handling
dedup.ts # cross-ref + heuristic merge
searchService.ts # orchestrates aggregate + dedup per resource type
routes/
health.ts providers.ts search.ts
comics.ts characters.ts creators.ts publishers.ts series.ts
shared.ts # shared deps, Zod request schemas, detail resolver
plugins/
auth.ts # X-API-Key check (constant-time), exempts /health + /docs
rateLimit.ts # @fastify/rate-limit (per-IP)
swagger.ts # @fastify/swagger + swagger-ui at /docs
errorHandler.ts # central error -> JSON envelope
test/
unit/ # mappers, dedup, cache, rate limiter, auth, env
integration/ # routes with upstreams mocked (undici MockAgent)
fixtures/ # captured sample raw responses per provider
- Additional providers (e.g. Grand Comics Database direct, League of Comic Geeks).
- Configurable merge precedence per field.
- Optional persistent catalog store for offline/warm-start scenarios.
- Cursor-based pagination for large result sets.
- Webhooks / change feeds for tracked series.
Out of scope by design: a persistent catalog DB, a web UI, user accounts beyond API-key gating, write operations to upstreams, and GraphQL.
Contributions are welcome. See CONTRIBUTING.md for dev setup, the npm scripts, conventional-commit and branch conventions, the TDD expectation, and the pre-PR checklist. All participants are expected to follow the Code of Conduct. To report a security issue, see SECURITY.md.
Released under the MIT License.
This project is a metadata aggregator, not a data redistributor. It bundles and caches no upstream data beyond transient operational caching, and you must obtain your own provider API keys and comply with each provider's terms - including Comic Vine's non-commercial-only restriction and Marvel's attribution requirement. Read DISCLAIMER.md before deploying.
{ "results": [ /* canonical records */ ], "meta": { "pagination": { "page": 1, "pageSize": 20, "total": 42, "hasMore": true }, "sources": [ { "provider": "metron", "queried": true, "ok": true, "count": 12 }, { "provider": "comicvine", "queried": false, "ok": false, "skippedReason": "not-configured" } ] }, "warnings": [ /* optional human-readable notes */ ] }