> ## Documentation Index
> Fetch the complete documentation index at: https://docs.niadra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity and verification

> Handles, assertions, profiles that merge and unmerge, and levels V0 to V4.

Niadra recognizes the same customer on WhatsApp, on the phone, in your app and in your CRM. It does so without guessing: every link between two identifiers is a recorded assertion, with a method and evidence, and every link can be undone. This page covers handles, profiles, assertions and the verification levels that decide what each conversation may read.

## Handles

A **handle** is an identifier of a subject in some channel or system. It has a `type`, a `value` and, when the type needs one, a `scope`:

| `type`                      | What it is                                                 | `scope`                       |
| --------------------------- | ---------------------------------------------------------- | ----------------------------- |
| `phone_e164`                | A phone number in E.164, such as `+14155550123`            |                               |
| `wa_id`, `wa_jid`, `wa_lid` | Identifiers WhatsApp delivers                              |                               |
| `wa_bsuid`                  | A WhatsApp user id scoped to one business account          | The WhatsApp Business account |
| `email`                     | An e-mail address, lowercased                              |                               |
| `gov_id_hmac`               | An HMAC of a national ID number, never the number in clear | The country                   |
| `app_user_id`               | The user id in your app or site                            |                               |
| `system_id`                 | The id of the subject in a system of record                | The system, such as `crm`     |
| `org_registry_hmac`         | An HMAC of a company registry number                       | The country                   |
| `email_domain`              | An organization's e-mail domain, only as a hint            |                               |
| `anon_id`                   | A visitor or device not yet identified                     |                               |

Values are classified by their format, never by the name of the field they came from, and normalized on the server. The SDKs have helpers that build each type:

<CodeGroup>
  ```python Python theme={null}
  from niadra import phone, email, whatsapp, whatsapp_bsuid, system_id, app_user, anonymous

  phone("+1 415 555 0123")          # phone_e164: +14155550123
  system_id("crm", "48213")         # system_id in the crm namespace
  whatsapp_bsuid("US.1a2b3c", "waba-771")
  ```

  ```typescript TypeScript theme={null}
  import { handles } from "@niadra/sdk";

  handles.phone("+14155550123");
  handles.systemId("48213", "crm");
  handles.waBsuid("US.1a2b3c", "waba-771");
  ```
</CodeGroup>

Every handle belongs to a kind of subject, in `subject_kind`: `person` (the default), `account` or `partner`. `org_registry_hmac` and `email_domain` are organization handles from the start. See [Accounts and partners](/en/concepts/accounts).

## Profiles and assertions

A **profile** is the customer as the memory sees them: the set of handles linked to one another. Each link is an **assertion** in an immutable log, with the method that produced it:

| Method              | When it happens                                                         |
| ------------------- | ----------------------------------------------------------------------- |
| `explicit_identify` | You stated it, through `identify()`                                     |
| `otp`, `login`      | The customer proved possession of the handle                            |
| `system_import`     | Initial load from your CRM or a file                                    |
| `same_event`        | Two strong handles arrived in the same event, such as phone and `wa_id` |
| `external_resolver` | Your own identity system resolved it and sent the result                |
| `declared`          | The customer said so; weak until confirmed                              |

When two handles in one event resolve to different profiles, the merge happens outside the batch answer, under a lease. Memory stays attached to the handle each fact came from, so splitting two profiles merged by mistake is a real operation: facts, orders and invoices go back to the right owner on their own.

<CodeGroup>
  ```python Python theme={null}
  niadra.identify(
      [phone("+14155550123"), system_id("crm", "48213")],
      method="system_import",
  )
  ```

  ```typescript TypeScript theme={null}
  await niadra.identify({
    handles: [handles.phone("+14155550123"), handles.systemId("48213", "crm")],
    method: "system_import",
  });
  ```

  ```bash cURL theme={null}
  curl -X POST "https://acme-prod.us-east-1.api.niadra.com/v1/batch" \
    -H "Authorization: Bearer $NIADRA_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"items": [{"type": "identify", "idempotency_key": "idf-48213", "method": "system_import",
         "handles": [{"type": "phone_e164", "value": "+14155550123"}, {"type": "system_id", "value": "48213", "scope": "crm"}],
         "occurred_at": "2026-09-22T17:00:00Z"}]}'
  ```
</CodeGroup>

`identify()` is sent right away, outside the queue, so the next `context()` already sees the merged profile.

## Safeguards against wrong merges

* Two profiles that already hold a handle at V3 or above are never merged automatically; only by a forced merge, with actor and reason.
* Test values and QA placeholders are blocked per type, from a fixed list you can extend.
* Each profile has per-type limits: one national ID per country, one `system_id` per namespace, a few phones. Past the limit, the weakest handle is demoted, and ingestion is never rejected.
* A handle that links many profiles in a short time (a store tablet, a PBX number, a support WhatsApp) becomes `ambiguous`: usable for reads, never for new merges.
* A phone number or BSUID rotation creates an alias, never an automatic merge.
* A person and an organization are never merged. A link is what connects them.

