API reference

Every route the engine exposes.

Base URL http://localhost:4000 in local dev. Operator and admin routes take Authorization: Bearer <token>.

public No credentials required.apikey Requires a service API key in the X-API-Key header.operator Requires a bearer token from /v1/auth/login (any operator).admin Requires a bearer token belonging to an admin.
Decisions
POST/v1/decisionsapikey

Score 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.

Headers
X-API-KeystringrequiredA service API key created by an admin in the dashboard.
Idempotency-KeystringoptionalRetry-safe: the same key returns the original decision instead of recomputing. Defaults to the event id.
X-Correlation-IdstringoptionalThreaded through logs and events for tracing.
Body
type"card.authorize" | "card.capture" | "card.refund" | "payment.authorize" | "wallet.withdraw" | "account.login" | "order.place"requiredChannel-agnostic event type. Selects which ruleset and policy apply.
amountnumberoptionalTransaction value, for money-bearing events. Non-negative. Feeds amount rules and the per-user anomaly baseline.
currencystring(3)optionalISO-4217 code, e.g. USD, ETB. Required when amount is present.
subject.userIdstringrequiredThe acting user's stable id. The primary entity in the graph and the key for anomaly baselines.
subject.deviceIdstringoptionalStable device/client id. Powers velocity, first-seen and device-linking (graph) signals.
subject.fingerprintstringoptionalClient-computed device fingerprint hash. Drives device.usersOnFingerprint and device.fingerprintDeviceMismatch (same fingerprint on a different deviceId — cloning/spoofing), independent of deviceId.
subject.ipstringoptionalClient 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.phonestringoptionalMSISDN (phone number) for mobile-money / telecom rails. A graph entity — flags SIM-box / account-farming via graph.usersOnPhone.
subject.channelstringoptionalOrigin rail, e.g. "visa", "telebirr", "web".
instrument.kind"card" | "wallet" | "bank"optionalPayment instrument type, for value-bearing events.
instrument.binstring(6-8)optionalCard issuer BIN — the first 6–8 digits only. Never send a full PAN.
instrument.issuerCountrystring(2)optionalISO-3166 issuer country, e.g. US.
instrument.threeDSbooleanoptionalWhether the transaction carried a 3-D Secure result.
attributesRecord<string, string | number | boolean>optionalAdditional validated scalar signals, e.g. { geoMismatch: true }. Readable in rules as attr.<key>.
Request
{
  "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 }
}
Response
{
  "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"
}
Response fields
idstringalwaysThe verdict id (prefixed vd_). Primary key in the append-only verdict log.
eventIdstringalwaysThe id assigned to this event. Use it to correlate a later chargeback label or case.
verdict"allow" | "challenge" | "review" | "deny"alwaysThe decision. allow = proceed; challenge = step-up (3DS/OTP); review = opens a case; deny = block.
scorenumber (0–100)alwaysThe risk score the policy banded into the verdict. -1 for a list/degraded short-circuit that skipped scoring.
reasons{ tag: string; points: number }[]alwaysEvery signal that fired and the points it contributed — the full, attributable explanation.
policyIdstringalwaysThe policy that decided this event.
policyVersionstringalwaysThe exact policy version applied — pin for audit and replay.
decidedAtstring (ISO-8601)alwaysWhen 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.

POST/v1/decisions/batchapikey

Score 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.

