Reference
Standalone tools
One-shot /v3/tools/* utilities — AML, face search/enroll, signature verify/search/enroll, document extract, registry lookup — with full request/response and errors.
The /v3/tools/* endpoints are one-shot utilities that mirror the console
Manual Tools, tenant-scoped by the API key. They run a single check without
creating a verification session — useful for ad-hoc screening, back-office
review, dedup lookups, and enrichment. Images are passed as base64-encoded
strings in the body (a data: URI prefix is accepted and stripped).
Info
These endpoints are on the /v3 surface and authenticate with x-api-key.
Errors carry { "detail": "<code>" }. They share the inference-heavy
kyc_v3_tools rate-limit bucket — expect 429 under bursty load and back off.
Image inputs
Every image argument funnels through one decode chokepoint. Only JPEG and
PNG are accepted — HEIC (iPhone), WEBP, GIF, BMP, and TIFF are rejected with
415 unsupported_image_type rather than failing confusingly downstream. Images
must be ≤ 8 MB after base64 decoding.
| Decode error | Status | Meaning |
|---|---|---|
<field>_required (e.g. image_required, reference_required, probe_required) | 400 | The base64 field was empty or missing. |
invalid_<field> (e.g. invalid_image) | 400 | Not decodable base64, or zero-length / over 8 MB. |
unsupported_image_type | 415 | Decoded bytes aren't JPEG or PNG. |
Billing & entitlements
Every tool carries the same entitlement gate and per-use billing as its console Manual Tool, at the rate your plan sets for its key (a tenant rate, else the platform rate, else 1 credit):
- If the feature isn't enabled for the tenant →
403 { "detail": "feature_not_enabled", "features": ["<KEY>"] }. - If the balance can't cover the per-use rate →
402 { "detail": "insufficient_credits" }. - On success, one use is debited to the credit ledger with
reason: "tool_usage". Failed calls are never charged.
document-extract is gated and billed under DOCUMENT_OCR, but only debits
when the read returned something — fields or a generic full_text. A
portrait-only read, 422 ocr_no_fields and 503 ocr_not_configured pass the
gate without being charged.
signature-enroll is gated and billed per enrolled specimen under
SIGNATURE_ENROLL, on /v3 and in the console alike. The console twin
additionally requires the operator to hold the console_manage_kyc permission.
face-enroll is billed per enrolled face under FACE_ENROLL, and additionally
needs the opt-in IDENTITY_IMPORT grant — the same capability as
identity import, off until the platform
enables it for your tenant (403 naming IDENTITY_IMPORT). Re-sending a photo
already enrolled for the identity is a free replay. Listing and removing
enrolled faces are neither gated nor charged.
registry-lookup is gated and billed under REGISTRY_VALIDATION, the same key
and rate as the console Registry Lookup — an opt-in key, off until the
platform enables it for your tenant (403 naming REGISTRY_VALIDATION). It
debits one use for every lookup the registry answered, not_found included; a
lookup that could not be made (502 registry_unavailable,
503 registry_not_configured) or a rejected number (400 invalid_number —
including one that is only punctuation, which has nothing to look up) is
free. A workflow step's registry check bills the same way.
business-document and registry-lookup cover Nepal's documents and
official records only. While Nepal is switched off for your account, both are
refused with the same 422 document_type_not_available body
({ detail, message, country: "NP" }) before anything runs — never charged.
Retries. Send an Idempotency-Key header (≤128 chars) and a retry of the
same request — same key, same body, same UTC day — is not charged again (nor
refused with a 402). Reusing a key with a different body is a new, billed
use.
Endpoints
| Endpoint | Body | Billed feature |
|---|---|---|
POST /v3/tools/aml-check/ | { full_name, date_of_birth?, nationality?, country?, document_number?, lists?, match_threshold? } | AML_CHECK |
POST /v3/tools/face-search/ | { image_base64, threshold?, top_k? } | FACE_SEARCH |
POST /v3/tools/signature-verify/ | { reference_base64, probe_base64, threshold? } | SIGNATURE_VERIFY |
POST /v3/tools/signature-search/ | { image_base64, threshold?, top_k? } (beta) | SIGNATURE_SEARCH |
POST /v3/tools/signature-enroll/ | { individual_id | external_user_id, image_base64, label? } → 201 | SIGNATURE_ENROLL |
POST /v3/tools/face-enroll/ | { external_user_id | individual_id, image_base64, consent_reference, label?, full_name?, source_system?, on_duplicate? } → 201 | FACE_ENROLL |
GET /v3/tools/face-enroll/ | ?external_user_id= | ?individual_id= | — |
DELETE /v3/tools/face-enroll/{face_id}/ | — | — |
POST /v3/tools/document-extract/ | { image_base64, document_type?, country? } | DOCUMENT_OCR |
POST /v3/tools/registry-lookup/ | { kind: company | pan, number, name?, threshold? } | REGISTRY_VALIDATION (opt-in) |
AML check
Screen a name against sanctions / PEP / watchlist datasets. Same engine as the
AML_SCREENING module, run standalone.
POST /v3/tools/aml-check/full_namestringbodyrequiredfirst_name + last_name.)date_of_birthstringbodyYYYY-MM-DD, to corroborate and disambiguate matches.nationalitystringbodycountrystringbodydocument_numberstringbodylistsarraybodymatch_thresholdnumberbodycurl -X POST https://acme.thirdfactor.ai/v3/tools/aml-check/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "full_name": "Aarav Sharma", "date_of_birth": "1990-04-12", "nationality": "NP" }'const resp = await fetch("https://acme.thirdfactor.ai/v3/tools/aml-check/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
full_name: "Aarav Sharma",
date_of_birth: "1990-04-12",
nationality: "NP",
}),
});
const result = await resp.json();import requests
resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/aml-check/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"full_name": "Aarav Sharma", "date_of_birth": "1990-04-12", "nationality": "NP"},
)
result = resp.json()Response Example
{
"risk_level": "clear",
"matches": [],
"possible_matches": [],
"informational_matches": [],
"lists_checked": ["pep", "sanctions"],
"provider": "watchlist",
"dataset": "default",
"algorithm": "logic-v1",
"threshold": 0.85
}A confirmed hit looks like this (abbreviated match object):
{
"risk_level": "high",
"matches": [
{
"entity_id": "NK-abc123",
"name": "Aarav Sharma",
"schema": "Person",
"score": 0.97,
"match": true,
"lists": ["sanctions"],
"topics": ["sanction"],
"datasets": ["us_ofac_sdn"],
"birth_date": "1990-04-12",
"countries": ["np"],
"aliases": [],
"positions": [],
"url": "https://…/entity/NK-abc123"
}
],
"possible_matches": [],
"informational_matches": [],
"lists_checked": ["pep", "sanctions"],
"provider": "watchlist"
}risk_levelstringhigh (a confirmed hit on a blocking list), medium (only sub-threshold
possible matches), low (only informational notices), or clear.
matchesarraypossible_matchesarrayinformational_matchesarraylists_checkedarrayErrors
| Status | Code | When |
|---|---|---|
400 | name_required | No full_name and no first_name/last_name. |
402 | insufficient_credits | Balance can't cover the AML_CHECK rate. |
403 | feature_not_enabled | AML_CHECK not enabled for the tenant. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
503 | screening_unavailable | The screening backend is unreachable. |
Face search
1:N search over the tenant's enrolled identities — rank identities whose enrolled face best matches the probe photo. Read-only (nothing is flagged).
The searchable gallery holds two face sources: selfies captured live during a session and photos enrolled from outside — in bulk through identity import, or one at a time through face enroll. Document portraits, vendor reference faces and liveness stills are excluded, so a hit always means "we hold this face as this person".
Warning
Pass vendor_data for people you imported. An identity is keyed on
vendor_data → external_user_id, so a returning customer who verifies through
a session created without vendor_data becomes a second identity — and the
automatic duplicate check then flags them as a duplicate of their own imported
record. Create the session with vendor_data set to the same unique id they
were imported under; see
importing existing identities.
POST /v3/tools/face-search/image_base64stringbodyrequiredthresholdnumberbodyDefault: 0.350.2..0.95.top_kintegerbodyDefault: 121..50.curl -X POST https://acme.thirdfactor.ai/v3/tools/face-search/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "image_base64": "'"$(base64 -i selfie.jpg)"'", "top_k": 5 }'import { readFileSync } from "node:fs";
const image_base64 = readFileSync("selfie.jpg").toString("base64");
const resp = await fetch("https://acme.thirdfactor.ai/v3/tools/face-search/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_base64, top_k: 5 }),
});
const { matches } = await resp.json();import base64, requests
image_base64 = base64.b64encode(open("selfie.jpg", "rb").read()).decode()
resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/face-search/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"image_base64": image_base64, "top_k": 5},
)
matches = resp.json()["matches"]Response Example
{
"matches": [
{
"individual_id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"name": "Aarav Sharma",
"external_user_id": "your-user-reference-123",
"status": "active",
"kyc_status": "approved",
"score": 0.9142,
"thumb": "https://acme.thirdfactor.ai/media/…/thumb.jpg"
}
],
"threshold": 0.35
}matchesarrayscore first.scorenumber0..1 (rounded to 4 decimals).thresholdnumberErrors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
402 | insufficient_credits | Balance can't cover the FACE_SEARCH rate. |
403 | feature_not_enabled | FACE_SEARCH not enabled. |
422 | no_face_detected | No usable face in the probe image. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
Signature verify
1:1 comparison — compare a probe signature against a reference. Both inputs are first cropped to the signature ink, so a full ID photo or a video frame is matched on its signature, not the surrounding scene.
POST /v3/tools/signature-verify/reference_base64stringbodyrequiredprobe_base64stringbodyrequiredthresholdnumberbodyDefault: 0.620.2..0.95.curl -X POST https://acme.thirdfactor.ai/v3/tools/signature-verify/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reference_base64": "<b64>", "probe_base64": "<b64>" }'const resp = await fetch("https://acme.thirdfactor.ai/v3/tools/signature-verify/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ reference_base64: refB64, probe_base64: probeB64 }),
});
const { verdict, matched } = await resp.json();resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/signature-verify/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"reference_base64": ref_b64, "probe_base64": probe_b64},
)
verdict = resp.json()["verdict"]Response Example
{
"score": 0.9,
"verdict": "match",
"matched": true,
"threshold": 0.62,
"signals": {}
}scorenumber0..1.verdictstringmatch (score >= threshold), review (score in the [threshold × 0.75, threshold) grey band), or mismatch.
matchedbooleantrue only for match.Errors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
402 | insufficient_credits | Balance can't cover the SIGNATURE_VERIFY rate. |
403 | feature_not_enabled | SIGNATURE_VERIFY not enabled. |
422 | not_a_signature / no_signature_detected | A human face was detected, or no signature ink was found. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
Signature search
Reverse (1:N) search over the tenant's enrolled signature specimens — a recall aid for operators, not a verdict.
POST /v3/tools/signature-search/image_base64stringbodyrequiredthresholdnumberbodyDefault: 0.60.2..0.95.top_kintegerbodyDefault: 121..50.Response Example
{
"matches": [
{
"individual_id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"name": "Aarav Sharma",
"external_user_id": "your-user-reference-123",
"status": "active",
"score": 0.8321,
"thumb": "https://acme.thirdfactor.ai/media/…/sig.png"
}
],
"threshold": 0.6,
"beta": true
}Note
Signature search is beta ("beta": true in the response). Treat hits as a
recall aid and confirm with the 1:1 signature verify
comparison before acting.
Errors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
402 | insufficient_credits | Balance can't cover the SIGNATURE_SEARCH rate. |
403 | feature_not_enabled | SIGNATURE_SEARCH not enabled. |
422 | no_signature_detected | No signature ink found in the probe. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
Signature enroll
Add a reference signature specimen to an identity's gallery, growing what
reverse search can find. Returns 201. Not gated or charged.
POST /v3/tools/signature-enroll/individual_idstringbodyexternal_user_id.external_user_idstringbodyindividual_id.image_base64stringbodyrequiredlabelstringbodycurl -X POST https://acme.thirdfactor.ai/v3/tools/signature-enroll/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "external_user_id": "your-user-reference-123", "image_base64": "<b64>", "label": "account-opening" }'resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/signature-enroll/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"external_user_id": "your-user-reference-123", "image_base64": sig_b64, "label": "account-opening"},
)
sample = resp.json() # 201Response Example
{
"sample_id": "7a8b9c0d-1e2f-4a3b-5c6d-7e8f9a0b1c2d",
"individual_id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
}Errors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
404 | individual_not_found | Neither individual_id nor external_user_id resolves for this tenant. |
422 | no_signature_detected | No signature ink found in the image. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
Face enroll
Enroll one face into your tenant's 1:N gallery — what face search
and the automatic duplicate check match against. The face counterpart of
signature enroll, and the one-at-a-time sibling of
identity import: same storage, same rules.
Returns 201.
POST /v3/tools/face-enroll/external_user_idstringbodyindividual_id.individual_idstringbodyexternal_user_id.image_base64stringbodyrequiredconsent_referencestringbodyrequiredlabelstringbodyfull_namestringbodysource_systemstringbodyDefault: apion_duplicatestringbodyDefault: reportreport enrolls and lists matches on other identities in duplicates; skip refuses with 409 (nothing written) when there is one; none skips the search.curl -X POST https://acme.thirdfactor.ai/v3/tools/face-enroll/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "external_user_id": "user-ref-123", "image_base64": "'"$(base64 -i photo.jpg)"'",
"label": "loan-application-2026", "consent_reference": "Customer T&C v3 accepted at signup" }'resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/face-enroll/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"external_user_id": "user-ref-123", "image_base64": photo_b64,
"label": "loan-application-2026",
"consent_reference": "Customer T&C v3 accepted at signup"},
)
face = resp.json() # 201 — or 200 with replayed: true for a re-sent photoResponse Example
{
"face_id": "9d2f7c11-4b8e-4a3d-bb6e-2f1c0a9e8d77",
"individual_id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"external_user_id": "user-ref-123",
"label": "loan-application-2026",
"identity_created": true,
"replayed": false,
"duplicates": []
}face_idstringreplayedbooleantrue (with HTTP 200) when this exact photo was already enrolled for the identity: the existing face is returned, nothing is written and nothing is charged again — so a retried call is safe.duplicatesarray{ individual_id, score, external_user_id, full_name }, recorded for your review and never acted on.The face is stored as an imported enrolment: searchable by 1:N, but never treated as a verification selfie.
Warning
Pass vendor_data for people you enroll. A person who later verifies
through a session created without vendor_data becomes a second identity,
and the duplicate check flags them against their own enrolment. Create their
sessions with vendor_data set to the external_user_id they were enrolled
under.
List and remove. GET /v3/tools/face-enroll/?external_user_id=… (or
?individual_id=…) lists an identity's enrolled faces — { face_id, label, origin, image_url, created_at }, where origin is api or import.
DELETE /v3/tools/face-enroll/{face_id}/ permanently removes one enrolled face
(and the identity this API created, once nothing else remains on it). Enrolled
faces hang off no session, so the evidence-retention sweep never reaches them —
this is how you dispose of one. Neither call is gated or charged, and both keep
working if the IDENTITY_IMPORT grant is later withdrawn.
Errors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
400 | consent_reference_required | consent_reference is missing or blank. |
400 | external_user_id_required | Neither external_user_id nor individual_id was sent. |
400 | invalid_on_duplicate | on_duplicate isn't report / skip / none (never silently coerced). |
402 | insufficient_credits | Balance can't cover the FACE_ENROLL rate. |
403 | feature_not_enabled | IDENTITY_IMPORT not granted (opt-in), or FACE_ENROLL switched off — features says which. |
404 | individual_not_found | individual_id doesn't name one of your identities. |
409 | duplicate | on_duplicate: skip and the face matches another identity — matches lists them. |
422 | no_face_detected / multiple_faces | No readable face, or more than one (a group photo would enroll an arbitrary person under this id). |
422 | embedding_mismatch | The photo was read by a different face model than your existing gallery. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
503 | face_engine_unavailable | The face engine is down — nothing was enrolled or charged. |
Document extract
Run OCR / field extraction on a single identity-document image via the platform
document-OCR service. Works on any account (independent of the tenant's Lens
config). Gated and charged under DOCUMENT_OCR — see billing &
entitlements — on this endpoint and on its console
twin alike; a read that returned no fields and no full_text is not charged.
POST /v3/tools/document-extract/image_base64stringbodyrequireddocument_typestringbodykey such as NP:citizenship, IN:id_card, IN:aadhaar or IN:pan_card, or a bare type (passport, drivers_license, citizenship, pan_id, voter_id, national_id, disability_id; legacy pan accepted — with country: IN it means India's PAN card). A bare type prefers the local document unless country narrows it. Omit it for auto-detect.countrystringbodydocument_type, or limits auto-detect to that country's documents.Which documents it classifies
Classification is limited to your account's document types: the platform's
country documents narrowed to the countries — and document types — enabled for
your account. There is no separate "international" switch: a country on the
international classifier that is enabled for you contributes its documents
(e.g. with India enabled an Aadhaar card is auto-detected as Aadhaar Card and an
Indian PAN card as PAN Card, with India as their country), and its documents
are read by that classifier — whether you send the hint or leave auto-detect on.
Auto-detect runs the local read first and hands the document to the
international classifier whenever the local read can't settle it within your
set: nothing recognised, a document family that an enabled international
country also issues with no country named on it, or a country named that is on
the international classifier. Its document type and issuing country then pick
the document, so a UAE ID or an Indian passport auto-detects with the same
fields and country as when you name it. List the set with a free GET:
GET /v3/tools/document-extract/{
"candidates": [
{ "key": "NP:citizenship", "country": "NP", "country_name": "Nepal", "type": "citizenship", "subtype": "", "label": "Citizenship certificate", "classifier": "local", "generic": false },
{ "key": "IN:aadhaar", "country": "IN", "country_name": "India", "type": "id_card", "subtype": "aadhaar", "label": "Aadhaar Card", "classifier": "international", "generic": false }
]
}A hint outside the set is refused before anything is read (400 document_type_not_available), and an auto-detected document whose country or
type isn't enabled for you is refused rather than reported as something else
(422 document_type_not_available) — neither is charged. Switching a country
off for your account removes its documents from the hint list and from
auto-detect immediately; documents read earlier are unaffected.
curl -X POST https://acme.thirdfactor.ai/v3/tools/document-extract/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "image_base64": "'"$(base64 -i passport.jpg)"'" }'import { readFileSync } from "node:fs";
const image_base64 = readFileSync("passport.jpg").toString("base64");
const resp = await fetch("https://acme.thirdfactor.ai/v3/tools/document-extract/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_base64 }),
});
const doc = await resp.json();import base64, requests
image_base64 = base64.b64encode(open("passport.jpg", "rb").read()).decode()
resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/document-extract/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"image_base64": image_base64},
)
doc = resp.json()Response Example
{
"fields": {
"full_name": "AARAV SHARMA",
"document_number": "PA1234567",
"date_of_birth": "1990-04-12",
"expiry_date": "2030-04-11",
"nationality": "NPL"
},
"translated_fields": {},
"detected_type": "passport",
"confidence": 0.96,
"is_document_valid": true,
"has_portrait": true,
"document": { "key": "NP:passport", "country": "NP", "country_name": "Nepal", "type": "passport", "subtype": "", "label": "Passport", "classifier": "local", "generic": false },
"country": "NP",
"country_name": "Nepal"
}fieldsobjecttranslated_fieldsobjectdetected_typestringpassport, driving-license).confidencenumberenginestringlocal (the platform document OCR) or international (the country's international classifier).is_document_validbooleanhas_portraitbooleandocumentobjectkey, country, country_name, type, subtype, label, classifier). Absent when nothing your set names was recognised.warningsstring[]document_type_mismatch (the read doesn't look like the hinted type), issuing_country_mismatch, international_check_unavailable (the local OCR stood in for the international classifier — an auto-detected document it can't place is then returned without claiming a country).Note
For document types with no trained template (e.g. disability cards) the tool
falls back to a generic extraction: it still returns fields plus a raw
full_text, and adds generic_extraction: true with an extraction_note.
Treat those fields as best-effort.
Errors
| Status | Code | When |
|---|---|---|
400 / 415 | image decode error | See Image inputs. |
400 | invalid_document_type | document_type (or invalid_country: country) was sent as something other than a string. |
400 | document_type_not_available | The hint names a document your account can't classify. |
400 | country_not_available | country isn't enabled for your account. |
402 | insufficient_credits | Balance can't cover the DOCUMENT_OCR rate. |
403 | feature_not_enabled | DOCUMENT_OCR not enabled for the tenant. |
422 | ocr_no_fields | No fields could be extracted (unsupported type or unreadable photo). |
422 | document_type_not_available | Auto-detect recognised a document whose country or type isn't enabled for your account. Not charged. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
503 | ocr_not_configured | The platform document-OCR service isn't configured. |
Registry lookup
Look a business up in Nepal's official records by its number — the API twin of
the console Registry Lookup tool, and the same registry check a
BUSINESS_DOCUMENT workflow step runs on an uploaded certificate. No image is
involved: you send the number, optionally with the name you expect on file.
kind | Official source | Number |
|---|---|---|
company | Office of the Company Registrar | Company registration number, e.g. 000000/00/00 (the registrar keys on the leading number) |
pan | Inland Revenue Department (IRD) PAN search | 9-digit PAN / VAT number |
POST /v3/tools/registry-lookup/Try it live in the API Explorer under Tools → Registry lookup — it sends real (billed) requests to your tenant with your API key.
Info
Opt-in. Registry lookups are gated on REGISTRY_VALIDATION, which is off
until the platform enables it for your tenant — until then every call is
403 feature_not_enabled with features: ["REGISTRY_VALIDATION"]. Ask your
ThirdFactor contact to switch it on. The records are Nepal's: while Nepal is
switched off for your account, lookups are 422 document_type_not_available
(the business-document tool's refusal).
x-api-keystringheaderrequiredIdempotency-Keystringheaderkindstringbodyrequiredcompany or pan (case-insensitive).numberstringbodyrequired०००००००००), or both scripts joined by a slash (००००००/००/०० / 000000/00/00) — are accepted: each lookup-ready variant is tried until one resolves.namestringbodyPvt. Ltd., Limited) and honorifics (M/s, श्री). Omit it to check the number alone.thresholdnumberbodyDefault: 0.8name.curl -X POST https://acme.thirdfactor.ai/v3/tools/registry-lookup/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "kind": "pan", "number": "000000000", "name": "Acme Technologies Pvt. Ltd." }'const resp = await fetch("https://acme.thirdfactor.ai/v3/tools/registry-lookup/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "pan",
number: "000000000",
name: "Acme Technologies Pvt. Ltd.",
}),
});
const lookup = await resp.json();
if (lookup.status === "matched") {
console.log(lookup.record.name_english, lookup.record.tax_clearance);
}import requests
resp = requests.post(
"https://acme.thirdfactor.ai/v3/tools/registry-lookup/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"kind": "pan", "number": "000000000", "name": "Acme Technologies Pvt. Ltd."},
timeout=60,
)
lookup = resp.json()Response Example
{
"kind": "pan",
"source": "ird_pan",
"status": "matched",
"found": true,
"number": "000000000",
"number_used": "000000000",
"name_check": { "similarity": 1.0, "threshold": 0.8, "matched": true },
"record": {
"number": "000000000",
"name_english": "ACME TECHNOLOGIES",
"name_nepali": "एक्मे टेक्नोलोजिज",
"registration_date_bs": "2072.04.11",
"address": "ललितपुर, महानगरपालिका कुपन्डोल",
"company_type": null,
"office": "आन्तरिक राजस्व कार्यालय ललितपुर-१",
"registrations": [
{ "type": "Income Tax", "status": "Active" },
{ "type": "VAT", "status": "Active" }
],
"tax_clearance": "Cleared"
}
}A company looked up by number alone, and a number with no record:
{
"kind": "company",
"source": "company_registrar",
"status": "matched",
"found": true,
"number": "000000/00/00",
"number_used": "000000/00/00",
"name_check": null,
"record": {
"number": "000000",
"name_english": "Acme Technologies",
"name_nepali": "एक्मे टेक्नोलोजिज",
"registration_date_bs": "2081-08-27",
"address": "Lalitpur Metropolitan City-1, Lalitpur",
"company_type": "All Nepali Ownership",
"office": null,
"registrations": null,
"tax_clearance": null
}
}{
"kind": "company", "source": "company_registrar", "status": "not_found", "found": false,
"number": "999999", "number_used": null, "name_check": null, "record": null
}statusstringmatched — the registry holds a record for the number (and the name, when
sent, reached threshold); name_mismatch — a record exists but the name
scored below threshold (the record is still returned so you can compare);
not_found — the registry has no record for the number.
foundbooleankindstringcompany or pan, as requested.sourcestringcompany_registrar or ird_pan — the official source that answered.numberstringnumber_usedstring | nullnull when not found.name_checkobject | null{ similarity, threshold, matched } (all 0–1) when you sent a name and a
record was found; null otherwise.
recordobject | nullWhat the registry holds; null when not found. Every key is always present —
number, name_english, name_nepali, registration_date_bs (Bikram
Sambat, as printed), address, company_type (company only),
office, registrations ([{ type, status }]) and tax_clearance (PAN
only); a value the registry doesn't report is null.
Note
A 200 is the registry's answer, so it is charged — including not_found and
name_mismatch. Treat not_found as "no official record for this number",
and name_mismatch as a hold for review rather than a rejection: registry
names are often recorded in only one script, or with a different legal form.
Errors
Errors carry { "detail": "<code>" }; a lookup that could not be made adds a
human-readable message (for example, which official website is down).
| Status | Code | When |
|---|---|---|
400 | kind_required / invalid_kind | kind missing or not company / pan (the body lists allowed). |
400 | number_required / invalid_number | number missing; not a string or integer; over 100 characters; nothing but punctuation (.., /-/ — nothing to look up, with a message); or outside the registry's format — refused before the lookup, or by the registry itself (with its reason in message). Not charged. |
400 | invalid_name / invalid_threshold | name not a string or over 200 characters; threshold not a number in (0, 1]. |
401 | authentication_failed | Missing or invalid API key. |
402 | insufficient_credits | Balance can't cover the REGISTRY_VALIDATION rate — checked before the registry is asked. |
403 | feature_not_enabled | REGISTRY_VALIDATION not enabled for the tenant (opt-in). |
422 | document_type_not_available | Nepal is switched off for your account — the same refusal ({ detail, message, country: "NP" }) as business-document. Checked after the 403 / 402 gates, before the registry is asked. Not charged. |
429 | rate-limited | Over the kyc_v3_tools bucket. |
502 | registry_unavailable | The registry service, or the official website behind it, didn't answer (timeout, outage, an expired certificate on the government site). Not charged — retry later. |
503 | registry_not_configured | Registry lookups aren't configured on this deployment. Not charged. |
Note
On an on-prem deployment the lookup is answered through the deployment's
ThirdFactor cloud connection. When the cloud account itself refuses
(out of credits, registry lookups not enabled for it, Nepal switched off for
it, rate-limited), the error keeps its status and code and adds
"source": "thirdfactor_cloud" so you can tell it from your own tenant's
402 / 403 / 422. business-document passes the cloud's refusals on the
same way.
Tools vs. a session
/v3/tools/* | Session | |
|---|---|---|
| Runs a full pipeline | ❌ — one check | ✅ — document + liveness + face + AML |
| Produces a decision | ❌ — raw result only | ✅ — decision + decision_v3 |
| Audit trail / webhook | ❌ | ✅ — logs, PDF, signed webhook |
| Hosted UI for the applicant | ❌ — you supply images | ✅ — hosted flow |
| Best for | Back-office / ad-hoc lookups | End-to-end identity verification |
Tip
For a full document + liveness + face-match pipeline with a decision and audit trail, create a session instead of stitching these tools together.
Common pitfalls
Warning
Sending HEIC/WEBP images. Phone cameras default to HEIC and Chrome to WEBP —
both are rejected with 415 unsupported_image_type. Transcode to JPEG or PNG
before base64-encoding.
Warning
Treating face/signature search as a verdict. Search is 1:N recall — a hit is a candidate, not a decision. Confirm identity with a 1:1 comparison or a full session before acting on a match.