Reference
Import an existing customer base
Bulk-enrol people you already hold elsewhere so their faces are searchable by 1:N — CSV or API, with a pilot batch first.
You're migrating onto ThirdFactor and you already hold the people you care about: a customer id, a name, a photo, sitting in another system. Identity import gets those faces into your 1:N gallery so that from day one a new applicant can be searched against your whole existing base — without putting anyone through a verification flow.
This guide walks the whole job: deciding whether you need it, getting the feature switched on, preparing the file, piloting it, running it, reading the report, proving it worked, and undoing it. The full endpoint contract — every field, every error code — lives in the Identities API reference; this page is the walkthrough.
Do you actually need this?
Import earns its place when you want 1:N face search over people who have not verified with us yet. Concretely, once a base is imported:
- A new applicant's selfie is checked against your existing customers during their session, so someone re-applying under a new name is flagged as a duplicate identity.
- An operator can take any photo — a branch camera still, a support ticket attachment — into Console → Tools → Face Search (1:N) and get back the customer id it belongs to.
- Your migrated customers appear in Console → Identities with the same
external_user_idyour own system uses, so later verifications accrete onto the same person rather than creating a second record.
Warning
An import is not a verification. Nobody stood in front of a camera. We did
not read a document, run a liveness check, screen anyone against sanctions or
PEP lists, or score anyone's risk. An imported person lands as an identity with
kyc_status: unknown, no session history, and one face photo whose only
provenance is the spreadsheet you uploaded. Never treat "they're in the
registry" as "they've been KYC'd."
If what you actually want is proof of identity for these people, you want verification sessions, not an import — or both: import now so 1:N works immediately, then re-verify the base at your own pace.
Get IDENTITY_IMPORT granted
It is opt-in per tenant and not self-serve. Ask the platform team.
Prepare a CSV
Unique id + photo URL are the only required columns. Getting the unique id right is the single highest-stakes decision here.
Pilot 20–50 rows
Read the report before you point us at 40,000 photos. This is the step that saves you.
Run the base, in batches of ≤ 5,000
Console upload or POST /v3/identities/import/. Rows fail individually.
Fix the failures, re-upload just those
The report CSV is designed to be edited and fed straight back in.
1. Get the feature switched on
IDENTITY_IMPORT is opt-in. Every other module in the product is on unless
the platform switches it off; this one is off until the platform switches it
on for your named tenant, because it writes biometric records with no
verification behind them. There is no toggle you can flip yourself.
Until it's granted, the API answers:
{ "detail": "feature_not_enabled", "features": ["IDENTITY_IMPORT"] }with 403, and the console shows no Import button on Identities.
Note
The grant covers starting imports. Reading a past batch and deleting one never require it — deliberately. Imported photos sit outside the automatic retention sweep, so deleting the batch is the only way to dispose of them; if withdrawing the grant also closed that door, it would strand the biometrics it created. A tenant whose grant was later withdrawn still sees an Imports button, as long as it has batches on file, for exactly that reason.
Operators also need the console_manage_kyc permission to use the console
path. API callers use a normal tenant API key (x-api-key).
2. Prepare the file
Two columns are required. Everything else is optional and only ever used to fill blanks.
| Column | Required | What it's for |
|---|---|---|
external_user_id | Yes | Your unique id for this person. Keys the identity. |
photo_url | Yes | An https URL we can fetch the face photo from. |
full_name | No | Display name in the console and in duplicate reports. |
email | No | Contact detail, filled only if the identity has none. |
phone | No | Same. |
country | No | Two-letter code. Anything else is dropped silently. |
Column order doesn't matter, unknown extra columns are ignored, and blank lines are skipped — so an export from your old system usually needs no editing at all. Excel's UTF-8 BOM is tolerated. These header spellings are accepted as aliases:
| Canonical | Also accepted |
|---|---|
external_user_id | id, unique_id, uniqueid, customer_id, user_id |
full_name | name, fullname, customer_name |
photo_url | photo, image, image_url, photo_link, picture |
email | email_address |
phone | mobile, phone_number |
A valid file is as small as this:
external_user_id,full_name,photo_url,email,phone,country
CUST-00001,Aarati Shrestha,https://files.example.com/photos/1.jpg,[email protected],+9779800000000,NP
CUST-00002,Bikash Thapa,https://files.example.com/photos/2.jpg,,+9779800000001,NP
CUST-00003,Chandra Gurung,https://files.example.com/photos/3.png,,,NPThe console hands you a one-row starter file with those columns already spelled correctly: Identities → Import → Template.
Getting the unique id right
This is the decision that outlives the migration. Spend a minute on it.
external_user_id is what a person is keyed on for the rest of their life in
the product. When that same person later verifies, the session's vendor_data
resolves the identity by exactly this value.
Warning
The id you import under must be the same value you later pass as
vendor_data. If you import Aarati as CUST-00001 and her verification
session is created with vendor_data: "user-90210" — or with no vendor_data
at all — she becomes a second identity. Her selfie then matches her own
imported photo, the duplicate check fires, and her session is held for review
as a duplicate of herself. Multiply that by your whole customer base and your
review queue becomes the migration.
So before you export anything, answer: which field in my system will my
backend send as vendor_data when this customer verifies? Use that field, in
that exact spelling — same case, same prefix, no trimming of leading zeros in
one place and not the other. If your session-create call sends
vendor_data: user.id, then external_user_id is user.id. Nothing else.
Tip
Check this with one person rather than trusting the export. Import a single
test row, then create a verification session with the same string as
vendor_data and confirm in Console → Identities that you end up with
one identity carrying both the imported photo and the new session — not
two rows.
3. Make the photos reachable
You give us URLs; we fetch each one server-side. That fetcher is deliberately strict, because "download whatever string the file says" is how a migration tool turns into an SSRF vector.
| We accept | We refuse |
|---|---|
https:// URLs (plain http only if your deployment explicitly allows it) | Any other scheme — bad_scheme |
| Hosts that resolve to publicly routable addresses | Private, loopback, link-local, reserved, NAT64/6to4 ranges — blocked_address |
| Up to 3 redirects, each hop re-validated | A longer chain — too_many_redirects |
| JPEG or PNG, sniffed from the file's own bytes | Anything else, whatever the Content-Type claims — unsupported_image_type |
| Files up to 8 MB | Larger — file_too_large |
A host allowlist can narrow this further per deployment (host_not_allowed),
and a URL that doesn't resolve fails as dns_failed, one that errors or times
out as fetch_failed.
Practical advice that matters more than the rules:
- One face per photo. A row whose photo has no readable face fails as
no_face_detected; a group shot fails asmultiple_faces. The second one is a refusal on purpose — enrolling an arbitrary person from a family photo under a customer's id is worse than failing the row. - Don't feed us ID-card scans. A passport page or a citizenship card where the portrait is a small corner region is a bad enrolment even when a face is detected: you are enrolling a photo of a photo. Use the headshot your old system already stored.
- Re-export, don't hotlink. Photos behind a session cookie, a signed URL that expires in five minutes, or a "view in app" page that returns HTML will all fail. Put the originals somewhere boring and publicly readable over https for the duration of the migration, then take it down.
- Test one URL from outside your network first.
curl -sIthe first row's photo from a machine that isn't your own laptop or VPN. An internal-only asset host is the single most common reason an entire import fails on row one and every row after it.
4. Pilot a small batch first
Do not point us at your whole base on the first run. Take 20–50 rows and import those, then read the report before doing anything else.
Choose the pilot rows deliberately rather than taking the first 50 alphabetically:
- a handful from each era of your data — the oldest records usually have the worst photos;
- any rows whose photos came from a different source (a scanned intake form, a different branch, an older app version);
- two or three people you personally know are in the base twice, to see what duplicate reporting says about them;
- one person you can later run a real verification for, to prove the
vendor_datalinkage end to end.
What a good pilot looks like: most rows created, zero or one
no_face_detected, no fetch_failed, no blocked_address, no
embedding_mismatch. That's a base worth importing in full.
What a pilot that just saved you looks like: every row fetch_failed (your
photo host isn't reachable from outside), or 40% multiple_faces (someone
exported group photos), or every row blocked_address (you gave us an internal
hostname). Each of those found on 50 rows costs you ten minutes. Found on
40,000 rows, it costs you a support ticket, a delete, and a re-run.
Tip
Pilot against a non-production tenant if you have one. If you must pilot in production, delete the pilot batch afterwards — it's two clicks in Past imports, and it keeps your registry clean of test rows.
5. Run the import
From the console
- Console → Identities → Import.
- Choose CSV. The file is parsed in your browser, so nothing is uploaded until you press the button; you get a preview of the first 20 rows and a count. Rows with no unique id or no photo URL are counted as skipped right there, with the first few named by row number, before anything is sent.
- Fill in Source system (e.g.
Legacy CRM) and Consent reference (e.g.Customer T&C v3 accepted at signup). Both are required — see the note below. - Choose what happens if a face already exists (see the table below).
- Press Import N. A progress bar tracks the worker; you can hit Run in the background and close the dialog, the import carries on.
- When it finishes, a link labelled Download the N rows that didn't land
appears whenever
totalexceedscreated + updated— N is that gap, so it covers rows that failed, rows that were skipped, and rows a stopped batch never reached. Below it, if anything landed, a second link offers the enrolled rows with any face matches found. A batch where nothing landed shows only the first link.
From the API
curl -X POST "$TF_BASE_URL/v3/identities/import/" \
-H "x-api-key: $TF_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",
"email": "[email protected]",
"country": "NP"
},
{
"external_user_id": "CUST-00002",
"full_name": "Bikash Thapa",
"photo_url": "https://files.example.com/photos/2.jpg",
"phone": "+9779800000000"
}
]
}'const MAX_ROWS = 5000; // per batch
async function importChunk(identities: Identity[]) {
const res = await fetch(`${process.env.TF_BASE_URL}/v3/identities/import/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!, // secret — server only
"content-type": "application/json",
},
body: JSON.stringify({
identities, // ≤ MAX_ROWS
source_system: "Legacy CRM",
consent_reference: "Customer T&C v3 accepted at signup",
on_duplicate: "report",
}),
});
if (res.status === 403) throw new Error("IDENTITY_IMPORT not granted yet");
if (!res.ok) throw new Error(`obsidian ${res.status}: ${await res.text()}`);
const { batch_id, total } = await res.json(); // 202 Accepted
await db.importBatches.insert({ batchId: batch_id, total, rows: identities.length });
return batch_id;
}
// Walk the base in batches, one at a time — each one is a few thousand
// downloads and embeddings on our side.
for (const chunk of chunked(await loadCustomers(), MAX_ROWS)) {
const batchId = await importChunk(chunk);
await waitForBatch(batchId); // poll, see step 6
}import itertools, os, requests
MAX_ROWS = 5000
def chunked(rows, size):
it = iter(rows)
while chunk := list(itertools.islice(it, size)):
yield chunk
def import_chunk(identities: list[dict]) -> str:
resp = requests.post(
f"{os.environ['TF_BASE_URL']}/v3/identities/import/",
headers={"x-api-key": os.environ["TF_API_KEY"]},
json={
"identities": identities, # <= MAX_ROWS
"source_system": "Legacy CRM",
"consent_reference": "Customer T&C v3 accepted at signup",
"on_duplicate": "report",
},
timeout=30,
)
if resp.status_code == 403:
raise RuntimeError("IDENTITY_IMPORT not granted yet")
resp.raise_for_status()
return resp.json()["batch_id"] # 202 Accepted
for chunk in chunked(load_customers(), MAX_ROWS):
batch_id = import_chunk(chunk)
wait_for_batch(batch_id) # poll, see step 6The call returns 202 immediately — each row is a download plus a face
embedding, so the work happens on a worker:
{
"batch_id": "7f1c2a90-4b6d-4e15-9a02-3d8c1b7e5f44",
"status": "pending",
"total": 2
}Note
source_system and consent_reference are required, and they are not
decoration. Every other face in the product traces back to a session where
someone stood in front of a camera and accepted your terms. An import has
neither, so these two strings are the only record of where the face came from
and on what basis you hold it. They are stamped onto every identity the import
touches (metadata.import), and the console's Past imports list labels
each batch with its source_system and shows the consent_reference when you
hover that label. Write something a compliance officer would accept in a year's
time, not "migration".
Choosing on_duplicate
The batch searches each new face against your gallery — including faces enrolled earlier in the same file, so the same person filed under two different ids in one CSV is caught. A row is never reported against the identity it belongs to, so re-importing someone or giving them a second photo is not a "duplicate".
| Value | What happens |
|---|---|
report (default) | Import the row and record the matching identities, with scores, on the row. Nothing is blocked; you review afterwards. |
skip | Don't enrol a row whose face already matches a different identity. The row ends as skipped / duplicate. |
none | Don't check at all. Fastest; you learn nothing about duplicates in your own data. |
An unrecognised value is a 400 invalid_on_duplicate — it is never quietly
coerced to the default, because "SKIP" silently becoming report would write
thousands of biometric rows you asked us not to write.
Tip
Start with report. A migration's duplicate rate is genuinely interesting
information about the system you're leaving, and report is the only setting
that hands it to you. Switch to skip only if you already know the source data
is full of duplicates and you don't want them in the registry.
Note
Batches are capped at 5,000 rows (deployment-configurable). Larger bases go in
chunks, as in the examples above. Run chunks sequentially rather than firing
twenty at once — POST /v3/identities/import/ shares the per-tenant tools rate
bucket and will answer 429 if you hammer it.
6. Read the outcome
Poll the batch. The console does this for you; over the API:
async function waitForBatch(batchId: string) {
for (;;) {
// Ask only for the rows that didn't land — error, skipped, and (after a
// batch that stopped early) the ones never reached.
const b = await fetch(
`${BASE}/v3/identities/import/${batchId}/?status=error,skipped,pending&page_size=200`,
{ headers: { "x-api-key": KEY } },
).then((r) => r.json());
if (b.status === "completed" || b.status === "failed") {
console.log(`${b.created} created, ${b.updated} updated, ` +
`${b.skipped} skipped, ${b.failed} failed` +
(b.error ? ` — stopped: ${b.error}` : ""));
return b; // b.rows holds just the problem rows
}
console.log(`${b.processed}/${b.total}…`);
await sleep(5000); // poll every 5s — don't tight-loop
}
}A finished batch reads like this:
{
"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": 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": 1,
"page": 1,
"page_size": 50
}What each row status means
| Row status | Meaning | What to do |
|---|---|---|
created | No identity had this external_user_id; one was created and the face enrolled. | Nothing. |
updated | The identity already existed; the face was added to it, and blank contact fields were filled. | Nothing. |
skipped | Under on_duplicate: skip, the face matched an existing identity. matches names it. | Decide whether it really is the same person. |
error | The row failed — detail says why. | Fix and re-import that row. |
pending | Never reached, because the batch stopped early. | Re-import these as-is once the cause is fixed. |
The batch itself is pending, running, completed or failed. A failed
batch keeps everything that already landed — error tells you why it stopped
(insufficient_credits, face_engine_unavailable, interrupted,
internal_error).
Info
The full list of per-row detail slugs — every URL, image and face failure —
is in the Identities API reference. The ones
you'll actually see in a migration are no_face_detected, multiple_faces,
fetch_failed and blocked_address.
Fix and re-upload just the failures
The report CSV is built for exactly this loop. In the console, the
Download the N rows that didn't land link appears under a finished batch
whenever total is greater than created + updated — including a batch that
stopped early with nothing failed and nothing skipped, where every missing row
is simply one nobody reached. N is that gap, and the file holds the failed,
skipped and never-reached rows together, each with its status, reason and a
human sentence:
row,external_user_id,full_name,photo_url,status,reason,detail,matches
2,CUST-00002,Bikash Thapa,https://files.example.com/photos/2.jpg,error,no_face_detected,No face could be read in this photo.,
7,CUST-00007,Deepa Rai,https://files.example.com/photos/7.jpg,error,multiple_faces,The photo has more than one face.,Fix the photo_url column, delete the extra columns (unknown columns are
ignored anyway, so you can also leave them), and upload the file again as a new
import. Re-importing a person who already landed is safe: the row comes back
updated, and contact fields that are already filled are left alone.
Note
Those two links belong to the run you are watching; closing the dialog clears them. The batch itself doesn't go anywhere — reopen Import and use the Report link beside it under Past imports, which hands back every row of the batch, landed or not.
Warning
An import never overwrites data that came off a verified document. Name, email, phone and country are merged only where the identity's field is currently blank, so re-importing someone with corrected values silently changes nothing.
Don't reach for the console instead: tags are the only field an operator can edit on an identity — name, email and phone are shown there read-only. A wrong detail on an already-populated identity is a support matter, not something the import path can fix.
7. Confirm it actually worked
Don't take the counters as proof. Search a face you can recognise.
In the console: Tools → Face Search (1:N), upload a photo of someone you
just imported — ideally a different photo of them than the one you imported,
which is the only thing that proves the gallery is matching faces rather than
files. The match should come back with their external_user_id. (That page is
a separate tool with its own permission and its own entitlement; if it isn't in
your sidebar, the API call below does the same search.)
Over the API:
curl -X POST "$TF_BASE_URL/v3/tools/face-search/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"image_base64":"'"$(base64 -i known-person.jpg)"'","top_k":5}'import base64, requests
probe = base64.b64encode(open("known-person.jpg", "rb").read()).decode()
matches = requests.post(
f"{BASE}/v3/tools/face-search/",
headers={"x-api-key": KEY},
json={"image_base64": probe, "top_k": 5},
timeout=15,
).json()["matches"]
assert matches and matches[0]["external_user_id"] == "CUST-00001"A hit carries individual_id, name, external_user_id, status,
kyc_status, score and a thumb — and on a freshly imported person that
kyc_status reads unknown, which is the shape of the warning at the top of
this page. If the top hit is the person you expect, the gallery is live. Finish the check by
creating one real verification session with vendor_data set to that same
external_user_id and confirming it lands on the existing identity rather than
minting a new one.
Note
Face search is a recall aid, not a verdict — see Face match. A hit tells an operator "look at this person"; it doesn't approve or decline anyone.
8. Undoing an import
Deleting a batch is the clean-up lever, and for imported faces it is the only one.
curl -X DELETE "$TF_BASE_URL/v3/identities/import/$BATCH_ID/" \
-H "x-api-key: $TF_API_KEY"{ "ok": true, "photos_removed": 4821, "identities_removed": 4790 }In the console it's the Delete link next to the batch in Identities → Import → Past imports. It appears once the batch has finished, and it asks you to confirm.
| Deleting a batch removes | It deliberately keeps |
|---|---|
| Every face photo the batch enrolled — the file and the row — which is what makes the person searchable | Identities the batch merely updated; they predate the import |
| Identities the batch created, while they are still nothing but an import | Identities that have since verified, gained another face, or gained a document — they are no longer the import's to delete |
Two 409s to expect: import_in_progress (wait for the batch to finish —
deleting under a running worker would race it) and already_deleted.
Warning
Imported photos are outside the automatic retention sweep. The nightly purge walks completed sessions; an imported photo hangs off no session, so it is never reached. That is intentional — a gallery that quietly aged itself out would stop answering 1:N searches with no warning — but it means these photos live until you delete the batch. If you have a retention commitment or an erasure request covering bulk-enrolled faces, batch delete is how you honour it. Keep the batch id.