An unknown, partial (a BSUID with no phone yet) or ambiguous handle gets a provisional profile and an empty context. When in doubt, the memory reveals nothing.

## Governing identity

Your team corrects identity through the governance routes, with a Console person holding the `security` or `integration` role, or a source key with the `admin` scope. Every write requires an `Idempotency-Key` header, answers **202** with an operation id and is applied under the profile lease, in order:

| What                                    | Route                                                                                                                                 |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| See why two handles are one profile     | [`GET /v1/identity/assertions`](/en/api/identity-assertions), filtered by `profile_id` and `state` (`applied`, `pending`, `rejected`) |
| State that handles belong together      | [`POST /v1/identity/assertions`](/en/api/identity-assertions-create), with `handles`, `method` and `reason`                           |
| Undo one wrong link                     | [`POST /v1/identity/assertions/{assertion_id}/retract`](/en/api/identity-assertion-retract)                                           |
| Force a merge the safeguards refused    | [`POST /v1/identity/merge`](/en/api/identity-merge), with `profile_ids`, `force` and `reason`                                         |
| Split a profile                         | [`POST /v1/identity/unmerge`](/en/api/identity-unmerge), with `profile_id` and the `handle_ids` or `assertion_ids` to separate        |
| Stop a value from ever linking profiles | [`POST /v1/identity/handles/block`](/en/api/identity-handles-block)                                                                   |

Niadra also proposes links it cannot prove, such as two profiles with the same name and a similar e-mail. It never applies them on its own: [`GET /v1/identity/suggestions`](/en/api/identity-suggestions) lists them with a `score` and the features behind it, [accept](/en/api/identity-suggestion-accept) turns one into an `accepted_suggestion` assertion, and [dismiss](/en/api/identity-suggestion-dismiss) closes it.

To find a profile from a handle, use [`POST /v1/profiles/search`](/en/api/profiles-search): the value goes in the body and is never logged. [`GET /v1/profiles/{profile_id}`](/en/api/profile) returns its handles, links, subject kind and pseudonym, masked for the role of whoever reads.

## Verification levels

The same customer gets different context depending on how much the conversation proved who they are:

| Level         | Meaning                         | Example                                                             |
| ------------- | ------------------------------- | ------------------------------------------------------------------- |
| `V0`          | Self-declared                   | "This is Marina" in an anonymous chat                               |
| `V1`          | Plausible by channel            | Came from that number, no strong attestation                        |
| `V2`          | Attested by the channel         | A call with network attestation A, an authenticated WhatsApp sender |
| `V3`          | Challenged                      | OTP on the same channel or a login in your app                      |
| `V4`          | Documentary or by an attendant  | Checked against the system of record or by a person                 |
| `no_customer` | A task with no customer present | Internal billing agent                                              |

Your policy sets the minimum level per category of memory. The context reports in `withheld` how many items were held back: the agent knows that verifying releases more, without seeing the content. `no_customer` sits outside the scale and is only accepted from sources with the `internal_agent` audience; that context never reaches the end customer.

## Effective level

The level that applies is the lowest of three: what you requested, the **source ceiling** and what the conversation proved. The ceiling is an attribute of the source: automated agents V2, sources that run OTP V3, V4 only for human desks and systems of record. A leaked agent key cannot declare V4.

The context response shows both sides in `verification`: `requested`, `effective` and, when effective is lower, `reason` (`source_ceiling` or `not_proven`).

## Raising the level

The level goes up through a `verify` event, never by inference. It applies to that conversation or task, with an optional expiry. After it, request a new `context()`: the SDK has already dropped the cached context of that conversation.

<CodeGroup>
  ```python Python theme={null}
  niadra.verify(
      "otp_whatsapp",
      "V3",
      handle=phone("+14155550123"),
      conversation_id="wa-8812",
  )
  ctx = niadra.context(subject=phone("+14155550123"), verification="V3", conversation_id="wa-8812")
  ```

  ```typescript TypeScript theme={null}
  await niadra.verify({
    handle: handles.phone("+14155550123"),
    method: "otp_whatsapp",
    level: "V3",
    conversation_id: "wa-8812",
  });
  const ctx = await niadra.context({ subject: handles.phone("+14155550123"), verification: "V3", conversation_id: "wa-8812" });
  ```
</CodeGroup>

The methods are `otp_whatsapp`, `otp_sms`, `login`, `kba`, `network_attestation` and `human_agent`. A `verify` above the source ceiling comes back in the 207 with the code `verification_not_allowed`. For voice, the network attestation of the call (STIR/SHAKEN and equivalents) maps A to V2, and B and C to V1.

## Next steps

<CardGroup cols={2}>
  <Card title="Context and views" href="/en/concepts/context">
    what each level releases in the context.
  </Card>

  <Card title="Accounts and partners" href="/en/concepts/accounts">
    organizations and role-based links.
  </Card>

  <Card title="List assertions" href="/en/api/identity-assertions">
    the API of the identity log.
  </Card>

  <Card title="Identity suggestions" href="/en/api/identity-suggestions">
    links to review, never applied on their own.
  </Card>
</CardGroup>
