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 errorStatusMeaning
<field>_required (e.g. image_required, reference_required, probe_required)400The base64 field was empty or missing.
invalid_<field> (e.g. invalid_image)400Not decodable base64, or zero-length / over 8 MB.
unsupported_image_type415Decoded 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

EndpointBodyBilled 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? } → 201SIGNATURE_ENROLL
POST /v3/tools/face-enroll/{ external_user_id | individual_id, image_base64, consent_reference, label?, full_name?, source_system?, on_duplicate? } → 201FACE_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_namestringbodyrequired
Name to screen. (Or supply first_name + last_name.)
date_of_birthstringbody
YYYY-MM-DD, to corroborate and disambiguate matches.
nationalitystringbody
ISO-2 nationality.
countrystringbody
ISO-2 country.
document_numberstringbody
Optional identifier to match on.
listsarraybody
Restrict to specific watchlists; defaults to the tenant's enabled lists.
match_thresholdnumberbody
Minimum score to count as a confirmed hit; defaults to the configured AML threshold.
curl -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_levelstring

high (a confirmed hit on a blocking list), medium (only sub-threshold possible matches), low (only informational notices), or clear.

matchesarray
Confirmed hits at/above the threshold.
possible_matchesarray
Sub-threshold or uncorroborated candidates for a reviewer.
informational_matchesarray
Non-blocking notices (e.g. person-of-interest datasets).
lists_checkedarray
The watchlists actually screened.

Errors

StatusCodeWhen
400name_requiredNo full_name and no first_name/last_name.
402insufficient_creditsBalance can't cover the AML_CHECK rate.
403feature_not_enabledAML_CHECK not enabled for the tenant.
429rate-limitedOver the kyc_v3_tools bucket.
503screening_unavailableThe screening backend is unreachable.

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_base64stringbodyrequired
Base64-encoded probe image (JPEG/PNG).
thresholdnumberbodyDefault: 0.35
Minimum similarity to return, clamped to 0.2..0.95.
top_kintegerbodyDefault: 12
Maximum matches to return, clamped to 1..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
}
matchesarray
Ranked candidates, highest score first.
scorenumber
Similarity in 0..1 (rounded to 4 decimals).
thresholdnumber
The effective threshold after clamping.

Errors

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
402insufficient_creditsBalance can't cover the FACE_SEARCH rate.
403feature_not_enabledFACE_SEARCH not enabled.
422no_face_detectedNo usable face in the probe image.
429rate-limitedOver 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_base64stringbodyrequired
Base64-encoded reference signature.
probe_base64stringbodyrequired
Base64-encoded probe signature.
thresholdnumberbodyDefault: 0.62
Decision threshold, clamped to 0.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": {}
}
scorenumber
Similarity in 0..1.
verdictstring

match (score >= threshold), review (score in the [threshold × 0.75, threshold) grey band), or mismatch.

matchedboolean
Convenience boolean, true only for match.

Errors

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
402insufficient_creditsBalance can't cover the SIGNATURE_VERIFY rate.
403feature_not_enabledSIGNATURE_VERIFY not enabled.
422not_a_signature / no_signature_detectedA human face was detected, or no signature ink was found.
429rate-limitedOver the kyc_v3_tools bucket.

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_base64stringbodyrequired
Base64-encoded probe signature.
thresholdnumberbodyDefault: 0.6
Minimum similarity to return, clamped to 0.2..0.95.
top_kintegerbodyDefault: 12
Maximum matches, clamped to 1..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

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
402insufficient_creditsBalance can't cover the SIGNATURE_SEARCH rate.
403feature_not_enabledSIGNATURE_SEARCH not enabled.
422no_signature_detectedNo signature ink found in the probe.
429rate-limitedOver 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_idstringbody
Enrolled individual UUID. Provide this or external_user_id.
external_user_idstringbody
Your external user reference. Provide this or individual_id.
image_base64stringbodyrequired
Base64-encoded signature to enroll.
labelstringbody
Optional label for the enrolled sample (truncated to 120 chars).
curl -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()  # 201

Response Example

{
  "sample_id": "7a8b9c0d-1e2f-4a3b-5c6d-7e8f9a0b1c2d",
  "individual_id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
}

Errors

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
404individual_not_foundNeither individual_id nor external_user_id resolves for this tenant.
422no_signature_detectedNo signature ink found in the image.
429rate-limitedOver 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_idstringbody
Your unique reference for the person. An identity is created when none holds it yet. Provide this or individual_id.
individual_idstringbody
An existing identity's UUID, instead of external_user_id.
image_base64stringbodyrequired
Base64-encoded photo (JPEG/PNG) holding exactly one readable face.
consent_referencestringbodyrequired
The consent or lawful basis for holding this face (≤200 chars). Stored with it — no session or terms acceptance stands behind an enrolled face, so this is the record of why you hold it.
labelstringbody
Optional label for the enrolled face (≤120 chars).
full_namestringbody
Optional — fills the identity's name only when it is blank (never overwrites a name read off a document).
source_systemstringbodyDefault: api
Optional name of the system the photo came from (≤120 chars).
on_duplicatestringbodyDefault: report
report 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 photo

