Skip to main content
Baobyte AML Suite
Documentation contents

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

GET/v1/screening/hits

Sanctions and PEP screening hits

Required permission: screening.hit.readlistScreeningHits
Show parameters and responses

Parameters

NameInTypeDescription
decisionquerypending | true_positive | false_positive | suppressed—
party_idquerystringNarrows 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.
limitqueryintegerPage size, capped at 200.
cursorquerystringOpaque 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
GET/v1/screening/hits/{hitID}

One screening hit, with the evidence behind it

Required permission: screening.hit.readgetScreeningHit

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 responses

Parameters

NameInTypeDescription
hitIDrequiredpathstring—

Responses

  • 200The hit, the listed party, the version pin and the runScreeningHitDetail
  • 404—
POST/v1/screening/hits/{hitID}/assign

Take a screening hit

Required permission: screening.hit.decideassignScreeningHit

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 responses

Parameters

NameInTypeDescription
hitIDrequiredpathstring—

Responses

  • 204Assigned
  • 404—
POST/v1/screening/hits/{hitID}/decision

Clear or confirm a screening hit

Required permission: screening.hit.decidedecideScreeningHit

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 responses

Parameters

NameInTypeDescription
hitIDrequiredpathstring—

Responses

  • 200The decision and what it obliged. Previously a 204, which made the escalation invisible to the analyst who caused it.ScreeningDecision
  • 202The 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`.object
  • 404—
  • 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—
POST/v1/screening/hits/{hitID}/reopen

Return a decided screening hit to the review queue

Required permission: screening.hit.reopenreopenScreeningHit

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 responses

Parameters

NameInTypeDescription
hitIDrequiredpathstring—

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—
POST/v1/screening/hits/{hitID}/suppress

Propose that a cleared match stops being raised

Required permission: screening.match.suppressproposeMatchSuppression

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 responses

Parameters

NameInTypeDescription
hitIDrequiredpathstring—

Responses

  • 202The approval request awaiting a second pair of eyesApprovalRequest
  • 400—
  • 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—
POST/v1/screening/runs

Screen a name against the published watchlist index

Required permission: screening.run.createrunScreening

**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 responses

Responses

  • 201The run, its hits, and what the engine actually did. `201` because a run is a stored resource, not a repeatable read.ScreeningResult
  • 409No 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.