Reference

Risk lists

Tenant-scoped phone / email / identifier block lists — full CRUD, 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.

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"
    }
  ]
}
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.

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.
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"
  }
}

Errors

StatusBodyWhen
400{ "error": "name is required." }Empty name.
400{ "error": "type must be phone, email, or identifier." }Invalid type.
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, and is_active 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": { … } }.

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.
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.

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.

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." }.

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.

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.

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": { … } }.

Errors

StatusBodyWhen
400{ "error": "value cannot be blank." }value present but normalizes to empty.
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)
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"
}

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 98123456789779812345678, 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