Reference
Risk lists
Tenant-scoped phone / email / identifier block lists — full CRUD, Exact / Fuzzy matching, entry upsert / bulk add / replace-all, normalization rules, and errors.
Lists are tenant-scoped risk lists used by flows. A flow can match applicant
data against a list and route matches to manual_review or rejected. All
endpoints are on the partner surface (/api/v1) and authenticate with
Authorization: Bearer <tenant_api_key>.
Info
Unlike the /v3 surface, these endpoints wrap responses in { "ok": true, … }
and return errors as { "error": "message" }. Branch on the HTTP status first.
List types and normalization
Every list has one immutable type, and every value is normalized on write.
Duplicate detection and flow matching always use the normalized value, so
formatting differences collapse to the same entry.
| Type | Normalization | "+977 9812-345678" becomes |
|---|---|---|
phone | Digits only | 9779812345678 |
email | Trimmed + lowercased | — |
identifier | Trimmed (case preserved) | — |
Note
Because phone normalization strips everything but digits, "+977 9812-345678"
and "9779812345678" collide as the same entry. Store numbers consistently
(with or without country code) so your matches are predictable.
Matching: Exact or Fuzzy
Every list has a match_mode:
exact(the default, and what every list created before matching modes existed is) — only an identical value hits.fuzzy— a near-miss hits too. The score isfloor((1 − edit distance / length of the longer value) × 100), using the Damerau–Levenshtein distance (a swapped pair of adjacent characters counts as one edit), on values cleaned exactly the way exact matching cleans them. A score at or above the entry's ownthreshold— else the list'ssimilarity_threshold(a whole number 50–100,85when omitted) — is a hit, and the best-scoring entry per list wins.
A fuzzy score of 100 is an exact hit and behaves as one. Below 100 a hit never
declines: Blocklist, Internal Fraud (plus its fraud tag), Watchlist and Custom
lists send the applicant to manual review with the reason
Fuzzy match 90% on <list>; Allowlist and VIP near-misses have no effect.
Face Image lists (managed in the console) are always exact. A matching change
applies to verifications screened after it — stored results are never
re-checked.
| Field | Where | Rules |
|---|---|---|
match_mode | list | exact or fuzzy. |
similarity_threshold | list | Fuzzy lists: a whole number 50–100. null on exact lists; sending one with match_mode: exact is a 400. |
threshold | entry | Fuzzy lists only: the entry's own similarity % (50–100) overriding the list's; null = the list threshold. Absent from exact-list entries; sending one to an exact list is a 400. |
Lists
List lists
GET /api/v1/lists/typestringqueryphone, email, or identifier. An unknown value returns 400.activebooleanquery1, true, yes.curl -s "https://acme.thirdfactor.ai/api/v1/lists/?type=email&active=true" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY"const resp = await fetch(
"https://acme.thirdfactor.ai/api/v1/lists/?type=email&active=true",
{ headers: { Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}` } },
);
const { lists } = await resp.json();import requests
resp = requests.get(
"https://acme.thirdfactor.ai/api/v1/lists/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
params={"type": "email", "active": "true"},
)
lists = resp.json()["lists"]Response Example
{
"ok": true,
"lists": [
{
"id": "90f2c1a8-4f4e-4d1c-9b95-7dfda01965ba",
"name": "Sanctions",
"description": "Client-managed sanctions list",
"type": "email",
"is_active": true,
"entry_count": 1200,
"created_at": "2026-05-18T08:00:00Z",
"updated_at": "2026-05-18T08:00:00Z",
"match_mode": "exact",
"similarity_threshold": null
}
]
}idstringtypestringphone, email, or identifier. Immutable after creation.is_activebooleanentry_countintegermatch_modestringsimilarity_thresholdinteger | nullnull on exact lists.Errors
| Status | Body | When |
|---|---|---|
400 | { "error": "type must be phone, email, or identifier." } | Unknown type filter. |
401 | auth error | Missing or invalid bearer token. |
Create a list
POST /api/v1/lists/namestringbodyrequiredtypestringbodyrequiredphone, email, identifier. Defaults to phone if omitted. Immutable after creation.descriptionstringbodyis_activebooleanbodyDefault: truematch_modestringbodyDefault: exactexact or fuzzy.similarity_thresholdintegerbody85). Sending it with match_mode: exact is a 400.curl -X POST "https://acme.thirdfactor.ai/api/v1/lists/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Sanctions", "type": "identifier", "description": "Client-managed OCR identifier list", "is_active": true }'const resp = await fetch("https://acme.thirdfactor.ai/api/v1/lists/", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Sanctions",
type: "identifier",
description: "Client-managed OCR identifier list",
is_active: true,
}),
});
const { list } = await resp.json();resp = requests.post(
"https://acme.thirdfactor.ai/api/v1/lists/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={
"name": "Sanctions",
"type": "identifier",
"description": "Client-managed OCR identifier list",
"is_active": True,
},
)
new_list = resp.json()["list"]Response Example
{
"ok": true,
"list": {
"id": "90f2c1a8-4f4e-4d1c-9b95-7dfda01965ba",
"name": "Sanctions",
"description": "Client-managed OCR identifier list",
"type": "identifier",
"is_active": true,
"entry_count": 0,
"created_at": "2026-05-18T08:00:00Z",
"updated_at": "2026-05-18T08:00:00Z",
"match_mode": "exact",
"similarity_threshold": null
}
}To create a fuzzy list, add "match_mode": "fuzzy" (and optionally
"similarity_threshold": 90; it defaults to 85).
Errors
| Status | Body | When |
|---|---|---|
400 | { "error": "name is required." } | Empty name. |
400 | { "error": "type must be phone, email, or identifier." } | Invalid type. |
400 | { "error": "match_mode must be one of: exact, fuzzy." } | Unknown match_mode. |
400 | { "error": "similarity_threshold must be a whole number from 50 to 100." } | Out of range, a fraction, a boolean or text. |
400 | { "error": "similarity_threshold is only allowed when match_mode is fuzzy." } | A threshold on an exact list. |
409 | { "error": "A list named '…' already exists.", "id": "<id>" } | A list with that name already exists — the existing id is returned. |
Update list metadata
PATCH /api/v1/lists/<list_id>/Only name, description, is_active, match_mode and
similarity_threshold are mutable. type is not.
curl -X PATCH "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Sanctions v2", "description": "Updated source", "is_active": true }'resp = requests.patch(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"name": "Sanctions v2", "description": "Updated source", "is_active": True},
)Response echoes the full list object (same shape as create), wrapped in
{ "ok": true, "list": { … } }.
Matching follows the create rules: { "match_mode": "fuzzy" } without a
similarity_threshold keeps the list's threshold, else 85;
{ "similarity_threshold": 92 } alone re-thresholds a fuzzy list;
{ "match_mode": "exact" } clears the list threshold and every entry's
threshold. Face Image lists are exact only. Matching changes are recorded in
the console audit log (blocklist.updated, attributed to the API key) and
apply to verifications screened after the change — stored results are never
re-checked. An invalid body changes nothing.
Warning
type is immutable because entries are normalized by type. Create a new
list if the type must change — patching type is silently ignored.
Errors
| Status | Body | When |
|---|---|---|
400 | { "error": "name cannot be blank." } | name present but empty. |
400 | { "error": "match_mode must be one of: exact, fuzzy." } | Unknown match_mode. |
400 | { "error": "Face Image lists only support exact matching." } | fuzzy on a Face Image list. |
400 | { "error": "similarity_threshold must be a whole number from 50 to 100." } | Invalid threshold. |
400 | { "error": "similarity_threshold is only allowed when match_mode is fuzzy." } | A threshold on a list that is (or becomes) exact. |
404 | { "error": "Not found." } | Unknown list, or it belongs to another tenant. |
Delete a list
DELETE /api/v1/lists/<list_id>/Deletes the list and all its entries.
curl -X DELETE "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY"{ "ok": true }A 404 { "error": "Not found." } is returned if the id doesn't resolve for the
tenant.
Entries
List entries
GET /api/v1/lists/<list_id>/entries/pageintegerqueryDefault: 1≥ 1).page_sizeintegerqueryDefault: 1001..500.qstringqueryvalue and comment.curl -s "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/?page=1&page_size=100&q=9812" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY"resp = requests.get(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
params={"page": 1, "page_size": 100, "q": "9812"},
)
entries = resp.json()["entries"]Response Example
{
"ok": true,
"total": 1,
"page": 1,
"page_size": 100,
"entries": [
{
"id": "4a4b1e25-7d9b-4d5f-8c5c-f6718794c747",
"value": "9812345678",
"comment": "Confirmed fraud source",
"created_at": "2026-05-18T08:00:00Z"
}
]
}totalintegerentriesarrayvaluestringthresholdinteger | nullnull when the list's similarity_threshold applies.Upsert one entry
POST /api/v1/lists/<list_id>/entries/Send a single value (+ optional comment). The value is normalized, then
matched: 201 when created, 200 when it already existed and the comment was
updated.
thresholdinteger | nullbodynull/blank = the list threshold. On an existing value, a sent threshold replaces the entry's and an omitted one keeps it. Any value on an exact list is a 400.curl -X POST "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "value": "+977 9812-345678", "comment": "Confirmed fraud source" }'resp = requests.post(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"value": "+977 9812-345678", "comment": "Confirmed fraud source"},
)
# resp.status_code == 201 (created) or 200 (updated)Response Example
{
"ok": true,
"entry": {
"id": "4a4b1e25-7d9b-4d5f-8c5c-f6718794c747",
"value": "9779812345678",
"comment": "Confirmed fraud source",
"created_at": "2026-05-18T08:00:00Z"
}
}A missing/blank value returns 400 { "error": "value is required." }. A
threshold sent to an exact list returns
400 { "error": "threshold is only allowed on Fuzzy lists." }; an invalid one
400 { "error": "threshold must be a whole number from 50 to 100." }.
Bulk add entries
Send an entries array to the same POST endpoint. Idempotent — duplicate
values (by normalized form) are skipped, so re-running a batch is safe.
On a fuzzy list each entry may carry threshold; a row with an invalid one is
skipped and reported in errors (invalid counts it) while the valid rows are
added. Existing values are left untouched, threshold included. Any threshold
in a batch for an exact list fails the whole request with
400 { "error": "threshold is only allowed on Fuzzy lists." }.
curl -X POST "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "entries": [ { "value": "9812345678", "comment": "source A" }, { "value": "[email protected]", "comment": "source B" } ] }'const resp = await fetch(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
entries: [
{ value: "9812345678", comment: "source A" },
{ value: "[email protected]", comment: "source B" },
],
}),
},
);
const summary = await resp.json();resp = requests.post(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"entries": [
{"value": "9812345678", "comment": "source A"},
{"value": "[email protected]", "comment": "source B"},
]},
)
summary = resp.json()Response Example
{
"ok": true,
"created": 2,
"skipped": 0,
"invalid": 0,
"errors": []
}createdintegerskippedintegerinvalidintegererrorsarray{ "row": <index>, "error": "…" } diagnostics for invalid rows.Replace all entries
PUT /api/v1/lists/<list_id>/entries/Atomically deletes every existing entry and inserts the supplied set — an
exact mirror of the payload. If any row is invalid, nothing is changed and a
400 is returned.
curl -X PUT "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "entries": [ { "value": "1111111111", "comment": "latest export" }, { "value": "2222222222", "comment": "latest export" } ] }'resp = requests.put(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"entries": [
{"value": "1111111111", "comment": "latest export"},
{"value": "2222222222", "comment": "latest export"},
]},
)Response Example
{
"ok": true,
"replaced": true,
"deleted": 1200,
"created": 2
}deletedintegercreatedintegerA payload with any invalid row returns
400 { "error": "invalid_entries", "invalid": <n>, "errors": [ … ] } (up to 50
diagnostics) and leaves the list untouched.
Fuzzy lists keep per-entry thresholds across a replace. For each entry in the payload:
threshold in the payload | Stored threshold |
|---|---|
| omitted | The threshold the same value had before the replace (none for a new value) — a sync job that doesn't send thresholds never wipes them. |
null / blank | None — the list's similarity_threshold applies. |
| a whole number 50–100 | That number. |
| anything else | The request fails (invalid_entries) and nothing changes. |
Any threshold in a replace for an exact list returns
400 { "error": "threshold is only allowed on Fuzzy lists." } and nothing changes.
Tip
Use PUT for client-owned nightly sync jobs where Obsidian should exactly
mirror the upstream source — it deletes everything not in the new payload. Use
bulk POST for incremental additions that must never remove existing entries.
Update one entry
PATCH /api/v1/lists/<list_id>/entries/<entry_id>/curl -X PATCH "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/4a4b1e25-.../" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "value": "9812345678", "comment": "Updated note" }'resp = requests.patch(
"https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/4a4b1e25-.../",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"value": "9812345678", "comment": "Updated note"},
)Response is { "ok": true, "entry": { … } }.
On a fuzzy list, threshold sets the entry's similarity % (a whole number
50–100) and null resets it to the list threshold.
Errors
| Status | Body | When |
|---|---|---|
400 | { "error": "value cannot be blank." } | value present but normalizes to empty. |
400 | { "error": "threshold is only allowed on Fuzzy lists." } | A threshold value on an exact list. |
400 | { "error": "threshold must be a whole number from 50 to 100." } | Invalid threshold — nothing is changed. |
404 | { "error": "Not found." } | Unknown entry / list, or another tenant's. |
409 | { "error": "entry value already exists." } | The new value collides with an existing entry. |
Delete one entry
DELETE /api/v1/lists/<list_id>/entries/<entry_id>/curl -X DELETE "https://acme.thirdfactor.ai/api/v1/lists/90f2c1a8-.../entries/4a4b1e25-.../" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY"{ "ok": true }POST vs PUT for syncing
Bulk POST | PUT (replace all) | |
|---|---|---|
| Existing entries | ✅ Kept | ❌ Deleted first |
| Duplicates | ⚠️ Skipped (idempotent) | ⚠️ De-duplicated in payload |
| Partial-invalid payload | ⚠️ Valid rows still added, invalid counted | ❌ All-or-nothing (400, no change) |
Per-entry threshold (fuzzy lists) | Set on new values; existing values untouched | Omitted → kept per value; null → list threshold |
| Best for | Incremental adds | Full mirror / nightly sync |
Blocklist matches in webhooks
When a flow matches an applicant against a list, the delivered webhook carries a
blocklist_match object:
{
"blocklist_id": "90f2c1a8-4f4e-4d1c-9b95-7dfda01965ba",
"blocklist_name": "Sanctions",
"block_type": "identifier",
"value": "123456789",
"action": "manual_review",
"match_type": "exact",
"score": 100
}match_type is exact (score 100) or fuzzy — a near-miss on a fuzzy
list, whose score is the similarity % and whose action is always
manual_review, never reject.
The SDK runtime also exposes a minimal, non-revealing existence check —
POST /api/v1/lists/check returns { "ok": true, "matched": <bool>, "action": "reject" | "manual_review" | null } without disclosing which list matched.
Common pitfalls
Warning
Inconsistent phone formatting. Phone values are reduced to digits only, so
whether you store the country code matters: +977 9812345678 →
9779812345678, but a bare 9812345678 stays 9812345678. Pick one
convention across your imports and the applicant data your flow checks against,
or matches will silently miss.
Warning
Expecting PUT to merge. PUT is destructive — it deletes every entry not
in the payload. For additive updates use bulk POST.