Global Sanctions Screening API
One API call. Ten sanctions lists. Under 100 milliseconds. Every match tells you exactly how it was found, why it scored the way it did, and what to do about it.
What Is Noble Sight?
Noble Sight is a free global sanctions screening API. One call screens a name against 10 sanctions lists — OFAC, UK, EU, UN, France, Belgium, Netherlands, Canada, and Australia — using hybrid trigram, phonetic, and AI-powered matching in under 100 milliseconds.
Every match tells you exactly what triggered it: which list, which name form, the character similarity score, and whether phonetics agreed. Provide secondary attributes — date of birth, country, or entity type — and mismatches are flagged with structured reasons. Nothing is hidden behind an opaque number.
Screening results are stored immutably with the list version used, full request and response, and trace ID. Designed for OFAC's 10-year recordkeeping requirement and SOC 2 audit readiness.
Why Noble Sight
| Capability | Noble Sight (Free) | Open Source Alternatives | Paid APIs ($79–999/mo) |
|---|---|---|---|
| Per-match pathway breakdown | Every match shows source, trigram score, soundex flag | Single opaque score | Basic score only |
| AI name variations | 89,117 pre-computed + real-time deep screen | None | None |
| Dismissal reasons | Structured DOB, country, entity type explanations | None | None |
| Audit persistence | Full request/response + OFAC list version | Stateless — lost on restart | Yes |
| OFAC list version tracking | Every screening tagged with publish ID | None | Varies |
| Real-time alert delivery | HMAC-signed webhooks with retry + cursor polling | None | Varies |
| Internal watchlist screening | SAR subjects, exited customers, 314(a) names — same fuzzy engine, same API call | None | Separate system |
| Response time | <100ms (benchmarked) | Not published | Not published |
Built Different
Noble Sight is a single 18 MB binary. No runtime. No interpreter. No framework. No node_modules. The entire screening engine, API server, and audit system compiles to one static executable running on a read-only filesystem as a non-root user.
Most screening vendors ship 500 MB–1 GB container images packed with runtimes, package managers, and shells — each one an entry point for attackers. Noble Sight ships on a distroless base image with no shell, no package manager, and no utilities. There is nothing to exploit because there is nothing there.
| Property | Noble Sight | Typical Screening Vendor |
|---|---|---|
| Container image | 18 MB, distroless, no shell | 500 MB–1 GB, full OS with package manager |
| Runtime | None — compiled to native machine code | JVM, Node.js, or Python interpreter |
| Attack surface | Zero shell utilities, zero writable paths | Full Linux userland |
| Cold start | <1 second | 15–30 seconds (JVM warmup) |
| Memory at idle | ~30 MB | 300–500 MB (runtime overhead) |
| Dependencies | Go standard library + PostgreSQL | Hundreds of transitive packages |
| SQL | Parameterized queries, no ORM | ORM-generated queries, abstraction layers |
Every dependency is a liability. Every abstraction layer is a place where queries slow down, errors get swallowed, and auditors lose the trail. Noble Sight has fewer moving parts because compliance software should be the most predictable thing in your stack — not the most complex.
Architecture
The screening engine implements the model cascade architecture recommended by Federal Reserve research (FEDS 2025-092). Allen & Hatfield found that LLMs reduce false positives by 92% but are 10,000x slower than fuzzy matching. Their recommendation: fast fuzzy matching for routine screens, LLM escalation for hard cases.
Noble Sight pre-computes LLM-generated spelling, transliteration, and cultural name variations at import time — amortizing the AI cost once per SDN update, not once per query. This innovation is not discussed in the Fed paper.
Lists Screened
| List | Source | Status |
|---|---|---|
| OFAC SDN | U.S. Treasury — Specially Designated Nationals | Live |
| OFAC Consolidated | U.S. Treasury — Non-SDN Consolidated List | Live |
| UK Sanctions | HM Treasury — UK Financial Sanctions (OFSI) | Live |
| EU Sanctions | European Commission — EU Consolidated Financial Sanctions | Live |
| UN Sanctions | UN Security Council — Consolidated List | Live |
| France | Direction Generale du Tresor — Registre National des Gels | Live |
| Canada | Global Affairs Canada — SEMA Consolidated List | Live |
| Australia | DFAT — Australian Consolidated Sanctions List | Live |
| Belgium | Belgian Federal Finance Ministry — National Terrorist List | Live |
| Netherlands | Rijksoverheid — National Terrorism List | Live |
All 10 lists use the same screening engine — trigram, phonetic, and AI-powered matching. Every list is watched continuously and delta-imported on change. Portfolio monitoring automatically re-screens your entities when any list updates.
Screen a Name in 60 Seconds
Noble Sight is currently in invite-only beta. Contact sales@noblesight.io for an invite code. With a code, the steps below take 60 seconds.
Get an API Key
curl -X POST https://noblesight.io/v1/keys \
-H "Content-Type: application/json" \
-d '{"client_id": "your-company", "accept_terms": true, "invite_code": "noble_inv_..."}'
Screen a Name
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"name": "Vladimir Putin"}'
Get Results
{
"screened_name": "Vladimir Putin",
"alert_id": 42,
"match_count": 1,
"matches": [{
"uid": "36095",
"first_name": "Vladimir",
"last_name": "PUTIN",
"score": 97.5,
"source": "OFAC",
"programs": ["RUSSIA-EO14024"],
"date_of_birth": "1952-10-07",
"place_of_birth": "Leningrad, Russia",
"nationalities": ["Russia"],
"match_sources": [{
"source": "ofac_sdn",
"trigram_score": 100,
"soundex_match": true
}]
}],
"meta": {
"disclaimer": "Noble Sight provides sanctions screening results for informational purposes only. This service is not a substitute for a comprehensive sanctions compliance program. Noble Sight does not provide legal, regulatory, or compliance advice. Screening results reflect data available at the time of the request and may not capture all sanctions designations, aliases, or name variations. Final screening decisions, risk assessments, and compliance obligations remain the sole responsibility of the subscribing institution. Use of this service does not satisfy or replace any obligation under OFAC regulations, the Bank Secrecy Act, or any other applicable law."
}
}
Workflow Playbook
The API reference tells you what each endpoint does. This guide shows you how to use them together. Eight workflows, step by step, with copy-paste curl examples.
1. Onboard a Customer
A new customer applies. You need to screen their name against sanctions lists before opening the account. Provide every attribute you have — name, date of birth, country, entity type, and government ID — to get the most accurate score with the fewest false positives.
Screen with full attributes
Pass everything you collected during KYC. Each attribute reduces false positives — a DOB mismatch alone can drop a 96 to a 38.
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-H "X-Trace-ID: onboarding-acct-7891" \
-d '{
"name": "Alexander Volkov",
"date_of_birth": "1985-03-15",
"country": "RU",
"entity_type": "individual",
"id_number": "P1234567",
"id_type": "Passport"
}'
Read the response
Check match_count and alert_id. If match_count is 0, the customer is clear. If an alert_id is present, a match exceeded your alert threshold and needs review.
{
"screening_id": 5678,
"alert_id": 0,
"screened_name": "Alexander Volkov",
"match_count": 0,
"average_score": 0,
"lists_screened": ["ofac", "uk", "eu", "france", "belgium", "netherlands", "un", "canada", "australia"],
"matches": [],
"meta": { "disclaimer": "..." }
}
No matches, no alert. Proceed with account opening. The screening is stored immutably with your trace ID for audit.
If there is a match
When the response includes an alert_id, a match needs investigation. Check the dismissal_reasons array — it tells you exactly which attributes mismatched and by how much. Then move to the alert triage workflow below.
{
"screening_id": 5679,
"alert_id": 44,
"screened_name": "Alexander Volkov",
"match_count": 1,
"matches": [{
"uid": "12345",
"first_name": "Aleksandr",
"last_name": "VOLKOV",
"score": 42.3,
"source": "OFAC",
"programs": ["RUSSIA-EO14024"],
"match_sources": [{"source": "ofac_sdn", "trigram_score": 84.5, "soundex_match": true}],
"dismissal_reasons": [
"DOB mismatch: provided 1985-03-15, SDN has 1960-07-22 (far mismatch, 0.4x)",
"Country matches: RU"
],
"date_of_birth": "1960-07-22",
"nationalities": ["Russia"]
}]
}
The raw trigram score was 84.5, but the 25-year DOB mismatch applied a 0.4x penalty, dropping the final score to 42.3. The dismissal_reasons array explains exactly why.
2. Triage and Resolve Alerts
A screening returned a match. Someone needs to review it, decide if it is real, and document the decision. This is the full alert lifecycle — from new alert through investigation to resolution. Every action is recorded immutably.
List new alerts
Pull all unreviewed alerts. Filter by score to prioritize the highest-confidence matches first.
curl "https://noblesight.io/v1/alerts?status=new&min_score=80&limit=25" \ -H "X-API-Key: $NOBLE_API_KEY"
Assign to an analyst
SLA deadlines are set automatically: 24 hours for scores 90+, 72 hours for all others.
curl -X POST "https://noblesight.io/v1/alerts/44/assign" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"assign_to": "analyst@yourcompany.com"}'
Investigate and add notes
Pull the full alert detail, compare secondary identifiers, and document your findings. Notes are immutable — they become part of the audit trail.
# Get full alert detail
curl "https://noblesight.io/v1/alerts/44" \
-H "X-API-Key: $NOBLE_API_KEY"
# Add an investigation note
curl -X POST "https://noblesight.io/v1/alerts/44/notes" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"note": "Checked passport against OFAC records. DOB differs by 25 years. Different person."}'
Resolve as false positive
Close the alert with a structured reason code. Examiners can aggregate these across your entire history to verify your process is systematic.
curl -X POST "https://noblesight.io/v1/alerts/44/resolve" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"status": "closed_false_positive",
"reason": "false_positive_dob_mismatch",
"note": "SDN DOB is 1960, customer DOB is 1985. 25-year gap. Not the same individual."
}'
Reason codes: false_positive_name_only, false_positive_dob_mismatch, false_positive_country_mismatch, false_positive_entity_type_mismatch, false_positive_other, true_match.
3. Handle a True Match
Your analyst confirmed a sanctions match. What happens next depends on what your institution did — block the transaction, reject a wire, or permit it under a General License. Each disposition carries a different OFAC reporting obligation and deadline.
Resolve as true match with disposition
Choose the disposition that matches your institution's action. blocked and rejected require filing with OFAC within 10 business days.
curl -X POST "https://noblesight.io/v1/alerts/42/resolve" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"status": "closed_true_match",
"reason": "true_match",
"disposition": "blocked",
"note": "Wire transfer blocked. Customer name and nationality confirmed against SDN entry 36095."
}'
Pull the OFAC report
For blocked and rejected dispositions, this endpoint assembles the evidence your compliance team needs to file with OFAC. Fill in the institution_fields (transaction description and value) before submitting.
curl "https://noblesight.io/v1/alerts/42/ofac-report" \ -H "X-API-Key: $NOBLE_API_KEY"
{
"alert_id": 42,
"report_type": "blocking",
"generated_at": "2026-03-10T14:30:00Z",
"screened_name": "Vladimir Putin",
"sdn_match": {
"uid": "36095",
"first_name": "Vladimir",
"last_name": "PUTIN",
"programs": ["RUSSIA-EO14024"],
"score": 97.5
},
"screening": {
"screened_at": "2026-03-09T10:15:00Z",
"ofac_publish_id": "1234",
"ofac_publish_date": "2026-03-08",
"trace_id": "onboarding-customer-42"
},
"resolution": {
"status": "closed_true_match",
"disposition": "blocked",
"reason": "true_match",
"resolved_by": "analyst@yourcompany.com",
"resolved_at": "2026-03-10T14:00:00Z"
},
"institution_fields": {
"transaction_description": null,
"transaction_value": null,
"instructions": "Complete transaction_description and transaction_value before filing with OFAC."
}
}
Know your deadlines
| Disposition | OFAC Obligation | Deadline |
|---|---|---|
blocked | File a blocking report | 10 business days |
rejected | File a reject report | 10 business days |
permitted_gl | Document the General License citation | Immediately |
no_action_required | Maintain internal documentation | Immediately |
4. Screen a Batch
Onboard a portfolio of customers, run a periodic re-screen, or process a file from your core banking system. Submit up to 10,000 names, poll for completion, review results. Standard tier and above.
Submit the batch
Each name can include its own secondary attributes. The response returns a batch ID for polling.
curl -X POST https://noblesight.io/v1/batch \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"names": [
{"full_name": "Vladimir Putin", "date_of_birth": "1952-10-07", "country": "RU"},
{"full_name": "Ali Khamenei", "country": "IR"},
{"full_name": "Jane Smith", "country": "US"}
]
}'
{
"id": 1234,
"status": "pending",
"total_names": 3,
"processed_names": 0,
"match_count": 0
}
Poll for completion
Check status until it reaches completed. Per-name results include match counts, highest scores, and alert IDs.
curl "https://noblesight.io/v1/batch/status?id=1234" \ -H "X-API-Key: $NOBLE_API_KEY"
{
"id": 1234,
"status": "completed",
"total_names": 3,
"processed_names": 3,
"match_count": 2,
"items": [
{"full_name": "Vladimir Putin", "status": "screened", "match_count": 1, "highest_score": 97.5, "alert_id": 42},
{"full_name": "Ali Khamenei", "status": "screened", "match_count": 1, "highest_score": 94.2, "alert_id": 43},
{"full_name": "Jane Smith", "status": "screened", "match_count": 0, "highest_score": 0}
]
}
Triage the alerts
Names that generated alerts need review. Use the alert triage workflow for each one, or resolve low-confidence matches in bulk:
curl -X POST "https://noblesight.io/v1/alerts/bulk/resolve" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"alert_ids": [43],
"status": "closed_false_positive",
"reason": "false_positive_name_only"
}'
5. Set Up Portfolio Monitoring
Onboarding screens a customer once. Portfolio monitoring screens them continuously — whenever sanctions lists change, Noble Sight automatically re-screens your enrolled entities using reverse screening. Add-on for any paid tier.
Add a customer to your portfolio
The entity is screened immediately, then re-screened automatically whenever any sanctions list updates.
curl -X POST https://noblesight.io/v1/portfolio \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "Dmitry Medvedev",
"date_of_birth": "1965-09-14",
"country": "RU"
}'
What happens when lists update
Noble Sight detects the delta — new, modified, or removed sanctions entries. Changed entries are screened against your entire portfolio. Matches generate alerts with source: "monitoring". You receive them the same way you receive any other alert: via webhooks or polling.
Review your portfolio
Check which entities are enrolled, their latest scores, and when they were last screened.
curl "https://noblesight.io/v1/portfolio?limit=25" \ -H "X-API-Key: $NOBLE_API_KEY"
Remove an entity when the relationship ends
Historical screening records are retained for audit. Only future re-screening stops.
curl -X DELETE "https://noblesight.io/v1/portfolio?full_name=Dmitry+Medvedev" \ -H "X-API-Key: $NOBLE_API_KEY"
6. Manage Internal Watchlists
Government lists tell you who regulators have designated. Internal watchlists tell you who your institution has flagged — SAR subjects, exited customers, 314(a) names. Noble Sight screens against both in the same API call. Standard tier and above.
Add a SAR subject
The name is available for screening immediately. Phonetic codes are computed at insert time.
curl -X POST https://noblesight.io/v1/watchlist \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "John Doe",
"list_name": "SAR Subjects",
"reason": "SAR filed 2026-03-01, case #2026-0042",
"entity_type": "individual",
"country": "US"
}'
Screen as normal — watchlist matches appear automatically
Every POST /v1/screen call checks your watchlist alongside all government lists. No opt-in flag, no separate call. Matches appear with source: "Internal Watchlist".
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"name": "Jon Doe"}'
# Response includes:
# "source": "Internal Watchlist"
# "match_sources": [{"source": "internal_watchlist", "trigram_score": 88.5, "soundex_match": true}]
# "remarks": "[SAR Subjects] SAR filed 2026-03-01, case #2026-0042"
Add a time-limited entry
Use expires_at for entries that should stop matching after a specific date — FinCEN 314(a) requests, time-limited law enforcement inquiries.
curl -X POST https://noblesight.io/v1/watchlist \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "Jane Roe",
"list_name": "314a Names",
"reason": "FinCEN 314(a) request dated 2026-03-15",
"expires_at": "2026-09-15T00:00:00Z"
}'
7. Set Up Webhooks
Without webhooks, your system polls for new alerts. With webhooks, Noble Sight pushes them to you the moment they are created — signed with HMAC-SHA256 so you can verify they came from us. Standard tier and above.
Register your endpoint
Provide a shared secret (minimum 32 characters) for signature verification. The secret is encrypted at rest — Noble Sight never stores it in plaintext.
curl -X POST https://noblesight.io/v1/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"url": "https://your-app.com/webhooks/noble",
"secret": "your-shared-secret-minimum-32-characters-long"
}'
Verify the signature in your handler
Every delivery includes X-Noble-Signature and X-Noble-Timestamp headers. The signature is HMAC-SHA256 over timestamp + "." + body. Verify the signature, then reject deliveries where the timestamp is more than 5 minutes old to prevent replay attacks.
# Go:
timestamp := r.Header.Get("X-Noble-Timestamp")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) { reject }
// Reject if timestamp is more than 5 minutes old
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Since(time.Unix(ts, 0)) > 5*time.Minute { reject }
# Node.js:
const timestamp = req.headers["x-noble-timestamp"];
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(timestamp + "." + body).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { reject }
if (Date.now()/1000 - parseInt(timestamp) > 300) { reject }
Process the event
The payload contains the alert ID, screened name, score, and source. Use the alert ID to fetch full details or route to your case management system.
{
"id": "evt_a1b2c3d4e5f6g7h8",
"type": "alert.created",
"created_at": "2026-03-23T10:05:00Z",
"data": {
"alert_id": 42,
"screened_name": "Vladimir Putin",
"score": 97.5,
"source": "api",
"sdn_uid": "36095"
}
}
Respond with any 2xx within 10 seconds. Failed deliveries retry up to 3 times with exponential backoff (1s, 5s, 25s). A webhook failure never suppresses an alert — it still appears in GET /v1/alerts.
8. Prepare for an Examination
An OFAC examiner asks for your screening records and alert decisions for the last quarter. Every screening and every alert action is stored immutably. Here is how to pull the evidence.
Export screening records
Bulk export returns the full request, full response, list version, and trace ID for every screening in the date range.
curl "https://noblesight.io/v1/export/bulk?from=2026-01-01&to=2026-03-31" \ -H "X-API-Key: $NOBLE_API_KEY"
Export alert activity
The case activity log shows every status change, assignment, note, and resolution with full attribution and timestamps. This is the audit trail examiners expect to see.
curl "https://noblesight.io/v1/alerts/activity?from=2026-01-01&to=2026-03-31&limit=500" \ -H "X-API-Key: $NOBLE_API_KEY"
Pull a specific screening by trace ID
If the examiner asks about a specific transaction, use the trace ID you attached during screening to pull the exact record.
curl "https://noblesight.io/v1/export?trace_id=onboarding-acct-7891" \ -H "X-API-Key: $NOBLE_API_KEY"
What examiners look for
| Examiner Question | Where the Answer Lives |
|---|---|
| "What list were you screening against on March 1?" | ofac_list_version in the screening record |
| "Who reviewed this alert and what did they decide?" | Alert activity log — actor, action, reason, timestamp |
| "How quickly are alerts resolved?" | SLA deadlines + due_at vs. resolution timestamp |
| "What percentage of alerts are false positives?" | Aggregate reason codes across alert resolutions |
| "Can you produce the screening that triggered this blocking report?" | GET /v1/export?trace_id=... |
API Reference
The API contract is the code. This is api/v1/api.go — the sole source of truth for request types, response types, error codes, validation constants, and the alert state machine. Embedded at compile time.
// Package v1 defines the public REST API contract for Noble.
// This file is the source of truth for the v1 API.
// Update this file first, then implement handlers in server.go.
//
// # API versioning
//
// The wire API has two version axes. The major axis is the URL path (/v1) —
// reserved for a wholesale redesign and changed almost never. The minor axis is
// the dated Noble-Version request header (see version.go), which is how routine
// breaking changes to response shapes are managed:
//
// - Clients pin a version: `Noble-Version: 2026-06-01` (a release date, not a
// SemVer — SemVer is for the installable noble CLI/SDK artifacts, not the
// wire). Version dates are the breaking-change dates from the changelog
// (GET /v1/changelog); additive changelog entries do not mint a version.
// The resolved version is echoed in the response header and recorded in the
// audit trail.
// - No header pins to the latest version, so existing callers keep working.
// An unrecognized version is rejected: 400 invalid_version.
// - Backward-compatibility policy: additive changes (new response fields, new
// endpoints, new optional request fields) do NOT bump the version. Only a
// change that alters or removes existing response shape mints a new dated
// version; clients pinned to an older date continue to receive the old
// shape via a response transform applied at that point.
//
// Today exactly one version exists (VersionLatest), so no transform layer is
// built yet — the header, validation, and contract are in place so the first
// breaking change has a home.
package v1
import (
"encoding/json"
"errors"
"fmt"
)
// =============================================================================
// POST /v1/screen - Screen a name against sanctions lists
// =============================================================================
// ScreenRequest is the input for screening a name against sanctions lists.
type ScreenRequest struct {
// Name to screen against sanctions lists (required)
Name string `json:"name"`
// Maximum number of matches to return (default: 10, max: 100)
Limit *int `json:"limit,omitempty"`
// DeepScreen enables real-time AI expansion of the search input.
// When true, the server calls Gemini to generate spelling/cultural
// variations of the input name and screens each one. Adds ~1-3s latency.
DeepScreen bool `json:"deep_screen,omitempty"`
// EntityType of the subject being screened: "individual", "entity", "vessel", "aircraft".
// When provided, matches against a different entity type are flagged with a dismissal reason.
EntityType string `json:"entity_type,omitempty"`
// DateOfBirth of the person being screened, in YYYY-MM-DD format.
// When provided, matches are penalized if the OFAC entity's DOB
// differs significantly.
DateOfBirth string `json:"date_of_birth,omitempty"`
// Country of residence (ISO 3166-1 alpha-2, e.g., "US", "RU", "CN").
// When provided, matches are penalized if the OFAC entity's nationality
// does not match.
Country string `json:"country,omitempty"`
// Address of the subject being screened (street, city, postal code).
// When provided, surfaced alongside the SDN's address data for manual comparison.
Address string `json:"address,omitempty"`
// IDNumber is any identifier published on a sanctions list: a government ID
// (passport, tax ID, cedula, national ID), a financial routing code
// (SWIFT/BIC), a corporate registration number, or a digital currency
// (crypto wallet) address. When provided, actively matched against the ID
// tables of every list in scope (OFAC, UK, EU, France, Belgium, UN, Canada,
// Australia) using exact match after normalization — uppercased with
// spaces, dashes, dots, and slashes stripped.
IDNumber string `json:"id_number,omitempty"`
// IDType filters ID matching to a single sanctions-list ID category.
// Optional; when omitted, the IDNumber is searched against every type.
//
// Common values (use the exact string as published by the source list):
//
// Government IDs:
// "Passport", "National ID", "Tax ID", "Cedula No.", "Driver's License",
// "Birth Certificate Number"
//
// Financial routing:
// "SWIFT/BIC" — bank/financial institution identifier
//
// Corporate:
// "Business Registration Number", "Certificate of Incorporation Number"
//
// Digital currency (OFAC SDN):
// "Digital Currency Address - XBT" (Bitcoin)
// "Digital Currency Address - ETH" (Ethereum)
// "Digital Currency Address - USDT" (Tether)
// "Digital Currency Address - USDC" (USD Coin)
// "Digital Currency Address - XMR" (Monero)
// "Digital Currency Address - SOL" (Solana)
// "Digital Currency Address - TRX" (Tron)
// "Digital Currency Address - XRP" (Ripple)
// "Digital Currency Address - LTC" (Litecoin)
// "Digital Currency Address - BCH" (Bitcoin Cash)
// plus ARB, BSC, BSV, BTG, DASH, ETC, XVG, ZEC.
//
// The full list of in-use idType strings is the union of every <idType>
// element across the publishers' source files (e.g., OFAC's sdn.xml and
// consolidated.xml).
IDType string `json:"id_type,omitempty"`
// IDs is an additional set of identifiers to screen alongside IDNumber.
// A single person or entity often carries several — passport plus tax ID
// plus a crypto wallet plus a SWIFT/BIC. Submitting them in one request
// avoids one round-trip per identifier and produces a single merged
// response with one audit row.
//
// Each entry is screened independently. The result set is the union of
// every ID's hits, deduplicated by sanctions-list UID; on hit, the
// corresponding ScreenMatch.MatchedVia field names which submitted
// IDs triggered the match.
//
// IDNumber/IDType (singular) and IDs (plural) compose — if both are
// present, the singular is screened in addition to every entry in IDs.
// Capped at MaxIDsPerRequest entries; entries with an empty Number are
// rejected with code "invalid_parameter".
IDs []IDInput `json:"ids,omitempty"`
// Lists restricts screening to the specified sanctions lists.
// Valid values: "ofac", "uk", "eu", "france", "belgium", "netherlands", "un", "canada", "australia".
// When omitted or empty, all lists are screened (default behavior).
Lists []string `json:"lists,omitempty"`
// Include opts into response fields that are excluded by default.
// Reserved for future per-field opt-ins. Unknown values are ignored —
// additive evolution without breaking clients. The value "summary" is
// accepted for backwards compatibility but is now a no-op: ScreenSummary
// is populated by default on every response.
Include []string `json:"include,omitempty"`
// DeepSummary, when true, generates the summary text with an LLM (Gemini)
// instead of the deterministic template. The verdict bucket itself
// remains deterministic — the LLM only writes prose. Requires Standard
// tier or higher. Adds ~1s latency.
DeepSummary bool `json:"deep_summary,omitempty"`
}
// IDInput is a single identifier in ScreenRequest.IDs. Type is optional and
// follows the same string-set documented on ScreenRequest.IDType (Passport,
// SWIFT/BIC, "Digital Currency Address - XBT", etc.). When Type is empty,
// the Number is matched against every ID type the publisher recorded.
type IDInput struct {
// Number is the identifier to screen for (e.g., passport number, SWIFT/BIC,
// crypto wallet address). Required. Same normalization as IDNumber:
// uppercased with spaces, dashes, dots, and slashes stripped before
// comparison.
Number string `json:"number"`
// Type restricts matching to a single sanctions-list ID category.
// Optional; when omitted, Number is searched against every type.
Type string `json:"type,omitempty"`
}
// ScreenResponse is the response from a screening request.
type ScreenResponse struct {
// TraceID is the audit key this screening is persisted under. It echoes the
// caller's X-Trace-ID, or a server-generated value when none was supplied —
// the screening is always retrievable. Feed it straight into
// GET /v1/export?trace_id= to pull the full audit record. Also returned in
// the X-Trace-ID response header.
TraceID string `json:"trace_id"`
// RequestID is the per-call HTTP request ID for this screening — the same
// value carried in the X-Request-Id response header and in the request_id
// field of error bodies. Distinct from TraceID: a trace can span many
// calls, each with its own RequestID. Persisted alongside TraceID so an
// operator can pivot between the two; see GET /v1/export?request_id=.
RequestID string `json:"request_id,omitempty"`
// Unique screening record ID for audit cross-referencing
ScreeningID int64 `json:"screening_id,omitempty"`
// Alert ID created for this screening, if the highest match score exceeded
// the tenant's alert threshold. Zero/omitted means no alert was generated.
// When a recent duplicate alert already exists, its ID is returned instead
// of creating a new one (idempotent behavior).
AlertID int64 `json:"alert_id,omitempty"`
// The name that was screened
ScreenedName string `json:"screened_name"`
// When this screening was performed (RFC 3339)
ScreenedAt string `json:"screened_at"`
// Number of matches returned
MatchCount int `json:"match_count"`
// Mean score across all matches in this screening (0-100).
// Useful for portfolio risk assessment. Zero when no matches.
AverageScore float64 `json:"average_score"`
// ListsScreened reports which sanctions lists were included in this screening.
// Always populated, even when the caller did not specify a lists filter.
ListsScreened []string `json:"lists_screened"`
// Matched sanctions entries, ranked by score
Matches []ScreenMatch `json:"matches"`
// ListVersions reports the publisher snapshot active at screening time
// for every list named in ListsScreened — one entry per list with
// imported data. Examiners use this to pin a screening decision to the
// exact list snapshots that produced it.
ListVersions []ListVersion `json:"list_versions,omitempty"`
// OFACListVersion is the OFAC entry from ListVersions, retained as a
// convenience field.
//
// Deprecated: use ListVersions for full multi-list coverage. New
// integrations should read ListVersions and filter for List == "ofac".
// Removal scheduled after the 90-day deprecation window.
OFACListVersion *OFACListInfo `json:"ofac_list_version,omitempty"`
// Summary is an executive synthesis of the screening result: verdict
// bucket, top match, distinct lists, strong-match count, and a one-line
// prose synthesis. Populated by default on every response — the
// deterministic path is pure-function with no I/O cost. Callers that
// want LLM-rewritten prose pass DeepSummary=true (paid tier).
Summary *ScreenSummary `json:"summary,omitempty"`
// Response metadata including legal disclaimer
Meta ResponseMeta `json:"meta"`
}
// SummaryVerdict is a deterministic classification of screening results,
// computed from match scores. The verdict is reproducible from the structured
// response alone — it is never decided by an LLM.
type SummaryVerdict string
// Verdict buckets driven by the top match score.
const (
VerdictConfirmedMatch SummaryVerdict = "confirmed_match" // top_score >= 95
VerdictStrongMatch SummaryVerdict = "strong_match" // top_score >= 80
VerdictPossibleMatch SummaryVerdict = "possible_match" // top_score >= 65
VerdictWeakMatch SummaryVerdict = "weak_match" // top_score >= 50
VerdictNoSignificantMatch SummaryVerdict = "no_significant_match" // < 50 or no matches
)
// ScreenSummary is an executive synthesis of a screening result. Populated
// on every screening response — the deterministic path is pure-function and
// emits a verdict bucket, top match, distinct lists, strong-match count, and
// a one-line prose synthesis at no I/O cost. Callers that want LLM-rewritten
// prose pass DeepSummary=true (paid tier).
//
// Verdict is always deterministic — even in LLM mode, the bucket is computed
// from scores and the LLM only rewrites the prose. This keeps the audit trail
// reproducible from structured data alone.
type ScreenSummary struct {
// Verdict is a coarse classification of the screening result, computed
// from the top match score (see Verdict* constants).
Verdict SummaryVerdict `json:"verdict"`
// Text is a one-line human-readable synthesis. Up to ~280 characters.
Text string `json:"text"`
// TopMatch identifies the single highest-scoring match. Absent (omitted from
// JSON) when no matches were returned. Lets callers act on the winning hit
// without reparsing the full Matches array.
TopMatch *SummaryTopMatch `json:"top_match,omitempty"`
// StrongMatches counts matches with score >= 80.
StrongMatches int `json:"strong_matches"`
// DistinctLists names the unique source labels present in matches
// (e.g., ["OFAC SDN", "UK", "France"]).
DistinctLists []string `json:"distinct_lists"`
// GeneratedBy identifies how Text was produced:
// "deterministic" or "llm:<model-id>" (e.g., "llm:gemini-2.5-flash").
GeneratedBy string `json:"generated_by"`
// ModelVersion is the exact model identifier when GeneratedBy starts with
// "llm:" (empty for deterministic).
ModelVersion string `json:"model_version,omitempty"`
// FallbackReason is populated only when the caller requested
// deep_summary=true but the LLM path did not execute and the response
// fell back to deterministic prose. Empty when deep_summary was not
// requested or when the LLM successfully produced the text. See the
// FallbackReason* constants for the documented values. Surfacing this
// prevents silent paid-feature non-delivery.
//
// Invariant: on a deep_summary=true response, either GeneratedBy starts
// with "llm:" or FallbackReason is non-empty. Callers can rely on this
// to detect silent paid-feature degradation without parsing prose.
FallbackReason string `json:"fallback_reason,omitempty"`
}
// Fallback reason codes for ScreenSummary.FallbackReason.
const (
// FallbackReasonLLMUnavailable indicates the server is not configured to
// call an LLM (e.g., GEMINI_API_KEY is unset). Operator action required.
FallbackReasonLLMUnavailable = "llm_unavailable"
// FallbackReasonLLMFailure indicates the LLM call returned an error
// (network failure, timeout, rate limit, invalid response). The
// deterministic summary is returned instead.
FallbackReasonLLMFailure = "llm_failure"
)
// SummaryTopMatch identifies the single highest-scoring match within a
// screening result and carries OFAC FAQ 1591's 5-step validation set for that
// entity: name, date of birth, place of birth, nationality, ID, and address.
// Exposing these on the summary lets AI agents and compliance officers
// confirm or dismiss the top hit without a second round-trip into Matches.
//
// All fields refer to the same entity — drawn from whichever ScreenMatch in
// the response carries the maximum Score. SummaryTopMatch is a pointer field
// on ScreenSummary so it is omitted from JSON entirely when no matches were
// returned.
type SummaryTopMatch struct {
// Score is the highest combined match score across all matches (0-100).
Score float64 `json:"score"`
// UID is the source-list record identifier of the top match
// (e.g., the OFAC UID). This is the list pointer, not a government ID
// — see IDs for passports, tax IDs, and cedulas.
UID string `json:"uid,omitempty"`
// Name is the rendered display name of the top match
// ("Last, First" for individuals, otherwise the last/full-name field).
Name string `json:"name,omitempty"`
// Source is the sanctions list that produced the top match
// (e.g., "OFAC SDN", "UK", "France").
Source string `json:"source,omitempty"`
// DateOfBirth from the sanctions list, in the publisher's original format
// (commonly YYYY-MM-DD or a free-form string). Empty when not provided
// by the source list.
DateOfBirth string `json:"date_of_birth,omitempty"`
// PlaceOfBirth from the sanctions list (free-form city/region/country).
PlaceOfBirth string `json:"place_of_birth,omitempty"`
// Nationalities lists every nationality the source has on file for the
// entity. OFAC and other publishers sometimes record multiple — all are
// retained verbatim.
Nationalities []string `json:"nationalities,omitempty"`
// IDs are government identifiers (passport, tax ID, national ID, cedula)
// for the entity. Used in OFAC 5-step validation step 3 (ID match).
IDs []MatchID `json:"ids,omitempty"`
// Addresses associated with the entity on the sanctions list. Used in
// OFAC 5-step validation step 5 (address match).
Addresses []MatchAddress `json:"addresses,omitempty"`
}
// ScreenMatch represents a single matched sanctions entry.
// Includes all secondary identifiers from the source list so compliance officers
// can perform OFAC's 5-step validation (FAQ 1591) without a separate lookup.
type ScreenMatch struct {
// UID is the unique identifier from the source list (e.g., OFAC UID).
UID string `json:"uid"`
// First name of the sanctioned entity (if individual)
FirstName *string `json:"first_name,omitempty"`
// Last name or full name of the sanctioned entity
LastName *string `json:"last_name,omitempty"`
// Type of entry: Individual, Entity, Vessel, Aircraft
SdnType *string `json:"sdn_type,omitempty"`
// Combined match score (0-100), higher is stronger match.
// When secondary attributes (DOB, country) are provided in the request,
// this score is adjusted downward for mismatches.
Score float64 `json:"score"`
// Additional remarks from the source list
Remarks *string `json:"remarks,omitempty"`
// Source of the match (e.g., "OFAC SDN", "OFAC CONS", "UK", "EU")
Source string `json:"source,omitempty"`
// Sanctions programs this entity is listed under (e.g., "SDGT", "IRAN", "UKRAINE-EO13662").
// Essential for determining which regulations apply and which general licenses may be relevant.
Programs []string `json:"programs,omitempty"`
// Per-source scoring breakdown showing how and why this entity matched
MatchSources []MatchSource `json:"match_sources"`
// DismissalReasons explains why the score was adjusted downward.
// Only present when secondary attributes (DOB, country, entity_type) were provided
// and a mismatch was detected.
//
// Deprecated: free-text reasons cannot be filtered, aggregated, or
// audit-mapped. Use DismissalSignals for the structured form. Both
// fields are populated in parallel during a 90-day deprecation window
// and are guaranteed to have equal length and ordered correspondence.
DismissalReasons []string `json:"dismissal_reasons,omitempty"`
// DismissalSignals is the structured form of DismissalReasons: stable
// reason codes, severity tiers, the conflicting request/target values,
// and the exact point reduction each signal applied to the score.
//
// Always emitted — empty array means "no penalties applied," never
// "we forgot." See the Dismissal* constants for the stable code set.
// Each signal's position in this array corresponds 1:1 with the
// position of its free-text form in DismissalReasons.
DismissalSignals []DismissalSignal `json:"dismissal_signals"`
// MatchedVia names which submitted identifier(s) triggered this match,
// when the hit came from ID-based screening (ScreenRequest.IDNumber or
// any entry in ScreenRequest.IDs). Empty when the match came only from
// name-based screening. A single match may carry multiple entries when
// several submitted IDs all resolve to the same sanctioned entity.
MatchedVia []IDInput `json:"matched_via,omitempty"`
// Secondary identifiers from the sanctions list for OFAC 5-step validation.
// These enable compliance officers to compare against transaction data.
Addresses []MatchAddress `json:"addresses,omitempty"`
IDs []MatchID `json:"ids,omitempty"`
DateOfBirth string `json:"date_of_birth,omitempty"`
PlaceOfBirth string `json:"place_of_birth,omitempty"`
Nationalities []string `json:"nationalities,omitempty"`
Citizenships []string `json:"citizenships,omitempty"`
}
// MatchAddress is an address associated with a sanctioned entity.
type MatchAddress struct {
Address1 string `json:"address1,omitempty"`
Address2 string `json:"address2,omitempty"`
City string `json:"city,omitempty"`
StateOrProvince string `json:"state_or_province,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
}
// MatchID is a government identifier (passport, tax ID, cedula) for a sanctioned entity.
type MatchID struct {
IDType string `json:"id_type"`
Number string `json:"number"`
Country string `json:"country,omitempty"`
}
// MatchSource provides scoring details for a single match pathway.
// An entity may match via multiple sources (e.g., both direct SDN and AI variation).
type MatchSource struct {
// How the match was found: "ofac_sdn", "ofac_aka", "ofac_ai_variation",
// "ofac_id", "uk_designation", "uk_alias", "uk_id", "deep_screen",
// or "internal_watchlist"
Source string `json:"source"`
// Raw trigram similarity score (0-100) for this source
TrigramScore float64 `json:"trigram_score"`
// Whether the last name matched phonetically via Soundex
SoundexMatch bool `json:"soundex_match"`
// The AI-generated variation that matched (only for source="ofac_ai_variation" or "deep_screen")
Variation string `json:"variation,omitempty"`
// Why Gemini generated this variation (only for source="ofac_ai_variation" or "deep_screen")
Reason string `json:"reason,omitempty"`
// MatchedText is the source-list string that produced this pathway's
// trigram score — the actual primary name, alias, or variation text
// the comparison fired against. Lets consumers distinguish "matched
// primary name 'Vladimir Putin'" from "matched 4-character alias
// 'JOHN'" and is the foundation for length- and quality-aware
// reweighting (and the calibration corpus those depend on).
MatchedText string `json:"matched_text,omitempty"`
// MatchedLength is the rune count of MatchedText. Pre-computed so
// aggregations like "fraction of dismissed alerts with matched_length
// <= 5" can run server-side without scanning every match.
MatchedLength int `json:"matched_length,omitempty"`
}
// =============================================================================
// Dismissal Signals
// =============================================================================
//
// Structured replacement for the legacy ScreenMatch.DismissalReasons []string
// field. Every penalty applied during attribute comparison emits a
// DismissalSignal carrying the canonical reason code, severity, conflicting
// request/target values, and the score impact.
//
// Reason codes are stable identifiers — consumers filter, aggregate, and
// audit-map on them. They are grounded in OFAC FAQ 5 ("How do I determine
// if I have a valid OFAC match?") where applicable: Step 2 (entity-type
// sanity), Step 3 (single-token name match), and Step 4 (full-entry
// comparison covering DOB, country, and the broader identity set).
// DismissalSignal is a structured reason a match's score was reduced.
type DismissalSignal struct {
// Field that drove the signal: "date_of_birth", "country",
// "entity_type", or "name".
Field string `json:"field"`
// ReasonCode is one of the Dismissal* constants below. Stable across
// releases — clients depend on these strings.
ReasonCode string `json:"reason_code"`
// Severity is the strength tier of this signal. High signals are
// safe-to-auto-dismiss; low signals are advisory.
Severity DismissalSeverity `json:"severity"`
// RequestValue is the value the request supplied, rendered as text.
RequestValue string `json:"request_value,omitempty"`
// TargetValue is the value the source list carries, rendered as text.
TargetValue string `json:"target_value,omitempty"`
// ImpactOnScore is the point reduction this signal applied to the
// match score (negative, on the same 0-100 scale as Score).
ImpactOnScore float64 `json:"impact_on_score"`
}
// DismissalSeverity is the strength tier of a DismissalSignal. Derived
// from penalty depth — locked in code, not data, so the mapping is
// reproducible from the response alone.
type DismissalSeverity string
const (
SeverityLow DismissalSeverity = "low"
SeverityMedium DismissalSeverity = "medium"
SeverityHigh DismissalSeverity = "high"
)
// Dismissal reason codes. Stable identifiers — clients filter on these.
//
// - DismissalEntityTypeMismatch and DismissalPartialNameMatchSingleToken
// are OFAC FAQ 5 Steps 2 and 3 respectively (not-a-valid-match
// conditions).
// - The DOB and country codes are OFAC FAQ 5 Step 4 (full-entry
// comparison). The "_far" suffix splits today's overloaded
// dob_year_mismatch and dob_mismatch into distinct codes per
// penalty depth.
const (
DismissalEntityTypeMismatch = "entity_type_mismatch"
DismissalPartialNameMatchSingleToken = "partial_name_match_single_token"
DismissalDOBYearNearMiss = "dob_year_near_miss"
DismissalDOBYearMismatch = "dob_year_mismatch"
DismissalDOBYearMismatchFar = "dob_year_mismatch_far"
DismissalDOBNearMiss = "dob_near_miss"
DismissalDOBMismatch = "dob_mismatch"
DismissalDOBMismatchFar = "dob_mismatch_far"
DismissalCountryMismatch = "country_mismatch"
)
// =============================================================================
// Sanctions List Selection
// =============================================================================
// List constants for selective screening.
const (
ListOFAC = "ofac"
ListUK = "uk"
ListEU = "eu"
ListFrance = "france"
ListBelgium = "belgium"
ListNetherlands = "netherlands"
ListUN = "un"
ListCanada = "canada"
ListAustralia = "australia"
)
// AllLists is the complete set of supported sanctions lists.
var AllLists = []string{ListOFAC, ListUK, ListEU, ListFrance, ListBelgium, ListNetherlands, ListUN, ListCanada, ListAustralia}
// validLists is the lookup set for O(1) validation.
var validLists = map[string]bool{
ListOFAC: true, ListUK: true, ListEU: true,
ListFrance: true, ListBelgium: true, ListNetherlands: true,
ListUN: true, ListCanada: true, ListAustralia: true,
}
// ValidateListNames returns an error message for the first invalid list name, or "".
func ValidateListNames(lists []string) string {
for _, l := range lists {
if !validLists[l] {
return fmt.Sprintf("unknown list %q; valid values: ofac, uk, eu, france, belgium, netherlands, un, canada, australia", l)
}
}
return ""
}
// DeduplicateLists removes duplicate list names, preserving order.
func DeduplicateLists(lists []string) []string {
seen := make(map[string]bool, len(lists))
out := make([]string, 0, len(lists))
for _, l := range lists {
if !seen[l] {
seen[l] = true
out = append(out, l)
}
}
return out
}
// =============================================================================
// Common Types
// =============================================================================
// Error type categories for machine-readable error handling.
const (
ErrTypeInvalidRequest = "invalid_request_error"
ErrTypeAuthentication = "authentication_error"
ErrTypeForbidden = "forbidden_error"
ErrTypeRateLimit = "rate_limit_error"
ErrTypeAPI = "api_error"
ErrTypeNotFound = "not_found_error"
ErrTypeConflict = "conflict_error"
ErrTypeComplianceRule = "compliance_rule_error"
ErrTypePaymentRequired = "payment_required"
)
// Error code constants for machine-readable error handling.
const (
CodeInvalidBody = "invalid_request_body"
CodeNameRequired = "name_required"
CodeNameTooLong = "name_too_long"
CodeMissingParameter = "missing_parameter"
CodeInvalidParameter = "invalid_parameter"
CodeKeyRequired = "api_key_required"
CodeKeyInvalid = "api_key_invalid"
CodeTenantNotProvisioned = "tenant_not_provisioned"
CodeTierInsufficient = "tier_insufficient"
CodeRateLimitExceeded = "rate_limit_exceeded"
CodeBatchTooLarge = "batch_too_large"
CodeTooManyIDs = "too_many_ids"
CodeNotFound = "not_found"
CodeInvalidTransition = "invalid_status_transition"
CodeInvalidReason = "invalid_reason_code"
CodeInvalidDisposition = "invalid_disposition"
CodeDispositionRequired = "disposition_required"
CodeGLCitationRequired = "gl_citation_required"
CodeFourEyesViolation = "four_eyes_violation"
CodeConcurrentModification = "concurrent_modification"
CodeInvalidAssignee = "invalid_assignee"
CodeNoteEmpty = "note_empty"
CodeNoteTooLong = "note_too_long"
CodeInternalError = "internal_error"
CodeMethodNotAllowed = "method_not_allowed"
CodeWebhookURLInvalid = "webhook_url_invalid"
CodeWebhookSecretTooShort = "webhook_secret_too_short"
CodeWebhookNotFound = "webhook_not_found"
CodeWebhookLimitExceeded = "webhook_limit_exceeded"
CodeWebhookSignatureInvalid = "webhook_signature_invalid"
CodeTrialExpired = "trial_expired"
CodeSignupClosed = "signup_closed"
CodeInviteRequired = "invite_code_required"
CodeInviteInvalid = "invite_code_invalid"
CodeInviteExpired = "invite_code_expired"
CodeInviteExhausted = "invite_code_exhausted"
CodeInviteRevoked = "invite_code_revoked"
CodeIdempotencyMismatch = "idempotency_key_mismatch"
CodeIdempotencyInProgress = "idempotency_in_progress"
CodeIdempotencyKeyTooLong = "idempotency_key_too_long"
CodeScopeRequired = "scope_required"
CodeScopeNoChange = "scope_no_change"
CodeInvalidVersion = "invalid_version"
)
// Error is the standard error response following Stripe's error model.
type Error struct {
// Error category: invalid_request_error, authentication_error, rate_limit_error, api_error
Type string `json:"type"`
// Machine-readable error code for programmatic handling
Code string `json:"code"`
// Human-readable error message
Message string `json:"message"`
// Which request parameter caused the error (if applicable)
Param string `json:"param,omitempty"`
// Server-generated request ID for debugging and audit correlation
RequestID string `json:"request_id"`
}
// ResponseMeta contains metadata included in every API response.
type ResponseMeta struct {
// Legal disclaimer bounding Noble Sight's liability
Disclaimer string `json:"disclaimer"`
}
// Disclaimer is the standard legal disclaimer included in all screening API responses.
// This text is a material part of Noble Sight's liability boundary and must not be
// modified without legal review.
const Disclaimer = "Noble Sight provides sanctions screening results for informational purposes only. " +
"This service is not a substitute for a comprehensive sanctions compliance program. " +
"Noble Sight does not provide legal, regulatory, or compliance advice. " +
"Screening results reflect data available at the time of the request and may not capture all " +
"sanctions designations, aliases, or name variations. " +
"Final screening decisions, risk assessments, and compliance obligations remain the sole " +
"responsibility of the subscribing institution. " +
"Use of this service does not satisfy or replace any obligation under OFAC regulations, " +
"the Bank Secrecy Act, or any other applicable law."
// =============================================================================
// Pagination — the uniform contract for every collection endpoint
// =============================================================================
//
// Every list endpoint (alerts, portfolio, watchlist, audit export, case
// activity) returns the same envelope and paginates the same way. This section
// is the contract; handlers must not deviate.
//
// # Envelope
//
// A list response is always:
//
// { "data": [ ... ], "has_more": true, "meta": { "disclaimer": "..." } }
//
// `data` is the page of items (the array key is ALWAYS "data" — never "alerts",
// "entities", "records", etc.). `has_more` is true when at least one more item
// exists past this page. There is no other top-level shape.
//
// # Cursor pagination only — never offset
//
// Pagination is keyset (cursor) based:
//
// GET /v1/portfolio?after=<id>&limit=<n>
//
// - `after` — exclusive cursor: return items with id > after. Omit (or 0) for
// the first page. The cursor is the `id` of the last item in the previous
// page's `data`. Results are ordered by `id` ascending (chronological and
// stable, since id is a monotonic BIGSERIAL). A descending/`before` cursor
// may be ADDED later without breaking this contract.
// - `limit` — page size; defaults to DefaultPageLimit, hard-capped at
// MaxPageLimit. Values above the cap are clamped, not rejected.
//
// Offset/page pagination (`?offset=`, `?page=`) is PROHIBITED. It is O(n) at
// depth — the database scans and discards `offset` rows on every request — and
// it returns duplicate or skipped rows when items are inserted concurrently,
// which is unacceptable for an append-only audit system. The scale that forces
// this: a portfolio-monitoring customer (e.g. a top-tier bank) may enroll tens
// of millions of entities; offset page 500,000 would scan ~50M rows per call,
// while a keyset cursor stays O(log n + limit) at any depth.
//
// # No `total` in list responses — counts live in a /stats companion
//
// List responses do NOT carry a row count. COUNT(*) over a large, filtered, or
// date-ranged set adds latency and lock contention to every page fetch and is
// racy under concurrent writes. Counts are instead a first-class, cheap read on
// a dedicated companion endpoint, uniformly: a collection at `/v1/<name>`
// exposes its counts at `/v1/<name>/stats`. Every stats response carries at
// least a `total`; most add a small, cheap breakdown (by_status, by_score_band,
// by_action, …). A stats endpoint honors the SAME filters as its list sibling,
// so the count matches the page the caller is browsing.
//
// /v1/alerts → /v1/alerts/stats (AlertStatsResponse)
// /v1/portfolio → /v1/portfolio/stats (PortfolioStatsResponse)
// /v1/webhooks → /v1/webhooks/stats (WebhookStatsResponse)
// /v1/alerts/activity → /v1/alerts/activity/stats (ActivityStatsResponse)
//
// # Full extraction is not pagination
//
// Paging through millions of rows via GET is for browsing/triage, not bulk
// extraction. A customer who needs their entire portfolio or audit history
// pulls it via the async export/batch job, which is built for volume.
//
// # Per-endpoint application
//
// /v1/alerts cursor + filters (already the reference implementation)
// /v1/portfolio cursor (requires a (tenant_id, id) index — see migrations)
// /v1/watchlist cursor
// /v1/export/bulk cursor + hard limit; large pulls use the export job
// /v1/alerts/activity cursor + hard limit
// /v1/webhooks NO pagination — capped at a handful per tenant; returns
// the {data} envelope with every webhook, no cursor params.
// List is the uniform envelope returned by every collection endpoint. The
// generic parameter is the item type (e.g. List[AlertResponse]). The "data"
// key and "has_more" flag are invariant across all list endpoints; meta is
// injected by the response writer.
type List[T any] struct {
// Data is the page of items, ordered by id ascending.
Data []T `json:"data"`
// HasMore reports whether at least one item exists past this page. When
// true, request the next page with ?after=<id of the last item in Data>.
HasMore bool `json:"has_more"`
}
// Pagination bounds for list endpoints.
const (
DefaultPageLimit = 50 // page size when ?limit is omitted
MaxPageLimit = 100 // hard cap; larger ?limit values are clamped to this
)
// PageParams is the parsed cursor-pagination query shared by list handlers.
// Produced by parsePageParams; never construct it from offset/page inputs.
type PageParams struct {
// After is the exclusive id cursor: return items with id > After.
// Zero means start from the beginning.
After int64
// Limit is the clamped page size, in [1, MaxPageLimit].
Limit int
}
// =============================================================================
// Changelog
// =============================================================================
//
// GET /v1/changelog returns the API's release notes as structured JSON so that
// customers — banks, AI compliance agents, MCP clients — can surface "what's
// new" inside their own dashboards without scraping HTML. The endpoint is
// public (no API key required) so prospects and discovery tools can read it.
//
// The source of truth is a checked-in Go slice (api/v1/changelog.go); new
// entries ship with the binary version and are reviewed in PRs.
// Changelog categories. Follows the Keep-a-Changelog convention
// (https://keepachangelog.com).
const (
ChangeAdded = "added"
ChangeChanged = "changed"
ChangeDeprecated = "deprecated"
ChangeRemoved = "removed"
ChangeFixed = "fixed"
ChangeSecurity = "security"
)
// ChangelogEntry is one published API change.
type ChangelogEntry struct {
// Release date in YYYY-MM-DD (UTC).
Date string `json:"date"`
// One of ChangeAdded, ChangeChanged, ChangeDeprecated, ChangeRemoved,
// ChangeFixed, ChangeSecurity.
Category string `json:"category"`
// Single-line headline. Required.
Summary string `json:"summary"`
// Optional longer body. Plain text; no HTML.
Details string `json:"details,omitempty"`
// Optional endpoint the change scopes to (e.g. "/v1/screen").
Endpoint string `json:"endpoint,omitempty"`
// Breaking marks this entry as an API version boundary: a change that
// altered or removed existing response shape, minting a new dated
// Noble-Version equal to Date. The set of supported API versions is derived
// from exactly the Breaking entries (see version.go), so the changelog is
// the single source of truth and the two cannot drift. Additive entries
// leave this false and never mint a version.
Breaking bool `json:"breaking,omitempty"`
}
// ChangelogResponse is returned by GET /v1/changelog.
type ChangelogResponse struct {
// Entries are ordered newest first.
Entries []ChangelogEntry `json:"entries"`
Meta ResponseMeta `json:"meta"`
}
// =============================================================================
// Sanctions list versions
// =============================================================================
//
// GET /v1/lists returns the active publisher snapshot for every sanctions list
// Noble screens against. Public — no API key required — so customers and AI
// compliance agents can answer "are you screening against the latest OFAC?"
// without spending screening quota. The same per-list version info is also
// embedded in every /v1/screen response's list_versions field; this endpoint
// is the standalone fetch path used by status dashboards and the
// noble://sanctions-lists MCP resource.
//
// One entry per list. Lists that have never been imported into this Noble
// deployment are omitted (rather than emitting a sentinel row) so consumers
// don't need to special-case missing data.
// SanctionsListVersion describes the active publisher snapshot for a single
// sanctions list.
type SanctionsListVersion struct {
// List is the canonical short identifier matching the values accepted in
// ScreenRequest.Lists: "ofac", "uk", "eu", "france", "belgium",
// "netherlands", "un", "canada", "australia".
List string `json:"list"`
// DisplayName is the human-readable name surfaced in dashboards
// (e.g., "OFAC SDN", "UK OFSI Consolidated", "EU Financial Sanctions").
DisplayName string `json:"display_name"`
// PublishID is the publish_info row identifier — the audit pointer back
// to the imported source file.
PublishID int `json:"publish_id"`
// PublishDate is the date the publisher released this version (YYYY-MM-DD),
// taken verbatim from the source list.
PublishDate string `json:"publish_date"`
// RecordCount is the number of entries in this version.
RecordCount int `json:"record_count"`
// ImportedAt is when Noble ingested this version (RFC 3339, UTC).
// Compare against PublishDate to see how stale the imported snapshot is.
ImportedAt string `json:"imported_at"`
}
// ListsResponse is returned by GET /v1/lists.
type ListsResponse struct {
// Lists ordered by canonical short identifier (alphabetical).
Lists []SanctionsListVersion `json:"lists"`
Meta ResponseMeta `json:"meta"`
}
// =============================================================================
// Idempotency
// =============================================================================
//
// All state-mutating POST endpoints accept an optional Idempotency-Key request
// header. Noble caches the response for 24 hours under (scope, method, path,
// key); a retry with the same body returns the cached response and produces
// zero new side effects (no second screening_result row, no duplicate webhook
// delivery, no second alert activity row).
//
// Idempotency-Key: <string> (1-255 chars; UUID v4 recommended)
//
// Scope:
// - Authenticated endpoints: the key is namespaced by tenant_id.
// - POST /v1/keys (unauthenticated signup): the key is namespaced by the
// client's source IP, so a customer whose first signup attempt's response
// was lost mid-flight can retry and receive the original plaintext key.
//
// Behavior on a second request with the same (scope, method, path, key):
//
// Same request body hash, first request completed
// → Replay cached status + body + headers (X-Request-Id, X-RateLimit-*,
// Content-Type). Replays do not consume rate-limit quota.
//
// Same request body hash, first request still in flight (< 60s)
// → 409 with code=idempotency_in_progress and Retry-After: 1.
//
// Same request body hash, first request abandoned (>= 60s, no completion)
// → Treat as new — claim the slot and re-process.
//
// Different request body hash
// → 400 with code=idempotency_key_mismatch. To retry with a different
// body, generate a fresh Idempotency-Key.
//
// Key longer than 255 characters
// → 400 with code=idempotency_key_too_long.
//
// Caching policy:
// - 2xx and 4xx responses ARE cached (4xx is deterministic from the request).
// - 5xx responses are NOT cached (transient errors should be retryable).
// - Replays do not re-trigger webhooks, do not insert new audit rows, and
// do not consume rate-limit quota.
//
// Endpoints that honor Idempotency-Key:
// POST /v1/screen, /v1/batch, /v1/portfolio, /v1/watchlist, /v1/webhooks,
// /v1/alerts/{id}/resolve, /v1/alerts/{id}/assign, /v1/alerts/{id}/review,
// /v1/alerts/{id}/escalate, /v1/alerts/{id}/notes,
// /v1/alerts/bulk/resolve, /v1/alerts/bulk/assign,
// /v1/account/checkout, /v1/keys.
//
// Endpoints that ignore Idempotency-Key (header is silently dropped):
// GET and DELETE methods (idempotent by HTTP semantics), and /v1/admin/*
// (operator-driven, Phase 1 only — revisit when IAP gating lands).
// MaxIdempotencyKeyLength caps the Idempotency-Key header at the Stripe-
// compatible 255-character limit. Longer keys return 400 idempotency_key_too_long.
const MaxIdempotencyKeyLength = 255
// IdempotencyRetentionHours is how long cached responses live before the
// hourly cleanup goroutine deletes them.
const IdempotencyRetentionHours = 24
// IdempotencyInProgressTimeoutSeconds is how long an in-progress row is
// considered live. After this window, the row is treated as abandoned and a
// retry may claim its slot. Matched to the server's WriteTimeout (60s) — any
// handler still in_progress past that point has already been killed by the
// timeout, so its row is safely reclaimable.
const IdempotencyInProgressTimeoutSeconds = 60
// =============================================================================
// POST /v1/batch - Submit a batch screening job
// =============================================================================
// BatchRequest is the input for batch screening.
type BatchRequest struct {
// Names to screen (required, max 10,000 per batch)
Names []BatchNameInput `json:"names"`
// Lists restricts screening to the specified sanctions lists (batch-level).
// Same values as ScreenRequest.Lists. Applied to all names in the batch.
Lists []string `json:"lists,omitempty"`
}
// BatchNameInput is a single name entry in a batch request.
type BatchNameInput struct {
// Full name to screen (required)
FullName string `json:"full_name"`
// Date of birth in YYYY-MM-DD format (optional)
DateOfBirth string `json:"date_of_birth,omitempty"`
// Country code ISO 3166-1 alpha-2 (optional)
Country string `json:"country,omitempty"`
// IDNumber is a passport, tax ID, or other government identifier (optional).
// When provided, actively matched against OFAC and UK sanctions ID tables.
IDNumber string `json:"id_number,omitempty"`
// IDType filters ID matching by type (e.g., "Passport") (optional).
IDType string `json:"id_type,omitempty"`
}
// BatchResponse is the response after creating a batch job.
type BatchResponse struct {
// Batch job ID for status polling
ID int64 `json:"id"`
// Current status: pending, processing, completed, failed.
// A job that encounters an unrecoverable error is marked "failed" with an error_message.
Status string `json:"status"`
// Total names submitted
TotalNames int `json:"total_names"`
// Names processed so far
ProcessedNames int `json:"processed_names"`
// Names that had at least one match
MatchCount int `json:"match_count"`
// Detailed results per name (populated as items are screened; complete when status=completed)
Items []BatchItemResponse `json:"items,omitempty"`
}
// BatchItemResponse is a single name result within a batch.
type BatchItemResponse struct {
// Name that was screened
FullName string `json:"full_name"`
// Status: pending, screened, error
Status string `json:"status"`
// Number of sanctions matches found
MatchCount int `json:"match_count"`
// Highest match score (0-100)
HighestScore float64 `json:"highest_score"`
// Alert ID created for this name, if the highest score exceeded the tenant's
// alert threshold. Zero/omitted means no alert was generated.
AlertID int64 `json:"alert_id,omitempty"`
}
// =============================================================================
// GET /v1/portfolio - List monitored entities
// =============================================================================
//
// Returns List[PortfolioEntityResponse], cursor-paginated by entity id
// (?after=&limit=), ordered id ascending — see the "Pagination" contract.
// Optional filters: q (name substring), min_score, max_score. Full extraction
// of a large portfolio belongs to the async export/batch job, not GET paging.
// PortfolioEntityResponse is a single entity in the portfolio.
type PortfolioEntityResponse struct {
// Entity ID
ID int64 `json:"id"`
// Full name being monitored
FullName string `json:"full_name"`
// Date of birth (YYYY-MM-DD) if known
DateOfBirth string `json:"date_of_birth,omitempty"`
// Country code (ISO 3166-1 alpha-2) if known
Country string `json:"country,omitempty"`
// Entity type: individual or entity
EntityType string `json:"entity_type"`
// Highest match score from last screening
HighestScore float64 `json:"highest_score"`
// Mean match score from last screening
AverageScore float64 `json:"average_score"`
// When this entity was last screened
LastScreenedAt string `json:"last_screened_at"`
// When this entity was first enrolled
CreatedAt string `json:"created_at"`
}
// PortfolioAddRequest is the input for adding or updating a portfolio entity.
type PortfolioAddRequest struct {
// Full name to monitor (required)
FullName string `json:"full_name"`
// Date of birth in YYYY-MM-DD format (optional)
DateOfBirth string `json:"date_of_birth,omitempty"`
// Country code ISO 3166-1 alpha-2 (optional)
Country string `json:"country,omitempty"`
}
// PortfolioStatsResponse is the count companion to GET /v1/portfolio. Answers
// "how many entities am I monitoring, and how many are high-risk?" without
// paging the whole portfolio. Honors the same q/min_score/max_score filters as
// the list endpoint, so the count matches the filtered view.
type PortfolioStatsResponse struct {
// Total active entities matching the (optional) filters.
Total int `json:"total"`
// ByScoreBand counts entities by highest match score (0–100):
// "95-100", "90-94", "80-89", "below-80".
ByScoreBand map[string]int `json:"by_score_band"`
}
// =============================================================================
// GET /v1/export - Compliance export of screening decisions
// =============================================================================
// ExportRecord is a single screening decision for compliance export.
// It contains the complete request, response, and metadata for regulatory audit trails.
type ExportRecord struct {
// Row ID
ID int64 `json:"id"`
// Trace ID from the original screening request
TraceID string `json:"trace_id"`
// RequestID is the per-call HTTP request ID of the original screening,
// stored alongside TraceID. Lets an operator pivot from a request_id to
// this record. Empty for screenings persisted before this field existed.
RequestID string `json:"request_id,omitempty"`
// Client that owns this record
ClientID string `json:"client_id"`
// Original screening request
Request ScreenRequest `json:"request"`
// Original screening response
Response ScreenResponse `json:"response"`
// OFAC list version active at screening time
OFACListVersion *OFACListInfo `json:"ofac_list_version,omitempty"`
// When the screening occurred (RFC 3339)
ScreenedAt string `json:"screened_at"`
}
// OFACListInfo identifies the OFAC sanctions list version.
//
// Deprecated: prefer ListVersion, which carries the same data plus the
// canonical list identifier. Retained while ScreenResponse.OFACListVersion
// is being phased out.
type OFACListInfo struct {
ID int `json:"id"`
PublishDate string `json:"publish_date"`
RecordCount int `json:"record_count"`
}
// ListVersion identifies the publisher snapshot active at screening time for
// a single sanctions list. ScreenResponse.ListVersions surfaces one entry
// per list named in ListsScreened so examiners can pin a screening decision
// to the exact list snapshots that produced it.
type ListVersion struct {
// List is the canonical identifier matching the values accepted in
// ScreenRequest.Lists (e.g., "ofac", "uk", "eu", "france", "belgium",
// "netherlands", "un", "canada", "australia").
List string `json:"list"`
// PublishID is the source-list publish_info row identifier — the audit
// pointer back to the imported file in <list>.publish_info.
PublishID int `json:"publish_id"`
// PublishDate is the date the publisher released this version
// (YYYY-MM-DD), taken verbatim from the source list.
PublishDate string `json:"publish_date"`
// RecordCount is the number of entries in this version.
RecordCount int `json:"record_count"`
}
// GET /v1/export/bulk returns List[ExportRecord], cursor-paginated by screening
// row id (?after=&limit=) within a required from/to (YYYY-MM-DD) date range,
// ordered id ascending — see the "Pagination" contract. limit is hard-capped at
// MaxPageLimit; a full audit pull belongs to the async export job. There is no
// total. Requires Standard tier or higher; client isolation enforced.
//
// Output format: the audit/export endpoints — GET /v1/export, GET
// /v1/export/bulk, and GET /v1/alerts/activity — accept ?format=json (default)
// or ?format=csv. CSV is a flat, examiner-friendly table, one row per record,
// with a header row; an unrecognized format value is a 400 invalid_parameter.
// Because a CSV body cannot carry the JSON meta.disclaimer, CSV responses
// surface the liability disclaimer in the X-Noble-Disclaimer response header
// and set Content-Disposition so the download is saved as a named .csv file.
// The full nested match detail is JSON-only; the CSV columns summarize each
// decision (top match, list version, request attributes).
// =============================================================================
// POST /v1/keys - Provision a new API key
// =============================================================================
//
// Behavior depends on the server's signup_mode:
//
// open - anyone may create a free-tier key; invite_code is optional
// and, when supplied, overrides the tier per the invite row.
// invite_only - invite_code is required; tier comes from the invite row.
// closed - all signups return 403 with code=signup_closed.
//
// signup_mode is a server configuration value (SIGNUP_MODE env var). It is
// not part of the request — clients discover the current mode through the
// 403 (signup_closed) or 400 (invite_code_required) error responses.
// TermsVersion is the current version of Noble Sight's Terms of Service.
// Bump this when terms change to require re-acceptance on new key creation.
const TermsVersion = "1.0"
// CodeTermsNotAccepted is the error code when accept_terms is not true.
const CodeTermsNotAccepted = "terms_not_accepted"
// Signup mode constants. These govern whether POST /v1/keys is reachable and
// whether it requires an invite code. The value is set via SIGNUP_MODE env var.
const (
SignupModeOpen = "open"
SignupModeInviteOnly = "invite_only"
SignupModeClosed = "closed"
)
// ValidSignupModes is the immutable set of accepted signup_mode values.
// Used at startup to validate the SIGNUP_MODE config and fail loud on typos.
var ValidSignupModes = map[string]bool{
SignupModeOpen: true,
SignupModeInviteOnly: true,
SignupModeClosed: true,
}
// CreateKeyRequest is the input for provisioning a new API key.
type CreateKeyRequest struct {
// Unique identifier for the client organization (required, 1-100 chars)
ClientID string `json:"client_id"`
// Human-readable label for the key (optional, max 255 chars)
Name string `json:"name,omitempty"`
// Must be true to create a key. Indicates acceptance of Noble Sight's
// Terms of Service (https://noblesight.io/terms) and acknowledgment that
// screening results are informational only and do not constitute legal
// or compliance advice.
AcceptTerms bool `json:"accept_terms"`
// InviteCode redeems an invite issued by Noble Sight. Required when the
// server is in signup_mode=invite_only. Optional in signup_mode=open
// (when supplied, the tier and scopes on the invite override the free-tier
// defaults — used for promo codes, design-partner trials, and sales-led
// trials). Format: noble_inv_ + 20 hex characters.
InviteCode string `json:"invite_code,omitempty"`
}
// CreateKeyResponse is returned after successfully creating an API key.
type CreateKeyResponse struct {
// Plaintext API key (shown once — store securely)
Key string `json:"key"`
// First 16 characters of the key for identification
KeyPrefix string `json:"key_prefix"`
// Tier assigned to this key
Tier string `json:"tier"`
// Daily rate limit (free tier)
RateLimitPerDay int `json:"rate_limit_per_day"`
// Version of the Terms of Service accepted
TermsVersion string `json:"terms_version"`
}
// =============================================================================
// POST /v1/admin/keys — Mint a comped or trial API key (internal admin only)
// =============================================================================
//
// Reserved for internal admin tooling (provisioning design-partner trials,
// pilot comps, event comps). Reachable in Phase 1 only via the localhost
// interface of the noble-server pod — `kubectl port-forward` from an
// operator who already has GKE IAM access. Phase 2 will gate `/v1/admin/*`
// via Google IAP and override `granted_by` from the IAP-verified JWT email
// so callers cannot misrepresent themselves; the request/response shapes
// below remain unchanged.
// AdminCreateKeyRequest mints a comped/trial API key for a customer.
type AdminCreateKeyRequest struct {
// Customer identifier (required, 1-100 chars)
ClientID string `json:"client_id"`
// Human-readable label for the key (optional, max 255 chars)
Name string `json:"name,omitempty"`
// Tier: free, standard, premium, enterprise. Trials are typically enterprise.
Tier string `json:"tier"`
// Trial duration in whole days. Zero/omitted means no expiry.
// 30 = 30-day trial, 180 = 6-month trial, 365 = year-long comp.
TrialDays int `json:"trial_days,omitempty"`
// Comp category for cohort analysis: design_partner, pilot, evaluation, event.
// Required when trial_days > 0.
CompCategory string `json:"comp_category,omitempty"`
// Free-text justification for the comp (required). Surfaced in audit logs
// and the eventual admin-key list endpoint.
CompReason string `json:"comp_reason"`
// Operator identity for the audit trail (e.g., gcloud account email).
// Required in Phase 1; overridden by IAP-verified email in Phase 2.
GrantedBy string `json:"granted_by"`
// Contact email — recorded in slog audit only, not the api_key row.
Email string `json:"email,omitempty"`
}
// AdminCreateKeyResponse echoes the audit metadata and returns the plaintext
// key ONCE. Operators copy the key from this response; it is not retrievable
// later (only the SHA-256 hash is stored).
type AdminCreateKeyResponse struct {
// Plaintext API key — shown once, store securely.
Key string `json:"key"`
// First 16 characters of the key for identification.
KeyPrefix string `json:"key_prefix"`
// Customer identifier this key was minted for.
ClientID string `json:"client_id"`
// Tier assigned to this key.
Tier string `json:"tier"`
// When the trial expires (RFC 3339). Empty when no trial.
TrialEndsAt string `json:"trial_ends_at,omitempty"`
// Comp category recorded for this key.
CompCategory string `json:"comp_category,omitempty"`
// Free-text justification recorded for this key.
CompReason string `json:"comp_reason"`
// Operator identity recorded for this key.
CompGrantedBy string `json:"comp_granted_by"`
}
// Error codes for admin endpoints.
const (
CodeReasonRequired = "comp_reason_required"
CodeCategoryRequired = "comp_category_required"
CodeGrantedByRequired = "granted_by_required"
CodeInvalidTrialDays = "invalid_trial_days"
CodeInvalidCategory = "invalid_comp_category"
CodeInvalidAdminTier = "invalid_tier"
CodeInviteNotesTooLong = "invite_notes_too_long"
CodeInvalidMaxRedemptions = "invalid_max_redemptions"
CodeInvalidExpiresAt = "invalid_expires_at"
)
// =============================================================================
// /v1/admin/invites — Mint and manage invite codes (internal admin only)
// =============================================================================
//
// Internal admin tooling for provisioning controlled signup access. Same
// auth posture as /v1/admin/keys: localhost-only in Phase 1 (reachable via
// kubectl port-forward), Google IAP in Phase 2. The created_by field is
// captured from the request in Phase 1 and overridden by the IAP-verified
// JWT email in Phase 2.
//
// Issuing one of these codes is the *only* way new customers (post-Royal
// Bank) enter the system while signup_mode=invite_only. Every code is a
// row with a created_by + timestamp — the auditable artifact a regulator
// reviews when asking "who granted this customer access?"
// AdminCreateInviteRequest mints a new invite code.
type AdminCreateInviteRequest struct {
// Tier granted to keys redeemed from this invite (required).
// Values: free, standard, premium, enterprise.
Tier string `json:"tier"`
// MaxRedemptions caps how many keys can be minted from this code.
// Defaults to 1. Use higher values for promo codes ("BLACKFRIDAY: 100 redemptions").
MaxRedemptions int `json:"max_redemptions,omitempty"`
// ExpiresAt is when the code stops being redeemable (RFC 3339).
// Empty/omitted = never expires. Use for time-limited promos.
ExpiresAt string `json:"expires_at,omitempty"`
// CreatedBy is the operator identity recorded for audit (e.g., gcloud
// account email). Required in Phase 1; overridden by IAP email in Phase 2.
CreatedBy string `json:"created_by"`
// Notes is free-text context for the audit trail (e.g., "Royal Bank beta",
// "Q1 conference comp"). Max 500 chars. Surfaced in the admin invite list.
Notes string `json:"notes,omitempty"`
// Scopes are capability scopes inherited by every key minted from this
// invite. Omit or pass empty for the default ["api"]. Pass ["api","mcp"]
// for design-partner invites that need MCP from day one. Must always
// include "api" — every key needs REST access.
Scopes []string `json:"scopes,omitempty"`
}
// AdminCreateInviteResponse echoes the audit metadata and returns the plaintext
// code ONCE. Codes are reversible (stored in plaintext so customers can be
// re-sent their code if lost), but operators should still capture this response.
type AdminCreateInviteResponse struct {
// Plaintext invite code (noble_inv_ + 20 hex chars).
Code string `json:"code"`
// Unique invite ID (UUID).
ID string `json:"id"`
// Tier granted by this invite.
Tier string `json:"tier"`
// Maximum redemptions allowed.
MaxRedemptions int `json:"max_redemptions"`
// Expiry timestamp (RFC 3339), empty when never expires.
ExpiresAt string `json:"expires_at,omitempty"`
// Operator who minted the invite.
CreatedBy string `json:"created_by"`
// Free-text notes recorded for this invite.
Notes string `json:"notes,omitempty"`
// When the invite was created (RFC 3339).
CreatedAt string `json:"created_at"`
// Scopes inherited by every key minted from this invite.
Scopes []string `json:"scopes"`
}
// InviteResponse is a single invite in admin listings. The plaintext code is
// included — invites are not secret credentials (the redeemed API key is).
type InviteResponse struct {
ID string `json:"id"`
Code string `json:"code"`
Tier string `json:"tier"`
MaxRedemptions int `json:"max_redemptions"`
RedemptionsUsed int `json:"redemptions_used"`
ExpiresAt string `json:"expires_at,omitempty"`
Status string `json:"status"`
CreatedBy string `json:"created_by"`
Notes string `json:"notes,omitempty"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
Scopes []string `json:"scopes"`
}
// InviteListResponse wraps a list of invites for admin tooling.
type InviteListResponse struct {
Total int `json:"total"`
Invites []InviteResponse `json:"invites"`
}
// MaxInviteNotesLength caps the notes field on an invite to prevent abuse.
const MaxInviteNotesLength = 500
// =============================================================================
// POST /v1/admin/keys/scopes - Grant or revoke capability scopes on an
// existing API key. Localhost-only. Writes an append-only audit row to
// customers.api_key_scope_change capturing before/after, operator, and
// reason — examiners reconstruct capability history from that table.
// =============================================================================
// AdminChangeKeyScopesRequest mutates the scopes on an existing API key.
// The change is transactional: the UPDATE on customers.api_key and the
// INSERT on customers.api_key_scope_change either both commit or neither.
type AdminChangeKeyScopesRequest struct {
// KeyID is customers.api_key.id of the target key (required).
KeyID int64 `json:"key_id"`
// Add lists scopes to grant. Already-present scopes are silently ignored.
// Recognized values today: "mcp". ("api" cannot be added — every key has
// it by construction.)
Add []string `json:"add,omitempty"`
// Remove lists scopes to revoke. Already-absent scopes are silently
// ignored. "api" cannot be removed — every key must retain REST access.
Remove []string `json:"remove,omitempty"`
// Reason is the human-readable justification recorded on the audit row
// (required). Examples: "Royal Bank MCP design-partner enrollment",
// "revoked per support ticket #1234". Max 500 chars.
Reason string `json:"reason"`
// ChangedBy is the operator identity recorded for audit (required).
// Phase 1: gcloud account email passed in the request body. Phase 2:
// overridden by the IAP-verified email and the request body field is
// ignored.
ChangedBy string `json:"changed_by"`
}
// AdminChangeKeyScopesResponse returns the before/after state and the
// audit row ID so operators can cite it in change tickets.
type AdminChangeKeyScopesResponse struct {
KeyID int64 `json:"key_id"`
ClientID string `json:"client_id"`
KeyPrefix string `json:"key_prefix"`
ScopesBefore []string `json:"scopes_before"`
ScopesAfter []string `json:"scopes_after"`
AuditID int64 `json:"audit_id"`
ChangedAt string `json:"changed_at"` // RFC 3339
}
// =============================================================================
// GET /v1/usage - Check API usage for the current key
// =============================================================================
// UsageResponse returns the current usage stats for the authenticated API key.
type UsageResponse struct {
// Tier of the authenticated key
Tier string `json:"tier"`
// Requests used in the current window
Used int `json:"used"`
// Total request limit for the current window
Limit int `json:"limit"`
// Requests remaining in the current window
Remaining int `json:"remaining"`
// Rate limit window: "day" (free) or "month" (paid)
Window string `json:"window"`
// Trial details — present only when this key is a comped/trial key.
// Customers and customer dashboards use this to show "your trial ends in N days".
Trial *TrialInfo `json:"trial,omitempty"`
}
// TrialInfo describes the trial window for a comped API key.
// Trial keys receive full enterprise-tier access until ends_at, after which
// a daily job flips the key's status to "expired" and subsequent requests
// return 402 Payment Required with code=trial_expired.
type TrialInfo struct {
// When the trial expires (RFC 3339, UTC)
EndsAt string `json:"ends_at"`
// Days remaining until expiry (0 on the day of expiry; never negative)
DaysRemaining int `json:"days_remaining"`
// Comp category for the trial: design_partner, pilot, evaluation, event
Category string `json:"category,omitempty"`
}
// =============================================================================
// GET /v1/alerts - List alerts for the authenticated tenant
// =============================================================================
//
// Returns List[AlertResponse], cursor-paginated by alert id (?after=&limit=),
// ordered id ascending — see the "Pagination" contract. Filters (all optional):
// status, source, min_score, max_score, assigned_to, from, to (YYYY-MM-DD).
// There is no total; use GET /v1/alerts/stats for queue counts.
//
// =============================================================================
// GET /v1/alerts/{id} - Single alert with full detail
// =============================================================================
//
// Returns a single AlertResponse including the match payload and the full case
// activity timeline (Activity, oldest first). 404 if the id is unknown or not
// owned by the caller's tenant.
// AlertResponse is a single alert in list or detail views.
type AlertResponse struct {
// Unique alert identifier
ID int64 `json:"id"`
// The name that was screened
ScreenedName string `json:"screened_name"`
// Combined match score (0-100)
Score float64 `json:"score"`
// Number of sanctions matches
MatchCount int `json:"match_count"`
// How the screening was initiated: "console", "api", "batch", "monitoring"
Source string `json:"source"`
// Alert lifecycle status
Status string `json:"status"`
// Analyst assigned to this alert (email or "apikey:<prefix>")
AssignedTo string `json:"assigned_to,omitempty"`
// Structured resolution reason (only when closed)
Reason string `json:"reason,omitempty"`
// Disposition records what action was taken for true matches.
// Values: "blocked", "rejected", "permitted_gl", "no_action_required".
Disposition string `json:"disposition,omitempty"`
// GLCitation documents which general license permits the transaction.
GLCitation string `json:"gl_citation,omitempty"`
// Whether the four-eyes rule applies to this alert (score >= 90)
FourEyesRequired bool `json:"four_eyes_required"`
// SLA deadline (RFC 3339). Score >= 90: 24h, others: 72h from assignment.
DueAt string `json:"due_at,omitempty"`
// Whether the SLA deadline has passed for an open alert
Overdue bool `json:"overdue"`
// SDN match details (present when matched against a specific SDN entry)
SdnUID int `json:"sdn_uid,omitempty"`
SdnFirstName string `json:"sdn_first_name,omitempty"`
SdnLastName string `json:"sdn_last_name,omitempty"`
// Full match payload (JSON array of all screening matches)
MatchPayload json.RawMessage `json:"match_payload,omitempty"`
// Portfolio entity (present when alert is linked to a monitored entity)
Entity *AlertEntityResponse `json:"entity,omitempty"`
// Case activity timeline (present in detail view, omitted in list)
Activity []AlertActivityResponse `json:"activity,omitempty"`
// When the alert was created (RFC 3339)
CreatedAt string `json:"created_at"`
}
// AlertEntityResponse is the portfolio entity linked to an alert.
type AlertEntityResponse struct {
FullName string `json:"full_name"`
DateOfBirth string `json:"date_of_birth,omitempty"`
Country string `json:"country,omitempty"`
EntityType string `json:"entity_type"`
}
// AlertActivityResponse is a single entry in the alert's audit trail.
type AlertActivityResponse struct {
// Who performed the action (email or "apikey:<prefix>")
Actor string `json:"actor"`
// Action type: "status_change", "assigned", "note", "escalated", "reopened"
Action string `json:"action"`
// Previous status (for status_change actions)
FromStatus string `json:"from_status,omitempty"`
// New status (for status_change actions)
ToStatus string `json:"to_status,omitempty"`
// Structured reason code (for resolution actions)
Reason string `json:"reason,omitempty"`
// Disposition (for true match closures)
Disposition string `json:"disposition,omitempty"`
// General license citation (for permitted_gl dispositions)
GLCitation string `json:"gl_citation,omitempty"`
// Free-text investigation note
Note string `json:"note,omitempty"`
// When this action occurred (RFC 3339)
Time string `json:"time"`
// ID is the case-activity row id. Present in the activity export
// (GET /v1/alerts/activity) where it is the cursor for ?after=; omitted in
// the alert-detail timeline.
ID int64 `json:"id,omitempty"`
// Alert ID (present in activity export, omitted in alert detail)
AlertID int64 `json:"alert_id,omitempty"`
}
// =============================================================================
// POST /v1/alerts/{id}/resolve - Resolve an alert with a structured reason
// =============================================================================
// AlertResolveRequest is the input for resolving an alert.
type AlertResolveRequest struct {
// Target status: "closed_false_positive" or "closed_true_match"
Status string `json:"status"`
// Structured reason code (required)
Reason string `json:"reason"`
// Disposition records what action was taken for true matches (required when status=closed_true_match).
// Values: "blocked", "rejected", "permitted_gl", "no_action_required".
// OFAC requires filing blocking/reject reports within 10 business days.
Disposition string `json:"disposition,omitempty"`
// GLCitation documents which general license permits the transaction (required when disposition=permitted_gl).
// Example: "GL 8A — Iran humanitarian trade" or "Russia GL 25G".
GLCitation string `json:"gl_citation,omitempty"`
// Optional investigation note
Note string `json:"note,omitempty"`
}
// =============================================================================
// POST /v1/alerts/{id}/assign - Assign an alert to an analyst
// =============================================================================
// AlertAssignRequest is the input for assigning an alert.
type AlertAssignRequest struct {
// Email of the analyst to assign to (required)
AssignTo string `json:"assign_to"`
}
// =============================================================================
// POST /v1/alerts/{id}/review - Start investigation (assigned → under_review)
// =============================================================================
// AlertReviewRequest is the input for starting investigation on an alert.
// Unlike resolve, no reason code is required — the analyst is beginning work.
type AlertReviewRequest struct {
// Optional note capturing initial observations
Note string `json:"note,omitempty"`
}
// =============================================================================
// POST /v1/alerts/{id}/notes - Add an investigation note
// =============================================================================
// AlertNoteRequest is the input for adding a note to an alert.
type AlertNoteRequest struct {
// Investigation note text (required, max 2000 chars)
Note string `json:"note"`
}
// =============================================================================
// POST /v1/alerts/{id}/escalate - Escalate an alert
// =============================================================================
// AlertEscalateRequest is the input for escalating an alert.
type AlertEscalateRequest struct {
// Optional note explaining the escalation
Note string `json:"note,omitempty"`
}
// =============================================================================
// Alert Constants
// =============================================================================
// Alert statuses define the lifecycle of an alert.
// Both the console UI and REST API enforce the same transitions.
const (
AlertStatusNew = "new"
AlertStatusAssigned = "assigned"
AlertStatusUnderReview = "under_review"
AlertStatusEscalated = "escalated"
AlertStatusClosedFP = "closed_false_positive"
AlertStatusClosedTrueMatch = "closed_true_match"
)
// Alert resolution reason codes. Structured reasons are required for closing
// an alert and serve as the auditable justification an OFAC examiner will review.
const (
ReasonFPNameOnly = "false_positive_name_only"
ReasonFPDOBMismatch = "false_positive_dob_mismatch"
ReasonFPCountryMismatch = "false_positive_country_mismatch"
ReasonFPEntityTypeMismatch = "false_positive_entity_type_mismatch"
ReasonFPOther = "false_positive_other"
ReasonTrueMatch = "true_match"
)
// Disposition constants record what action was taken on a confirmed true match.
// OFAC requires blocking/reject reports within 10 business days (31 C.F.R. Part 501).
const (
DispositionBlocked = "blocked"
DispositionRejected = "rejected"
DispositionPermittedGL = "permitted_gl"
DispositionNoActionRequired = "no_action_required"
)
// validReasons is the immutable set of accepted resolution reason codes.
// Unexported to prevent runtime mutation — compliance state machine integrity.
var validReasons = map[string]bool{
ReasonFPNameOnly: true,
ReasonFPDOBMismatch: true,
ReasonFPCountryMismatch: true,
ReasonFPEntityTypeMismatch: true,
ReasonFPOther: true,
ReasonTrueMatch: true,
}
// IsValidReason reports whether r is an accepted resolution reason code.
func IsValidReason(r string) bool { return validReasons[r] }
// validDispositions is the immutable set of accepted disposition values for true matches.
var validDispositions = map[string]bool{
DispositionBlocked: true,
DispositionRejected: true,
DispositionPermittedGL: true,
DispositionNoActionRequired: true,
}
// IsValidDisposition reports whether d is an accepted disposition value.
func IsValidDisposition(d string) bool { return validDispositions[d] }
// validTransitions defines the allowed status transitions for alerts.
// Alerts cannot be closed without passing through under_review — this ensures
// every closure has an investigation step in the audit trail. Closed alerts
// can be reopened to under_review when an examiner finds a premature closure.
// Escalated alerts can be sent back to under_review for further investigation.
// Unexported to prevent runtime mutation — compliance state machine integrity.
var validTransitions = map[string]map[string]bool{
AlertStatusNew: {AlertStatusAssigned: true, AlertStatusEscalated: true},
AlertStatusAssigned: {AlertStatusUnderReview: true, AlertStatusEscalated: true},
AlertStatusUnderReview: {AlertStatusEscalated: true, AlertStatusClosedFP: true, AlertStatusClosedTrueMatch: true},
AlertStatusEscalated: {AlertStatusUnderReview: true, AlertStatusClosedFP: true, AlertStatusClosedTrueMatch: true},
AlertStatusClosedFP: {AlertStatusUnderReview: true},
AlertStatusClosedTrueMatch: {AlertStatusUnderReview: true},
}
// IsValidTransition reports whether transitioning from one status to another is allowed.
func IsValidTransition(from, to string) bool {
allowed, ok := validTransitions[from]
return ok && allowed[to]
}
// FourEyesScoreThreshold is the minimum score (0-100) that triggers the
// four-eyes rule: a different analyst must close the alert than the one
// who last moved it through the workflow.
const FourEyesScoreThreshold = 90
// SLA deadlines based on alert score.
const (
SLAHighScoreHours = 24 // score >= FourEyesScoreThreshold
SLADefaultScoreHours = 72 // score < FourEyesScoreThreshold
)
// MaxNoteLength is the maximum character length for investigation notes.
const MaxNoteLength = 2000
// AlertService sentinel errors. Defined here (alongside the interface in server.go)
// so that both internal/alert and HTTP handlers share the same error values.
var (
ErrAlertNotFound = errors.New("alert not found")
ErrAlertInvalidReason = errors.New("invalid reason code")
ErrAlertInvalidTransition = errors.New("invalid status transition")
ErrAlertInvalidDisposition = errors.New("invalid disposition")
ErrAlertDispositionRequired = errors.New("disposition required for true match closure")
ErrAlertGLCitationRequired = errors.New("gl_citation required when disposition is permitted_gl")
ErrAlertFourEyes = errors.New("four-eyes required: a different analyst must close high-score alerts")
ErrAlertConcurrentModified = errors.New("alert status changed concurrently")
ErrAlertInvalidAssignee = errors.New("assignee is not an active team member")
ErrAlertEmptyNote = errors.New("note cannot be empty")
ErrAlertNoteTooLong = errors.New("note exceeds 2000 character limit")
)
// API limits for batch operations
const (
// MaxBatchSize is the maximum number of names in a single batch.
MaxBatchSize = 10_000
// MaxIDsPerRequest caps how many entries ScreenRequest.IDs may contain.
// Real entities rarely carry more than a handful of identifiers; the cap
// is generous enough for compounded passport + tax ID + crypto wallet
// portfolios while bounding worst-case work per screening call.
MaxIDsPerRequest = 20
)
// =============================================================================
// GET /v1/alerts/activity - Export case activity audit trail
// =============================================================================
//
// Returns List[AlertActivityResponse]: the case-activity audit trail, cursor-
// paginated by activity row id (?after=&limit=). Filters: from, to (YYYY-MM-DD),
// alert_id, actor, action. Large pulls belong to the async export job.
// =============================================================================
// POST /v1/alerts/bulk/resolve - Bulk resolve alerts
// =============================================================================
// BulkResolveRequest resolves multiple alerts with the same status and reason.
type BulkResolveRequest struct {
// Alert IDs to resolve
AlertIDs []int64 `json:"alert_ids"`
// Target status: "closed_false_positive" or "closed_true_match"
Status string `json:"status"`
// Structured reason code (required)
Reason string `json:"reason"`
// Disposition for true matches (required when status=closed_true_match)
Disposition string `json:"disposition,omitempty"`
// GL citation (required when disposition=permitted_gl)
GLCitation string `json:"gl_citation,omitempty"`
// Optional note applied to all alerts
Note string `json:"note,omitempty"`
}
// BulkResolveResponse reports per-alert success or failure.
type BulkResolveResponse struct {
Resolved int `json:"resolved"`
Failed int `json:"failed"`
Results []BulkResolveResult `json:"results"`
}
// BulkResolveResult is the outcome for a single alert in a bulk resolve.
type BulkResolveResult struct {
AlertID int64 `json:"alert_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// MaxBulkResolveSize limits how many alerts can be resolved in a single bulk request.
const MaxBulkResolveSize = 200
// =============================================================================
// POST /v1/alerts/bulk/assign - Bulk assign alerts
// =============================================================================
// BulkAssignRequest assigns multiple alerts to the same analyst.
type BulkAssignRequest struct {
// Alert IDs to assign
AlertIDs []int64 `json:"alert_ids"`
// Email of the analyst to assign to (required)
AssignTo string `json:"assign_to"`
}
// BulkAssignResponse reports per-alert success or failure.
type BulkAssignResponse struct {
Assigned int `json:"assigned"`
Failed int `json:"failed"`
Results []BulkAssignResult `json:"results"`
}
// BulkAssignResult is the outcome for a single alert in a bulk assign.
type BulkAssignResult struct {
AlertID int64 `json:"alert_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// MaxBulkAssignSize limits how many alerts can be assigned in a single bulk request.
const MaxBulkAssignSize = 200
// =============================================================================
// GET /v1/alerts/stats - Alert queue statistics
// =============================================================================
// AlertStatsResponse provides a breakdown of the alert queue for management
// dashboards and compliance reporting. Answers "how many alerts by status
// and score tier?" without paginating through the full list.
type AlertStatsResponse struct {
// Total alerts across all statuses
Total int `json:"total"`
// Counts by status
ByStatus map[string]int `json:"by_status"`
// Counts by score band for open (non-closed) alerts
ByScoreBand map[string]int `json:"by_score_band"`
// SLA compliance for open alerts
Overdue int `json:"overdue"`
WithinSLA int `json:"within_sla"`
NoDeadline int `json:"no_deadline"`
}
// ActivityStatsResponse is the count companion to GET /v1/alerts/activity.
// Answers "how many case-activity records match this filter/period?" without
// paging the trail. Honors the same alert_id/actor/action/from/to filters as
// the list endpoint.
type ActivityStatsResponse struct {
// Total activity records matching the (optional) filters.
Total int `json:"total"`
// ByAction counts records by action (e.g. assign, escalate, dismiss).
ByAction map[string]int `json:"by_action"`
}
// =============================================================================
// GET /v1/alerts/{id}/ofac-report - Generate OFAC-ready blocking/reject report
// =============================================================================
// OFACReportResponse assembles everything OFAC needs for a blocking or reject
// report into a single response. Only available on alerts resolved as
// closed_true_match with a disposition of "blocked" or "rejected".
type OFACReportResponse struct {
// Alert that triggered this report
AlertID int64 `json:"alert_id"`
// Report type derived from disposition: "blocking" or "reject"
ReportType string `json:"report_type"`
// When this report was generated (RFC 3339)
GeneratedAt string `json:"generated_at"`
// The name that was screened
ScreenedName string `json:"screened_name"`
// SDN entry that matched
SDNMatch OFACReportSDNMatch `json:"sdn_match"`
// Screening audit data
Screening OFACReportScreening `json:"screening"`
// How the alert was resolved
Resolution OFACReportResolution `json:"resolution"`
// Fields your institution must complete before filing
InstitutionFields OFACReportInstitutionFields `json:"institution_fields"`
}
// OFACReportSDNMatch contains the matched SDN entry details for the report.
type OFACReportSDNMatch struct {
UID string `json:"uid"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Programs []string `json:"programs,omitempty"`
Score float64 `json:"score"`
MatchSources []MatchSource `json:"match_sources,omitempty"`
}
// OFACReportScreening contains audit trail data from the screening.
type OFACReportScreening struct {
ScreenedAt string `json:"screened_at"`
OFACPublishID string `json:"ofac_publish_id,omitempty"`
OFACPublishDate string `json:"ofac_publish_date,omitempty"`
TraceID string `json:"trace_id"`
}
// OFACReportResolution contains the alert resolution details.
type OFACReportResolution struct {
Status string `json:"status"`
Disposition string `json:"disposition"`
Reason string `json:"reason"`
ResolvedBy string `json:"resolved_by"`
ResolvedAt string `json:"resolved_at"`
GLCitation string `json:"gl_citation,omitempty"`
Note string `json:"note,omitempty"`
}
// OFACReportInstitutionFields are fields Noble Sight cannot provide.
// Your institution must complete these before filing with OFAC.
type OFACReportInstitutionFields struct {
TransactionDescription *string `json:"transaction_description"`
TransactionValue *string `json:"transaction_value"`
Instructions string `json:"instructions"`
}
// ErrAlertNotTrueMatch is returned when requesting an OFAC report for an alert
// that is not resolved as a true match with a reportable disposition.
var ErrAlertNotTrueMatch = errors.New("OFAC report requires a true match alert with blocked or rejected disposition")
// Error code for OFAC report precondition failures.
const CodeAlertNotReportable = "alert_not_reportable"
// =============================================================================
// GET /v1/account - Account overview for the authenticated API key
// =============================================================================
// AccountResponse returns the account overview for the authenticated API key.
// Combines tier, usage, billing status, and rate limits in a single call.
type AccountResponse struct {
// Client identifier
ClientID string `json:"client_id"`
// Current API key prefix (for identification)
KeyPrefix string `json:"key_prefix"`
// Current tier: free, standard, premium, enterprise
Tier string `json:"tier"`
// Billing status: "active", "none" (free tier), "past_due", "canceled"
BillingStatus string `json:"billing_status"`
// Usage in the current rate limit window
Usage AccountUsage `json:"usage"`
// Features available on this tier
Features AccountFeatures `json:"features"`
// Capability scopes granted to this key. Every key has "api" (REST access).
// Additional scopes like "mcp" gate access to specific surfaces — the MCP
// server sends X-Noble-Client: mcp on every request, which the API rejects
// with 403 (code: scope_required) when "mcp" is not present here. Agents
// can read this to discover whether MCP setup will work before attempting.
Scopes []string `json:"scopes"`
}
// AccountUsage contains current usage stats.
type AccountUsage struct {
// Requests used in the current window
Used int `json:"used"`
// Total request limit for the current window
Limit int `json:"limit"`
// Requests remaining
Remaining int `json:"remaining"`
// Rate limit window: "day" (free) or "month" (paid)
Window string `json:"window"`
}
// AccountFeatures describes what the current tier unlocks.
type AccountFeatures struct {
Screening bool `json:"screening"`
DeepScreen bool `json:"deep_screen"`
BatchScreening bool `json:"batch_screening"`
PortfolioMonitor bool `json:"portfolio_monitoring"`
ComplianceExports bool `json:"compliance_exports"`
AlertManagement bool `json:"alert_management"`
MaxBatchSize int `json:"max_batch_size"`
// MCP reports whether this key can use the Model Context Protocol server —
// the 'mcp' scope. Granted on every tier.
MCP bool `json:"mcp"`
// AlertThreshold is the minimum match score (0-100) that triggers alert creation.
// Screenings with a highest match score below this value do not generate alerts.
// Default: 80. Configured per tenant.
AlertThreshold int `json:"alert_threshold"`
}
// =============================================================================
// POST /v1/account/checkout - Create a Stripe Checkout session for tier upgrade
// =============================================================================
// CheckoutRequest is the input for creating a billing checkout session.
type CheckoutRequest struct {
// Target tier to upgrade to: "standard", "premium"
// Enterprise requires contacting sales.
Tier string `json:"tier"`
}
// CheckoutResponse returns the Stripe Checkout URL for payment.
type CheckoutResponse struct {
// URL to redirect the user to for payment
CheckoutURL string `json:"checkout_url"`
}
// Error codes for billing operations.
const (
CodeBillingNotEnabled = "billing_not_enabled"
CodeAlreadyOnTier = "already_on_tier"
CodeInvalidTier = "invalid_upgrade_tier"
)
// =============================================================================
// POST /v1/watchlist - Add an internal watchlist entry
// =============================================================================
// WatchlistAddRequest is the input for adding a watchlist entry.
type WatchlistAddRequest struct {
// Full name to add to the watchlist (required)
FullName string `json:"full_name"`
// List name for categorization (e.g., "SAR Subjects", "Exited Customers").
// Defaults to "default".
ListName string `json:"list_name,omitempty"`
// Why this entity is on the watchlist (recommended for audit trail)
Reason string `json:"reason,omitempty"`
// Government identifier (optional)
IDNumber string `json:"id_number,omitempty"`
// ID type (e.g., "Passport", "SSN") (optional)
IDType string `json:"id_type,omitempty"`
// Country code ISO 3166-1 alpha-2 (optional)
Country string `json:"country,omitempty"`
// Entity type: "individual" or "entity" (default: "individual")
EntityType string `json:"entity_type,omitempty"`
// Expiration date in RFC 3339 format (optional). Entry stops matching after this date.
ExpiresAt string `json:"expires_at,omitempty"`
}
// WatchlistResponse is a single watchlist entry.
type WatchlistResponse struct {
// Entry ID
ID int64 `json:"id"`
// Full name on the watchlist
FullName string `json:"full_name"`
// List category
ListName string `json:"list_name"`
// Why this entity was added
Reason string `json:"reason,omitempty"`
// Government identifier
IDNumber string `json:"id_number,omitempty"`
// ID type
IDType string `json:"id_type,omitempty"`
// Country code
Country string `json:"country,omitempty"`
// Entity type
EntityType string `json:"entity_type"`
// Who added this entry
AddedBy string `json:"added_by"`
// When this entry was created (RFC 3339)
CreatedAt string `json:"created_at"`
// When this entry expires (RFC 3339), if set
ExpiresAt string `json:"expires_at,omitempty"`
// Entry status: "active" or "removed"
Status string `json:"status"`
}
// GET /v1/watchlist returns List[WatchlistResponse], cursor-paginated by entry
// id (?after=&limit=), ordered id ascending — see the "Pagination" contract.
// Optional filters: list_name, status (defaults to "active").
// MaxWatchlistNameLength is the maximum length for a watchlist entry name.
const MaxWatchlistNameLength = 500
// MaxWatchlistImportSize is the maximum entries in a single bulk import.
const MaxWatchlistImportSize = 10_000
// =============================================================================
// Webhooks — POST /v1/webhooks, GET /v1/webhooks, DELETE /v1/webhooks?id=N
// =============================================================================
// Webhook limits.
const (
// MaxWebhooksPerTenant is the maximum number of active webhook registrations per tenant.
MaxWebhooksPerTenant = 5
// MinWebhookSecretLength is the minimum length for the shared HMAC secret.
MinWebhookSecretLength = 32
// MaxWebhookURLLength is the maximum allowed URL length.
MaxWebhookURLLength = 2048
// MaxWebhookRetries is the number of delivery attempts before marking as failed.
MaxWebhookRetries = 3
)
// WebhookCreateRequest registers a URL to receive alert notifications.
type WebhookCreateRequest struct {
// HTTPS URL to receive POST notifications (required)
URL string `json:"url"`
// Shared secret for HMAC-SHA256 signature verification (required, min 32 chars).
// Noble signs every outbound payload with this secret. The customer verifies
// the X-Noble-Signature header to confirm authenticity.
Secret string `json:"secret"`
}
// WebhookResponse is a registered webhook. The URL is masked after creation.
type WebhookResponse struct {
ID int64 `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// GET /v1/webhooks returns List[WebhookResponse]. Unlike the other collection
// endpoints it is NOT paginated — a tenant holds at most MaxWebhooksPerTenant
// registrations, so the endpoint returns them all in data and ignores any
// ?after=/?limit= cursor params.
// WebhookStatsResponse is the count companion to GET /v1/webhooks. Because
// registrations are capped per tenant, the headline value is how much of that
// budget remains — "how many more webhooks can I register?"
type WebhookStatsResponse struct {
// Total registered webhooks for the tenant.
Total int `json:"total"`
// ByStatus counts registrations by status (e.g. active, disabled).
ByStatus map[string]int `json:"by_status"`
// Limit is the per-tenant cap (MaxWebhooksPerTenant).
Limit int `json:"limit"`
// Remaining registrations available before hitting Limit (never negative).
Remaining int `json:"remaining"`
}
// WebhookEvent is the payload POSTed to customer webhook URLs.
// Customers verify authenticity via the X-Noble-Signature header:
//
// signature = HMAC-SHA256(secret, request_body)
// header: X-Noble-Signature: sha256=<hex>
type WebhookEvent struct {
// Unique event ID (evt_ prefix)
ID string `json:"id"`
// Event type: "alert.created"
Type string `json:"type"`
// When this event was generated (RFC 3339)
CreatedAt string `json:"created_at"`
// Event payload (AlertResponse JSON)
Data json.RawMessage `json:"data"`
}
// =============================================================================
// GET /status - Public system status (no auth required)
// =============================================================================
// StatusResponse reports system health for customer monitoring dashboards.
// No authentication required — compliance teams need to check this independently.
type StatusResponse struct {
// Overall status: "operational", "degraded", "outage"
Status string `json:"status"`
// Human-readable description
Message string `json:"message"`
// Component health
Components StatusComponents `json:"components"`
// When this status was generated (RFC 3339)
Timestamp string `json:"timestamp"`
}
// StatusComponents reports health of individual subsystems.
type StatusComponents struct {
// Database connectivity
Database ComponentStatus `json:"database"`
// Screening API availability
Screening ComponentStatus `json:"screening"`
}
// ComponentStatus is the health of a single component.
type ComponentStatus struct {
// Status: "operational", "degraded", "outage"
Status string `json:"status"`
// Response time in milliseconds (where measurable)
LatencyMs *int64 `json:"latency_ms,omitempty"`
}
// Status constants for system health reporting.
const (
StatusOperational = "operational"
StatusDegraded = "degraded"
StatusOutage = "outage"
)
// =============================================================================
// Headers
// =============================================================================
//
// X-API-Key (required for /v1/* endpoints): API key for authentication.
// Keys use the format: noble_live_ + 32 hex characters.
//
// Screening API tiers:
// Free: 100 screenings/day ($0/mo)
// Standard: 5,000 screenings/mo ($199/mo)
// Premium: 25,000 screenings/mo ($499/mo)
// Enterprise: 100,000+ screenings/mo (custom pricing)
//
// Portfolio Monitoring: per-entity add-on, available on any paid tier.
//
// X-Trace-ID (optional): Client-provided trace ID for audit logging.
// Will be included in logs and database query comments.
//
// X-Noble-Client (optional): Identifies the official Noble client making the
// request. Currently recognized values:
// mcp — the `noble mcp` Model Context Protocol
// server. When this header is present, the
// API key must carry the "mcp" scope or
// the request is rejected 403
// (code: scope_required).
// Third-party clients should not set this header.
// Setting it without the matching scope is harmless
// but produces a 403 instead of normal access.
//
// Response headers on every request:
// X-Request-Id: Server-generated request ID (req_ + 16 hex chars).
//
// Response headers on authenticated requests:
// X-RateLimit-Limit: Request limit for this key (daily or monthly).
// X-RateLimit-Remaining: Requests remaining in current window.
// X-RateLimit-Window: "day" (Free) or "month" (paid tiers).
// X-RateLimit-Reset: Unix timestamp when the rate limit window resets.
//
// Response headers on 429 (rate limit exceeded):
// Retry-After: Seconds until the rate limit window resets.
//
// Response headers on requests authenticated by a trial/comp key:
// X-Trial-Ends-At: RFC 3339 timestamp when the trial expires.
// X-Trial-Days-Remaining: Whole days until trial expiry (0 on the final day).
//
// Response on 402 (trial expired):
// Error code: trial_expired. The customer's trial_ends_at has passed and a
// nightly job has flipped the key's status to "expired". Contact sales to
// convert or extend.
Changelog
Recent API changes. Subscribe by polling GET /v1/changelog — public, no API key required, returns the same entries as machine-readable JSON.
| Date | Category | Change |
|---|---|---|
2026-06-07 |
added |
Stats companions for portfolio, webhooks, and case activity — uniform collection counts
/v1
Counts are now a first-class read on every collection, not just alerts. New GET /v1/portfolio/stats (total + by_score_band, honors q/min_score/max_score), GET /v1/webhooks/stats (total, by_status, limit, remaining), and GET /v1/alerts/activity/stats (total + by_action, honors the same alert_id/actor/action/from/to filters as the list) join the existing GET /v1/alerts/stats. The convention is uniform: a collection at /v1/<name> exposes its counts at /v1/<name>/stats, and a stats endpoint honors the same filters as its list sibling. This recovers the row counts dropped from list envelopes in the 2026-06-01 cursor-pagination change, without re-adding O(n) total to page fetches. Additive — no version change. |
2026-06-06 |
added |
Noble-Version header for API version pinning
/v1
Clients may pin a dated API version with the Noble-Version request header (e.g., Noble-Version: 2026-06-01); the resolved version is echoed in the Noble-Version response header. No header pins to the latest version, so existing integrations are unaffected; an unrecognized version is rejected with 400 invalid_version. This entry is additive and therefore does not itself mint a new version — the latest version remains 2026-06-01 (cursor pagination), the most recent breaking change. |
2026-06-01 |
changed |
Uniform cursor pagination across all list endpoints — {data, has_more}, ?after=&limit=
/v1/alerts
Every collection endpoint (alerts, portfolio, watchlist, export/bulk, alerts/activity, webhooks) now returns the same envelope: {"data": [...], "has_more": bool}. The array key is always "data". Pagination is keyset cursor only — ?after=<id>&limit=<n>, ordered by id ascending; pass the id of the last item in data as ?after= for the next page. This is a breaking change to the wire shape: the per-endpoint keys (alerts, entities, entries, records, webhooks), offset/page params, and total counts are removed. Use GET /v1/alerts/stats for bounded counts; use the async export job for full extraction. /v1/webhooks returns the same envelope but is not paginated (capped per tenant). |
2026-05-27 |
fixed |
summary.fallback_reason guaranteed on every non-LLM deep_summary response
/v1/screen
Hardened the deep_summary path with a deferred invariant: when deep_summary=true and the response carries deterministic prose (LLM unavailable, marshalling error, Gemini failure, or empty LLM payload), fallback_reason is now guaranteed to be populated with llm_unavailable or llm_failure. Closes a gap where callers requesting a paid feature could receive deterministic prose with no signal that the LLM did not deliver. Empty LLM text or model from the Gemini path is now treated as llm_failure rather than passing through. |
2026-05-26 |
added |
match_sources[].matched_text and matched_length surface which string fired each pathway
/v1/screen
Every match-source row now carries the actual source-list string that produced its trigram score — the primary name, alias, or variation text the comparison hit. matched_length is the rune count, pre-computed for server-side aggregation. Lets consumers distinguish 'matched primary name Vladimir Putin' from 'matched 4-character alias JOHN', and is the foundation for length- and quality-aware scoring follow-ups. Empty when not applicable (e.g., exact ID matches). |
2026-05-26 |
fixed |
Filter OFAC metadata (Gender, Secondary sanctions risk:) out of top_match.ids and matches[].ids
/v1/screen
OFAC publishes some non-identifier features (gender, sanctions-program annotations) using the same <ID> element as real identifiers. These were leaking into the ids array on every match and misleading compliance officers and AI agents. Only real identifier types (Passport, Tax ID, SWIFT/BIC, crypto wallet addresses, etc.) now surface; the rest are filtered at the API boundary. |
2026-05-26 |
added |
summary.fallback_reason surfaces when deep_summary silently falls back to deterministic
/v1/screen
When deep_summary=true is requested but the LLM path doesn't execute (server not configured, Gemini call failed/timeout), the response now carries fallback_reason (llm_unavailable | llm_failure) so the caller can tell that a paid feature did not deliver. Absent when deep_summary was not requested or when the LLM produced the prose successfully. |
2026-05-26 |
added |
Structured dismissal_signals on every match (reason codes, severity, score impact)
/v1/screen
Each penalty applied during attribute comparison now emits a structured signal alongside the legacy free-text reason. Stable reason codes enable filtering, aggregation, and audit-defensible dismissal records. The legacy dismissal_reasons field is preserved byte-for-byte during a 90-day deprecation window; both fields are guaranteed to have equal length and ordered correspondence. |
2026-05-26 |
deprecated |
dismissal_reasons (free-text array) — use dismissal_signals instead
/v1/screen
Free-text reasons cannot be filtered or aggregated. The structured dismissal_signals field carries the same information as reason codes, severity, and per-signal score impact. Removal scheduled with the next major API version; both fields remain populated until then. |
2026-05-26 |
changed |
Enforce OFAC FAQ 5 Step 3: single-token name match against multi-token sanctioned name is penalized
/v1/screen
Matches where only a single token of the request name overlaps with a multi-token sanctioned name (e.g., 'Putin' alone against 'Vladimir Putin') now receive a 50% score reduction and a partial_name_match_single_token dismissal signal. This codifies OFAC's 'just one of two or more names matching, i.e. just the last name' not-a-valid-match condition. |
Full feed: GET /v1/changelog
Auth
Every request requires an API key. Create one with your client ID, pass it in the X-API-Key header, and you're screening. Keys are prefixed noble_live_ for easy identification in your code.
POST /v1/keys
Invite-only: requires an invite_code issued by Noble Sight. Contact sales@noblesight.io for access. Rate limited to 10 requests per minute per IP.
curl -X POST https://noblesight.io/v1/keys \
-H "Content-Type: application/json" \
-d '{"client_id": "acme-bank", "name": "Production Key", "accept_terms": true, "invite_code": "noble_inv_..."}'
Response:
{
"key": "noble_live_a1b2c3d4e5f6...",
"key_prefix": "noble_live_a1b2",
"tier": "free",
"rate_limit_per_day": 100,
"terms_version": "1.0"
}
The full key is shown once. Store it securely — it cannot be retrieved again.
accept_terms must be true. This confirms your institution acknowledges that screening results are informational only and do not constitute legal or compliance advice. The terms_version in the response records which version was accepted for your audit trail.
GET /v1/usage
Check how many screenings you've used in the current rate limit window.
curl https://noblesight.io/v1/usage \ -H "X-API-Key: $NOBLE_API_KEY"
Response:
{
"tier": "free",
"used": 42,
"limit": 100,
"remaining": 58,
"window": "day"
}
Tiers
| Tier | Price | Screenings | Window |
|---|---|---|---|
free | $0 | 100 | Per day |
standard | $199/mo | 5,000 | Per month |
premium | $499/mo | 25,000 | Per month |
enterprise | Custom | 100,000+ | Per month |
Rate Limit Headers
Every authenticated response includes rate limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Request limit for this key |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Window | day (free) or month (paid) |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds until reset (only on 429 responses) |
X-Request-Id | Server-generated request ID for debugging |
Screen
One call screens a name against 18,598 sanctioned entities, 24,526 aliases, and 89,117 AI-generated spelling variations — in under 100 milliseconds. Every match shows exactly how it scored and why, down to the individual data source.
POST /v1/screen
Screen a name against all active sanctions lists. Add date of birth, country, or entity type to reduce false positives — each mismatch applies a transparent score penalty.
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-H "X-Trace-ID: onboarding-customer-42" \
-d '{
"name": "Vladimir Putin",
"date_of_birth": "1952-10-07",
"country": "RU",
"entity_type": "individual",
"id_number": "E12345678",
"id_type": "Passport",
"lists": ["ofac", "uk", "eu"]
}'
Every screening response includes a meta.disclaimer field. This is the full legal disclaimer bounding Noble Sight's liability — present in every response so your audit trail captures it alongside each screening decision. The disclaimer text is defined in the API contract.
When a screening produces matches above the alert threshold, the response includes an alert_id field — the ID of the alert created for this screening. Use it to track the match through the alert lifecycle, receive webhook notifications, or poll for new alerts using cursor-based pagination.
The Model Cascade
The architecture follows the model cascade recommended by Federal Reserve research (FEDS 2025-092): fast fuzzy matching for routine screens, AI escalation for hard cases. Every request passes through two paths.
Allen & Hatfield (2025) found LLMs reduce false positives by 92% but are 10,000x slower than fuzzy matching. Their recommendation: a model cascade — fast matching for easy cases, AI escalation for hard ones.
Match Sources
Every match includes a match_sources array — the complete evidence trail. When an OFAC examiner asks "why did this name match?", the answer is already in the response.
| Source | What It Matches Against | Example |
|---|---|---|
sdn |
Primary SDN entry names. Trigram similarity against full name, first name, and last name. | "Vladimir Putin" matches "Vladimir Vladimirovich PUTIN" |
aka |
Known aliases from OFAC's AKA list. Same trigram + Soundex matching. | "Abu Bakr al-Baghdadi" matches AKA "Ibrahim Awwad Ibrahim al-Badri" |
ai_variation |
89,117 Gemini-generated spelling, transliteration, and cultural variations. Pre-computed at import time with audit reasons. | "Qasem Soleimani" matches "Qassem Suleimani" (reason: "Common Farsi-to-English transliteration") |
deep_screen |
Real-time Gemini expansion of the search input. Only when deep_screen: true. |
"Димитрий Медведев" expands to "Dmitry Medvedev", "Dmitri Medvedev" |
ofac_id |
Exact match against OFAC ID numbers (passports, tax IDs, cedulas). Normalized: case-insensitive, strips spaces/dashes/dots. Score: 100. | Passport "E12345678" matches OFAC id_list entry for a sanctioned individual |
uk_id |
Exact match against UK sanctions identification numbers. Same normalization as OFAC. Score: 100. | National ID matches UK identification table entry |
internal_watchlist |
Trigram + Soundex match against your tenant's internal watchlist entries (SAR subjects, exited customers, etc.). Same scoring formula as sanctions matching. | "John Doe" matches your watchlist entry "Jon Doe" (list: "Exited Customers") |
Each source entry includes its own trigram_score and soundex_match flag. The entity's final score uses the best-scoring source.
The Scoring Formula
Character similarity and phonetic agreement combine into one number:
score = MIN(1.0, trigram_score + soundex_boost) × 100
The trigram score measures character-level similarity (0.0–1.0) across the full name, first name, and last name fields. The engine takes the highest. Minimum threshold: 0.15.
The Soundex boost rewards phonetic agreement. Last name match: +0.30. Both first and last: +0.15 additional. "Jon Smyth" scores up to 0.45 higher against "John Smith" than character matching alone.
The score is capped at 100. The entity's final score is the best across all match sources — SDN, AKA, and AI variation.
Soundex handles reversed name order — "Maduro Nicolas" matches "Nicolas MADURO MOROS." AI variations do not receive a Soundex boost because they are already expanded forms.
What the Score Means
| Range | Meaning | Typical Action |
|---|---|---|
90–100 |
Strong match — near-exact name or phonetic equivalent | Escalate for review |
70–89 |
Moderate match — likely a transliteration or variant spelling | Review with context |
50–69 |
Weak match — partial similarity only | Low priority review |
Reducing False Positives
A missed sanction is a federal violation. Too many false positives cause alert fatigue — analysts stop looking carefully, which leads to the same outcome. The score has to be right.
When you provide entity_type, date_of_birth, or country, Noble Sight cross-references them against OFAC data for each match. Mismatches reduce the score using multiplicative penalties. The match is never hidden — the original score and every penalty reason are always in the response.
Entity Type
| Condition | Multiplier | A 96.7 becomes... |
|---|---|---|
| Entity type matches (or not provided) | 1.0 — no penalty | 96.7 |
| Entity type mismatch (e.g., individual vs. vessel) | 0.5 | 48.4 |
Date of Birth
| DOB Difference | Multiplier | A 96.7 becomes... |
|---|---|---|
| Exact match (within 30 days) | 1.0 — no penalty | 96.7 |
| Near miss (within 1 year) | 0.8 | 77.4 |
| Mismatch (within 5 years) | 0.6 | 58.0 |
| Far mismatch (over 5 years) | 0.4 | 38.7 |
Country
| Condition | Multiplier | A 96.7 becomes... |
|---|---|---|
| Country matches OFAC nationality | 1.0 — no penalty | 96.7 |
| Country does not match | 0.7 | 67.7 |
Penalties are multiplicative and applied in order: entity type, then date of birth, then country. A name scoring 96.7 with matching entity type, a 15-year DOB mismatch, and wrong country:
96.7 × 1.0 × 0.4 × 0.7 = 27.1
The dismissal_reasons array shows exactly which penalties were applied and why.
Entities without OFAC attribute data are never penalized. Absence of data is not evidence of mismatch.
ID Number Matching
When you provide id_number, Noble Sight performs an exact match against sanctions ID tables across all supported lists — passports, tax IDs, cedulas, and other government identifiers. This catches the most dangerous evasion pattern: a sanctioned individual who changes their name but reuses an identity document.
ID numbers are normalized before matching: spaces, dashes, dots, and slashes are stripped, and the comparison is case-insensitive. An ID match always scores 100 — a deterministic identifier hit is the strongest possible signal.
If the same entity is found by both name and ID, the results are merged: the score stays at 100, and both source types (ofac_sdn + ofac_id) appear in match_sources. No duplicates.
Optionally provide id_type (e.g., "Passport", "National ID") to narrow the search to a specific document type.
List Selection
By default, every screening runs against all 10 sanctions lists: OFAC, OFAC Consolidated, UK, EU, France, Belgium, Netherlands, UN, Canada, and Australia. Use the lists parameter to restrict screening to specific lists — a US broker-dealer might only need OFAC, while an EU bank might screen against OFAC + EU + member states.
Valid values: ofac, uk, eu, france, belgium, netherlands, un, canada, australia. The response always includes a lists_screened field showing which lists were queried — populated even when you don't specify a filter.
Internal watchlist screening always runs regardless of the lists filter — it's your tenant's own data, not a government list.
Deep Screen
Deep screen adds real-time Gemini AI expansion. Instead of matching only the name you provide, it generates cultural, phonetic, and transliteration variations of your input — then screens each one. Use it for onboarding, investigations, or any situation where you need the highest possible recall.
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"name": "Димитрий Медведев", "deep_screen": true}'
| Aspect | Default Screen | Deep Screen |
|---|---|---|
| Latency | <100ms | +1-3 seconds (Gemini API call) |
| Matching | Input name vs. SDN + AKA + stored AI variations | Input name + AI-expanded variations vs. SDN + AKA + stored AI variations |
| Use case | Transaction screening, high-volume daily checks | Customer onboarding, due diligence, investigations |
| Tier | Free and above | Standard and above |
Batch
Screen up to 10,000 names in a single request. Submit a batch, poll for status, and retrieve results when complete. Standard tier and above.
POST /v1/batch
Submit a batch of up to 10,000 names. Each name is screened independently against all active sanctions lists with the same matching engine as single screens.
curl -X POST https://noblesight.io/v1/batch \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"names": [
{"full_name": "Vladimir Putin", "date_of_birth": "1952-10-07", "country": "RU"},
{"full_name": "Ali Khamenei", "country": "IR", "id_number": "A12345678", "id_type": "Passport"},
{"full_name": "John Smith"}
]
}'
Response:
{
"id": 1234,
"status": "pending",
"total_names": 3,
"processed_names": 0,
"match_count": 0
}
GET /v1/batch/status
Check batch progress. When status reaches completed, the response includes per-name results with match counts and highest scores.
curl "https://noblesight.io/v1/batch/status?id=1234" \ -H "X-API-Key: $NOBLE_API_KEY"
Response (completed):
{
"id": 1234,
"status": "completed",
"total_names": 3,
"processed_names": 3,
"match_count": 2,
"items": [
{"full_name": "Vladimir Putin", "status": "screened", "match_count": 1, "highest_score": 97.5, "alert_id": 42},
{"full_name": "Ali Khamenei", "status": "screened", "match_count": 1, "highest_score": 94.2, "alert_id": 43},
{"full_name": "John Smith", "status": "screened", "match_count": 0, "highest_score": 0}
]
}
Batch statuses: pending, processing, completed, failed.
Alerts
Screening produces matches. Alerts turn matches into a compliance workflow — who reviewed it, what they decided, and why. Every status change, note, and resolution is recorded immutably. This is the audit trail an OFAC examiner expects to see.
Lifecycle
Alerts follow a strict state machine. Each transition is recorded in an immutable case activity log.
| Status | Meaning | Can Transition To |
|---|---|---|
new |
Alert created, awaiting triage | assigned, under_review, escalated, closed_false_positive, closed_true_match |
assigned |
Assigned to an analyst with SLA deadline | under_review, escalated |
under_review |
Active investigation | escalated, closed_false_positive, closed_true_match |
escalated |
Requires senior compliance review | closed_false_positive, closed_true_match |
closed_false_positive |
Dismissed — not a true match | under_review (reopen) |
closed_true_match |
Confirmed — sanctions match verified | under_review (reopen) |
GET /v1/alerts
List alerts for your organization. Filter by status, source, score range, assignee, or date range to focus on what needs attention.
curl "https://noblesight.io/v1/alerts?status=new&min_score=80&limit=25" \ -H "X-API-Key: $NOBLE_API_KEY"
Query Parameters
| Param | Type | Description |
|---|---|---|
status | string | Filter by status (e.g., new, under_review) |
source | string | Filter by source (api, console, monitor) |
min_score | number | Minimum score threshold (0-100) |
max_score | number | Maximum score threshold (0-100) |
assigned_to | string | Filter by assigned analyst email |
from | string | Start date (YYYY-MM-DD) |
to | string | End date (YYYY-MM-DD) |
after | integer | Cursor: return alerts with ID greater than this value. Pass the id of the last alert in the previous page's data. Omit for the first page. |
limit | integer | Page size (default: 50, max: 100). Larger values are clamped. |
Cursor pagination only — keyset by id, ordered ascending. The response is the uniform { "data": [...], "has_more": bool } envelope: when has_more is true, request the next page with ?after=<id of the last item in data>. There is no page/offset and no total (use GET /v1/alerts/stats for queue counts). For compliance teams: because every alert has a monotonically increasing ID and pages never overlap or skip, your system can prove to an examiner that it processed every alert in sequence.
GET /v1/alerts/{id}
Retrieve full alert detail: the match payload, SDN entry data, complete case activity timeline, and final disposition.
curl "https://noblesight.io/v1/alerts/42" \ -H "X-API-Key: $NOBLE_API_KEY"
POST /v1/alerts/{id}/assign
Assign an alert to an analyst for investigation. SLA deadlines are set automatically: 24 hours for scores 90 and above, 72 hours for all others.
curl -X POST "https://noblesight.io/v1/alerts/42/assign" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"assign_to": "analyst@yourcompany.com"}'
POST /v1/alerts/{id}/notes
Add an investigation note without changing the alert's status. Notes are immutable and become part of the audit trail.
curl -X POST "https://noblesight.io/v1/alerts/42/notes" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"note": "Checked DOB against passport — different person."}'
POST /v1/alerts/{id}/escalate
Escalate an alert for senior compliance review. Optionally include a note explaining why.
curl -X POST "https://noblesight.io/v1/alerts/42/escalate" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"note": "Name matches known alias pattern — needs MLRO sign-off."}'
POST /v1/alerts/{id}/resolve
Close an alert with a structured reason code. True match closures require a disposition — the action your institution took. The four-eyes rule is enforced for alerts scoring 90 or above.
curl -X POST "https://noblesight.io/v1/alerts/42/resolve" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"status": "closed_true_match",
"reason": "true_match",
"disposition": "blocked",
"note": "Transaction blocked per OFAC regulations."
}'
POST /v1/alerts/bulk/resolve
Resolve up to 200 alerts in a single request. Each alert gets its own result in the response — failures on individual alerts don't block the rest.
curl -X POST "https://noblesight.io/v1/alerts/bulk/resolve" \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"alert_ids": [42, 43],
"status": "closed_false_positive",
"reason": "false_positive_name_only"
}'
GET /v1/alerts/activity
Export the case activity audit trail across all alerts. Built for OFAC examination preparation — every status change, note, assignment, and resolution with full attribution and timestamps.
curl "https://noblesight.io/v1/alerts/activity?from=2026-01-01&to=2026-03-01&limit=100" \ -H "X-API-Key: $NOBLE_API_KEY"
Query Parameters
| Param | Type | Description |
|---|---|---|
from | string | Start date (YYYY-MM-DD) |
to | string | End date (YYYY-MM-DD) |
alert_id | integer | Filter to a specific alert |
actor | string | Filter by actor email |
action | string | Filter by action type |
after | integer | Cursor: return activity rows with id greater than this (pass the last item's id from the previous page) |
limit | integer | Results per page (default: 50, max: 100) |
Cursor-paginated by activity id ascending; returns the { "data": [...], "has_more": bool } envelope. A full audit pull uses the async export job.
After a True Match
Screening finds the match. The alert workflow documents your decision. But what happens next? When your institution confirms a sanctions match, OFAC requires specific actions within specific deadlines. The disposition you choose determines your reporting obligation under 31 CFR Part 501.
| Disposition | OFAC Obligation | Deadline | Filed With |
|---|---|---|---|
blocked |
File a blocking report. Property or transactions must be frozen and reported. | 10 business days | OFAC Compliance Division |
rejected |
File a reject report. Wires, ACH, and trade transactions that were stopped. | 10 business days | OFAC Compliance Division |
permitted_gl |
No filing required, but maintain documentation of which General License applies and why. Cite the specific GL in the gl_citation field. |
N/A — document immediately | Internal records |
no_action_required |
No filing required. Match confirmed but no reportable transaction or property involved. | N/A — document immediately | Internal records |
Noble Sight stores the disposition, reason code, analyst attribution, and timestamp for every resolution. This is the evidence your compliance team will present during an examination.
GET /v1/alerts/{id}/ofac-report
For blocked and rejected dispositions, this endpoint assembles an OFAC-ready report — the screened name, SDN match details, sanctions programs, OFAC list version, and resolution audit trail in a single response. Only available on alerts closed as closed_true_match.
curl "https://noblesight.io/v1/alerts/42/ofac-report" \ -H "X-API-Key: $NOBLE_API_KEY"
Response:
{
"alert_id": 42,
"report_type": "blocking",
"generated_at": "2026-03-10T14:30:00Z",
"screened_name": "Vladimir Putin",
"sdn_match": {
"uid": "36095",
"first_name": "Vladimir",
"last_name": "PUTIN",
"programs": ["RUSSIA-EO14024"],
"score": 97.5,
"match_sources": [{"source": "ofac_sdn", "trigram_score": 100, "soundex_match": true}]
},
"screening": {
"screened_at": "2026-03-09T10:15:00Z",
"ofac_publish_id": "1234",
"ofac_publish_date": "2026-03-08",
"trace_id": "onboarding-customer-42"
},
"resolution": {
"status": "closed_true_match",
"disposition": "blocked",
"reason": "true_match",
"resolved_by": "analyst@yourcompany.com",
"resolved_at": "2026-03-10T14:00:00Z",
"note": "Transaction blocked per OFAC regulations."
},
"institution_fields": {
"transaction_description": null,
"transaction_value": null,
"instructions": "Complete transaction_description and transaction_value before filing with OFAC. Blocking reports must be filed within 10 business days (31 CFR 501.603)."
}
}
Returns 409 Conflict if the alert is not resolved as a true match with a blocked or rejected disposition. The institution_fields section contains null values your team must fill in — Noble Sight provides the sanctions match evidence, your institution provides the transaction context.
What OFAC Needs vs. What the Report Provides
| OFAC Requires | Report Field | Source |
|---|---|---|
| Name of blocked/rejected party | screened_name |
Noble Sight |
| SDN list entry matched | sdn_match.uid, sdn_match.first_name, sdn_match.last_name |
Noble Sight |
| Sanctions program | sdn_match.programs |
Noble Sight |
| Date of action | screening.screened_at |
Noble Sight |
| OFAC list version | screening.ofac_publish_id, screening.ofac_publish_date |
Noble Sight |
| Description of blocked property or rejected transaction | institution_fields.transaction_description |
Your institution |
| Value of blocked property or rejected transaction | institution_fields.transaction_value |
Your institution |
How to Contact OFAC
Different situations require different OFAC channels. Use the right one.
| Situation | OFAC Channel | Contact |
|---|---|---|
| "Is this a real match?" — questions about potential hits, list interpretation, or screening methodology | Compliance Hotline | ofac.treasury.gov/ofac-compliance-hotline |
| Filing a blocking or reject report | OFAC Compliance Division | ofac.treasury.gov/contact-ofac |
| Requesting a license to proceed with a blocked transaction | Licensing Hotline | ofac.treasury.gov/ofac-license-application-page |
| Reporting a potential violation your institution discovered | Voluntary Self-Disclosure Portal | ofac.treasury.gov/disclosure |
| Issues downloading or accessing OFAC sanctions lists | Technical Support | 1-800-540-6322 or O_F_A_C@treasury.gov |
Voluntary Self-Disclosure
If your institution discovers it processed a transaction involving a sanctioned party — even if the screening system flagged it correctly but the alert was mishandled — OFAC's Voluntary Self-Disclosure program provides significant penalty mitigation. Under OFAC's Enforcement Guidelines, voluntary self-disclosure is treated as a mitigating factor and can reduce civil monetary penalties by up to 50%.
Noble Sight's immutable audit trail — the full screening response, alert lifecycle, analyst attribution, and resolution — provides the documentary evidence your legal team needs to prepare a self-disclosure. Every decision is traceable. Nothing is reconstructed after the fact.
SDN List Removal Petitions
If your customer believes they have been incorrectly placed on the SDN list, OFAC accepts petitions for removal. This is a formal legal process — not a customer service request. Petitions must demonstrate that the listing criteria no longer apply or were applied in error.
Noble Sight's screening history can support a petition by documenting the entity's match history, score trends over time, and any secondary attribute mismatches that were flagged.
Dispositions
When closing an alert as a true match, you must record a disposition — the action your institution took. This maps to OFAC reporting obligations (31 C.F.R. Part 501).
| Disposition | Meaning | OFAC Reporting |
|---|---|---|
blocked |
Transaction or property blocked | Blocking report required within 10 business days |
rejected |
Transaction rejected (wire, ACH, trade) | Reject report required within 10 business days |
permitted_gl |
Permitted under a General License | Must cite the specific GL (e.g., "GL-2023-001") |
no_action_required |
Match confirmed but no reportable action | Maintain documentation |
When disposition is permitted_gl, the gl_citation field is required — documenting which General License authorizes the activity.
Four-Eyes Rule
Alerts scoring 90 or above require two different people to close. The analyst who last changed the status cannot be the one who resolves it. No single person can both investigate and dismiss a high-confidence match — the system enforces this at the API level.
Reason Codes
Structured reason codes replace free-text dismissals. Examiners can query and aggregate dismissal patterns across your entire alert history — showing that your compliance process is systematic, not ad hoc.
| Code | Meaning |
|---|---|
false_positive_name_only | Name similarity only, no corroborating data |
false_positive_dob_mismatch | Date of birth does not match |
false_positive_country_mismatch | Country or nationality does not match |
false_positive_entity_type_mismatch | Entity type mismatch (e.g., individual vs. vessel) |
false_positive_other | Other reason (include details in note) |
true_match | Confirmed sanctions match |
Webhooks
Screening finds matches. Alerts track decisions. Webhooks deliver them to you in real time. Register an HTTPS endpoint, and Noble Sight POSTs every new alert to it — signed with HMAC-SHA256 so you can verify authenticity. Retry delivery handles transient failures. Standard tier and above.
POST /v1/webhooks
Register a webhook URL to receive alert notifications. Provide a shared secret (minimum 32 characters) for HMAC-SHA256 signature verification. The secret is encrypted at rest with AES-256-GCM — Noble Sight never stores it in plaintext.
curl -X POST https://noblesight.io/v1/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"url": "https://your-app.com/webhooks/noble",
"secret": "your-shared-secret-minimum-32-characters-long"
}'
Response:
{
"id": 1,
"url": "https://your-app.com/***",
"status": "active",
"created_at": "2026-03-23T10:00:00Z"
}
URLs are masked in all responses after creation — only the host is shown. Maximum 5 active webhooks per tenant.
GET /v1/webhooks
List active webhooks for your tenant.
curl https://noblesight.io/v1/webhooks \ -H "X-API-Key: $NOBLE_API_KEY"
Response:
{
"data": [{
"id": 1,
"url": "https://your-app.com/***",
"status": "active",
"created_at": "2026-03-23T10:00:00Z"
}],
"has_more": false
}
DELETE /v1/webhooks
Disable a webhook. Soft delete — the record is preserved for audit trail, but no further deliveries are attempted.
curl -X DELETE "https://noblesight.io/v1/webhooks?id=1" \ -H "X-API-Key: $NOBLE_API_KEY"
Returns 204 No Content on success.
Event Payload
When an alert is created — from a screening, batch job, or portfolio monitoring hit — Noble Sight POSTs a JSON event to every active webhook for your tenant.
{
"id": "evt_a1b2c3d4e5f6g7h8",
"type": "alert.created",
"created_at": "2026-03-23T10:05:00Z",
"data": {
"alert_id": 42,
"screened_name": "Vladimir Putin",
"score": 97.5,
"source": "api",
"sdn_uid": "36095"
}
}
Signature Verification
Every webhook delivery includes two headers: X-Noble-Signature (HMAC-SHA256) and X-Noble-Timestamp (Unix seconds). The signature covers timestamp + "." + body. Verify the signature, then reject deliveries where the timestamp is more than 5 minutes old to prevent replay attacks.
# Verify in your webhook handler:
# 1. Read X-Noble-Timestamp and the raw request body
# 2. Compute HMAC-SHA256 over timestamp + "." + body with your secret
# 3. Compare against the X-Noble-Signature header (constant-time)
# 4. Reject if timestamp is more than 5 minutes old (replay protection)
#
# Header format: sha256=<hex-encoded HMAC>
#
# Example (Go):
# timestamp := r.Header.Get("X-Noble-Timestamp")
# mac := hmac.New(sha256.New, []byte(secret))
# mac.Write([]byte(timestamp))
# mac.Write([]byte("."))
# mac.Write(body)
# expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
# if !hmac.Equal([]byte(expected), []byte(header)) { reject }
# ts, _ := strconv.ParseInt(timestamp, 10, 64)
# if time.Since(time.Unix(ts, 0)) > 5*time.Minute { reject }
Always use constant-time comparison (hmac.Equal in Go, crypto.timingSafeEqual in Node.js) to prevent timing attacks. The 5-minute window accommodates clock skew and retry backoff while stopping replay attacks.
Delivery and Retry
| Aspect | Detail |
|---|---|
| Attempts | Up to 4 attempts (initial + 3 retries) with exponential backoff: 1s, 5s, 25s. |
| Success | Any 2xx response. The delivery is marked delivered and no further retries occur. |
| Failure | Non-2xx responses or network errors trigger retries. After all attempts are exhausted, the delivery is marked failed. |
| Timeout | 10 seconds per attempt. Your endpoint must respond within this window. |
| Redirects | Not followed. Webhook URLs must resolve directly — this prevents SSRF via redirect chains. |
| Audit trail | Every delivery attempt is recorded: status, HTTP response code, attempt count, and error message. Available for compliance review. |
| URL validation | HTTPS required. Private IPs, loopback addresses, and localhost are blocked to prevent SSRF. |
Webhook delivery is notification only. If delivery fails after all retries, the alert still exists in your alert queue and is still visible via GET /v1/alerts. A webhook failure never suppresses, delays, or alters an alert. This matters for examination readiness: your compliance team can always demonstrate that every alert was created, recorded, and available for review regardless of notification channel status.
Webhooks vs. Polling
Two ways to consume alerts. Use whichever fits your architecture — or both.
| Approach | How | Best For |
|---|---|---|
| Webhooks | Register a URL. Noble Sight POSTs every alert to you in real time. | Real-time processing. Event-driven architectures. Minimal latency between alert creation and your team seeing it. |
| Polling | Call GET /v1/alerts?after=<last_id> on a schedule. Store the last-seen ID. |
Simpler infrastructure. No public endpoint needed. Firewall-friendly. Batch processing workflows. |
Portfolio
Add your customers to a monitored portfolio. When sanctions lists change, Noble Sight automatically re-screens them using reverse screening — only the changed sanctions entries are checked against your portfolio, not the other way around. Fast, targeted, always current. Add-on for any paid tier.
GET /v1/portfolio
List all active entities in your monitored portfolio.
curl "https://noblesight.io/v1/portfolio?limit=25" \ -H "X-API-Key: $NOBLE_API_KEY"
Cursor-paginated by id ascending. When has_more is true, request the next page with ?after=<id of the last entity in data>.
Response:
{
"data": [
{
"id": 101,
"full_name": "Dmitry Medvedev",
"country": "RU",
"entity_type": "individual",
"highest_score": 94.2,
"average_score": 87.1,
"last_screened_at": "2026-03-09T12:00:00Z",
"created_at": "2026-01-15T09:30:00Z"
},
{
"id": 102,
"full_name": "Acme Trading LLC",
"entity_type": "entity",
"highest_score": 0,
"average_score": 0,
"last_screened_at": "2026-03-09T12:00:00Z",
"created_at": "2026-02-01T14:00:00Z"
}
],
"has_more": false
}
POST /v1/portfolio
Add an entity to your monitored portfolio. It is screened immediately against current sanctions lists, then re-screened automatically whenever lists change.
curl -X POST https://noblesight.io/v1/portfolio \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "Dmitry Medvedev",
"date_of_birth": "1965-09-14",
"country": "RU"
}'
PUT /v1/portfolio
Update an existing portfolio entity. Use this when customer data changes (e.g., corrected name spelling or updated country).
curl -X PUT https://noblesight.io/v1/portfolio \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "Dmitrii Medvedev",
"date_of_birth": "1965-09-14",
"country": "RU"
}'
DELETE /v1/portfolio
Remove an entity from monitoring. It will no longer be re-screened when sanctions lists update. Historical screening records are retained for audit — nothing is deleted.
curl -X DELETE "https://noblesight.io/v1/portfolio?full_name=Dmitry+Medvedev" \ -H "X-API-Key: $NOBLE_API_KEY"
How Reverse Screening Works
| Aspect | Detail |
|---|---|
| Trigger | OFAC publishes a new list version. Noble detects the delta (added, modified, or removed entries). |
| Direction | Changed SDN names are screened against all active portfolio entities. Removed entries don't trigger screening. |
| Matching | Same trigram + Soundex engine as standard screening. |
| Alerts | Matches generate alerts with score, match details, SDN UID, and the publish ID of the list version that triggered it. |
| Dedup | Entities are deduplicated by client_id + full_name (case-insensitive). |
Pricing: $0.50/entity/mo (up to 10K), $0.35 (10K-50K), $0.20 (50K+).
Watchlist
Government sanctions lists are public. The risks your institution has already identified are not. Every bank maintains internal lists — names from filed SARs, de-risked customers, law enforcement inquiries, and compliance investigations. These lists represent hard-won institutional knowledge that no government authority publishes. Noble Sight screens against them automatically, alongside OFAC, UK, and other government lists, in the same API call. Standard tier and above.
Two Kinds of Lists
Noble Sight screens against two categories of data in every request:
| Category | What It Is | Who Maintains It |
|---|---|---|
| Government lists | Public sanctions and regulatory designations — OFAC SDN, OFAC Consolidated, UK Sanctions, EU Sanctions, FinCEN 311 Special Measures, UN Consolidated List. Published by government authorities, machine-readable, updated continuously. | Noble Sight ingests these automatically. You don't upload anything — we watch the source, detect changes, and delta-import within minutes. |
| Internal watchlists | Your institution's own intelligence — SAR subjects, exited customers, 314(a) names, law enforcement inquiries, adverse media flags, correspondent risk. Confidential by nature, institution-specific, not available from any public source. | You manage these via the /v1/watchlist API. Noble Sight screens against them with the same matching engine used for government lists — trigram, phonetic, and AI-powered matching. |
Why Internal Watchlists Matter
A sanctions list tells you who the government has designated. An internal watchlist tells you who your institution has flagged — and that intelligence is often more actionable than a public list.
Consider: a customer was exited two years ago for structuring. They apply for a new account under a slightly different name. OFAC screening returns nothing — they're not sanctioned. But your internal watchlist catches the match because the name is still there, with the reason and case number attached. Without that list in your screening pipeline, the onboarding proceeds and the risk walks back in.
The FFIEC BSA/AML Examination Manual expects institutions to maintain systems that identify and monitor suspicious activity on an ongoing basis. Examiners will ask how you screen new customers against your own prior findings — not just government lists. A watchlist that lives in a spreadsheet or a separate system that nobody queries during onboarding is a control gap. A watchlist that is checked on every screening call, with the same fuzzy matching engine, is a control.
What Goes on an Internal Watchlist
These are sources of risk intelligence that are confidential, institution-specific, and cannot be published or aggregated by any third party. The only way to screen against them is for your institution to maintain them.
| Source | What It Is | Why It Matters |
|---|---|---|
| SAR Subjects | Individuals and entities named in your institution's Suspicious Activity Reports — filings submitted to FinCEN when you detect potential money laundering, fraud, terrorist financing, or other criminal activity. U.S. institutions file over 4 million SARs per year. These names are your own intelligence about suspicious behavior, and SAR confidentiality rules (31 USC 5318(g)(2)) prohibit sharing them outside your institution. | If you filed a SAR on someone, you should know if they — or someone with a similar name — attempt to open a new account or transact again. Most banks track SAR subjects in spreadsheets or siloed case management systems. Putting them in a watchlist that is screened automatically closes that gap. |
| Exited Customers | Customers whose accounts were closed due to suspicious activity, compliance violations, or risk appetite decisions. De-risking is a significant action — the decision was made for a reason. | Exited customers sometimes return under variant names, through different branches, or through correspondent relationships. If the original exit decision was risk-based, the risk hasn't changed because the account was closed. |
| FinCEN 314(a) Names | Section 314(a) of the USA PATRIOT Act authorizes FinCEN to send financial institutions lists of names on behalf of federal law enforcement agencies investigating money laundering and terrorism. Institutions must search their records and report matches within 14 days. These requests arrive roughly every two weeks and contain names of active investigative targets. 314(a) data is confidential — institutions cannot share or redistribute it. | 314(a) names are time-sensitive law enforcement intelligence. Adding them to your watchlist ensures they are screened not just once during the initial search, but on every future screening call — catching the target if they attempt new activity at your institution after the initial 314(a) search window closes. |
| Law Enforcement Inquiries | Names from subpoenas, grand jury requests, National Security Letters, or informal law enforcement inquiries. These indicate that a person or entity is under active investigation, even if they have not been charged. | An active investigation means elevated risk. Screening future transactions against these names provides early warning if the subject re-engages with your institution. |
| Adverse Media | Entities flagged through your adverse media monitoring program — news reports of fraud, corruption, money laundering, or other financial crime. A person may not appear on any government list but may be widely reported as under investigation or indictment. No single authoritative public list exists for adverse media — it depends on your institution's monitoring sources and risk criteria. | Adverse media often leads sanctions designations by months or years. Adding flagged names to your watchlist provides early screening coverage before the government acts. |
| Correspondent Risk | Financial institutions in high-risk jurisdictions, under regulatory action, or flagged by your correspondent banking due diligence process. | Screening wire originators and beneficiaries against your correspondent risk list catches transactions routed through institutions you've already identified as elevated risk. |
POST /v1/watchlist
Add an entry to your internal watchlist. The name is available for screening immediately. Phonetic codes are computed at insert time for fast fuzzy matching.
curl -X POST https://noblesight.io/v1/watchlist \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{
"full_name": "John Doe",
"list_name": "SAR Subjects",
"reason": "SAR filed 2026-03-01, case #2026-0042",
"entity_type": "individual",
"country": "US"
}'
Response:
{
"id": 17,
"full_name": "John Doe",
"list_name": "SAR Subjects",
"reason": "SAR filed 2026-03-01, case #2026-0042",
"entity_type": "individual",
"country": "US",
"added_by": "apikey:royal_bank",
"created_at": "2026-03-16T19:00:00Z",
"status": "active"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
full_name | string | Yes | Name to screen against (max 500 chars) |
list_name | string | No | Category label (default: "default"). Use it to organize entries by source: "SAR Subjects", "Exited Customers", "314a Names", etc. |
reason | string | No | Why this entity is on the list. Strongly recommended — this is what an examiner will ask about. |
id_number | string | No | Government identifier (passport, SSN, tax ID) |
id_type | string | No | Type of identifier (e.g., "Passport", "SSN") |
country | string | No | Country code (ISO 3166-1 alpha-2) |
entity_type | string | No | "individual" or "entity" (default: "individual") |
expires_at | string | No | RFC 3339 expiration date. Entry stops matching after this date. Useful for time-limited law enforcement requests. |
GET /v1/watchlist
List your watchlist entries. Filter by list category or status. Paginated.
curl "https://noblesight.io/v1/watchlist?list_name=SAR+Subjects&limit=25" \ -H "X-API-Key: $NOBLE_API_KEY"
Response:
{
"data": [
{
"id": 17,
"full_name": "John Doe",
"list_name": "SAR Subjects",
"reason": "SAR filed 2026-03-01, case #2026-0042",
"entity_type": "individual",
"country": "US",
"added_by": "apikey:royal_bank",
"created_at": "2026-03-16T19:00:00Z",
"status": "active"
}
],
"has_more": false
}
Query Parameters
| Parameter | Default | Description |
|---|---|---|
list_name | all lists | Filter by list category |
status | active | Filter by status: active or removed |
after | 0 | Cursor: return entries with id greater than this (pass the id of the last entry in the previous page's data) |
limit | 50 | Results per page (max 100) |
DELETE /v1/watchlist
Remove a watchlist entry. This is a soft delete — the entry is marked removed and excluded from future screening, but the record is preserved for audit trail. Nothing is ever hard-deleted.
curl -X DELETE "https://noblesight.io/v1/watchlist?id=17" \ -H "X-API-Key: $NOBLE_API_KEY"
Returns 204 No Content on success. Returns 404 if the entry doesn't exist or belongs to a different tenant.
How Watchlist Screening Works
| Aspect | Detail |
|---|---|
| Automatic | Every POST /v1/screen request checks your watchlist alongside all active government lists. No opt-in flag, no separate API call. One request screens everything. |
| Matching | Same trigram + Soundex engine as sanctions screening. A slightly misspelled name on your watchlist still matches — because the same person with a typo is still the same risk. |
| Scoring | Same formula as sanctions matches. An exact watchlist hit scores 100. Partial matches score proportionally. |
| Source | Watchlist matches appear with source: "Internal Watchlist" and match_sources[].source: "internal_watchlist". The list name and reason are included in the match remarks. |
| Expiration | Entries with an expires_at date are automatically excluded from screening after that date. No manual cleanup needed. |
| Tenant isolation | Each API key screens against its own watchlist only. Your entries are never visible to other tenants. |
| Alerts | Watchlist matches generate alerts through the same workflow as sanctions matches — same state machine, same four-eyes rule, same audit trail. |
Audit
Every screening decision is stored immutably — the full request, full response, list version, and trace ID. No record is ever modified or deleted. Designed for OFAC's 10-year recordkeeping requirement and SOC 2 audit readiness.
What Gets Stored
Every call to /v1/screen creates an immutable record. Pass your own trace ID via the X-Trace-ID header to link screening decisions to your internal workflows:
curl -X POST https://noblesight.io/v1/screen \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-H "X-Trace-ID: onboarding-customer-42" \
-d '{"name": "Test Name"}'
| Field | Description |
|---|---|
trace_id |
Your X-Trace-ID header value, or auto-generated UUID |
request |
Full screening request (name, DOB, country, deep_screen flag) |
response |
Complete match results with scores and match sources |
ofac_publish_id |
The exact OFAC SDN list version used for this screening |
screened_at |
UTC timestamp of the screening decision |
GET /v1/export
Export a single screening decision by trace ID. Standard tier and above.
curl "https://noblesight.io/v1/export?trace_id=onboarding-customer-42" \ -H "X-API-Key: $NOBLE_API_KEY"
GET /v1/export/bulk
Bulk export screening decisions by date range for regulatory audits. Requires from and to (YYYY-MM-DD; to inclusive).
curl "https://noblesight.io/v1/export/bulk?from=2026-01-01&to=2026-03-01&limit=100" \ -H "X-API-Key: $NOBLE_API_KEY"
Cursor-paginated by record id ascending; returns the { "data": [...], "has_more": bool } envelope. When has_more is true, pass ?after=<id of the last record in data>. A full audit pull uses the async export job.
OFAC List Version Tracking
Every screening result records which OFAC list version was active at the time of the screen. When an examiner asks "what list were you screening against on March 1?" — the answer is in the audit record, not a reconstruction.
Webhook Delivery Records
Every webhook delivery attempt is recorded with status, HTTP response code, attempt count, error message, and timestamps. These records are part of the audit trail — when an examiner asks "how was your compliance team notified of this alert?", the delivery log provides the answer: which endpoint received the notification, when it was delivered, and whether retries were needed.
Pricing
Screen any name against 18,598+ sanctioned entities. Every tier includes explainable scores and immutable audit trails. Pay only when you need more volume or deeper analysis.
| Feature | Free | Standard | Premium | Enterprise |
|---|---|---|---|---|
| Price | $0/mo | $199/mo | $499/mo | Custom |
| Screenings | 100/day | 5,000/mo | 25,000/mo | 100,000+/mo |
| Sanctions screening | Yes | Yes | Yes | Yes |
| Explainable scores | Yes | Yes | Yes | Yes |
| Match source breakdown | Yes | Yes | Yes | Yes |
| Alert management | Yes | Yes | Yes | Yes |
| Audit trail persistence | Yes | Yes | Yes | Yes |
| Deep screen (AI expansion) | — | Yes | Yes | Yes |
| Batch screening | — | Up to 1,000 | Up to 5,000 | Up to 10,000 |
| Webhook notifications | — | Yes | Yes | Yes |
| Compliance exports | — | Yes | Yes | Yes |
| Portfolio monitoring | — | — | Yes | Yes |
| Dedicated support | — | — | — | Yes |
| Custom integration | — | — | — | Yes |
What every tier includes
Three layers of matching across 10 sanctions lists: character similarity, phonetic matching, and 89,117 pre-computed AI name variations. Secondary attribute scoring — date of birth and country — suppresses false positives automatically. Every result includes the score, the match sources, the dismissal reasons, and the list version. Persisted for 10 years.
Most vendors charge thousands per month for less transparency than the free tier provides.
Standard — $199/mo
5,000 screenings per month. Batch screening up to 1,000 names per job. Deep screen sends your search input through real-time Gemini AI expansion — catching transliterations and cultural variants that pre-computed variations miss. Webhook notifications deliver alerts to your endpoint in real time with HMAC-SHA256 signed payloads. Compliance exports let you pull screening decisions by trace ID or date range for regulatory audits.
Premium — $499/mo
25,000 screenings per month. Batch screening up to 5,000 names per job. Everything in Standard, plus portfolio monitoring — register customer names and get automatically re-screened when sanctions lists update. When OFAC adds a new SDN entry, you know within minutes if any of your customers match. Not days. Minutes.
Enterprise
100,000+ screenings per month. Batch screening up to 10,000 names per job. Dedicated support, custom integration, and volume pricing. Contact sales@noblesight.io.
Upgrade via API
No sales calls required for Standard or Premium. One API call creates a Stripe Checkout session. See Account for the endpoint.
Account
View your tier, usage, and billing status. Upgrade from free to a paid plan without contacting sales.
GET /v1/account
Returns your current tier, usage within the rate limit window, billing status, and which features are available. One call instead of guessing.
curl https://noblesight.io/v1/account \ -H "X-API-Key: $NOBLE_API_KEY"
{
"client_id": "acme-bank",
"key_prefix": "noble_live_a1b2",
"tier": "free",
"billing_status": "none",
"usage": {
"used": 42,
"limit": 100,
"remaining": 58,
"window": "day"
},
"features": {
"screening": true,
"deep_screen": false,
"batch_screening": false,
"portfolio_monitoring": false,
"compliance_exports": false,
"webhook_notifications": false,
"alert_management": true,
"max_batch_size": 0
}
}
POST /v1/account/checkout
Create a Stripe Checkout session to upgrade your API key to a paid tier. Returns a URL — redirect your user or open it in a browser.
curl -X POST https://noblesight.io/v1/account/checkout \
-H "Content-Type: application/json" \
-H "X-API-Key: $NOBLE_API_KEY" \
-d '{"tier": "standard"}'
{
"checkout_url": "https://checkout.stripe.com/c/pay/..."
}
On successful payment, your API key tier is upgraded automatically. No downtime, no key rotation. See Pricing for tier details.
Support
Integration help, compliance questions, or paid tier inquiries. We respond within one business day.
support@noblesight.io · sales@noblesight.io for paid tiers · Bluesky
System Status
Monitor Noble Sight's availability programmatically. No authentication required.
curl https://noblesight.io/status | jq
Returns 200 when operational, 503 during outages. Includes database latency and component health. Point your uptime monitor here.