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.

TypeNormalization"+977 9812-345678" becomes
phoneDigits only9779812345678
emailTrimmed + lowercased—
identifierTrimmed (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 is floor((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 own threshold — else the list's similarity_threshold (a whole number 50–100, 85 when 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.

FieldWhereRules
match_modelistexact or fuzzy.
similarity_thresholdlistFuzzy lists: a whole number 50–100. null on exact lists; sending one with match_mode: exact is a 400.
thresholdentryFuzzy 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/
typestringquery
Filter by list type: phone, email, or identifier. An unknown value returns 400.
activebooleanquery
Filter by active state. Truthy values: 1, 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
    }
  ]
}
idstring
List UUID.
typestring
phone, email, or identifier. Immutable after creation.
is_activeboolean
Whether flows evaluate this list.
entry_countinteger
Number of entries currently in the list.
match_modestring
exact or fuzzy — see Matching.
similarity_thresholdinteger | null
Fuzzy lists: the similarity % (50–100) a value must reach. null on exact lists.

Errors

StatusBodyWhen
400{ "error": "type must be phone, email, or identifier." }Unknown type filter.
401auth errorMissing or invalid bearer token.

Create a list

POST /api/v1/lists/
namestringbodyrequired
Display name. Must be unique per tenant.
typestringbodyrequired
One of phone, email, identifier. Defaults to phone if omitted. Immutable after creation.
descriptionstringbody
Free-text source note.
is_activebooleanbodyDefault: true
Whether flows evaluate this list.
match_modestringbodyDefault: exact
exact or fuzzy.
similarity_thresholdintegerbody
Fuzzy lists only: a whole number 50–100 (default 85). 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

StatusBodyWhen
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

StatusBodyWhen
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
Page number (clamped to ≥ 1).
page_sizeintegerqueryDefault: 100
Items per page, clamped to 1..500.
qstringquery
Substring filter over the entry value 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"
    }
  ]
}
totalinteger
Total entries matching the filter (across all pages).
entriesarray
Entry rows for this page, newest first.
valuestring
The normalized value stored on write.
thresholdinteger | null
Fuzzy lists only (absent on exact lists): the entry's own similarity %, or null 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 | nullbody
Fuzzy lists only: the entry's similarity % — a whole number 50–100 that overrides the list threshold; null/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": []
}
createdinteger
Rows actually inserted.
skippedinteger
Rows dropped as duplicates of existing/other-batch values.
invalidinteger
Rows that failed validation.
errorsarray
Up to 20 { "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
}
deletedinteger
Entries removed before the replace.
createdinteger
Entries inserted from the payload (after de-duplication).

A 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 payloadStored threshold
omittedThe 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 / blankNone — the list's similarity_threshold applies.
a whole number 50–100That number.
anything elseThe 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

StatusBodyWhen
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 POSTPUT (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 untouchedOmitted → kept per value; null → list threshold
Best forIncremental addsFull 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.

FAQ