API reference
Watchlists
12 operations in this area of the contract. Every one declares the permission it requires.
Contract version 0.2.0All areas
Operations
/v1/watchlists/entries/{entryID}/removalRemove an entry, with a reason
A soft delete. The row survives, because an audit entry pointing at a row that no longer exists is only half a record, and "who was on the blacklist in March, and why" needs the row.
The required compliance rationale travels only in a closed JSON body. It must never be a query parameter: URLs are retained by access logs, browser history, referrers and support screenshots. Taking a name OFF a blacklist is the change an examiner is most likely to ask about, so a missing, blank or over-long body refuses the operation. The existing `watchlists.list.write` permission and its configured MFA policy still govern this action. The rationale remains on the protected soft-deleted entry; the generic immutable audit event records only opaque entry/list ids, the reason code and that a removal rationale was recorded, so `audit.event.read` does not bypass `watchlists.entry.read`.
Removal from either blacklist or greylist is mandatory maker-checker. A valid request therefore records the exact digest-bound proposal and returns `202`; the entry remains live until a different authorised user approves it. `200` is retained for installations deliberately constructed without the optional workflow dependency, whose audit record explicitly states that four-eyes was not enforced.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
entryIDrequired | path | string | — |
Responses
200The removed entry, still readableWatchlistEntry202The removal was recorded for independent approval and was not applied.WatchlistChangeApproval400—403—404No such entry, or it was already removed — a second removal must not write a second audit event.422—
/v1/watchlists/imports/{importID}/quarantineThe rows one import rejected
This is what makes quarantining better than failing rather than merely different. Rows held in a table that nothing can read are rows that were dropped with extra steps.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
importIDrequired | path | string | — |
Responses
200Quarantined rows, with their line numbers and reasonsWatchlistRowErrorPage404—
/v1/watchlists/listsThe internal lists this institution maintains
Show parameters and responsesHide parameters and responses
Responses
200Internal lists, completeWatchlistPage403—
/v1/watchlists/listsRegister an internal blacklist or greylist
A blacklist and a greylist are different decisions rather than two severities of one: a blacklist entry says "do not do business with this party", a greylist entry says "do business and watch it". Both surface as screening hits and the analyst has to be able to tell them apart, so the distinction is a column rather than a convention.
A duplicate key is refused rather than silently updated: two teams maintaining a list under the same key is how an institution ends up with a third copy nobody is sure is authoritative.
Show parameters and responsesHide parameters and responses
Responses
201The listWatchlist409—422—
/v1/watchlists/lists/{listKey}/entriesA list's entries
Removal is a soft delete, so `include_removed=true` is how an examiner asking who was on the blacklist in March gets an answer. The maintenance screen does not want them, which is why it is opt-in.
Results use an immutable descending UUIDv7 keyset. `next_cursor` resumes after the last entry on this bounded page, so every matching row remains reachable without OFFSET scans and edits to a name or rationale cannot move a row across a page boundary. Counts derived from `items` are page-scoped, never totals for the master list.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
include_removed | query | boolean | — |
party_id | query | string | Narrows to the entries about one party this platform holds. |
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. |
limit | query | integer | Page size, capped at 200. |
Responses
200A bounded, resumable page of entries in immutable descending UUIDv7 orderWatchlistEntryPage404—
/v1/watchlists/lists/{listKey}/entriesAdd or update one entry
Re-adding an existing `external_id` UPDATES the entry rather than failing, because the alternative is a maintenance screen that can only ever append. The unique index covers live rows only, so a customer exited in 2024 and re-onboarded and re-exited in 2026 gets a second entry while the first stays as the record of the first exit.
`reason` is required and enforced non-empty by the database: an entry nobody can justify is one nobody will ever dare remove. `party_id` is what lets screening tell a self-match from a real one.
Adding to a blacklist is a mandatory maker-checker change. In that case this operation records the exact digest-bound proposal, applies no entry, and returns `202` with the approval identity. Adding to a greylist increases scrutiny and remains an immediate `201` operation.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Responses
201The entryWatchlistEntry202A blacklist addition was recorded for independent approval and was not applied.WatchlistChangeApproval404—422—
/v1/watchlists/lists/{listKey}/exportExport a list in the format import accepts
Round-tripping is a requirement rather than a nicety: an export a compliance team cannot feed back in is a dead end, and the usual way it becomes one is a field that survives the database and not the file.
Only live entries are exported — not removed, not expired. `X-Watchlist-Entry-Count` carries the row count so a script can reconcile what it received without parsing the file it just downloaded.
Deliberately unaudited: an export reads, and it changes no list.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Responses
200The list as CSV404—
/v1/watchlists/lists/{listKey}/importBulk import entries from a CSV
The body is the CSV itself (`curl --data-binary @list.csv`), not a multipart form: the caller is an operations script or a compliance officer, not a browser.
**Malformed rows are quarantined, not fatal, and the run finishes `partial`.** ADR-0014 made this argument for transaction ingest and it is stronger here: an upload that aborts on row 9 leaves the institution screening against the list as it stood before the upload while the screen says the upload failed — a state that reads as a fixable inconvenience and is actually a compliance gap, because the names in rows 10 through 40,000 are not being screened for and nobody has been told which ones. The rejected rows are kept verbatim with their line numbers and are readable at `/imports/{importID}/quarantine`.
A missing required HEADER column is treated differently and fails the whole file, because every row would quarantine for the same reason and forty thousand identical quarantine lines hide the one fact that matters.
The format is exactly what `/export` produces. The two round-trip, and a test asserts it. An optional bare filename travels in the bounded `X-Filename` header, never a query parameter: request URLs are routinely retained in access logs, browser history and support traces. It is retained on the protected reconciliation row and is not copied to the generic audit event.
A blacklist import is mandatory maker-checker, including a one-row file so bulk import cannot bypass the single-entry gate. The parsed valid records and quarantined failures are bound into the approval payload; no list entry or reconciliation row is written before approval. Greylist import increases scrutiny and remains an immediate `200`.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Idempotency-Key | header | string | The caller's own reference for this operation. ADR-0012 requires it on mutating endpoints because payment-adjacent callers retry and a duplicate filing is a regulatory incident. On the ingest endpoints it is not a separate identity: it supplies `external_ref` when the body omits it, and the uniqueness of (tenant, source, external_ref, booking_ts) in the database is what makes the retry harmless (ADR-0014). Sending both with different values is refused rather than resolved. |
X-Filename | header | string | Optional bare filename retained on the reconciliation record; path separators are refused. |
Responses
200The reconciliation record for this fileWatchlistImportResult202A blacklist import was parsed and recorded for independent approval, but no entries were applied.WatchlistChangeApproval400—404—413The file exceeds the endpoint's limit. Split it; import matches on external_id, so an overlap is an update.415The body was not a CSV.
/v1/watchlists/lists/{listKey}/publishPublish a list into the screening index
Its own permission because it is its own decision: editing an entry changes a list, publishing changes who the institution screens against from that moment on. Every screening hit pins the version it matched, which is what makes "which list, as it stood when, produced this hit" answerable about a filing made two years ago (ADR-0015).
Idempotent on CONTENT rather than on time. The version string is a timestamp plus a digest over the entry set, so republishing an unchanged list writes nothing and comes back with `published: false`. A version per save would turn the version history into a log of who pressed the button, and every hit pins a version precisely so that history means something.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Responses
200What was published, or that nothing needed to beWatchlistPublishResult404—
/v1/watchlists/sourcesConfigured provider feeds and their refresh state
Expect this to be empty, and expect any row in it to be failing. No provider adapter ships (Q6), so a configured source records `ErrNoProvider` against itself on every run — which is the honest state of an installation with no licensed feed, and far better than a placeholder that makes screening look like it is happening.
Show parameters and responsesHide parameters and responses
Responses
200Sources, completeWatchlistSourcePage
/v1/watchlists/sources/{listKey}Bind a list to a provider feed
PUT rather than POST because there is at most one source per list key and the underlying write is an upsert: sending the same body twice must leave the same single binding.
`enabled` defaults to false and should stay there until somebody licenses a feed. Q6 has not established whether a commercial list may be redistributed to an on-premise client's data centre at all, and a source that defaulted to enabled would answer that question by accident on first install.
`list_kind` and `list_source` restate CHECK constraints that also live on `app.watchlist`. The duplication is accepted deliberately: this module cannot read that table, and discovering after a fetch and a parse that "PEP" is not a valid kind turns a configuration mistake into a failed job. Failing at configuration time is the cheaper place to be wrong.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Responses
200The source bindingWatchlistSource422—
/v1/watchlists/sources/{listKey}/refreshRefresh one source now
Runs the source whether or not it is due, synchronously, so the operator sees the answer rather than being told a job was queued. The scheduled equivalent is the six-hourly `refresh_watchlists` job, which runs every source that has fallen due.
A refresh that cannot run comes back `200` with `status: "failed"` and the reason, not as an error. The refusal is a FACT about that feed which has just been recorded against the source, and a 5xx would roll back the record of why it failed — the one thing an operator needs. In every installation today the reason is that no provider adapter is registered.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
listKeyrequired | path | string | An internal list's stable key. Text rather than a uuid because it is also the contract between this module and the screening index, which may not be referenced by a foreign key across the module boundary (ADR-0001). |
Responses
200The outcome, including a recorded failureWatchlistRefreshResult404—