This tutorial walks through building a production-ready OFAC screening workflow in Python using the Tech Compass API. You will end up with working code that screens names against the current OFAC Specially Designated Nationals list, handles confidence levels correctly, retrieves full entity detail on a match, and stores the audit record reference alongside your own data.
The API handles the SDN list synchronisation, fuzzy name matching, and immutable audit logging. Your code handles the decision logic and integration with your onboarding or transaction system.
What you are building
The workflow makes three types of API call:
- Check the current SDN list version via
GET /v1/us/sanctions/ofac/list-version(no auth required, free to call). - Screen a name via
GET /v1/us/sanctions/ofac/screen(authenticated, metered). - Fetch full SDN entity detail via
GET /v1/us/sanctions/ofac/entity/{uid}when a match is returned.
A sensible production pattern is to poll the list-version endpoint daily and only trigger re-screening of your existing customer base when the sdn_list_date changes. This avoids unnecessary spend while ensuring your records reflect the current list.
Prerequisites
You need Python 3.8 or later and the requests library:
pip install requests
You also need a Tech Compass API key. Sign up at api.techcompass.com.au/signup.html to get one. OFAC screening is available on the Professional plan. Substitute your key wherever you see tc_live_YOUR_KEY in the examples below.
Step 1: Check the list version
Before screening, check whether the SDN list has been updated since you last ran. The /list-version endpoint is unauthenticated, so it costs nothing to call. Poll it daily and store the returned sdn_list_date. If the date has not changed, there is no need to re-screen your existing population.
import requests
BASE_URL = "https://api.techcompass.com.au"
def get_list_version():
"""
Returns the current SDN list date and record count.
No authentication required.
"""
resp = requests.get(f"{BASE_URL}/v1/us/sanctions/ofac/list-version", timeout=10)
resp.raise_for_status()
data = resp.json() # list-version returns a flat dict, no "data" wrapper
return {
"sdn_list_date": data["sdn_list_date"],
"record_count": data["record_count"],
"source": data["source"],
}
version = get_list_version()
print(f"SDN list date: {version['sdn_list_date']}")
print(f"Record count: {version['record_count']}")
Store the returned sdn_list_date in your database or cache. Compare it to the value from your last run. If they match, skip re-screening. If they differ, queue your customer population for a re-screen pass.
Step 2: Screen a name
The screen endpoint accepts a name parameter (minimum two characters) and an optional entity_type parameter. Supplying entity_type narrows the match scope and reduces false positives. Valid values are individual, entity, vessel, and aircraft.
API_KEY = "tc_live_YOUR_KEY"
HEADERS = {
"Authorization": f"ApiKey {API_KEY}",
}
def screen_name(name: str, entity_type: str = None) -> dict:
"""
Screen a name against the current OFAC SDN list.
Args:
name: The name to screen (min 2 characters).
entity_type: Optional. One of: individual, entity, vessel, aircraft.
Returns:
The full API response dict, including data, meta, and data_lineage.
"""
params = {"name": name}
if entity_type:
params["entity_type"] = entity_type
resp = requests.get(
f"{BASE_URL}/v1/us/sanctions/ofac/screen",
headers=HEADERS,
params=params,
timeout=10,
)
resp.raise_for_status()
return resp.json()
result = screen_name("John Smith", entity_type="individual")
data = result["data"]
print(f"Query: {data['query_name']}")
print(f"Total matches: {data['total_matches']}")
print(f"List date: {data['sdn_list_date']}")
print(f"Request ID: {result['meta']['request_id']}")
for match in data.get("matches", []):
print(f" Match: {match['name']} (uid={match['uid']})")
print(f" Confidence: {match['confidence']}")
print(f" Matched on: {match['matched_on']}")
print(f" Programs: {match['programs']}")
print(f" Type: {match['entity_type']}")
The response always includes meta.request_id, which is the handle for the immutable audit record created by this call. Store it. Even a zero-match result has an audit record, and that record is your evidence that the screen was performed.
Understanding confidence scores
The API returns a confidence field on each match. The four levels and their recommended handling are:
- exact: The name matches an SDN entry or listed alias exactly after normalisation. Block or escalate immediately. Do not proceed with onboarding or the transaction.
- high: Levenshtein distance of 1 or a common misspelling pattern. Escalate to a compliance officer for manual review before proceeding.
- medium: Partial name overlap or string similarity at or above 80%. Flag for enhanced due diligence. Collect additional identifying information before deciding.
- low: Weak similarity. Log the result and monitor. Low confidence matches on common names are typically false positives, but the record should be retained.
Your decision logic should act on confidence level, not just on whether total_matches is greater than zero. A result with ten low-confidence matches on a common name requires a different response than a single exact match.
Step 3: Fetch full entity detail on a match
When a screen returns a match at exact or high confidence, fetch the full SDN record using the uid from the match. The entity detail endpoint returns aliases, addresses, nationalities, dates of birth, place of birth, program designations, and any remarks attached to the entry.
def get_entity(uid: str) -> dict:
"""
Fetch the full SDN record for a designated entity.
Args:
uid: The SDN uid returned in a screen match.
Returns:
The full entity record dict.
"""
resp = requests.get(
f"{BASE_URL}/v1/us/sanctions/ofac/entity/{uid}",
headers=HEADERS,
timeout=10,
)
resp.raise_for_status()
return resp.json()["data"]
# Example: fetch detail for the first high-or-exact match
result = screen_name("Viktor Bout", entity_type="individual")
matches = result["data"].get("matches", [])
for match in matches:
if match["confidence"] in ("exact", "high"):
entity = get_entity(match["uid"])
print(f"Name: {entity['name']}")
print(f"Type: {entity['entity_type']}")
print(f"Programs: {entity['programs']}")
print(f"Aliases: {entity.get('aliases', [])}")
print(f"Nationalities: {entity.get('nationalities', [])}")
print(f"DOB: {entity.get('dates_of_birth', [])}")
print(f"Remarks: {entity.get('remarks', '')}")
break
The alias list is useful for disambiguation. If your customer's date of birth does not match any of the DOB entries on the SDN record, that is a strong indicator of a false positive. Document the comparison and your rationale in your case management system, along with the request_id.
Putting it together: a simple onboarding check
Here is a single function that runs the full check and returns a structured decision dict. Store the returned dict alongside your onboarding record.
def check_customer(name: str, entity_type: str = "individual") -> dict:
"""
Run an OFAC screen for a customer and return a decision dict.
The returned dict includes:
- request_id: link to the immutable audit record
- screened_name: the name as submitted
- sdn_list_date: the list version used
- total_matches: number of matches returned
- decision: CLEAR, REVIEW, or BLOCK
- confidence: highest confidence level found (or None)
- matches: list of match summaries
"""
result = screen_name(name, entity_type=entity_type)
data = result["data"]
request_id = result["meta"]["request_id"]
matches = data.get("matches", [])
# Determine the highest confidence level present
confidence_rank = {"exact": 4, "high": 3, "medium": 2, "low": 1}
top_confidence = None
top_rank = 0
for match in matches:
rank = confidence_rank.get(match["confidence"], 0)
if rank > top_rank:
top_rank = rank
top_confidence = match["confidence"]
# Map confidence to decision
if top_confidence in ("exact", "high"):
decision = "BLOCK"
elif top_confidence == "medium":
decision = "REVIEW"
elif top_confidence == "low":
decision = "MONITOR"
else:
decision = "CLEAR"
return {
"request_id": request_id,
"screened_name": data["query_name"],
"sdn_list_date": data["sdn_list_date"],
"total_matches": data["total_matches"],
"decision": decision,
"confidence": top_confidence,
"matches": [
{
"uid": m["uid"],
"name": m["name"],
"confidence": m["confidence"],
"matched_on": m["matched_on"],
"programs": m["programs"],
}
for m in matches
],
}
# Usage
check = check_customer("Jane Doe", entity_type="individual")
print(f"Decision: {check['decision']}")
print(f"Confidence: {check['confidence']}")
print(f"Request ID: {check['request_id']}")
# Store check['request_id'] in your database alongside the customer record
# Your onboarding table: UPDATE customers SET ofac_request_id = %s WHERE id = %s
The request_id is the critical piece to persist. It is the link between your customer record and the immutable audit log entry. If a regulator audits your AML process and asks whether a specific customer was screened and what the result was, the request_id is your answer.
Running against a batch
For an initial load or a re-screen after a list version change, loop over your customer list. Add a short sleep between calls to stay well within rate limits, and collect all results for bulk database insertion.
import time
# Example customer list: list of dicts with name and entity_type
customers = [
{"id": "cust_001", "name": "Acme Trading LLC", "entity_type": "entity"},
{"id": "cust_002", "name": "Mohammed Al-Rashid", "entity_type": "individual"},
{"id": "cust_003", "name": "Pacific Star Shipping", "entity_type": "vessel"},
]
results = []
for customer in customers:
try:
check = check_customer(customer["name"], customer["entity_type"])
results.append({
"customer_id": customer["id"],
"ofac_request_id": check["request_id"],
"decision": check["decision"],
"confidence": check["confidence"],
"sdn_list_date": check["sdn_list_date"],
"total_matches": check["total_matches"],
})
print(f"{customer['id']}: {check['decision']} (confidence={check['confidence']})")
except requests.HTTPError as exc:
print(f"{customer['id']}: API error {exc.response.status_code}")
time.sleep(0.2) # 200ms between calls
# results is now ready for bulk INSERT into your screening_log table
print(f"\nProcessed {len(results)} customers.")
For large populations, consider running the batch overnight and storing the sdn_list_date that was current at the time of the run. If the list updates mid-batch, your records will accurately reflect which version was used for each individual screen, because the API embeds the list date in every response.
What the audit log gives you
Every call to the screen endpoint creates an immutable audit record before the response is returned. This happens regardless of whether any matches are found. A zero-match result is still a completed screen, and the audit record proves it. The meta.request_id in the response is the identifier for that record. It ties your screening activity to a specific name, a specific list version, and a specific timestamp, all stored server-side and not modifiable after the fact.
For regulatory examination, this matters. AML and sanctions obligations do not just require that you screen. They require that you can demonstrate you screened, when you screened, what list version you used, and what the result was. Storing the request_id alongside each customer or transaction record gives your compliance team a traceable chain from customer onboarding decision back to the specific screen that supported it. The OFAC API reference documents the full response schema and the audit record fields.
Get your API key
OFAC screening is available on the Professional plan. Free tier covers 10,000 calls per month for other products. Sign up to try the API.
Get API Key View OFAC API Docs Full Product Details