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:

CallNeeds 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 400source_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:

RuleRow 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

EndpointPurposeEntitlement
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/json
identitiesarraybodyrequired

The people to enrol. Capped at 5000 rows per batch (IDENTITY_IMPORT_MAX_ROWS). Split a larger migration into several batches.

source_systemstringbodyrequired

Which software this data came from, e.g. "Legacy CRM". Truncated to 120 characters.

consent_referencestringbodyrequired

The consent or lawful basis for holding these photos, e.g. "Customer T&C v3 accepted at signup". Truncated to 200 characters.

on_duplicatestringbodyDefault: report

report — 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_idstringbodyrequired

The person's unique id in your system (max 255). Matches an existing identity if one already carries it, otherwise creates one.

photo_urlstringbodyrequired

https URL (max 1000 chars) of a JPEG or PNG holding exactly one face.

full_namestringbody
Name, if you have one.
emailstringbody
Contact email.
phonestringbody
Contact phone.
countrystringbody
ISO 3166-1 alpha-2. Anything that isn't two letters is dropped silently.
curl -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();  // 202
import 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"]  # 202

Response Example

{
  "batch_id": "7f1c2a90-4b6d-4e15-9a02-3d8c1b7e5f44",
  "status": "pending",
  "total": 2
}
batch_idstring
Poll this with GET /v3/identities/import/{batch_id}/. Keep it — it is also the handle for deletion.
statusstring
pending on acceptance; the worker moves it to running.
totalinteger
Rows accepted into the batch.

Note

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>" }.

StatusCodeWhen
400no_rowsidentities was missing or empty.
400invalid_rowAn entry in identities wasn't an object.
400too_many_rowsMore than 5000 rows in one batch.
400source_system_requiredsource_system missing or blank.
400consent_reference_requiredconsent_reference missing or blank.
400invalid_on_duplicateon_duplicate wasn't report, skip or none.
401invalid API keyMissing or wrong x-api-key.
403feature_not_enabledIDENTITY_IMPORT not granted to this tenant. Body carries "features": ["IDENTITY_IMPORT"].
429rate-limitedOver 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>
statusstringquery

Comma-separated row statuses to return, e.g. error,skipped. Omit for every row.

pageintegerqueryDefault: 1
Page of rows.
page_sizeintegerqueryDefault: 50
Rows per page, max 200.
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
}
statusstring
pending, running, completed or failed.
processedinteger
Rows the worker has finished, live while running.
createdinteger
Rows that created a new identity.
updatedinteger
Rows that enrolled a face onto an identity that already existed.
skippedinteger
Rows left out under on_duplicate: "skip".
failedinteger
Rows that errored.
errorstring
Batch-level failure slug. Empty unless the whole batch stopped.
rowsarray
The per-row report, ordered by row number and paged.
row_countinteger
Total rows matching the status filter (not just this page).

Row statuses

statusMeaning
pendingNot processed yet — either still queued, or the batch stopped before reaching it.
createdA new identity was created and the face enrolled.
updatedThe face was enrolled onto an identity that already carried this external_user_id.
skippedMatched an existing identity under on_duplicate: "skip".
errorThe 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.

detailMeaning
external_user_id_requiredThe row carried no unique id.
photo_url_requiredThe row carried no photo URL.
bad_urlThe URL has no host.
bad_schemeNot http(s), or plain http on a deployment that hasn't enabled it.
host_not_allowedThe host isn't in the deployment's import allowlist.
dns_failedThe host could not be resolved.
blocked_addressThe host resolves to an address that isn't publicly routable.
fetch_failedThe download failed — non-2xx, timeout, empty body, or a redirect with no target.
too_many_redirectsMore than three redirect hops.
unsupported_image_typeThe bytes aren't JPEG or PNG.
file_too_largeOver the 8 MB cap.
no_face_detectedNo usable face in the photo.
multiple_facesMore than one face in the photo.
embedding_mismatchRead by a different face model than the tenant's existing gallery.
duplicateMatched an existing identity and was skipped (on_duplicate: "skip").
internal_errorThe 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.

errorMeaningWhat to do
insufficient_creditsThe balance ran out mid-batch.Top up, then re-import the rows still pending.
face_engine_unavailableThe face engine could not be reached.Retry the batch.
interruptedA worker died and a sweep recovered the batch.Re-import the rows still pending.
internal_errorUnexpected failure.Retry; contact support if it repeats.

Errors

StatusCodeWhen
401invalid API keyMissing or wrong x-api-key.
404not_foundNo such batch for this tenant.
429rate-limitedOver 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 stopsstatus: "failed", error: "insufficient_credits" — rather than enrolling the rest for free. The rows it never reached stay pending; 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_removedinteger
Face photos deleted.
identities_removedinteger
Identities the import created that had nothing else on file.

Errors

StatusCodeWhen
401invalid API keyMissing or wrong x-api-key.
404not_foundNo such batch for this tenant.
409import_in_progressThe batch is still pending or running. Wait for it to finish.
409already_deletedThe 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 importSession
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 forMigrating an existing customer baseVerifying 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