API reference
Screening
7 operations in this area of the contract. Every one declares the permission it requires.
Contract version 0.2.0All areas
Operations
/v1/screening/hitsSanctions and PEP screening hits
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
decision | query | pending | true_positive | false_positive | suppressed | — |
party_id | query | string | Narrows the queue to the hits raised against one party, matched on the party of the screening request rather than on the hit. This is what a customer screen asks for; without it the only way to show one customer's matches is to read the whole tenant's queue and filter client-side, which silently goes wrong at the first page boundary. A value that is not a UUID is a 400, not an ignored filter. |
limit | query | integer | Page size, capped at 200. |
cursor | query | string | Opaque keyset cursor from a previous response. Offset pagination is deliberately not offered: `OFFSET 500000` is a table scan, and if it were offered it would be used (ADR-0012). Only the collections that document this parameter can be resumed. The rest report `truncated: true` and refuse a cursor rather than silently returning the first page again — see the `truncated` field on the page schemas. |
Responses
200Hits, highest score firstScreeningHitPage
/v1/screening/hits/{hitID}One screening hit, with the evidence behind it
The hit workspace. The queue row carries two names, a score and three corroboration pills, which is enough to sort work and not enough to decide any of it — declaring a customer a sanctions match commits the institution to freezing assets and to filing.
Five things come back together, because a decision assembled from five screens is a decision most analysts take from the first one:
`hit` the match, including the identity of the listed party SNAPSHOTTED at match time. Those fields are absent on a hit raised before the snapshot existed, which is a real state and not a null to paper over. `entry` the watchlist entry as the list holds it TODAY, with every recorded spelling and the corroboration attributes the score was computed from. Where this disagrees with the snapshot on `hit`, the difference is itself evidence — a delisting, or a corrected programme — and a screen must label which of the two it is showing. `version` the published list version the hit is pinned to. This is what makes "which list, as it stood when" answerable about a filing years later. `request` the run: its mode, its latency, and the POLICY DOCUMENT snapshotted when it ran. That last one is what answers "what threshold was in force when this ran" — the configuration may have been retuned since, and reading it live would answer a different question. `party` the subject's own profile. ABSENT, not null, when the run screened a free-text counterparty with no party row — a name off a payment message is the common case — and also when the party cannot be read.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hitIDrequired | path | string | — |
Responses
200The hit, the listed party, the version pin and the runScreeningHitDetail404—
/v1/screening/hits/{hitID}/assignTake a screening hit
Assigns the hit to the calling user. Self-assignment only, which is what a triage queue needs: an endpoint accepting an assignee would let one analyst push a sanctions review into another's queue with nothing having authorised it.
Gated on screening.hit.decide rather than a separate key, mirroring monitoring.alert.triage, which covers both assign and close. Taking a match is the first act of reviewing it, and a role able to claim ownership of a sanctions review it cannot then complete is a worse arrangement than one permission for both.
404 when the hit does not exist in this tenant OR has already been decided: a finished review is not assignable, and the caller must not be told an assignment happened that did not.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hitIDrequired | path | string | — |
Responses
204Assigned404—
/v1/screening/hits/{hitID}/decisionClear or confirm a screening hit
Confirming a match escalates it, in the transaction that records the decision: a KYC review is raised against the customer with reason `screening_hit` (BRD §8.1, §8.4) and the subject is re-scored with trigger `screening_hit` and the hit id as the trigger reference (BRD §8.2). Clearing a hit does neither — the analyst has just said this is not the listed person.
Nothing in that escalation can fail the decision. The states that would stop it — no party behind the hit, no customer behind the party, a review already outstanding, no active risk model — are ordinary, are detected before anything is written, and are reported in `escalation_skipped` rather than raised. What remains is the database failing, and that fails the request, leaves the hit `pending` and is safe to retry.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hitIDrequired | path | string | — |
Responses
200The decision and what it obliged. Previously a 204, which made the escalation invisible to the analyst who caused it.ScreeningDecision202The clearance was sent for approval and has NOT been applied. The hit is still pending and nothing was escalated. Track `request_id` through `/v1/approvals`.object404—409The hit has already been decided. Reopen it to decide it again — this used to be a `404`, which told an analyst that a match they were looking at did not exist.422—
/v1/screening/hits/{hitID}/reopenReturn a decided screening hit to the review queue
Undoes a determination. The hit returns to `pending`, and its decision, author, timestamp and rationale are cleared from the row — the audit chain (`screening.hit.reopened`) becomes the only record of what was decided, which is why the reason is mandatory.
It exists because a determination used to be final: the decision UPDATE is guarded by `AND decision = 'pending'`, so a `true_positive` recorded by a mis-click could not be undone through any surface of this product, and every attempt answered "not found".
Reopening DECIDES nothing. The hit returns to the state in which the four-eyes gate on clearances applies again in full, so it cannot be used to walk a clearance past a checker. It carries its own permission, granted more narrowly than `screening.hit.decide`, because erasing a determination is a larger capability than making one.
The review deadline is deliberately NOT reset: a hit raised on Monday and reopened on Friday is immediately in breach, which is the truth. Restarting the clock would make decide-then-reopen a way to clear an SLA breach without reviewing anything.
Refused with `409` on a hit that is already pending, and on a `suppressed` one: a suppression is a standing approved decision, and the way to undo it is to revoke the suppression rather than to reopen the hit it silenced.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hitIDrequired | path | string | — |
Responses
204The hit is back in the queue. No body: nothing was decided, and a hit built from this UPDATE could not carry `party_id` or `mode` — read it from the queue instead.404—409The hit is pending, or is suppressed rather than decided.422—
/v1/screening/hits/{hitID}/suppressPropose that a cleared match stops being raised
This does not suppress anything. It records a maker-checker proposal; a different, role-authorised user approves it through `/v1/approvals/{approvalID}/approve`, and the suppression is written inside that approval's transaction.
Deciding a hit answers "is this customer that listed person, today". A suppression answers it for every screening run until it expires, which is why it is a two-step control rather than a second button beside the decision. `app.match_suppression` requires two distinct people in a CHECK constraint, and both ids are taken from the approval record: the maker from the request, the checker from its decision. Neither can be named in a request body, which is the defect migration 00013 closed on case closure and migration 00016 recorded as still open here.
**Nothing identifying is read from the body.** The party, the watchlist and the matched entry's external id are resolved server-side from the hit itself. A caller able to name the party could stop the institution screening a customer of their choosing, and the evidence of that would be an absence of hits nobody is looking for.
The hit must already be decided `false_positive` by a named user: a suppression makes a REVIEWED determination durable, and one raised on a pending hit would silence a match before anybody examined it. It also needs a customer — a hit from an ad-hoc name screening has no party, and a suppression keyed to a name alone would silence the match for everyone who shares it.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hitIDrequired | path | string | — |
Responses
202The approval request awaiting a second pair of eyesApprovalRequest400—404—409The hit is not a cleared false positive, it has no customer behind it, or a suppression proposal is already pending on it.422—
/v1/screening/runsScreen a name against the published watchlist index
**The only way to screen a name in this product.**
Everything downstream of a screening hit shipped before this endpoint did. The IDF-weighted matcher, the corroboration adjustment, the per-tenant policy resolution, the watchlist version pinning, the four-eyes suppression and the audit chaining are all built and tested — and `Screening.Screen` had exactly one non-test caller in the repository, the demo seeder. Every screening hit that existed in this product had been manufactured by a fixture, and a compliance officer handed a name by a correspondent bank had no answer from it. BRD §8.4 requires the capability; nothing could invoke it.
It works today against the institution's own internal lists, which need no licence, and it works unchanged the day a licensed sanctions feed is registered (Q6).
### There is no allow/deny field, deliberately
Q7 — "is real-time screening blocking a payment authorization?" — is still open. Its implemented default is a synchronous endpoint with a per-tenant timeout **and** an explicit per-tenant fail-open/fail-closed policy that "must be the client's documented, signed choice, surfaced in the admin console rather than buried in a config file". The timeout half is built; the policy half is not. An endpoint answering `decision: allow` would imply a posture nobody has agreed to, and would make the *absence of a hit* look like a positive clearance. `POST /v1/transactions` withholds a decision for the same reason and in the same words.
So this returns what it knows: which entries matched, how strongly, on which pinned list version, and what corroborated or contradicted each one. Whether a payment stops is the caller's policy decision, with somewhere left for Q7's answer to land.
### The deadline
Bounded by `screening.match.timeout_ms`, the per-tenant budget (default 2000 ms, BRD §9's sub-two-second figure). It is applied as a context deadline covering the whole request, so exceeding it cancels the database work and releases the pool connection instead of leaving a query running behind a caller who has given up. Nothing is committed when it fires — a request row without its hits would read as a run that found nothing — so a `503` here is never evidence that a name is clear, and retrying is safe.
### Not idempotent, and deliberately not
A run is a stored resource with its own id, its snapshotted policy and its latency, and it is the row a filing cites two years later. The same name screened twice against a list that changed in between is two compliance events and both must exist, so this endpoint takes no `Idempotency-Key` and collapses nothing.
`identifiers` is not accepted. `ScreenInput` carries no identifier field and the corroboration code never assigns an identifier result, so the field would be inert — and an inert field advertising a check nobody performs is worse than an absent one.
Show parameters and responsesHide parameters and responses
Responses
201The run, its hits, and what the engine actually did. `201` because a run is a stored resource, not a repeatable read.ScreeningResult409No watchlist has been published, so there was nothing to screen against. An error rather than an empty result: reporting "no hits" when no list exists is a silent compliance failure.422—503The run exceeded this tenant's `screening.match.timeout_ms` budget and was rolled back. Nothing was recorded, so this is not evidence that the name is clear. Retrying is safe.