Reference
Identity import
Bulk-enrol people already collected elsewhere so their faces are searchable by 1:N — the opt-in /v3/identities/import/ endpoints, per-row reports, billing and deletion.
/v3/identities/import/ brings people you already hold in another system into
the identity registry, so their faces are searchable by 1:N on day one —
without putting anyone through a verification flow. You supply a unique id, a
photo URL, and optionally a name and contact details; we fetch each photo,
confirm it holds exactly one readable face, and enrol that face into the
tenant's gallery.
This is the migration path for a tenant moving off other software. It is not a verification: an imported identity has no document, no liveness, no decision — only a searchable face and the provenance you recorded for it. For the end-to-end migration walkthrough — pilot batch, CSV columns, what to do with the row report — see Import an existing customer base.
Info
These endpoints are on the /v3 surface and authenticate with x-api-key.
Errors carry { "detail": "<code>" }; whole-request refusals on the import
call add an error sentence you can show an operator verbatim.
Identity import is opt-in
Warning
IDENTITY_IMPORT is off until the platform enables it for your tenant.
Unlike most features, which are on unless switched off, identity import is an
opt-in key — it writes biometric records with no verification behind them. Until
it is granted, every call to POST /v3/identities/import/ returns:
{ "detail": "feature_not_enabled", "features": ["IDENTITY_IMPORT"] }You cannot self-serve this. Ask ThirdFactor to enable it for your tenant before you build against it.
The gate is asymmetric on purpose:
| Call | Needs the IDENTITY_IMPORT grant? |
|---|---|
POST /v3/identities/import/ — start an import | ✅ Yes |
GET /v3/identities/import/{batch_id}/ — read progress | ❌ No |
DELETE /v3/identities/import/{batch_id}/ — delete a batch | ❌ No |
Imported faces sit outside the session-driven retention sweep, so deleting the batch is their only disposal route. If revoking the feature also revoked the delete, withdrawing the grant would strand the biometrics it created — so reading and deleting stay available whatever the entitlement says today.
Before you import
Confirm the entitlement is on
A single-row test import is the cheapest check. A 403 feature_not_enabled
means the platform hasn't granted it yet.
Use the same unique id you will pass as vendor_data
This is the one that bites — see the warning below.
Make the photo URLs publicly fetchable over https
We download them server-side. Pre-signed object-storage URLs work well; make sure they outlive the import.
Confirm every photo is a JPEG or PNG with one face
A group photo, a blank avatar or a HEIC export fails that row.
Record where the data came from and on what basis
source_system and consent_reference are required, and stamped onto every
identity the import touches.
Keep the batch_id
It is how you read the row report, and the only handle for deleting the faces the import enrolled.
Warning
Pass vendor_data on sessions for people you imported. An identity is keyed
on (tenant, external_user_id). A returning customer who verifies without
vendor_data gets a brand-new identity — and duplicate detection then matches
their live selfie against their own imported photo and holds the session for
review. Every imported customer looks like a fraud signal.
The fix is entirely on your side: when you create a session for someone who is
in the import, set
vendor_data to the same unique id you used
as external_user_id in the import. The session then attaches to the existing
identity instead of competing with it.
How an import runs
Each row is a network fetch plus a face embedding, so the work happens on a
worker. POST returns 202 with a batch_id immediately; poll GET for
progress and per-row outcomes.
POST /v3/identities/import/ ──▶ 202 { batch_id, status: "pending", total }
│
worker picks it up (status: "running")
│
GET /v3/identities/import/{batch_id}/ ──▶ processed / created /
│ updated / skipped / failed
▼
status: "completed" | "failed"Rows fail individually. One unreachable photo, one group shot, one blank
avatar — that row is marked error with a reason and the batch carries on.
Only a handful of conditions stop a whole batch (see
batch-level errors).
Batch status is pending, running, completed or failed. A failed
batch keeps the rows that already landed — those enrolments are real — and the
rows it never reached stay pending.
Provenance is required
source_system and consent_reference are not decoration. Every other face in
the product traces back to a session where the person stood in front of a camera
and accepted the tenant's terms. An import has no session and no
terms-acceptance behind it, so these two strings are the only record of
where a face came from and on what basis you hold it. Both are stamped onto
every identity the import touches (under the identity's metadata.import,
alongside the batch id and row number), and both surface in the console: the
Past imports list labels each batch with its source_system and shows the
consent_reference on hover.
Omitting either is a 400 — source_system_required or
consent_reference_required — not a silent default.
Photo URLs: what we will and won't fetch
We download each photo_url from our servers, which means an operator-supplied
string turns into an outbound request from inside our network. Every hop is
validated, not just the first:
| Rule | Row fails with |
|---|---|
https only. Plain http needs an explicit deployment setting (for an on-prem asset host with no certificate). | bad_scheme |
The host must resolve, and every address it answers with must be publicly routable. Private, loopback, link-local (169.254.x — cloud metadata), reserved, multicast, NAT64 and 6to4 ranges are refused. | blocked_address, dns_failed |
| Redirects are followed by hand, at most 3, re-validating the new URL at every hop. | too_many_redirects |
| 8 MB cap on the body. | file_too_large |
| 15 seconds per socket operation — the connect and each read — re-applied on every redirect hop, not a total budget for the row. | fetch_failed |
The bytes must sniff as JPEG or PNG. Content-Type is not trusted. | unsupported_image_type |
| An optional deployment allowlist can narrow permitted hosts further. | host_not_allowed |
| The URL must be present and have a host. It is stored truncated to 1000 characters, so keep them shorter than that. | photo_url_required, bad_url |
Anything else that goes wrong on the wire — a 404, a timeout, an empty body — is
fetch_failed. The message deliberately never echoes the resolved address: an
import report that did would be an internal network scanner for anyone who can
post rows.
The 15-second timeout is usually the first limit an integrator on slow object
storage meets: a cold-storage tier that takes twenty seconds to serve the first
byte fails the row with fetch_failed, however healthy the URL looks in a
browser. Warm the objects, or serve the import from a hot bucket.
Exactly one readable face
Each photo must contain exactly one detectable face:
- No face → the row fails with
no_face_detected. - More than one face → the row fails with
multiple_faces. A group photo would otherwise enrol an arbitrary person under your customer's id, which is worse than not enrolling them at all. - An embedding of a different width than the tenant's existing gallery →
embedding_mismatch. The row would insert fine and then never be findable, so it fails loudly instead.
Endpoints
| Endpoint | Purpose | Entitlement |
|---|---|---|
POST /v3/identities/import/ | Queue a batch → 202 { batch_id, status, total } | IDENTITY_IMPORT |
GET /v3/identities/import/{batch_id}/ | Progress + per-row report | — |
DELETE /v3/identities/import/{batch_id}/ | Remove the faces the import enrolled | — |
Start an import
POST /v3/identities/import/
x-api-key: <tenant_api_key>
Content-Type: application/jsonidentitiesarraybodyrequiredThe people to enrol. Capped at 5000 rows per batch
(IDENTITY_IMPORT_MAX_ROWS). Split a larger migration into several batches.
source_systemstringbodyrequiredWhich software this data came from, e.g. "Legacy CRM". Truncated to 120
characters.
consent_referencestringbodyrequiredThe consent or lawful basis for holding these photos, e.g. "Customer T&C v3 accepted at signup". Truncated to 200 characters.
on_duplicatestringbodyDefault: reportreport — import the row and record its 1:N matches for review.
skip — leave a matching row out (status skipped, detail duplicate).
none — skip the duplicate check entirely (fastest). An unrecognised value is
a 400 invalid_on_duplicate, never coerced to the default.
Each entry in identities:
external_user_idstringbodyrequiredThe person's unique id in your system (max 255). Matches an existing identity if one already carries it, otherwise creates one.
photo_urlstringbodyrequiredhttps URL (max 1000 chars) of a JPEG or PNG holding exactly one face.
full_namestringbodyemailstringbodyphonestringbodycountrystringbodycurl -X POST https://acme.thirdfactor.ai/v3/identities/import/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_system": "Legacy CRM",
"consent_reference": "Customer T&C v3 accepted at signup",
"on_duplicate": "report",
"identities": [
{
"external_user_id": "CUST-00001",
"full_name": "Aarati Shrestha",
"photo_url": "https://files.example.com/photos/1.jpg"
},
{
"external_user_id": "CUST-00002",
"full_name": "Bikash Thapa",
"photo_url": "https://files.example.com/photos/2.jpg",
"phone": "+9779800000000"
}
]
}'const resp = await fetch("https://acme.thirdfactor.ai/v3/identities/import/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
source_system: "Legacy CRM",
consent_reference: "Customer T&C v3 accepted at signup",
identities: customers.map((c) => ({
external_user_id: c.id, // the same id you will send as vendor_data
full_name: c.name,
photo_url: c.photoUrl,
})),
}),
});
const { batch_id } = await resp.json(); // 202import requests
resp = requests.post(
"https://acme.thirdfactor.ai/v3/identities/import/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={
"source_system": "Legacy CRM",
"consent_reference": "Customer T&C v3 accepted at signup",
"identities": [
{
"external_user_id": c["id"],
"full_name": c["name"],
"photo_url": c["photo_url"],
}
for c in customers
],
},
)
batch_id = resp.json()["batch_id"] # 202Response Example
{
"batch_id": "7f1c2a90-4b6d-4e15-9a02-3d8c1b7e5f44",
"status": "pending",
"total": 2
}batch_idstringGET /v3/identities/import/{batch_id}/. Keep it — it is also the handle for deletion.statusstringpending on acceptance; the worker moves it to running.totalintegerNote
Common column spellings are accepted as aliases, so an export from your old
system usually needs no renaming: id / unique_id / uniqueid /
customer_id / user_id for external_user_id, name / fullname /
customer_name for full_name, photo / image / image_url / photo_link
/ picture for photo_url, mobile / phone_number for phone, and
email_address for email. Unknown keys are ignored.
Errors
Whole-request refusals return { "detail": "<code>", "error": "<sentence>" }.
| Status | Code | When |
|---|---|---|
400 | no_rows | identities was missing or empty. |
400 | invalid_row | An entry in identities wasn't an object. |
400 | too_many_rows | More than 5000 rows in one batch. |
400 | source_system_required | source_system missing or blank. |
400 | consent_reference_required | consent_reference missing or blank. |
400 | invalid_on_duplicate | on_duplicate wasn't report, skip or none. |
401 | invalid API key | Missing or wrong x-api-key. |
403 | feature_not_enabled | IDENTITY_IMPORT not granted to this tenant. Body carries "features": ["IDENTITY_IMPORT"]. |
429 | rate-limited | Over the inference-heavy kyc_v3_tools bucket — the same one the standalone tools share. |
Note
There is no 402 on this endpoint. Credits are checked and debited per row
as the batch runs, not up front — so a tenant with an empty balance and a free
import isn't refused at the door. See Billing.
A row missing external_user_id or photo_url is not a 400. It is
accepted into the batch and reported as a failed row
(external_user_id_required / photo_url_required), so one malformed line in a
5000-row export doesn't cost you the whole import.
Import status and row report
Polling is how you learn a batch finished. An import fires no
webhook and no Connect event — there is no
identity_import.* topic to subscribe to — so poll this endpoint until status
is completed or failed. Console operators with console_manage_kyc
additionally get an in-console notification when one lands (everyone but the
operator who started it, who is already watching the progress bar).
GET /v3/identities/import/{batch_id}/
x-api-key: <tenant_api_key>statusstringqueryComma-separated row statuses to return, e.g. error,skipped. Omit for every
row.
pageintegerqueryDefault: 1page_sizeintegerqueryDefault: 50200.curl -s "https://acme.thirdfactor.ai/v3/identities/import/$BATCH_ID/?status=error&page_size=200" \
-H "x-api-key: $OBSIDIAN_API_KEY"async function waitForImport(batchId) {
for (;;) {
const resp = await fetch(
`https://acme.thirdfactor.ai/v3/identities/import/${batchId}/`,
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const batch = await resp.json();
if (batch.status === "completed" || batch.status === "failed") return batch;
await new Promise((r) => setTimeout(r, 5000));
}
}import time, requests
while True:
batch = requests.get(
f"https://acme.thirdfactor.ai/v3/identities/import/{batch_id}/",
headers={"x-api-key": OBSIDIAN_API_KEY},
).json()
if batch["status"] in ("completed", "failed"):
break
time.sleep(5)
failed = requests.get(
f"https://acme.thirdfactor.ai/v3/identities/import/{batch_id}/",
headers={"x-api-key": OBSIDIAN_API_KEY},
params={"status": "error", "page_size": 200},
).json()["rows"]Response Example
{
"batch_id": "7f1c2a90-4b6d-4e15-9a02-3d8c1b7e5f44",
"status": "completed",
"source_system": "Legacy CRM",
"total": 2,
"processed": 2,
"created": 1,
"updated": 0,
"skipped": 0,
"failed": 1,
"error": "",
"created_at": "2026-09-16T04:10:00Z",
"finished_at": "2026-09-16T04:10:37Z",
"rows": [
{
"row": 1,
"external_user_id": "CUST-00001",
"status": "created",
"detail": "",
"message": "",
"individual_id": "3c9a1e77-2b40-4d8f-9a11-6e5c4b3a2d10",
"matches": []
},
{
"row": 2,
"external_user_id": "CUST-00002",
"status": "error",
"detail": "no_face_detected",
"message": "No face could be read in this photo.",
"individual_id": null,
"matches": []
}
],
"row_count": 2,
"page": 1,
"page_size": 50
}statusstringpending, running, completed or failed.processedintegerrunning.createdintegerupdatedintegerskippedintegeron_duplicate: "skip".failedintegererrorstringrowsarrayrow_countintegerstatus filter (not just this page).Row statuses
status | Meaning |
|---|---|
pending | Not processed yet — either still queued, or the batch stopped before reaching it. |
created | A new identity was created and the face enrolled. |
updated | The face was enrolled onto an identity that already carried this external_user_id. |
skipped | Matched an existing identity under on_duplicate: "skip". |
error | The row did not enrol; detail says why. |
Row detail slugs
detail is a stable machine slug; message is the same reason as a sentence
you can show an operator.
detail | Meaning |
|---|---|
external_user_id_required | The row carried no unique id. |
photo_url_required | The row carried no photo URL. |
bad_url | The URL has no host. |
bad_scheme | Not http(s), or plain http on a deployment that hasn't enabled it. |
host_not_allowed | The host isn't in the deployment's import allowlist. |
dns_failed | The host could not be resolved. |
blocked_address | The host resolves to an address that isn't publicly routable. |
fetch_failed | The download failed — non-2xx, timeout, empty body, or a redirect with no target. |
too_many_redirects | More than three redirect hops. |
unsupported_image_type | The bytes aren't JPEG or PNG. |
file_too_large | Over the 8 MB cap. |
no_face_detected | No usable face in the photo. |
multiple_faces | More than one face in the photo. |
embedding_mismatch | Read by a different face model than the tenant's existing gallery. |
duplicate | Matched an existing identity and was skipped (on_duplicate: "skip"). |
internal_error | The row could not be processed. Safe to re-import. |
Batch-level errors
These stop the whole batch. Rows already enrolled are kept; untouched rows stay
pending and come back in the report as work still to do.
error | Meaning | What to do |
|---|---|---|
insufficient_credits | The balance ran out mid-batch. | Top up, then re-import the rows still pending. |
face_engine_unavailable | The face engine could not be reached. | Retry the batch. |
interrupted | A worker died and a sweep recovered the batch. | Re-import the rows still pending. |
internal_error | Unexpected failure. | Retry; contact support if it repeats. |
Errors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing or wrong x-api-key. |
404 | not_found | No such batch for this tenant. |
429 | rate-limited | Over the per-tenant read bucket. |
A batch_id that isn't a UUID never reaches this view at all — the route only
matches a UUID — so it comes back as a plain routing 404 with no detail
code. Branch on the status, not the body, when you can't trust the id.
Duplicate handling
With on_duplicate: "report" (the default) each row is searched against the
tenant's existing gallery and against faces enrolled earlier in the same
batch — so the same person appearing twice in one file is reported on the second
row. Hits are recorded on the row and never acted on:
"matches": [
{
"individual_id": "9b2c7d10-5e41-4f88-a0c3-1d2e3f4a5b6c",
"score": 0.9312,
"external_user_id": "CUST-00088",
"full_name": "Aarati Shrestha"
}
]The threshold is the tenant's own duplicate threshold — the same one live
verifications use — so "duplicate" means the same thing in an import report as
it does mid-flow. An identity is never reported as a duplicate of itself: the
row's own external_user_id is resolved first and excluded from the search.
score is a face-embedding cosine in 0..1. Treat a hit as a candidate for
review, not a verdict.
Merging into existing identities
When a row's external_user_id already exists for the tenant, the import
enrols the face onto that identity (status updated) and fills blank
contact fields only. An import never overwrites a name, email, phone or country
that came off a verified document. The imported photo becomes the identity's
primary face only when it has no primary face already — a real selfie always
outranks an import.
Billing
Identity import is metered under the IDENTITY_IMPORT key, per enrolled
record — one debit per row that created or updated an identity, posted to the
credit ledger with reason: "tool_usage". Rows
that error or are skipped cost nothing.
Note
Imports are free until the platform sets an explicit rate. No rate is assumed: a default price would otherwise bill thousands of credits for a migration nobody agreed to pay for. Check with ThirdFactor before you plan a large import if pricing matters to you.
Two behaviours worth designing around:
- Each row's debit is keyed on
(batch, row), so a redelivered or retried batch replays rather than charges twice. - If the balance runs out mid-batch the import stops —
status: "failed",error: "insufficient_credits"— rather than enrolling the rest for free. The rows it never reached staypending; top up and re-import those.
Retention and deletion
Warning
Imported photos hang off no session, so the evidence-retention sweep never
reaches them. They are kept until the import batch is deleted. DELETE is the
disposal route — plan for it before you enrol thousands of faces.
That is deliberate, not an oversight: a gallery that quietly aged itself out would stop answering 1:N searches with no warning.
DELETE /v3/identities/import/{batch_id}/
x-api-key: <tenant_api_key>Deleting a batch removes:
- every face photo the import enrolled (file and record) — which is what made those people searchable; and
- identities the import created, but only while they are still nothing but an import. The moment someone has verified through a session, or another face or document exists on the identity, it is no longer the import's to delete.
Identities the import merely updated are never touched — they predate it.
curl -X DELETE "https://acme.thirdfactor.ai/v3/identities/import/$BATCH_ID/" \
-H "x-api-key: $OBSIDIAN_API_KEY"resp = requests.delete(
f"https://acme.thirdfactor.ai/v3/identities/import/{batch_id}/",
headers={"x-api-key": OBSIDIAN_API_KEY},
)
removed = resp.json() # {"ok": true, "photos_removed": …, "identities_removed": …}Response Example
{
"ok": true,
"photos_removed": 4821,
"identities_removed": 4796
}photos_removedintegeridentities_removedintegerErrors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing or wrong x-api-key. |
404 | not_found | No such batch for this tenant. |
409 | import_in_progress | The batch is still pending or running. Wait for it to finish. |
409 | already_deleted | The batch has already been deleted. |
Deletion does not require the IDENTITY_IMPORT entitlement to still be
granted, for the reason above: withdrawing the feature must never strand the
biometrics it created.
Import vs. a session
| Identity import | Session | |
|---|---|---|
| Proves who the person is | ❌ — you assert it | ✅ — document + liveness + face match |
| Produces a decision | ❌ | ✅ — decision + decision_v3 |
| Applicant involvement | ❌ — nobody is asked anything | ✅ — hosted flow |
| Makes the face 1:N searchable | ✅ | ✅ |
| Evidence retention sweep | ❌ — kept until the batch is deleted | ✅ |
| Best for | Migrating an existing customer base | Verifying a new customer |
Tip
Import gives you recall on day one; sessions give you assurance. The usual
pattern is to import the back catalogue once, then verify each customer
properly the next time they come through — passing vendor_data so the session
lands on the identity the import created.
Common pitfalls
Warning
Verifying an imported customer without vendor_data. The single most common
failure after a migration: a fresh identity is created, duplicate detection
matches it against the imported record, and the session goes to review. Always
send the same unique id you imported.
Warning
Photo URLs that expire, or that are only reachable inside your network. We
fetch them from our servers, not from your browser. A pre-signed URL that has
already expired, an intranet host, or anything resolving to a private address
fails with fetch_failed, dns_failed or blocked_address.
Warning
Group photos and HEIC exports. Phone libraries default to HEIC and staff
directories are full of two-person shots — those rows fail with
unsupported_image_type and multiple_faces. Transcode to JPEG/PNG and crop to
one person before importing.
Note
Assuming the batch failed because some rows did. failed counts rows;
the batch status is separate. A completed batch with failed: 37 did its
job — pull ?status=error, fix those 37 photos, and re-import just them.
FAQ
Related
Import an existing customer base
The migration guide — pilot batch, CSV columns, and what to do with the row report.
Standalone tools
Face search over the gallery an import populates.
Sessions
Verify a customer properly — and pass vendor_data to land on the imported identity.
Usage & credits
The ledger where per-record import debits land.