Response 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_idstring
The enrolled face — keep it to remove the face later.
replayedboolean
true (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
Other identities whose face resembles this one — { 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

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
400consent_reference_requiredconsent_reference is missing or blank.
400external_user_id_requiredNeither external_user_id nor individual_id was sent.
400invalid_on_duplicateon_duplicate isn't report / skip / none (never silently coerced).
402insufficient_creditsBalance can't cover the FACE_ENROLL rate.
403feature_not_enabledIDENTITY_IMPORT not granted (opt-in), or FACE_ENROLL switched off — features says which.
404individual_not_foundindividual_id doesn't name one of your identities.
409duplicateon_duplicate: skip and the face matches another identity — matches lists them.
422no_face_detected / multiple_facesNo readable face, or more than one (a group photo would enroll an arbitrary person under this id).
422embedding_mismatchThe photo was read by a different face model than your existing gallery.
429rate-limitedOver the kyc_v3_tools bucket.
503face_engine_unavailableThe 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_base64stringbodyrequired
Base64-encoded document image (JPEG/PNG).
document_typestringbody
Optional hint from your account's document types (see below): a candidate key 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.
countrystringbody
Optional ISO 3166-1 alpha-2 issuing country — narrows a bare document_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"
}
fieldsobject
Extracted document fields (as printed).
translated_fieldsobject
Romanised / English read of the same fields for non-Latin documents.
detected_typestring
The document type the OCR identified (e.g. passport, driving-license).
confidencenumber
Detection confidence (0–1) of the local detector — on a read by the international classifier, the local detector's confidence in the same document family (null when it didn't recognise it).
enginestring
Which engine read the fields: local (the platform document OCR) or international (the country's international classifier).
is_document_validboolean
Whether the document passed structural validity checks.
has_portraitboolean
Whether a portrait face was found on the document.
documentobject
The matched document type from your candidate set, with its country context (key, country, country_name, type, subtype, label, classifier). Absent when nothing your set names was recognised.
warningsstring[]
Present when something needs a second look: 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

StatusCodeWhen
400 / 415image decode errorSee Image inputs.
400invalid_document_typedocument_type (or invalid_country: country) was sent as something other than a string.
400document_type_not_availableThe hint names a document your account can't classify.
400country_not_availablecountry isn't enabled for your account.
402insufficient_creditsBalance can't cover the DOCUMENT_OCR rate.
403feature_not_enabledDOCUMENT_OCR not enabled for the tenant.
422ocr_no_fieldsNo fields could be extracted (unsupported type or unreadable photo).
422document_type_not_availableAuto-detect recognised a document whose country or type isn't enabled for your account. Not charged.
429rate-limitedOver the kyc_v3_tools bucket.
503ocr_not_configuredThe 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.

kindOfficial sourceNumber
companyOffice of the Company RegistrarCompany registration number, e.g. 000000/00/00 (the registrar keys on the leading number)
panInland Revenue Department (IRD) PAN search9-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-keystringheaderrequired
Your tenant API key.
Idempotency-Keystringheader
Optional (≤128 chars). A retry with the same key and body on the same UTC day isn't charged again.
kindstringbodyrequired
company or pan (case-insensitive).
numberstringbodyrequired
The registration number or PAN as printed (an integer PAN is accepted too), at most 100 characters. Numbers copied off a bilingual certificate — Devanagari digits (०००००००००), or both scripts joined by a slash (००००००/००/०० / 000000/00/00) — are accepted: each lookup-ready variant is tried until one resolves.
namestringbody
The business name you expect (≤200 chars). Fuzzy-matched against the registry's English and Nepali names (for a PAN, the taxpayer's registered businesses too), ignoring case, punctuation, legal suffixes (Pvt. Ltd., Limited) and honorifics (M/s, श्री). Omit it to check the number alone.
thresholdnumberbodyDefault: 0.8
Minimum name similarity (0–1, above 0) that counts as a match. Ignored without name.
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
}
statusstring

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

foundboolean
Whether a record exists for the number.
kindstring
company or pan, as requested.
sourcestring
company_registrar or ird_pan — the official source that answered.
numberstring
The number as you sent it (trimmed).
number_usedstring | null
The lookup-ready variant that resolved (Devanagari digits transliterated, a bilingual composite split); null 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 | null

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

StatusCodeWhen
400kind_required / invalid_kindkind missing or not company / pan (the body lists allowed).
400number_required / invalid_numbernumber 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.
400invalid_name / invalid_thresholdname not a string or over 200 characters; threshold not a number in (0, 1].
401authentication_failedMissing or invalid API key.
402insufficient_creditsBalance can't cover the REGISTRY_VALIDATION rate — checked before the registry is asked.
403feature_not_enabledREGISTRY_VALIDATION not enabled for the tenant (opt-in).
422document_type_not_availableNepal 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.
429rate-limitedOver the kyc_v3_tools bucket.
502registry_unavailableThe registry service, or the official website behind it, didn't answer (timeout, outage, an expired certificate on the government site). Not charged — retry later.
503registry_not_configuredRegistry 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 forBack-office / ad-hoc lookupsEnd-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.

FAQ