Headers
X-API-KeystringrequiredA service API key.
Body
eventsEvent[] (1–100)requiredThe events to score. Each event is the same shape as POST /v1/decisions.
Request
{ "events": [
  { "type": "card.authorize", "amount": 20, "currency": "USD", "subject": { "userId": "u1" } },
  { "type": "account.login", "subject": { "userId": "u2", "fingerprint": "fp_x" } }
] }
Response
{ "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.

POST/v1/decisions/asyncapikey

Accept 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.

Headers
X-API-KeystringrequiredA service API key.
Body
(event)EventrequiredSame shape as POST /v1/decisions.
Request
{ "id": "evt_9f2a", "type": "card.authorize", "amount": 30, "currency": "USD", "subject": { "userId": "u1" } }
Response
{ "accepted": true, "id": "evt_9f2a", "correlationId": "cor_…" }

202 Accepted. The verdict is not in this response — poll GET /v1/decisions/{id}.

GET/v1/decisions/:idapikey

Fetch 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.

Headers
X-API-KeystringrequiredA service API key.
Response
{ "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.

Auth
GET/v1/auth/statuspublic

Whether an admin exists yet

Response
{ "needsBootstrap": true }

needsBootstrap is true until the first (admin) account is created.

POST/v1/auth/registerpublic

Bootstrap 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.

Body
emailstringrequiredLogin email (normalized lowercase).
passwordstringrequiredAt least 8 characters. Stored scrypt-hashed.
Request
{ "email": "admin@bank.com", "password": "••••••••" }
Response
{ "token": "eyJ…", "user": { "email": "admin@bank.com", "role": "admin" } }
POST/v1/auth/loginpublic

Exchange 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).

Body
emailstringrequiredOperator login email.
passwordstringrequiredOperator password.
Request
{ "email": "maya@bank.com", "password": "••••••••" }
Response
{ "token": "eyJ…", "user": { "email": "maya@bank.com", "role": "analyst" } }

Send the token as `Authorization: Bearer <token>` on operator endpoints.

GET/v1/auth/usersadmin

List operators

Response
[{ "email": "maya@bank.com", "role": "analyst", "createdAt": "…" }]
POST/v1/auth/usersadmin

Add an operator

Body
emailstringrequired
passwordstringrequiredTemporary password, ≥ 8 chars.
role"analyst" | "admin"optionalDefaults to analyst.
Request
{ "email": "ana@bank.com", "password": "••••••••", "role": "analyst" }
Response
{ "email": "ana@bank.com", "role": "analyst", "createdAt": "…" }
POST/v1/auth/logoutoperator

Log out (revoke this token)

Revokes the presented bearer token immediately, before it would expire.

Response
{ "ok": true }
POST/v1/auth/logout-alloperator

Log out everywhere

Revokes every active session for the current user — use after a suspected compromise.

Response
{ "ok": true }
POST/v1/auth/refreshoperator

Refresh the token

Exchanges a still-valid token for a fresh one (sliding session). The old token stays valid until it expires.

Response
{ "token": "eyJ…", "user": { "email": "maya@bank.com", "role": "analyst" } }
Cases
GET/v1/casesoperator

List the review queue

Query
queuestringoptionalDefaults to "risk-ops".
Response
[{ "id": "cse_…", "eventId": "evt_…", "verdictId": "vd_…", "queue": "risk-ops",
   "verdict": "review", "score": 55, "status": "open",
   "openedAt": "2026-09-17T10:22:00.000Z", "audit": [ … ] }]
Response fields
idstringalwaysCase id (cse_).
eventIdstringalwaysThe event that opened the case.
verdictIdstringalwaysThe verdict that opened it.
queuestringalwaysThe review queue the case sits in.
verdict"review"alwaysAlways review — only review verdicts open cases.
scorenumberalwaysThe risk score at decision time.
status"open" | "assigned" | "resolved"alwaysCase lifecycle state.
assignedTostringnullableAnalyst email, once assigned.
resolution{ outcome, analyst, note?, at }nullablePresent once resolved.
openedAtstring (ISO-8601)alwaysWhen the case opened.
audit{ action, at, actor, detail? }[]alwaysAppend-only trail: opened / assigned / resolved, each with actor and timestamp.

Same object shape is returned by GET /v1/cases/:id.

GET/v1/cases/:idoperator

Fetch one case with its audit trail

Response
{ "id": "cse_…", "status": "assigned", "assignedTo": "maya@bank.com", "audit": [ … ] }

404 when the case does not exist.

POST/v1/cases/:id/assignoperator

Assign a case to yourself

Response
{ "id": "cse_…", "status": "assigned", "assignedTo": "maya@bank.com" }

The analyst is taken from your token, not the body.

POST/v1/cases/:id/resolveoperator

Resolve a case and emit a label

Body
outcome"fraud" | "legit" | "inconclusive"requiredGround truth. "inconclusive" records no label.
notestringoptionalRationale kept on the case.
Request
{ "outcome": "fraud", "note": "confirmed ATO" }
Response
{ "id": "cse_…", "status": "resolved", "resolution": { "outcome": "fraud", … } }

409 if already resolved. Resolving feeds a training label into Feedback.

Config
GET/v1/rulesoperator

The active rulesets, rendered as DSL

Response
[{ "eventType": "card.authorize", "version": "seed@v0.4",
   "rules": [{ "id": "r_velocity", "tag": "velocity", "weight": 28, "dsl": "rule r_velocity { … }" }] }]
GET/v1/policiesoperator

The active policies and their bands

Response
[{ "eventType": "card.authorize",
   "policy": { "id": "pol_card_authorize", "version": "v0.4.0", "onError": "fail_open",
     "bands": [{ "verdict": "allow", "min": 0, "max": 24 }, … ] } }]
POST/v1/policiesadmin

Publish 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.

Body
idstringrequiredExisting policy id.
versionstringrequiredNew, immutable version label.
onError"fail_open" | "fail_closed"requiredFallback verdict on a degraded engine.
bandsBand[]required{ verdict, min, max, reviewQueue? } contiguous over 0–100.
Request
{ "id": "pol_card_authorize", "version": "v0.4.1", "onError": "fail_closed",
  "bands": [ { "verdict": "allow", "min": 0, "max": 20 }, … ] }
Response
{ "ok": true, "version": "v0.4.1" }
POST/v1/policies/:id/rollbackadmin

Roll a policy back to an earlier version

Body
toVersionstringrequiredA version from the policy's history.
Request
{ "toVersion": "v0.4.0" }
Response
{ "ok": true }
GET/v1/policies/:id/historyoperator

A policy's version history

Response
["v0.4.0", "v0.4.1"]
POST/v1/policies/:id/simulateoperator

See which verdict a score would get

Body
scorenumberrequired0–100.
Request
{ "score": 61 }
Response
{ "verdict": "review", "reviewQueue": "risk-ops" }

Test config changes without sending a real event.

GET/v1/verdictsoperator

Recent entries from the append-only verdict log

Query
limitnumberoptional1–100, default 25.
Response
[{ "id": "vd_…", "verdict": "deny", "score": 82, "decidedAt": "…" }]
POST/v1/backtestadmin

Replay 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.

Body
eventTypestringrequiredWhich event type's history to replay, e.g. card.authorize.
policyobjectoptionalCandidate { bands, onError } to test. Omit to keep the live policy.
rulesRule[]optionalCandidate ruleset to re-evaluate. Omit to reuse each decision's recorded score.
Request
{
  "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 }
  ] }
}
Response
{
  "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 }
}
Response fields
eventTypestringalwaysThe event type replayed.
sampleSizenumberalwaysReplay samples available for this event type.
labelednumberalwaysOf those, how many had a fraud/legit label and were scored. A candidate is only judged on labeled history.
candidate.truePositivesnumberalwaysLabeled-fraud events the candidate would flag (review/challenge/deny).
candidate.falsePositivesnumberalwaysLabeled-legit events the candidate would flag.
candidate.falseNegativesnumberalwaysLabeled-fraud events the candidate would allow.
candidate.trueNegativesnumberalwaysLabeled-legit events the candidate would allow.
candidate.precisionnumber (0–1)alwaysTP ÷ (TP + FP) for the candidate.
candidate.recallnumber (0–1)alwaysTP ÷ (TP + FN) — share of fraud caught.
candidate.falsePositiveRatenumber (0–1)alwaysFP ÷ (FP + TN).
baseline.*objectalwaysThe same confusion counts for the currently-live policy, on the same labeled set.
delta.fraudCaughtnumberalwaysCandidate TP minus baseline TP. Positive = more fraud caught.
delta.falsePositivesnumberalwaysCandidate FP minus baseline FP. Negative = fewer false positives.
delta.flipsnumberalwaysDecisions whose verdict would change versus what actually happened.

