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.
| 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.
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"
}
]
}idstringtypestringphone, email, or identifier. Immutable after creation.is_activebooleanentry_countintegerErrors
| 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: truecurl -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
| Status | Body | When |
|---|---|---|
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
| Status | Body | When |
|---|---|---|
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≥ 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"
}
]
}totalintegerentriesarrayvaluestringUpsert 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": []
}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.
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
| Status | Body | When |
|---|---|---|
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 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) |
| 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"
}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.