{"openapi":"3.1.0","info":{"title":"Verdict Engine API","version":"0.7.0","description":"The self-hosted Verdict fraud & risk decisioning engine. Send an event, get an auditable verdict. Base URL is the engine you deploy.","license":{"name":"Apache-2.0"}},"servers":[{"url":"http://localhost:4000","description":"Local dev — replace with your engine's URL"}],"tags":[{"name":"Decisions"},{"name":"Auth"},{"name":"Cases"},{"name":"Config"},{"name":"API Keys"},{"name":"Analytics"},{"name":"Graph"},{"name":"Webhooks"},{"name":"Notifications"},{"name":"Settings"},{"name":"Labels"},{"name":"Data governance"},{"name":"System"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"},"BearerAuth":{"type":"http","scheme":"bearer"}}},"paths":{"/v1/decisions":{"post":{"operationId":"decide","tags":["Decisions"],"summary":"Score an event and return a verdict","description":"The one endpoint on the request path. Normalizes the event, scores it against the active policy, and returns allow / challenge / review / deny. Rate-limited per API key (429 with Retry-After after RATE_LIMIT_DECISIONS_PER_MIN, default 600/min); every response carries X-RateLimit-Limit and X-RateLimit-Remaining. verdict is one of %allow% · %challenge% · %review% · %deny%. A review verdict opens a case. Send an Idempotency-Key to make retries safe.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"The verdict id (prefixed vd_). Primary key in the append-only verdict log."},"eventId":{"type":"string","description":"The id assigned to this event. Use it to correlate a later chargeback label or case."},"verdict":{"type":"string","enum":["allow","challenge","review","deny"],"description":"The decision. allow = proceed; challenge = step-up (3DS/OTP); review = opens a case; deny = block."},"score":{"type":"number","description":"The risk score the policy banded into the verdict. -1 for a list/degraded short-circuit that skipped scoring."},"reasons":{"type":"array","items":{"type":"object","properties":{"tag":{"type":"string"},"points":{"type":"number"}}},"description":"Every signal that fired and the points it contributed — the full, attributable explanation."},"policyId":{"type":"string","description":"The policy that decided this event."},"policyVersion":{"type":"string","description":"The exact policy version applied — pin for audit and replay."},"decidedAt":{"type":"string","description":"When the verdict was reached."}},"required":["id","eventId","verdict","score","reasons","policyId","policyVersion","decidedAt"]},"example":{"id":"vd_0mu4…","eventId":"evt_0mu4…","verdict":"review","score":55,"reasons":[{"tag":"takeover","points":33},{"tag":"no_3ds","points":22}],"policyId":"pol_card_authorize","policyVersion":"v0.4.0","decidedAt":"2026-09-17T10:22:00.000Z"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["card.authorize","card.capture","card.refund","payment.authorize","wallet.withdraw","account.login","order.place"],"description":"Channel-agnostic event type. Selects which ruleset and policy apply."},"amount":{"type":"number","description":"Transaction value, for money-bearing events. Non-negative. Feeds amount rules and the per-user anomaly baseline."},"currency":{"type":"string","description":"ISO-4217 code, e.g. USD, ETB. Required when amount is present."},"subject":{"type":"object","properties":{"userId":{"type":"string","description":"The acting user's stable id. The primary entity in the graph and the key for anomaly baselines."},"deviceId":{"type":"string","description":"Stable device/client id. Powers velocity, first-seen and device-linking (graph) signals."},"fingerprint":{"type":"string","description":"Client-computed device fingerprint hash. Drives device.usersOnFingerprint and device.fingerprintDeviceMismatch (same fingerprint on a different deviceId — cloning/spoofing), independent of deviceId."},"ip":{"type":"string","description":"Client IP. Resolved to a coarse location for geo.country / geo.distanceKm / geo.impossibleTravel, and linked in the entity graph (shared-IP / ring signals). Never placed in URLs or logs."},"phone":{"type":"string","description":"MSISDN (phone number) for mobile-money / telecom rails. A graph entity — flags SIM-box / account-farming via graph.usersOnPhone."},"channel":{"type":"string","description":"Origin rail, e.g. \"visa\", \"telebirr\", \"web\"."}}},"instrument":{"type":"object","properties":{"kind":{"type":"string","enum":["card","wallet","bank"],"description":"Payment instrument type, for value-bearing events."},"bin":{"type":"string","description":"Card issuer BIN — the first 6–8 digits only. Never send a full PAN."},"issuerCountry":{"type":"string","description":"ISO-3166 issuer country, e.g. US."},"threeDS":{"type":"boolean","description":"Whether the transaction carried a 3-D Secure result."}}},"attributes":{"type":"object","additionalProperties":true,"description":"Additional validated scalar signals, e.g. { geoMismatch: true }. Readable in rules as attr.<key>."}},"required":["type"]},"example":{"type":"card.authorize","amount":3500,"currency":"USD","subject":{"userId":"usr_3f9a","deviceId":"dev_2b1","fingerprint":"fp_9c1e77a2b4","ip":"203.0.113.7","channel":"visa"},"instrument":{"kind":"card","bin":"411111","issuerCountry":"US","threeDS":false}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Retry-safe: the same key returns the original decision instead of recomputing. Defaults to the event id."},{"name":"X-Correlation-Id","in":"header","required":false,"schema":{"type":"string"},"description":"Threaded through logs and events for tracing."}]}},"/v1/decisions/batch":{"post":{"operationId":"decide-batch","tags":["Decisions"],"summary":"Score up to 100 events in one call","description":"Submit an array of events under `events`; each is scored independently and the response preserves order. A malformed event fails only its own entry (ok:false) — the rest still return verdicts. Idempotency is keyed per event by its id. Counts as one rate-limited request. Cap 100 events per call.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"results":[{"ok":true,"decision":{"id":"vd_…","verdict":"allow","score":4,"reasons":[]}},{"ok":false,"error":{"code":"INGEST_UNKNOWN_TYPE","message":"unknown event type: …"}}]}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"string","description":"The events to score. Each event is the same shape as POST /v1/decisions."}},"required":["events"]},"example":{"events":[{"type":"card.authorize","amount":20,"currency":"USD","subject":{"userId":"u1"}},{"type":"account.login","subject":{"userId":"u2","fingerprint":"fp_x"}}]}}}}}},"/v1/decisions/async":{"post":{"operationId":"decide-async","tags":["Decisions"],"summary":"Accept an event and score it off the response path","description":"Returns 202 immediately with the event id; the verdict is computed in the background, written durably, and fanned out on the bus (subscribe a webhook to verdict.reached.v1). Poll GET /v1/decisions/{id} for the result. Idempotency is keyed by the event id. 202 Accepted. The verdict is not in this response — poll GET /v1/decisions/{id}.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"accepted":true,"id":"evt_9f2a","correlationId":"cor_…"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"(event)":{"type":"string","description":"Same shape as POST /v1/decisions."}},"required":["(event)"]},"example":{"id":"evt_9f2a","type":"card.authorize","amount":30,"currency":"USD","subject":{"userId":"u1"}}}}}}},"/v1/decisions/{id}":{"get":{"operationId":"decide-get","tags":["Decisions"],"summary":"Fetch a decision by event id","description":"Returns the verdict for an event id (e.g. one submitted via /async). O(1) lookup. 404 while it is still pending or if the id is unknown. 404 with code NOT_FOUND while the async decision is still pending.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"id":"vd_…","eventId":"evt_9f2a","verdict":"allow","score":4,"reasons":[],"decidedAt":"2026-01-01T00:00:00.000Z"}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/auth/status":{"get":{"operationId":"auth-status","tags":["Auth"],"summary":"Whether an admin exists yet","description":"needsBootstrap is true until the first (admin) account is created.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"needsBootstrap":true}}}}}}},"/v1/auth/register":{"post":{"operationId":"auth-register","tags":["Auth"],"summary":"Bootstrap the admin account","description":"Creates the first user as admin. Once any user exists this returns 403 REGISTRATION_CLOSED — further operators are added by an admin.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"token":"eyJ…","user":{"email":"admin@bank.com","role":"admin"}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","description":"Login email (normalized lowercase)."},"password":{"type":"string","description":"At least 8 characters. Stored scrypt-hashed."}},"required":["email","password"]},"example":{"email":"admin@bank.com","password":"••••••••"}}}}}},"/v1/auth/login":{"post":{"operationId":"auth-login","tags":["Auth"],"summary":"Exchange credentials for a token","description":"Returns a bearer token valid for 12 hours. Rate-limited per IP (429 with Retry-After after RATE_LIMIT_LOGIN_PER_MIN attempts, default 10/min). Send the token as `Authorization: Bearer <token>` on operator endpoints.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"token":"eyJ…","user":{"email":"maya@bank.com","role":"analyst"}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","description":"Operator login email."},"password":{"type":"string","description":"Operator password."}},"required":["email","password"]},"example":{"email":"maya@bank.com","password":"••••••••"}}}}}},"/v1/auth/users":{"get":{"operationId":"auth-users-list","tags":["Auth"],"summary":"List operators","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":[{"email":"maya@bank.com","role":"analyst","createdAt":"…"}]}}}}},"post":{"operationId":"auth-users-create","tags":["Auth"],"summary":"Add an operator","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"email":"ana@bank.com","role":"analyst","createdAt":"…"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","description":""},"password":{"type":"string","description":"Temporary password, ≥ 8 chars."},"role":{"type":"string","enum":["analyst","admin"],"description":"Defaults to analyst."}},"required":["email","password"]},"example":{"email":"ana@bank.com","password":"••••••••","role":"analyst"}}}}}},"/v1/auth/logout":{"post":{"operationId":"auth-logout","tags":["Auth"],"summary":"Log out (revoke this token)","description":"Revokes the presented bearer token immediately, before it would expire.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}}}},"/v1/auth/logout-all":{"post":{"operationId":"auth-logout-all","tags":["Auth"],"summary":"Log out everywhere","description":"Revokes every active session for the current user — use after a suspected compromise.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}}}},"/v1/auth/refresh":{"post":{"operationId":"auth-refresh","tags":["Auth"],"summary":"Refresh the token","description":"Exchanges a still-valid token for a fresh one (sliding session). The old token stays valid until it expires.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"token":"eyJ…","user":{"email":"maya@bank.com","role":"analyst"}}}}}}}},"/v1/cases":{"get":{"operationId":"list-cases","tags":["Cases"],"summary":"List the review queue","description":"Same object shape is returned by GET /v1/cases/:id.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Case id (cse_)."},"eventId":{"type":"string","description":"The event that opened the case."},"verdictId":{"type":"string","description":"The verdict that opened it."},"queue":{"type":"string","description":"The review queue the case sits in."},"verdict":{"type":"string","description":"Always review — only review verdicts open cases."},"score":{"type":"number","description":"The risk score at decision time."},"status":{"type":"string","enum":["open","assigned","resolved"],"description":"Case lifecycle state."},"assignedTo":{"type":"string","description":"Analyst email, once assigned."},"resolution":{"type":"object","description":"Present once resolved."},"openedAt":{"type":"string","description":"When the case opened."},"audit":{"type":"array","items":{"type":"object"},"description":"Append-only trail: opened / assigned / resolved, each with actor and timestamp."}},"required":["id","eventId","verdictId","queue","verdict","score","status","openedAt","audit"]}}}}},"parameters":[{"name":"queue","in":"query","required":false,"schema":{"type":"string","description":"Defaults to \"risk-ops\"."},"description":"Defaults to \"risk-ops\"."}]}},"/v1/cases/{id}":{"get":{"operationId":"get-case","tags":["Cases"],"summary":"Fetch one case with its audit trail","description":"404 when the case does not exist.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/cases/{id}/assign":{"post":{"operationId":"assign-case","tags":["Cases"],"summary":"Assign a case to yourself","description":"The analyst is taken from your token, not the body.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"id":"cse_…","status":"assigned","assignedTo":"maya@bank.com"}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/cases/{id}/resolve":{"post":{"operationId":"resolve-case","tags":["Cases"],"summary":"Resolve a case and emit a label","description":"409 if already resolved. Resolving feeds a training label into Feedback.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"outcome":{"type":"string","enum":["fraud","legit","inconclusive"],"description":"Ground truth. \"inconclusive\" records no label."},"note":{"type":"string","description":"Rationale kept on the case."}},"required":["outcome"]},"example":{"outcome":"fraud","note":"confirmed ATO"}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/rules":{"get":{"operationId":"cfg-rules","tags":["Config"],"summary":"The active rulesets, rendered as DSL","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":[{"eventType":"card.authorize","version":"seed@v0.4","rules":[{"id":"r_velocity","tag":"velocity","weight":28,"dsl":"rule r_velocity { … }"}]}]}}}}}},"/v1/policies":{"get":{"operationId":"cfg-policies","tags":["Config"],"summary":"The active policies and their bands","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}}},"post":{"operationId":"cfg-publish","tags":["Config"],"summary":"Publish a new policy version","description":"Validated: bands must cover 0–100 with no gaps or overlaps. The new version becomes active immediately; the previous one is retained for rollback.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true,"version":"v0.4.1"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Existing policy id."},"version":{"type":"string","description":"New, immutable version label."},"onError":{"type":"string","enum":["fail_open","fail_closed"],"description":"Fallback verdict on a degraded engine."},"bands":{"type":"array","items":{"type":"string"},"description":"{ verdict, min, max, reviewQueue? } contiguous over 0–100."}},"required":["id","version","onError","bands"]}}}}}},"/v1/policies/{id}/rollback":{"post":{"operationId":"cfg-rollback","tags":["Config"],"summary":"Roll a policy back to an earlier version","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"toVersion":{"type":"string","description":"A version from the policy's history."}},"required":["toVersion"]},"example":{"toVersion":"v0.4.0"}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/policies/{id}/history":{"get":{"operationId":"cfg-history","tags":["Config"],"summary":"A policy's version history","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":["v0.4.0","v0.4.1"]}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/policies/{id}/simulate":{"post":{"operationId":"cfg-simulate","tags":["Config"],"summary":"See which verdict a score would get","description":"Test config changes without sending a real event.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"verdict":"review","reviewQueue":"risk-ops"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"score":{"type":"number","description":"0–100."}},"required":["score"]},"example":{"score":61}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/verdicts":{"get":{"operationId":"cfg-verdicts","tags":["Config"],"summary":"Recent entries from the append-only verdict log","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":[{"id":"vd_…","verdict":"deny","score":82,"decidedAt":"…"}]}}}},"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"number","description":"1–100, default 25."},"description":"1–100, default 25."}]}},"/v1/backtest":{"post":{"operationId":"cfg-backtest","tags":["Config"],"summary":"Replay a candidate rule/policy change over labeled history","description":"Re-runs a candidate policy (and optionally a candidate ruleset) against the stored event + feature snapshots for events that have a fraud/legit label, and reports how it would have performed versus the live policy. Read-only — nothing is published. Powers the dashboard's test-before-you-publish panel. 400 if the candidate policy has gaps.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"eventType":{"type":"string","description":"The event type replayed."},"sampleSize":{"type":"number","description":"Replay samples available for this event type."},"labeled":{"type":"number","description":"Of those, how many had a fraud/legit label and were scored. A candidate is only judged on labeled history."},"candidate":{"type":"object","properties":{"truePositives":{"type":"number","description":"Labeled-fraud events the candidate would flag (review/challenge/deny)."},"falsePositives":{"type":"number","description":"Labeled-legit events the candidate would flag."},"falseNegatives":{"type":"number","description":"Labeled-fraud events the candidate would allow."},"trueNegatives":{"type":"number","description":"Labeled-legit events the candidate would allow."},"precision":{"type":"number","description":"TP ÷ (TP + FP) for the candidate."},"recall":{"type":"number","description":"TP ÷ (TP + FN) — share of fraud caught."},"falsePositiveRate":{"type":"number","description":"FP ÷ (FP + TN)."}}},"baseline":{"type":"object","properties":{"*":{"type":"object","description":"The same confusion counts for the currently-live policy, on the same labeled set."}}},"delta":{"type":"object","properties":{"fraudCaught":{"type":"number","description":"Candidate TP minus baseline TP. Positive = more fraud caught."},"falsePositives":{"type":"number","description":"Candidate FP minus baseline FP. Negative = fewer false positives."},"flips":{"type":"number","description":"Decisions whose verdict would change versus what actually happened."}}}},"required":["eventType","sampleSize","labeled"]},"example":{"eventType":"card.authorize","sampleSize":1240,"labeled":312,"candidate":{"truePositives":190,"falsePositives":40,"falseNegatives":22,"trueNegatives":60,"precision":0.83,"recall":0.9,"falsePositiveRate":0.19},"baseline":{"truePositives":170,"falsePositives":55,"falseNegatives":42,"trueNegatives":45},"delta":{"fraudCaught":20,"falsePositives":-15,"flips":48}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"eventType":{"type":"string","description":"Which event type's history to replay, e.g. card.authorize."},"policy":{"type":"object","description":"Candidate { bands, onError } to test. Omit to keep the live policy."},"rules":{"type":"array","items":{"type":"string"},"description":"Candidate ruleset to re-evaluate. Omit to reuse each decision's recorded score."}},"required":["eventType"]},"example":{"eventType":"card.authorize","policy":{"bands":[{"verdict":"allow","min":0,"max":34},{"verdict":"review","min":35,"max":69,"reviewQueue":"risk-ops"},{"verdict":"deny","min":70,"max":100}]}}}}}}},"/v1/apikeys":{"get":{"operationId":"keys-list","tags":["API Keys"],"summary":"List service keys","description":"Only the prefix is stored for display — the full key is never retrievable.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":[{"id":"key_…","name":"payment-gateway","prefix":"vk_live_a1b2c3","createdBy":"admin@bank.com","createdAt":"…","revoked":false}]}}}}},"post":{"operationId":"keys-create","tags":["API Keys"],"summary":"Create a service key","description":"Optionally scope the key to specific actions and set an expiry. A key with no scopes grants both. Send the plaintext as X-API-Key. A request to an endpoint outside the key's scope is rejected with 403. Only the SHA-256 hash is stored.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"plaintext":{"type":"string","description":"The full key (vk_live_…). Returned only on creation — it is never recoverable afterwards."},"key":{"type":"object","properties":{"id":{"type":"string","description":"Key id, used to revoke it."},"name":{"type":"string","description":"The label you gave it."},"prefix":{"type":"string","description":"First chars of the key, shown in listings for identification."},"scopes":{"type":"array","items":{"type":"string"},"description":"Actions this key may perform."},"expiresAt":{"type":"string","description":"When the key expires, if ever."},"expired":{"type":"boolean","description":"Whether the key has already expired."},"revoked":{"type":"boolean","description":"Whether the key has been revoked."}}}},"required":["plaintext"]},"example":{"plaintext":"vk_live_a1b2c3…","key":{"id":"key_…","name":"payment-gateway","prefix":"vk_live_a1b2c3","scopes":["decisions"],"expiresAt":"2026-12-16T…","expired":false,"createdBy":"admin@bank.com","createdAt":"2026-09-17T10:00:00.000Z","revoked":false}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"A label, e.g. the service that will use it."},"scopes":{"type":"string","enum":["decisions","labels"],"description":"Actions the key may perform. Omit for full access (both)."},"expiresInDays":{"type":"number","description":"Days until the key expires. Omit for a key that never expires."}},"required":["name"]},"example":{"name":"payment-gateway","scopes":["decisions"],"expiresInDays":90}}}}}},"/v1/apikeys/{id}/revoke":{"post":{"operationId":"keys-revoke","tags":["API Keys"],"summary":"Revoke a key immediately","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/analytics/summary":{"get":{"operationId":"analytics","tags":["Analytics"],"summary":"Rollups from the verdict log and resolved cases","description":"A read-model maintained by a projector that consumes verdict + case events — never queried off the write path.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"totals":{"type":"object","properties":{"decisions":{"type":"number","description":"All decisions seen by the projector."},"byVerdict":{"type":"object","additionalProperties":{"type":"number"},"description":"Count per verdict (allow/challenge/review/deny)."}}},"scoreBuckets":{"type":"number","description":"Score histogram in 10-point buckets (0–9 … 100)."},"topTags":{"type":"object","additionalProperties":{"type":"number"},"description":"How often each signal fired."},"byDay":{"type":"object","additionalProperties":{"type":"string"},"description":"Per-day totals and verdict breakdown."},"cases":{"type":"object","description":"Analyst case-resolution outcomes."},"labels":{"type":"object","description":"Ground-truth labels by outcome and source — includes chargebacks that never became cases."},"falsePositiveRate":{"type":"number","description":"legit ÷ (fraud + legit) over resolved cases."}},"required":["scoreBuckets","topTags","byDay","cases","labels","falsePositiveRate"]}}}}}}},"/v1/model":{"get":{"operationId":"model","tags":["Analytics"],"summary":"The adaptive model's learned signal weights","description":"Per-tag weights the adaptive scorer learns from labels — how often each signal rode a fraud outcome. Projected even when the weighted scorer is live, so you can see what the learned model would do before switching SCORER=learned.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"active":{"type":"string","enum":["weighted","learned"],"description":"Which scorer is live (set by the SCORER env var). The model is projected either way."},"weights":{"type":"object","properties":{"tag":{"type":"array","items":{"type":"string","description":"The signal tag."}},"fraud":{"type":"array","items":{"type":"number","description":"Times this tag rode a fraud label."}},"legit":{"type":"array","items":{"type":"number","description":"Times this tag rode a legit label."}},"weight":{"type":"array","items":{"type":"number","description":"Learned points, from the smoothed fraud rate. Used by the learned scorer."}},"trusted":{"type":"array","items":{"type":"boolean","description":"Whether it has enough labels (≥3) to be used; until then the scorer keeps the hand weight."}}}}},"required":["active"]},"example":{"active":"weighted","weights":[{"tag":"takeover","fraud":41,"legit":3,"weight":42,"trusted":true},{"tag":"velocity","fraud":22,"legit":9,"weight":32,"trusted":true}]}}}}}}},"/v1/graph/{kind}/{id}":{"get":{"operationId":"graph-neighborhood","tags":["Graph"],"summary":"An entity's linked users, devices, IPs and phones, and its cluster size","description":"The entity graph links the identifiers events carry (kind is user, device, ip or phone). ringSize is the connected cluster the entity sits in — the ring signal rules read as graph.ringSize; a shared phone drives graph.usersOnPhone (SIM-box / mobile-money farming). Cards are not nodes: only the issuer BIN is held, which would over-link. 404-safe: an unknown entity returns empty links and ringSize 1.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["user","device","ip"],"description":"The entity kind queried (from the path)."},"id":{"type":"string","description":"The entity id queried."},"users":{"type":"array","items":{"type":"string"},"description":"Distinct users directly linked to this entity."},"devices":{"type":"array","items":{"type":"string"},"description":"Distinct devices directly linked."},"ips":{"type":"array","items":{"type":"string"},"description":"Distinct IPs directly linked."},"ringSize":{"type":"number","description":"Size of the connected cluster this entity sits in (walk capped at 500). 1 means unconnected."}},"required":["kind","id","users","devices","ips","ringSize"]},"example":{"kind":"device","id":"dev_ring","users":["usr_1","usr_2","usr_3"],"devices":[],"ips":["203.0.113.7"],"ringSize":8}}}}},"parameters":[{"name":"kind","in":"path","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/webhooks":{"get":{"operationId":"webhooks-list","tags":["Webhooks"],"summary":"List webhook endpoints and their delivery status","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","items":{"type":"string"},"description":"The events an endpoint may subscribe to."},"endpoints":{"type":"object","properties":{"id":{"type":"array","items":{"type":"string","description":"Endpoint id (used to revoke)."}},"url":{"type":"array","items":{"type":"string","description":"Where deliveries are POSTed."}},"events":{"type":"array","items":{"type":"array","items":{"type":"string"},"description":"Subscribed events."}},"active":{"type":"array","items":{"type":"boolean","description":"Whether it's live."}},"lastStatus":{"type":"array","items":{"type":"string","enum":["delivered","failed"],"description":"Last delivery outcome."}},"failures":{"type":"array","items":{"type":"number","description":"Consecutive failed deliveries."}}}}},"required":["events"]}}}}}},"post":{"operationId":"webhooks-create","tags":["Webhooks"],"summary":"Register an endpoint to receive signed events","description":"Each delivery is a POST with headers X-Verdict-Event, X-Verdict-Delivery and X-Verdict-Signature (`sha256=<hmac>` of the raw body with the endpoint secret). Verify the signature before trusting the payload. Deliveries are durable: each is retried with exponential backoff and dead-lettered after 8 failed attempts (inspect and re-drive via the delivery-log endpoints). Delivered payload shape: { id, type, occurredAt, correlationId, data }, where data is the event payload.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string","description":"The signing secret — returned only on creation. Store it to verify signatures."},"id":{"type":"string","description":"Endpoint id."}},"required":["secret","id"]}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"http(s) URL to deliver to."},"events":{"type":"array","items":{"type":"string"},"description":"One or more of verdict.reached.v1, case.resolved.v1, label.recorded.v1."}},"required":["url","events"]}}}}}},"/v1/webhooks/{id}/revoke":{"post":{"operationId":"webhooks-revoke","tags":["Webhooks"],"summary":"Deactivate an endpoint","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/webhooks/deliveries":{"get":{"operationId":"webhooks-deliveries","tags":["Webhooks"],"summary":"Delivery log (pending & dead-lettered)","description":"Deliveries awaiting a retry, and ones that exhausted their attempts (dead-lettered).","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"pending":[{"id":"whd_…","endpointId":"whk_…","event":"verdict.reached.v1","status":"pending","attempts":2,"nextAttemptAt":"…","lastError":"fetch failed"}],"dead":[]}}}}}}},"/v1/webhooks/deliveries/redrive":{"post":{"operationId":"webhooks-redrive","tags":["Webhooks"],"summary":"Re-drive dead-lettered deliveries","description":"Moves every dead-lettered delivery back to pending for another attempt.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"requeued":3}}}}}}},"/v1/notifications":{"get":{"operationId":"notifications-list","tags":["Notifications"],"summary":"List alert channels and subscribable events","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}}},"post":{"operationId":"notifications-create","tags":["Notifications"],"summary":"Add a Slack, Telegram or webhook alert channel","description":"Slack incoming-webhooks, Telegram bots, and generic HTTPS endpoints. For Telegram, set url to https://api.telegram.org/bot<token>/sendMessage and target to the chat id. For verdict.reached, set minVerdict to only alert at or above a severity (allow < challenge < review < deny). Set throttlePerMin to cap alerts per channel per minute (excess is dropped). Delivery is durable — queued, retried with backoff, and dead-lettered — and runs off the decision path.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["slack","telegram","webhook"],"description":"Channel kind."},"url":{"type":"string","description":"HTTPS URL (Slack webhook, Telegram bot sendMessage URL, or your endpoint). Stored server-side; shown masked."},"target":{"type":"string","description":"Telegram chat id — required for the telegram type."},"events":{"type":"array","items":{"type":"string"},"description":"Events to alert on (see the list endpoint)."},"minVerdict":{"type":"string","enum":["allow","challenge","review","deny"],"description":"verdict.reached severity floor."},"throttlePerMin":{"type":"number","description":"Max alerts per minute for this channel (0 = unlimited); omit for the env default."}},"required":["type","url","events"]}}}}}},"/v1/notifications/{id}/test":{"post":{"operationId":"notifications-test","tags":["Notifications"],"summary":"Send a test alert to a channel","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/notifications/{id}/revoke":{"post":{"operationId":"notifications-revoke","tags":["Notifications"],"summary":"Deactivate a channel","description":"","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ok":true}}}}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}]}},"/v1/notifications/deliveries":{"get":{"operationId":"notifications-deliveries","tags":["Notifications"],"summary":"List alert deliveries (pending + dead-lettered)","description":"Durable alert deliveries awaiting a retry, and those that exhausted their retries. Bodies are omitted.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"pending":[{"id":"ntd_…","channelId":"ntf_…","event":"alert.anomaly.v1","status":"pending","attempts":2,"nextAttemptAt":"2026-01-01T00:00:04.000Z","createdAt":"2026-01-01T00:00:00.000Z"}],"dead":[]}}}}}}},"/v1/notifications/deliveries/redrive":{"post":{"operationId":"notifications-redrive","tags":["Notifications"],"summary":"Re-drive dead-lettered alerts","description":"Moves every dead-lettered alert delivery back to the queue for another attempt.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"requeued":3}}}}}}},"/v1/config/rate-limits":{"get":{"operationId":"config-rate-limits-get","tags":["Settings"],"summary":"Get rate-limit settings","description":"The effective per-minute budgets and whether each is a dashboard override or the env default.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"windowMs":60000,"decisionsPerMin":600,"loginPerMin":10,"overridden":{"decisionsPerMin":false,"loginPerMin":false}}}}}}},"put":{"operationId":"config-rate-limits-put","tags":["Settings"],"summary":"Update rate-limit settings","description":"Override the per-minute budgets at runtime — no redeploy. Send a field as null to revert it to the env default; changes apply within ~10s across replicas.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"windowMs":60000,"decisionsPerMin":1200,"loginPerMin":10,"overridden":{"decisionsPerMin":true,"loginPerMin":false}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"decisionsPerMin":{"type":"number","description":"Per-API-key budget for POST /v1/decisions (null = revert to default)."},"loginPerMin":{"type":"number","description":"Per-IP budget for POST /v1/auth/login (null = revert to default)."}}},"example":{"decisionsPerMin":1200,"loginPerMin":10}}}}}},"/v1/config/alerts":{"get":{"operationId":"config-alerts-get","tags":["Settings"],"summary":"Get alert settings","description":"The anomaly z-score threshold at or above which a decision emits alert.anomaly.v1.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"anomalyZScore":3,"overridden":{"anomalyZScore":false}}}}}}},"put":{"operationId":"config-alerts-put","tags":["Settings"],"summary":"Update alert settings","description":"Set the anomaly z-score threshold (send null to revert to the default of 3). Applies within ~10s.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"anomalyZScore":2.5,"overridden":{"anomalyZScore":true}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"anomalyZScore":{"type":"number","description":"σ above the user's baseline that triggers an anomaly alert (null = default 3)."}}},"example":{"anomalyZScore":2.5}}}}}},"/v1/config/retention":{"get":{"operationId":"config-retention-get","tags":["Settings"],"summary":"Get retention settings","description":"The retention window (in days) for each append-only collection, and whether each is a dashboard override or the env default. 0 means keep forever.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"sweepMinutes":60,"days":{"verdicts":365,"activity":90,"replay":90,"idempotency":7,"deadLetter":30},"overridden":{"verdicts":false,"activity":false,"replay":false,"idempotency":false,"deadLetter":false}}}}}}},"put":{"operationId":"config-retention-put","tags":["Settings"],"summary":"Update retention settings","description":"Set the retention window (days) per collection. 0 keeps that collection forever; send a field as null to revert it to the env default. Applies on the next sweep.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"sweepMinutes":60,"days":{"verdicts":365,"activity":120,"replay":90,"idempotency":3,"deadLetter":30},"overridden":{"verdicts":false,"activity":true,"replay":false,"idempotency":true,"deadLetter":false}}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"verdicts":{"type":"number","description":"Days to keep the verdict log (0 = forever, null = env default)."},"activity":{"type":"number","description":"Days to keep the API activity log."},"replay":{"type":"number","description":"Days to keep replay samples."},"idempotency":{"type":"number","description":"Days to keep idempotency keys."},"deadLetter":{"type":"number","description":"Days to keep dead-lettered outbox rows."}}},"example":{"activity":120,"idempotency":3}}}}}},"/v1/config/retention/run":{"post":{"operationId":"config-retention-run","tags":["Settings"],"summary":"Run the retention prune now","description":"Trigger a retention sweep immediately (the job also runs on a timer) and return how many records were pruned per collection.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"ranAt":"2026-01-01T00:00:00.000Z","pruned":{"verdicts":0,"activity":4,"replay":4,"idempotency":120,"deadLetter":0}}}}}}}},"/v1/config/model":{"get":{"operationId":"config-model","tags":["Settings"],"summary":"Scoring model status","description":"The active scorer (weighted / learned / ml) and, for the trained ML model, its provenance and per-feature weights. The model's weights can be bundled in the image, or loaded — and refreshed — from a local file (MODEL_PATH), an HTTPS URL (MODEL_URL), or S3-compatible object storage (MODEL_S3_*), so you retrain and roll out without a redeploy. provenance is 'synthetic-demo' while the bundled demonstration weights (trained on synthetic data — not a fraud model) are serving, and 'operator-supplied' once a model is loaded from MODEL_PATH / MODEL_URL / MODEL_S3_*.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"scorer":"ml","ml":{"active":true,"source":"s3","provenance":"operator-supplied","trainedAt":"2026-09-23","metrics":{"auc":0.843,"accuracy":0.873,"samples":20000},"bias":-3.857,"features":[{"name":"device.fingerprintDeviceMismatch","weight":1.332},{"name":"geo.impossibleTravel","weight":1.237}]}}}}}}}},"/v1/config/storage":{"get":{"operationId":"config-storage","tags":["Settings"],"summary":"Get storage & disk health","description":"The document store's size and per-collection row counts, the health of the filesystems that data lives on (total / free / used — for monitoring Docker volumes), and a per-service breakdown of the space consumed on disk. All are sampled into /metrics each sweep (verdict_storage_*, verdict_disk_total_bytes, verdict_disk_free_bytes, verdict_disk_used_ratio, verdict_disk_component_bytes). Set DISK_HEALTH_PATHS to the volume mounts you want watched; the engine can only see filesystems mounted into its own container.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"totalBytes":{"type":"number","description":"Logical size of the document store on disk."},"collections":{"type":"array","items":{"type":"object"},"description":"Row count per collection."},"disks":{"type":"array","items":{"type":"string"},"description":"Per-filesystem health: { path, totalBytes, freeBytes, usedBytes, usedPercent } for each monitored mount (deduplicated by filesystem)."},"components":{"type":"array","items":{"type":"string"},"description":"Per-service disk breakdown: { name, kind: database|model, bytes, path? } — what is consuming the disk."}},"required":["totalBytes","collections","disks","components"]},"example":{"totalBytes":48210944,"collections":[{"name":"replay-samples","rows":91200},{"name":"verdicts","rows":91200},{"name":"activity","rows":91200},{"name":"audit-log","rows":312}],"disks":[{"path":"/var/lib/postgresql/data","totalBytes":53687091200,"freeBytes":48800000000,"usedBytes":4887091200,"usedPercent":9}],"components":[{"name":"Document store","kind":"database","bytes":48210944},{"name":"ML model file","kind":"model","bytes":2048,"path":"/models/verdict-weights.json"}]}}}}}}},"/v1/audit":{"get":{"operationId":"audit-list","tags":["Settings"],"summary":"List the config-change audit trail","description":"Newest-first, paginated record of every operator mutation — who changed what, when, and the result. Secret-ish request fields (URLs, tokens, passwords) are redacted before storage. Read-only.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"total":42,"entries":[{"id":"aud_…","at":"2026-01-01T00:00:00.000Z","actor":"admin@bank.com","role":"admin","action":"PUT /v1/config/rate-limits","status":200,"params":{"decisionsPerMin":1200}}]}}}}},"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"number","description":"Page size (default 50, max 200)."},"description":"Page size (default 50, max 200)."},{"name":"offset","in":"query","required":false,"schema":{"type":"number","description":"Rows to skip (default 0)."},"description":"Rows to skip (default 0)."}]}},"/v1/labels/chargeback":{"post":{"operationId":"chargeback","tags":["Labels"],"summary":"Record a chargeback as a fraud label","description":"A PSP/system endpoint — takes a service API key. 202 Accepted. If the event was seen before, its fired tags are credited to the model.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"recorded":{"type":"string","description":"Acknowledged; the label is stored and broadcast to scoring and analytics."}},"required":["recorded"]},"example":{"recorded":true}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"eventId":{"type":"string","description":"The original event that was charged back."}},"required":["eventId"]},"example":{"eventId":"evt_…"}}}}}},"/v1/privacy/erase":{"post":{"operationId":"privacy-erase","tags":["Data governance"],"summary":"Erase a user's personal data (right to erasure)","description":"Removes a user's graph identity, learned spending baseline, and stored replay samples (which hold whole events). The append-only verdict log is out of scope by design — it holds only an event id, verdict and tags — and is governed by a retention window.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","description":"The user erased."},"replaySamplesRemoved":{"type":"number","description":"Replay-log entries deleted for this user."},"activityEntriesRemoved":{"type":"number","description":"API activity-log entries deleted for this user."},"graph":{"type":"boolean","description":"Whether the graph identity was unlinked and removed."},"baseline":{"type":"boolean","description":"Whether the anomaly baseline was dropped."}},"required":["userId","replaySamplesRemoved","activityEntriesRemoved","graph","baseline"]},"example":{"userId":"usr_3f9a","replaySamplesRemoved":12,"activityEntriesRemoved":12,"graph":true,"baseline":true}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","description":"The user to erase across the engine's stores."}},"required":["userId"]},"example":{"userId":"usr_3f9a"}}}}}},"/health":{"get":{"operationId":"health","tags":["System"],"summary":"Liveness probe","description":"The process is up. Use it for the liveness probe only; use /readyz for readiness.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"status":"ok","name":"verdict-engine","version":"0.7.0"}}}}}}},"/readyz":{"get":{"operationId":"readyz","tags":["System"],"summary":"Readiness probe","description":"Verifies the datastore is reachable (the query also confirms the schema/migrations are in place) and, when Redis is configured, that Redis answers. Returns 200 when the instance can serve durable decisions, or 503 until then. Point your orchestrator's readiness probe here. 503 with { status: 'not_ready', checks } while a dependency is down. redis is 'skipped' when REDIS_URL is unset.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"},"example":{"status":"ready","version":"0.7.0","checks":{"store":"ok","redis":"skipped"}}}}}}}},"/metrics":{"get":{"operationId":"metrics","tags":["System"],"summary":"Prometheus metrics","description":"Operational metrics in Prometheus text format — decision latency & outcomes, 429s, degraded decisions, storage/disk, and outbox/webhook/notification delivery counters and queue depth. Public by default (keep it on a private port); set METRICS_TOKEN to require an Authorization: Bearer token.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}}}}}}