Every route the engine exposes.
Base URL http://localhost:4000 in local dev. Operator and admin routes take Authorization: Bearer <token>.
/v1/decisionsPOST/v1/decisions/batchPOST/v1/decisions/asyncGET/v1/decisions/:idGET/v1/auth/statusPOST/v1/auth/registerPOST/v1/auth/loginGET/v1/auth/usersPOST/v1/auth/usersPOST/v1/auth/logoutPOST/v1/auth/logout-allPOST/v1/auth/refreshGET/v1/casesGET/v1/cases/:idPOST/v1/cases/:id/assignPOST/v1/cases/:id/resolveGET/v1/rulesGET/v1/policiesPOST/v1/policiesPOST/v1/policies/:id/rollbackGET/v1/policies/:id/historyPOST/v1/policies/:id/simulateGET/v1/verdictsPOST/v1/backtestGET/v1/apikeysPOST/v1/apikeysPOST/v1/apikeys/:id/revokeGET/v1/analytics/summaryGET/v1/modelGET/v1/graph/:kind/:idGET/v1/webhooksPOST/v1/webhooksPOST/v1/webhooks/:id/revokeGET/v1/webhooks/deliveriesPOST/v1/webhooks/deliveries/redriveGET/v1/notificationsPOST/v1/notificationsPOST/v1/notifications/:id/testPOST/v1/notifications/:id/revokeGET/v1/notifications/deliveriesPOST/v1/notifications/deliveries/redriveGET/v1/config/rate-limitsPUT/v1/config/rate-limitsGET/v1/config/alertsPUT/v1/config/alertsGET/v1/config/retentionPUT/v1/config/retentionPOST/v1/config/retention/runGET/v1/config/modelGET/v1/config/storageGET/v1/auditPOST/v1/labels/chargebackPOST/v1/privacy/eraseGET/healthGET/readyzGET/metrics/v1/decisionsapikeyScore an event and return a verdict
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.
| X-API-Key | string | required | A service API key created by an admin in the dashboard. |
| Idempotency-Key | string | optional | Retry-safe: the same key returns the original decision instead of recomputing. Defaults to the event id. |
| X-Correlation-Id | string | optional | Threaded through logs and events for tracing. |
| type | "card.authorize" | "card.capture" | "card.refund" | "payment.authorize" | "wallet.withdraw" | "account.login" | "order.place" | required | Channel-agnostic event type. Selects which ruleset and policy apply. |
| amount | number | optional | Transaction value, for money-bearing events. Non-negative. Feeds amount rules and the per-user anomaly baseline. |
| currency | string(3) | optional | ISO-4217 code, e.g. USD, ETB. Required when amount is present. |
| subject.userId | string | required | The acting user's stable id. The primary entity in the graph and the key for anomaly baselines. |
| subject.deviceId | string | optional | Stable device/client id. Powers velocity, first-seen and device-linking (graph) signals. |
| subject.fingerprint | string | optional | Client-computed device fingerprint hash. Drives device.usersOnFingerprint and device.fingerprintDeviceMismatch (same fingerprint on a different deviceId — cloning/spoofing), independent of deviceId. |
| subject.ip | string | optional | 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. |
| subject.phone | string | optional | MSISDN (phone number) for mobile-money / telecom rails. A graph entity — flags SIM-box / account-farming via graph.usersOnPhone. |
| subject.channel | string | optional | Origin rail, e.g. "visa", "telebirr", "web". |
| instrument.kind | "card" | "wallet" | "bank" | optional | Payment instrument type, for value-bearing events. |
| instrument.bin | string(6-8) | optional | Card issuer BIN — the first 6–8 digits only. Never send a full PAN. |
| instrument.issuerCountry | string(2) | optional | ISO-3166 issuer country, e.g. US. |
| instrument.threeDS | boolean | optional | Whether the transaction carried a 3-D Secure result. |
| attributes | Record<string, string | number | boolean> | optional | Additional validated scalar signals, e.g. { geoMismatch: true }. Readable in rules as attr.<key>. |
{
"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 }
}{ "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" }
| id | string | always | The verdict id (prefixed vd_). Primary key in the append-only verdict log. |
| eventId | string | always | The id assigned to this event. Use it to correlate a later chargeback label or case. |
| verdict | "allow" | "challenge" | "review" | "deny" | always | The decision. allow = proceed; challenge = step-up (3DS/OTP); review = opens a case; deny = block. |
| score | number (0–100) | always | The risk score the policy banded into the verdict. -1 for a list/degraded short-circuit that skipped scoring. |
| reasons | { tag: string; points: number }[] | always | Every signal that fired and the points it contributed — the full, attributable explanation. |
| policyId | string | always | The policy that decided this event. |
| policyVersion | string | always | The exact policy version applied — pin for audit and replay. |
| decidedAt | string (ISO-8601) | always | When the verdict was reached. |
verdict is one of allow · challenge · review · deny. A review verdict opens a case. Send an Idempotency-Key to make retries safe.
/v1/decisions/batchapikeyScore up to 100 events in one call
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.
| X-API-Key | string | required | A service API key. |
| events | Event[] (1–100) | required | The events to score. Each event is the same shape as POST /v1/decisions. |
{ "events": [
{ "type": "card.authorize", "amount": 20, "currency": "USD", "subject": { "userId": "u1" } },
{ "type": "account.login", "subject": { "userId": "u2", "fingerprint": "fp_x" } }
] }{ "results": [ { "ok": true, "decision": { "id": "vd_…", "verdict": "allow", "score": 4, "reasons": [] } }, { "ok": false, "error": { "code": "INGEST_UNKNOWN_TYPE", "message": "unknown event type: …" } } ] }
Counts as one rate-limited request. Cap 100 events per call.
/v1/decisions/asyncapikeyAccept an event and score it off the response path
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.
| X-API-Key | string | required | A service API key. |
| (event) | Event | required | Same shape as POST /v1/decisions. |
{ "id": "evt_9f2a", "type": "card.authorize", "amount": 30, "currency": "USD", "subject": { "userId": "u1" } }{ "accepted": true, "id": "evt_9f2a", "correlationId": "cor_…" }202 Accepted. The verdict is not in this response — poll GET /v1/decisions/{id}.
/v1/decisions/:idapikeyFetch a decision by event id
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.
| X-API-Key | string | required | A service API key. |
{ "id": "vd_…", "eventId": "evt_9f2a", "verdict": "allow", "score": 4, "reasons": [], "decidedAt": "2026-01-01T00:00:00.000Z" }
404 with code NOT_FOUND while the async decision is still pending.
/v1/auth/statuspublicWhether an admin exists yet
{ "needsBootstrap": true }needsBootstrap is true until the first (admin) account is created.
/v1/auth/registerpublicBootstrap the admin account
Creates the first user as admin. Once any user exists this returns 403 REGISTRATION_CLOSED — further operators are added by an admin.
| string | required | Login email (normalized lowercase). | |
| password | string | required | At least 8 characters. Stored scrypt-hashed. |
{ "email": "admin@bank.com", "password": "••••••••" }{ "token": "eyJ…", "user": { "email": "admin@bank.com", "role": "admin" } }/v1/auth/loginpublicExchange credentials for a token
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).
| string | required | Operator login email. | |
| password | string | required | Operator password. |
{ "email": "maya@bank.com", "password": "••••••••" }{ "token": "eyJ…", "user": { "email": "maya@bank.com", "role": "analyst" } }Send the token as `Authorization: Bearer <token>` on operator endpoints.
/v1/auth/usersadminList operators
[{ "email": "maya@bank.com", "role": "analyst", "createdAt": "…" }]/v1/auth/usersadminAdd an operator
| string | required | ||
| password | string | required | Temporary password, ≥ 8 chars. |
| role | "analyst" | "admin" | optional | Defaults to analyst. |
{ "email": "ana@bank.com", "password": "••••••••", "role": "analyst" }{ "email": "ana@bank.com", "role": "analyst", "createdAt": "…" }/v1/auth/logoutoperatorLog out (revoke this token)
Revokes the presented bearer token immediately, before it would expire.
{ "ok": true }/v1/auth/logout-alloperatorLog out everywhere
Revokes every active session for the current user — use after a suspected compromise.
{ "ok": true }/v1/auth/refreshoperatorRefresh the token
Exchanges a still-valid token for a fresh one (sliding session). The old token stays valid until it expires.
{ "token": "eyJ…", "user": { "email": "maya@bank.com", "role": "analyst" } }/v1/casesoperatorList the review queue
| queue | string | optional | Defaults to "risk-ops". |
[{ "id": "cse_…", "eventId": "evt_…", "verdictId": "vd_…", "queue": "risk-ops", "verdict": "review", "score": 55, "status": "open", "openedAt": "2026-09-17T10:22:00.000Z", "audit": [ … ] }]
| id | string | always | Case id (cse_). |
| eventId | string | always | The event that opened the case. |
| verdictId | string | always | The verdict that opened it. |
| queue | string | always | The review queue the case sits in. |
| verdict | "review" | always | Always review — only review verdicts open cases. |
| score | number | always | The risk score at decision time. |
| status | "open" | "assigned" | "resolved" | always | Case lifecycle state. |
| assignedTo | string | nullable | Analyst email, once assigned. |
| resolution | { outcome, analyst, note?, at } | nullable | Present once resolved. |
| openedAt | string (ISO-8601) | always | When the case opened. |
| audit | { action, at, actor, detail? }[] | always | Append-only trail: opened / assigned / resolved, each with actor and timestamp. |
Same object shape is returned by GET /v1/cases/:id.
/v1/cases/:idoperatorFetch one case with its audit trail
{ "id": "cse_…", "status": "assigned", "assignedTo": "maya@bank.com", "audit": [ … ] }404 when the case does not exist.
/v1/cases/:id/assignoperatorAssign a case to yourself
{ "id": "cse_…", "status": "assigned", "assignedTo": "maya@bank.com" }The analyst is taken from your token, not the body.
/v1/cases/:id/resolveoperatorResolve a case and emit a label
| outcome | "fraud" | "legit" | "inconclusive" | required | Ground truth. "inconclusive" records no label. |
| note | string | optional | Rationale kept on the case. |
{ "outcome": "fraud", "note": "confirmed ATO" }{ "id": "cse_…", "status": "resolved", "resolution": { "outcome": "fraud", … } }409 if already resolved. Resolving feeds a training label into Feedback.
/v1/rulesoperatorThe active rulesets, rendered as DSL
[{ "eventType": "card.authorize", "version": "seed@v0.4",
"rules": [{ "id": "r_velocity", "tag": "velocity", "weight": 28, "dsl": "rule r_velocity { … }" }] }]/v1/policiesoperatorThe active policies and their bands
[{ "eventType": "card.authorize", "policy": { "id": "pol_card_authorize", "version": "v0.4.0", "onError": "fail_open", "bands": [{ "verdict": "allow", "min": 0, "max": 24 }, … ] } }]
/v1/policiesadminPublish a new policy version
Validated: bands must cover 0–100 with no gaps or overlaps. The new version becomes active immediately; the previous one is retained for rollback.
| id | string | required | Existing policy id. |
| version | string | required | New, immutable version label. |
| onError | "fail_open" | "fail_closed" | required | Fallback verdict on a degraded engine. |
| bands | Band[] | required | { verdict, min, max, reviewQueue? } contiguous over 0–100. |
{ "id": "pol_card_authorize", "version": "v0.4.1", "onError": "fail_closed",
"bands": [ { "verdict": "allow", "min": 0, "max": 20 }, … ] }{ "ok": true, "version": "v0.4.1" }/v1/policies/:id/rollbackadminRoll a policy back to an earlier version
| toVersion | string | required | A version from the policy's history. |
{ "toVersion": "v0.4.0" }{ "ok": true }/v1/policies/:id/historyoperatorA policy's version history
["v0.4.0", "v0.4.1"]/v1/policies/:id/simulateoperatorSee which verdict a score would get
| score | number | required | 0–100. |
{ "score": 61 }{ "verdict": "review", "reviewQueue": "risk-ops" }
Test config changes without sending a real event.
/v1/verdictsoperatorRecent entries from the append-only verdict log
| limit | number | optional | 1–100, default 25. |
[{ "id": "vd_…", "verdict": "deny", "score": 82, "decidedAt": "…" }]
/v1/backtestadminReplay a candidate rule/policy change over labeled history
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.
| eventType | string | required | Which event type's history to replay, e.g. card.authorize. |
| policy | object | optional | Candidate { bands, onError } to test. Omit to keep the live policy. |
| rules | Rule[] | optional | Candidate ruleset to re-evaluate. Omit to reuse each decision's recorded score. |
{
"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 }
] }
}{
"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 }
}| eventType | string | always | The event type replayed. |
| sampleSize | number | always | Replay samples available for this event type. |
| labeled | number | always | Of those, how many had a fraud/legit label and were scored. A candidate is only judged on labeled history. |
| candidate.truePositives | number | always | Labeled-fraud events the candidate would flag (review/challenge/deny). |
| candidate.falsePositives | number | always | Labeled-legit events the candidate would flag. |
| candidate.falseNegatives | number | always | Labeled-fraud events the candidate would allow. |
| candidate.trueNegatives | number | always | Labeled-legit events the candidate would allow. |
| candidate.precision | number (0–1) | always | TP ÷ (TP + FP) for the candidate. |
| candidate.recall | number (0–1) | always | TP ÷ (TP + FN) — share of fraud caught. |
| candidate.falsePositiveRate | number (0–1) | always | FP ÷ (FP + TN). |
| baseline.* | object | always | The same confusion counts for the currently-live policy, on the same labeled set. |
| delta.fraudCaught | number | always | Candidate TP minus baseline TP. Positive = more fraud caught. |
| delta.falsePositives | number | always | Candidate FP minus baseline FP. Negative = fewer false positives. |
| delta.flips | number | always | Decisions whose verdict would change versus what actually happened. |
Powers the dashboard's test-before-you-publish panel. 400 if the candidate policy has gaps.
/v1/apikeysadminList service keys
[{ "id": "key_…", "name": "payment-gateway", "prefix": "vk_live_a1b2c3",
"createdBy": "admin@bank.com", "createdAt": "…", "revoked": false }]Only the prefix is stored for display — the full key is never retrievable.
/v1/apikeysadminCreate a service key
Optionally scope the key to specific actions and set an expiry. A key with no scopes grants both.
| name | string | required | A label, e.g. the service that will use it. |
| scopes | ("decisions" | "labels")[] | optional | Actions the key may perform. Omit for full access (both). |
| expiresInDays | number | optional | Days until the key expires. Omit for a key that never expires. |
{ "name": "payment-gateway", "scopes": ["decisions"], "expiresInDays": 90 }{ "plaintext": "vk_live_a1b2c3…", // shown ONCE — store it now
"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 } }| plaintext | string | always | The full key (vk_live_…). Returned only on creation — it is never recoverable afterwards. |
| key.id | string | always | Key id, used to revoke it. |
| key.name | string | always | The label you gave it. |
| key.prefix | string | always | First chars of the key, shown in listings for identification. |
| key.scopes | string[] | always | Actions this key may perform. |
| key.expiresAt | string (ISO-8601) | nullable | When the key expires, if ever. |
| key.expired | boolean | always | Whether the key has already expired. |
| key.revoked | boolean | always | Whether the key has been revoked. |
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.
/v1/apikeys/:id/revokeadminRevoke a key immediately
{ "ok": true }/v1/analytics/summaryoperatorRollups from the verdict log and resolved cases
A read-model maintained by a projector that consumes verdict + case events — never queried off the write path.
{
"totals": { "decisions": 1240, "byVerdict": { "allow": 900, "review": 210, "deny": 130 } },
"scoreBuckets": [ … 11 buckets 0–100 … ],
"topTags": { "velocity": 320, "takeover": 180 },
"byDay": { "2026-09-17": { "total": 210, "review": 40, "deny": 22 } },
"cases": { "resolved": 90, "fraud": 61, "legit": 29 },
"labels": { "fraud": 74, "legit": 29, "analyst": 90, "chargeback": 13 },
"falsePositiveRate": 0.32
}| totals.decisions | number | always | All decisions seen by the projector. |
| totals.byVerdict | Record<verdict, number> | always | Count per verdict (allow/challenge/review/deny). |
| scoreBuckets | number[11] | always | Score histogram in 10-point buckets (0–9 … 100). |
| topTags | Record<tag, number> | always | How often each signal fired. |
| byDay | Record<date, DayCounts> | always | Per-day totals and verdict breakdown. |
| cases | { resolved, fraud, legit, inconclusive } | always | Analyst case-resolution outcomes. |
| labels | { fraud, legit, analyst, chargeback } | always | Ground-truth labels by outcome and source — includes chargebacks that never became cases. |
| falsePositiveRate | number (0–1) | always | legit ÷ (fraud + legit) over resolved cases. |
/v1/modeloperatorThe adaptive model's learned signal weights
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.
{
"active": "weighted",
"weights": [
{ "tag": "takeover", "fraud": 41, "legit": 3, "weight": 42, "trusted": true },
{ "tag": "velocity", "fraud": 22, "legit": 9, "weight": 32, "trusted": true }
]
}| active | "weighted" | "learned" | always | Which scorer is live (set by the SCORER env var). The model is projected either way. |
| weights[].tag | string | always | The signal tag. |
| weights[].fraud | number | always | Times this tag rode a fraud label. |
| weights[].legit | number | always | Times this tag rode a legit label. |
| weights[].weight | number (0–45) | always | Learned points, from the smoothed fraud rate. Used by the learned scorer. |
| weights[].trusted | boolean | always | Whether it has enough labels (≥3) to be used; until then the scorer keeps the hand weight. |
/v1/graph/:kind/:idoperatorAn entity's linked users, devices, IPs and phones, and its cluster size
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.
{
"kind": "device", "id": "dev_ring",
"users": ["usr_1", "usr_2", "usr_3"],
"devices": [], "ips": ["203.0.113.7"],
"ringSize": 8
}| kind | "user" | "device" | "ip" | always | The entity kind queried (from the path). |
| id | string | always | The entity id queried. |
| users | string[] | always | Distinct users directly linked to this entity. |
| devices | string[] | always | Distinct devices directly linked. |
| ips | string[] | always | Distinct IPs directly linked. |
| ringSize | number | always | Size of the connected cluster this entity sits in (walk capped at 500). 1 means unconnected. |
404-safe: an unknown entity returns empty links and ringSize 1.
/v1/webhooksadminList webhook endpoints and their delivery status
{
"events": ["verdict.reached.v1", "case.resolved.v1", "label.recorded.v1"],
"endpoints": [{ "id": "whk_…", "url": "https://app/hooks", "events": ["verdict.reached.v1"],
"active": true, "lastStatus": "delivered", "failures": 0 }]
}| events | string[] | always | The events an endpoint may subscribe to. |
| endpoints[].id | string | always | Endpoint id (used to revoke). |
| endpoints[].url | string | always | Where deliveries are POSTed. |
| endpoints[].events | string[] | always | Subscribed events. |
| endpoints[].active | boolean | always | Whether it's live. |
| endpoints[].lastStatus | "delivered" | "failed" | nullable | Last delivery outcome. |
| endpoints[].failures | number | always | Consecutive failed deliveries. |
/v1/webhooksadminRegister an endpoint to receive signed events
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).
| url | string | required | http(s) URL to deliver to. |
| events | string[] | required | One or more of verdict.reached.v1, case.resolved.v1, label.recorded.v1. |
{ "url": "https://app/hooks/verdict", "events": ["verdict.reached.v1"] }{ "id": "whk_…", "url": "https://app/hooks/verdict", "events": ["verdict.reached.v1"],
"active": true, "failures": 0, "secret": "whsec_…" }| secret | string | always | The signing secret — returned only on creation. Store it to verify signatures. |
| id | string | always | Endpoint id. |
Delivered payload shape: { id, type, occurredAt, correlationId, data }, where data is the event payload.
/v1/webhooks/:id/revokeadminDeactivate an endpoint
{ "ok": true }/v1/webhooks/deliveriesadminDelivery log (pending & dead-lettered)
Deliveries awaiting a retry, and ones that exhausted their attempts (dead-lettered).
{ "pending": [{ "id": "whd_…", "endpointId": "whk_…", "event": "verdict.reached.v1",
"status": "pending", "attempts": 2, "nextAttemptAt": "…", "lastError": "fetch failed" }],
"dead": [] }/v1/webhooks/deliveries/redriveadminRe-drive dead-lettered deliveries
Moves every dead-lettered delivery back to pending for another attempt.
{ "requeued": 3 }/v1/notificationsadminList alert channels and subscribable events
{ "events": ["verdict.reached.v1", "case.resolved.v1", "label.recorded.v1", "alert.dead_letter.v1"],
"channels": [{ "id": "ntf_…", "type": "slack", "urlHint": "https://hooks.slack.com/…",
"events": ["verdict.reached.v1"], "minVerdict": "deny", "active": true, "failures": 0 }] }/v1/notificationsadminAdd a Slack, Telegram or webhook alert channel
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.
| type | "slack" | "telegram" | "webhook" | required | Channel kind. |
| url | string | required | HTTPS URL (Slack webhook, Telegram bot sendMessage URL, or your endpoint). Stored server-side; shown masked. |
| target | string | optional | Telegram chat id — required for the telegram type. |
| events | string[] | required | Events to alert on (see the list endpoint). |
| minVerdict | "allow" | "challenge" | "review" | "deny" | optional | verdict.reached severity floor. |
| throttlePerMin | number | optional | Max alerts per minute for this channel (0 = unlimited); omit for the env default. |
{ "type": "telegram", "url": "https://api.telegram.org/bot<token>/sendMessage", "target": "-1001234567890", "events": ["verdict.reached.v1"], "minVerdict": "deny", "throttlePerMin": 30 }{ "id": "ntf_…", "type": "slack", "urlHint": "https://hooks.slack.com/…", "events": ["verdict.reached.v1"], "minVerdict": "deny", "throttlePerMin": 30, "active": true, "failures": 0 }/v1/notifications/:id/testadminSend a test alert to a channel
{ "ok": true }/v1/notifications/:id/revokeadminDeactivate a channel
{ "ok": true }/v1/notifications/deliveriesadminList alert deliveries (pending + dead-lettered)
Durable alert deliveries awaiting a retry, and those that exhausted their retries. Bodies are omitted.
{ "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/redriveadminRe-drive dead-lettered alerts
Moves every dead-lettered alert delivery back to the queue for another attempt.
{ "requeued": 3 }/v1/config/rate-limitsadminGet rate-limit settings
The effective per-minute budgets and whether each is a dashboard override or the env default.
{ "windowMs": 60000, "decisionsPerMin": 600, "loginPerMin": 10,
"overridden": { "decisionsPerMin": false, "loginPerMin": false } }/v1/config/rate-limitsadminUpdate rate-limit settings
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.
| decisionsPerMin | number | null | optional | Per-API-key budget for POST /v1/decisions (null = revert to default). |
| loginPerMin | number | null | optional | Per-IP budget for POST /v1/auth/login (null = revert to default). |
{ "decisionsPerMin": 1200, "loginPerMin": 10 }{ "windowMs": 60000, "decisionsPerMin": 1200, "loginPerMin": 10,
"overridden": { "decisionsPerMin": true, "loginPerMin": false } }/v1/config/alertsadminGet alert settings
The anomaly z-score threshold at or above which a decision emits alert.anomaly.v1.
{ "anomalyZScore": 3, "overridden": { "anomalyZScore": false } }/v1/config/alertsadminUpdate alert settings
Set the anomaly z-score threshold (send null to revert to the default of 3). Applies within ~10s.
| anomalyZScore | number | null | optional | σ above the user's baseline that triggers an anomaly alert (null = default 3). |
{ "anomalyZScore": 2.5 }{ "anomalyZScore": 2.5, "overridden": { "anomalyZScore": true } }/v1/config/retentionadminGet retention settings
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.
{ "sweepMinutes": 60,
"days": { "verdicts": 365, "activity": 90, "replay": 90, "idempotency": 7, "deadLetter": 30 },
"overridden": { "verdicts": false, "activity": false, "replay": false, "idempotency": false, "deadLetter": false } }/v1/config/retentionadminUpdate retention settings
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.
| verdicts | number | null | optional | Days to keep the verdict log (0 = forever, null = env default). |
| activity | number | null | optional | Days to keep the API activity log. |
| replay | number | null | optional | Days to keep replay samples. |
| idempotency | number | null | optional | Days to keep idempotency keys. |
| deadLetter | number | null | optional | Days to keep dead-lettered outbox rows. |
{ "activity": 120, "idempotency": 3 }{ "sweepMinutes": 60,
"days": { "verdicts": 365, "activity": 120, "replay": 90, "idempotency": 3, "deadLetter": 30 },
"overridden": { "verdicts": false, "activity": true, "replay": false, "idempotency": true, "deadLetter": false } }/v1/config/retention/runadminRun the retention prune now
Trigger a retention sweep immediately (the job also runs on a timer) and return how many records were pruned per collection.
{ "ranAt": "2026-01-01T00:00:00.000Z",
"pruned": { "verdicts": 0, "activity": 4, "replay": 4, "idempotency": 120, "deadLetter": 0 } }/v1/config/modeloperatorScoring model status
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.
{ "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 } ] } }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_*.
/v1/config/storageadminGet storage & disk health
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.
{ "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" } ] }| totalBytes | number | always | Logical size of the document store on disk. |
| collections | { name, rows }[] | always | Row count per collection. |
| disks | DiskUsage[] | always | Per-filesystem health: { path, totalBytes, freeBytes, usedBytes, usedPercent } for each monitored mount (deduplicated by filesystem). |
| components | StorageComponent[] | always | Per-service disk breakdown: { name, kind: database|model, bytes, path? } — what is consuming the disk. |
/v1/auditadminList the config-change audit trail
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.
| limit | number | optional | Page size (default 50, max 200). |
| offset | number | optional | Rows to skip (default 0). |
{ "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 } }
] }/v1/labels/chargebackapikeyRecord a chargeback as a fraud label
A PSP/system endpoint — takes a service API key.
| X-API-Key | string | required | Service API key. |
| eventId | string | required | The original event that was charged back. |
{ "eventId": "evt_…" }{ "recorded": true }| recorded | true | always | Acknowledged; the label is stored and broadcast to scoring and analytics. |
202 Accepted. If the event was seen before, its fired tags are credited to the model.
/v1/privacy/eraseadminErase a user's personal data (right to erasure)
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.
| userId | string | required | The user to erase across the engine's stores. |
{ "userId": "usr_3f9a" }{ "userId": "usr_3f9a", "replaySamplesRemoved": 12, "activityEntriesRemoved": 12,
"graph": true, "baseline": true }| userId | string | always | The user erased. |
| replaySamplesRemoved | number | always | Replay-log entries deleted for this user. |
| activityEntriesRemoved | number | always | API activity-log entries deleted for this user. |
| graph | boolean | always | Whether the graph identity was unlinked and removed. |
| baseline | boolean | always | Whether the anomaly baseline was dropped. |
/healthpublicLiveness probe
The process is up. Use it for the liveness probe only; use /readyz for readiness.
{ "status": "ok", "name": "verdict-engine", "version": "0.7.0" }/readyzpublicReadiness probe
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.
{ "status": "ready", "version": "0.7.0",
"checks": { "store": "ok", "redis": "skipped" } }503 with { status: 'not_ready', checks } while a dependency is down. redis is 'skipped' when REDIS_URL is unset.
/metricspublicPrometheus metrics
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.
# HELP verdict_decisions_total Decisions returned, by verdict
# TYPE verdict_decisions_total counter
verdict_decisions_total{verdict="allow",cached="false"} 128