Powers the dashboard's test-before-you-publish panel. 400 if the candidate policy has gaps.

API Keys
GET/v1/apikeysadmin

List service keys

Response
[{ "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.

POST/v1/apikeysadmin

Create a service key

Optionally scope the key to specific actions and set an expiry. A key with no scopes grants both.

Body
namestringrequiredA label, e.g. the service that will use it.
scopes("decisions" | "labels")[]optionalActions the key may perform. Omit for full access (both).
expiresInDaysnumberoptionalDays until the key expires. Omit for a key that never expires.
Request
{ "name": "payment-gateway", "scopes": ["decisions"], "expiresInDays": 90 }
Response
{ "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 } }
Response fields
plaintextstringalwaysThe full key (vk_live_…). Returned only on creation — it is never recoverable afterwards.
key.idstringalwaysKey id, used to revoke it.
key.namestringalwaysThe label you gave it.
key.prefixstringalwaysFirst chars of the key, shown in listings for identification.
key.scopesstring[]alwaysActions this key may perform.
key.expiresAtstring (ISO-8601)nullableWhen the key expires, if ever.
key.expiredbooleanalwaysWhether the key has already expired.
key.revokedbooleanalwaysWhether 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.

POST/v1/apikeys/:id/revokeadmin

Revoke a key immediately

Response
{ "ok": true }
Analytics
GET/v1/analytics/summaryoperator

Rollups 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.

Response
{
  "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
}
Response fields
totals.decisionsnumberalwaysAll decisions seen by the projector.
totals.byVerdictRecord<verdict, number>alwaysCount per verdict (allow/challenge/review/deny).
scoreBucketsnumber[11]alwaysScore histogram in 10-point buckets (0–9 … 100).
topTagsRecord<tag, number>alwaysHow often each signal fired.
byDayRecord<date, DayCounts>alwaysPer-day totals and verdict breakdown.
cases{ resolved, fraud, legit, inconclusive }alwaysAnalyst case-resolution outcomes.
labels{ fraud, legit, analyst, chargeback }alwaysGround-truth labels by outcome and source — includes chargebacks that never became cases.
falsePositiveRatenumber (0–1)alwayslegit ÷ (fraud + legit) over resolved cases.
GET/v1/modeloperator

The 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.

Response
{
  "active": "weighted",
  "weights": [
    { "tag": "takeover", "fraud": 41, "legit": 3, "weight": 42, "trusted": true },
    { "tag": "velocity", "fraud": 22, "legit": 9, "weight": 32, "trusted": true }
  ]
}
Response fields
active"weighted" | "learned"alwaysWhich scorer is live (set by the SCORER env var). The model is projected either way.
weights[].tagstringalwaysThe signal tag.
weights[].fraudnumberalwaysTimes this tag rode a fraud label.
weights[].legitnumberalwaysTimes this tag rode a legit label.
weights[].weightnumber (0–45)alwaysLearned points, from the smoothed fraud rate. Used by the learned scorer.
weights[].trustedbooleanalwaysWhether it has enough labels (≥3) to be used; until then the scorer keeps the hand weight.
Graph
GET/v1/graph/:kind/:idoperator

An 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.

Response
{
  "kind": "device", "id": "dev_ring",
  "users": ["usr_1", "usr_2", "usr_3"],
  "devices": [], "ips": ["203.0.113.7"],
  "ringSize": 8
}
Response fields
kind"user" | "device" | "ip"alwaysThe entity kind queried (from the path).
idstringalwaysThe entity id queried.
usersstring[]alwaysDistinct users directly linked to this entity.
devicesstring[]alwaysDistinct devices directly linked.
ipsstring[]alwaysDistinct IPs directly linked.
ringSizenumberalwaysSize 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.

Webhooks
GET/v1/webhooksadmin

List webhook endpoints and their delivery status

Response
{
  "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 }]
}
Response fields
eventsstring[]alwaysThe events an endpoint may subscribe to.
endpoints[].idstringalwaysEndpoint id (used to revoke).
endpoints[].urlstringalwaysWhere deliveries are POSTed.
endpoints[].eventsstring[]alwaysSubscribed events.
endpoints[].activebooleanalwaysWhether it's live.
endpoints[].lastStatus"delivered" | "failed"nullableLast delivery outcome.
endpoints[].failuresnumberalwaysConsecutive failed deliveries.
POST/v1/webhooksadmin

Register 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).

Body
urlstringrequiredhttp(s) URL to deliver to.
eventsstring[]requiredOne or more of verdict.reached.v1, case.resolved.v1, label.recorded.v1.
Request
{ "url": "https://app/hooks/verdict", "events": ["verdict.reached.v1"] }
Response
{ "id": "whk_…", "url": "https://app/hooks/verdict", "events": ["verdict.reached.v1"],
  "active": true, "failures": 0, "secret": "whsec_…" }
Response fields
secretstringalwaysThe signing secret — returned only on creation. Store it to verify signatures.
idstringalwaysEndpoint id.

Delivered payload shape: { id, type, occurredAt, correlationId, data }, where data is the event payload.

POST/v1/webhooks/:id/revokeadmin

Deactivate an endpoint

Response
{ "ok": true }
GET/v1/webhooks/deliveriesadmin

Delivery log (pending & dead-lettered)

Deliveries awaiting a retry, and ones that exhausted their attempts (dead-lettered).

Response
{ "pending": [{ "id": "whd_…", "endpointId": "whk_…", "event": "verdict.reached.v1",
    "status": "pending", "attempts": 2, "nextAttemptAt": "…", "lastError": "fetch failed" }],
  "dead": [] }
POST/v1/webhooks/deliveries/redriveadmin

Re-drive dead-lettered deliveries

Moves every dead-lettered delivery back to pending for another attempt.

Response
{ "requeued": 3 }
Notifications
GET/v1/notificationsadmin

List alert channels and subscribable events

Response
{ "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 }] }
POST/v1/notificationsadmin

Add 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.

Body
type"slack" | "telegram" | "webhook"requiredChannel kind.
urlstringrequiredHTTPS URL (Slack webhook, Telegram bot sendMessage URL, or your endpoint). Stored server-side; shown masked.
targetstringoptionalTelegram chat id — required for the telegram type.
eventsstring[]requiredEvents to alert on (see the list endpoint).
minVerdict"allow" | "challenge" | "review" | "deny"optionalverdict.reached severity floor.
throttlePerMinnumberoptionalMax alerts per minute for this channel (0 = unlimited); omit for the env default.
Request
{ "type": "telegram", "url": "https://api.telegram.org/bot<token>/sendMessage", "target": "-1001234567890", "events": ["verdict.reached.v1"], "minVerdict": "deny", "throttlePerMin": 30 }
Response
{ "id": "ntf_…", "type": "slack", "urlHint": "https://hooks.slack.com/…", "events": ["verdict.reached.v1"], "minVerdict": "deny", "throttlePerMin": 30, "active": true, "failures": 0 }
POST/v1/notifications/:id/testadmin

Send a test alert to a channel

Response
{ "ok": true }
POST/v1/notifications/:id/revokeadmin

Deactivate a channel

Response
{ "ok": true }
GET/v1/notifications/deliveriesadmin

List alert deliveries (pending + dead-lettered)

Durable alert deliveries awaiting a retry, and those that exhausted their retries. Bodies are omitted.

Response
{ "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": [] }
POST/v1/notifications/deliveries/redriveadmin

Re-drive dead-lettered alerts

Moves every dead-lettered alert delivery back to the queue for another attempt.

Response
{ "requeued": 3 }
Settings
GET/v1/config/rate-limitsadmin

Get rate-limit settings

The effective per-minute budgets and whether each is a dashboard override or the env default.

Response
{ "windowMs": 60000, "decisionsPerMin": 600, "loginPerMin": 10,
  "overridden": { "decisionsPerMin": false, "loginPerMin": false } }
PUT/v1/config/rate-limitsadmin

Update 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.

Body
decisionsPerMinnumber | nulloptionalPer-API-key budget for POST /v1/decisions (null = revert to default).
loginPerMinnumber | nulloptionalPer-IP budget for POST /v1/auth/login (null = revert to default).
Request
{ "decisionsPerMin": 1200, "loginPerMin": 10 }
Response
{ "windowMs": 60000, "decisionsPerMin": 1200, "loginPerMin": 10,
  "overridden": { "decisionsPerMin": true, "loginPerMin": false } }
GET/v1/config/alertsadmin

Get alert settings

The anomaly z-score threshold at or above which a decision emits alert.anomaly.v1.

Response
{ "anomalyZScore": 3, "overridden": { "anomalyZScore": false } }
PUT/v1/config/alertsadmin

Update alert settings

Set the anomaly z-score threshold (send null to revert to the default of 3). Applies within ~10s.

Body
anomalyZScorenumber | nulloptionalσ above the user's baseline that triggers an anomaly alert (null = default 3).
Request
{ "anomalyZScore": 2.5 }
Response
{ "anomalyZScore": 2.5, "overridden": { "anomalyZScore": true } }
GET/v1/config/retentionadmin

Get 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.

Response
{ "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/v1/config/retentionadmin

Update 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.

Body
verdictsnumber | nulloptionalDays to keep the verdict log (0 = forever, null = env default).
activitynumber | nulloptionalDays to keep the API activity log.
replaynumber | nulloptionalDays to keep replay samples.
idempotencynumber | nulloptionalDays to keep idempotency keys.
deadLetternumber | nulloptionalDays to keep dead-lettered outbox rows.
Request
{ "activity": 120, "idempotency": 3 }
Response
{ "sweepMinutes": 60,
  "days": { "verdicts": 365, "activity": 120, "replay": 90, "idempotency": 3, "deadLetter": 30 },
  "overridden": { "verdicts": false, "activity": true, "replay": false, "idempotency": true, "deadLetter": false } }
POST/v1/config/retention/runadmin

Run 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.

Response
{ "ranAt": "2026-01-01T00:00:00.000Z",
  "pruned": { "verdicts": 0, "activity": 4, "replay": 4, "idempotency": 120, "deadLetter": 0 } }
GET/v1/config/modeloperator

Scoring 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.

Response
{ "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_*.

GET/v1/config/storageadmin

Get 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.

Response
{ "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" } ] }
Response fields
totalBytesnumberalwaysLogical size of the document store on disk.
collections{ name, rows }[]alwaysRow count per collection.
disksDiskUsage[]alwaysPer-filesystem health: { path, totalBytes, freeBytes, usedBytes, usedPercent } for each monitored mount (deduplicated by filesystem).
componentsStorageComponent[]alwaysPer-service disk breakdown: { name, kind: database|model, bytes, path? } — what is consuming the disk.
GET/v1/auditadmin

List 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.

Query
limitnumberoptionalPage size (default 50, max 200).
offsetnumberoptionalRows to skip (default 0).
Response
{ "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 } }
] }
Labels
POST/v1/labels/chargebackapikey

Record a chargeback as a fraud label

A PSP/system endpoint — takes a service API key.

Headers
X-API-KeystringrequiredService API key.
Body
eventIdstringrequiredThe original event that was charged back.
Request
{ "eventId": "evt_…" }
Response
{ "recorded": true }
Response fields
recordedtruealwaysAcknowledged; 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.

Data governance
POST/v1/privacy/eraseadmin

Erase 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.

Body
userIdstringrequiredThe user to erase across the engine's stores.
Request
{ "userId": "usr_3f9a" }
Response
{ "userId": "usr_3f9a", "replaySamplesRemoved": 12, "activityEntriesRemoved": 12,
  "graph": true, "baseline": true }
Response fields
userIdstringalwaysThe user erased.
replaySamplesRemovednumberalwaysReplay-log entries deleted for this user.
activityEntriesRemovednumberalwaysAPI activity-log entries deleted for this user.
graphbooleanalwaysWhether the graph identity was unlinked and removed.
baselinebooleanalwaysWhether the anomaly baseline was dropped.
System
GET/healthpublic

Liveness probe

The process is up. Use it for the liveness probe only; use /readyz for readiness.

Response
{ "status": "ok", "name": "verdict-engine", "version": "0.7.0" }
GET/readyzpublic

Readiness 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.

Response
{ "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.

GET/metricspublic

Prometheus 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.

Response
# HELP verdict_decisions_total Decisions returned, by verdict
# TYPE verdict_decisions_total counter
verdict_decisions_total{verdict="allow",cached="false"} 128