
# Identity Hub API

The Identity Hub is the server-to-server API you use to register the people and businesses you want to monitor as **entities**, and to read back their risk state. Once an entity exists you can stream its [events](https://trust.myaza.co/documentation/monitoring-events/markdown) for scoring.

## Base URL & authentication

All Identity Hub endpoints live under a single base URL, the same for sandbox and production:

```
https://trust.myaza.app/api/identity
```

Every endpoint requires a **secret (`sk_`) key** as a Bearer token: these are backend-only, server-to-server calls that read and write identity data, so a publishable (`pk_`) key is rejected with `403 secret_key_required`.

```bash
curl "https://trust.myaza.app/api/identity/entities/user_42" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

The key identifies your organisation and its environment; `sk_test_` keys operate on sandbox data, `sk_live_` on production. See [Authentication](https://trust.myaza.co/documentation/authentication/markdown).

## Register or update an entity

`POST /entities`: create or update an entity, keyed by your own `externalUserId`. The call is **idempotent**: sending the same `externalUserId` again updates the existing entity rather than creating a duplicate.

### Request

```json
{
  "externalUserId": "user_42",
  "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" },
  "type": "INDIVIDUAL",
  "kycProvenance": "MYAZA_VERIFIED",
  "kycSource": null,
  "profile": {
    "fullName": "John Doe",
    "dateOfBirth": "1990-01-01",
    "nationality": "NG",
    "idType": "bvn",
    "idNumber": "12345678901",
    "address": { "city": "Lagos", "country": "NG" },
    "declaredMonthlyVolume": 500000,
    "declaredTxnTypes": ["transfer", "deposit"],
    "walletAddresses": [
      { "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "chain": "bitcoin", "label": "withdrawal" }
    ]
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `externalUserId` | Yes | Your own stable reference for the user. The entity's primary key within your org + environment. |
| `metadata` | No | Your own correlation fields for this entity (a loan id, an account id). Bounded (16 KiB, four levels, 100 keys), returned on every read and carried on every [webhook](https://trust.myaza.co/documentation/webhooks/markdown#your-reference-on-every-event) about the entity. Sending it again replaces the stored object. |
| `type` | No | `INDIVIDUAL` (default) or `BUSINESS`. |
| `kycProvenance` | No | How the entity was KYC'd: `MYAZA_VERIFIED`, `EXTERNAL_VERIFIED`, or `UNVERIFIED` (default). |
| `kycSource` | No | Free-text label of the external verification source, when `kycProvenance` is `EXTERNAL_VERIFIED`. |
| `profile` | No | Declared attributes, all optional. `nationality` and declared fields feed monitoring rules (e.g. cross-border). |
| `profile.walletAddresses` | No | Up to 20 crypto wallet addresses (`{ address, chain?, label? }`, address 20–128 chars). Omit the key to leave the stored set unchanged; send an array (including `[]`) to replace it. Attaching a wallet enrols the entity in [`WALLET` screening](https://trust.myaza.co/documentation/screening/markdown#types); changing the set re-screens promptly. Wallets are risk attributes, never identity keys. |

### Response

`201 Created` for a new entity, `200 OK` for an update:

```json
{
  "entityId": "ent_01j9abc123",
  "identityId": "idn_01j9xyz456",
  "externalUserId": "user_42",
  "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" },
  "kycProvenance": "MYAZA_VERIFIED",
  "created": true,
  "screening": { "status": "QUEUED", "types": ["SANCTIONS", "PEP"] }
}
```

- **`metadata`** echoes your customer metadata exactly as stored.
- **`identityId`** is the entity's link to a **global identity**: a person or company shared across organisations and environments. It's `null` until resolution links one (see below).
- **`screening`** reflects [screening](https://trust.myaza.co/documentation/screening/markdown) enrolment. It's the string `"INACTIVE"` when screening is not configured for your organisation, never a fake `QUEUED`.

### KYC provenance & identity resolution

When you declare an entity **verified** (`MYAZA_VERIFIED` or `EXTERNAL_VERIFIED`) and supply an ID (`profile.idType` + `profile.idNumber`), the Hub runs **resolution**: it links the entity to a global identity, matching an existing one where the ID is already known or creating a new one. `UNVERIFIED` entities skip resolution and stay unlinked.

A linked identity carries its own `trustState` (`UNVERIFIED`, `VERIFIED`, `FLAGGED`), risk tier, and the set of verified identifiers (e.g. BVN **and** NIN) the person has accumulated across your org.

## Look up an entity

`GET /entities/:externalUserId`: fetch an entity by your own reference, scoped to the key's org and environment.

```json
{
  "entity": {
    "entityId": "ent_01j9abc123",
    "externalUserId": "user_42",
    "metadata": { "loanId": "loan_20191", "accountId": "acc_7732" },
    "identityId": "idn_01j9xyz456",
    "type": "INDIVIDUAL",
    "status": "ACTIVE",
    "disposition": "APPROVED",
    "kycProvenance": "MYAZA_VERIFIED",
    "kycSource": null,
    "kycVerifiedAt": "2026-04-27T12:00:00.000Z",
    "riskTier": "LOW",
    "riskScore": 12,
    "createdAt": "2026-04-27T12:00:00.000Z",
    "profile": { "fullName": "John Doe", "nationality": "NG" }
  },
  "identity": {
    "id": "idn_01j9xyz456",
    "type": "INDIVIDUAL",
    "trustState": "VERIFIED",
    "riskTier": "LOW",
    "riskScore": 12
  },
  "identifiers": [
    { "idType": "bvn", "idNumber": "12345678901" }
  ],
  "verifications": []
}
```

`identity` and `identifiers` are populated only when the entity is linked. `verifications` is the entity's full KYC history (newest first). A missing entity returns `404 { "error": "entity_not_found" }`.

## Update an entity

`PATCH /entities/:externalUserId`: change some of what you hold about an entity without resending the rest. Only the keys you send are touched; an absent key leaves the stored value alone, and `null` clears a nullable field. (`POST /entities` remains the full upsert, which replaces the whole profile.)

```json
{
  "metadata": { "loanId": "loan_20191", "tier": "gold" },
  "profile": { "nationality": "GH", "declaredMonthlyVolume": 750000 }
}
```

The body accepts the same `metadata`, `kycSource`, `kycProvenance` (`EXTERNAL_VERIFIED` or `UNVERIFIED` only) and `profile` fields as the create call. `externalUserId` and `type` cannot change: the reference is the entity's key, and a person does not become a business.

**Identity facts on a Myaza-verified entity are locked.** When `kycProvenance` is `MYAZA_VERIFIED`, its `fullName`, `dateOfBirth`, `nationality`, `idType` and `idNumber` came from a verification we ran, and a patch touching them is refused with `400 field_not_editable` naming the fields. Run a new verification to change them ([verify again](https://trust.myaza.co/documentation/api-verify-again/markdown)). Everything you declare yourself, contacts, address, declared volume, wallets, your own metadata, stays editable.

Changing `idType` or `idNumber` on an externally-verified entity re-runs identity resolution, exactly as the create call does.

An `address` you send is filed as declared evidence in the entity's address book, as `email` and `phone` are in its contact book. It may be one `addressLine`, or structured parts (`line1`, `line2`, `city`, `state`, `country` as ISO-2, `postcode`), which are kept and composed into the line. It becomes the headline address only while nothing a verification found outranks it: an address read from a government record or a proof-of-address document keeps that place. `declaredMonthlyVolume` takes an optional `declaredVolumeCurrency` (an ISO-4217 code such as `NGN`).

### Response `200 OK`

```json
{
  "entityId": "ent_01j9abc123",
  "externalUserId": "user_42",
  "identityId": "idn_01j9xyz456",
  "changedFields": ["metadata", "nationality", "declaredMonthlyVolume"],
  "screening": null
}
```

`changedFields` names what actually moved (empty when nothing did). `screening` is set only when the wallet set changed and WALLET screening was re-queued. Your endpoints receive `entity.updated` carrying the same field names, never the values.

## Delete an entity

`DELETE /entities/:externalUserId`: remove the entity from your organisation. The body may carry a `reason` (up to 500 characters) for your audit log.

Deletion is **soft**. The entity leaves your dashboard and every Identity Hub read, its ongoing screening is paused (history, adjudications and cleared matches are kept), and its verifications stay where they are. The global identity it resolved to is untouched: that record is shared and belongs to the person. Myaza keeps the entity, marked as deleted, and can restore it on request; there is no restore endpoint.

**Registering the same `externalUserId` again brings it back.** A new `POST /entities`, a new verification for that user, or activity for it through the [events API](https://trust.myaza.co/documentation/monitoring-events/markdown) revives the deleted entity in place rather than creating a second one, because the reference is the key.

```bash
curl -X DELETE "https://trust.myaza.app/api/identity/entities/user_42" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Account closed at the customer\u2019s request" }'
```

### Response `200 OK`

```json
{
  "entityId": "ent_01j9abc123",
  "externalUserId": "user_42",
  "deleted": true,
  "deletedAt": "2026-09-05T10:12:00.000Z",
  "screeningPaused": true
}
```

Your endpoints receive `entity.deleted`. A deleted entity answers `404 entity_not_found` on every other endpoint, the same as one that never existed.

## Bulk import

For backfilling an existing user base, `POST /entities/import` accepts **1–1000** entities in one call. It's **durable and asynchronous**: the request is persisted and acknowledged with `202` immediately, then a worker processes each item in the background (nothing is lost on a restart).

### Request

```json
{
  "entities": [
    { "externalUserId": "user_1", "type": "INDIVIDUAL", "kycProvenance": "EXTERNAL_VERIFIED", "kycSource": "acme-kyc" },
    { "externalUserId": "user_2", "type": "BUSINESS" }
  ]
}
```

Each element uses the same shape as a single [entity registration](#register-or-update-an-entity).

### Response

```json
{ "jobId": "job_01j9def789", "status": "PROCESSING", "total": 2 }
```

### Poll import progress

`GET /entities/import/:jobId`:

```json
{
  "jobId": "job_01j9def789",
  "status": "COMPLETED",
  "totalCount": 2,
  "succeededCount": 2,
  "failedCount": 0,
  "pendingCount": 0,
  "createdAt": "2026-04-27T12:00:00.000Z",
  "completedAt": "2026-04-27T12:00:05.000Z",
  "failures": []
}
```

`failures` lists up to 50 failed items with `externalUserId` and an `error` string so you can retry them. An unknown job returns `404 { "error": "import_job_not_found" }`.

## Address surface

Entities whose flow captured an [address pin](https://trust.myaza.co/documentation/address-intelligence/markdown) carry an address surface:

| Method | Endpoint | What it does |
|---|---|---|
| `POST` | `/entities/:externalUserId/address/verify` | Mint a [presence watch](https://trust.myaza.co/documentation/address-intelligence/markdown#presence-verification) for the captured pin. One live watch per entity; a repeat call returns the existing one (`existing: true`). `422 no_address_pin` when no pin was ever captured. |
| `POST` | `/entities/:externalUserId/address/revoke` | Revoke the live watch. |
| `POST` | `/entities/:externalUserId/address/reattest` | A short-window watch answering "still at this address: yes / no / unknown". Billed on resolution like any watch; never live whereabouts. |
| `GET` | `/entities/:externalUserId/address` | The stability attestation: capture tier, presence outcome, months of tenure, `strong`/`moderate`/`weak` grade, and moves in 24 months. |
| `GET` | `/entities/:externalUserId/address/packet` | The navigation packet for delivery and recovery teams: the pin with its Plus Code, the entrance-photo URL and the directions your own flow collected. |

## Monitoring switches

Two per-entity switches, the same ones the dashboard's entity page offers. Both are idempotent: switching on what is already on changes nothing and says so.

| Method | Endpoint | What it does |
|---|---|---|
| `POST` | `/entities/:externalUserId/screening/ongoing` | Body `{ "ongoing": true }` enrols the entity in sanctions, PEP and adverse-media screening where it is not yet enrolled and resumes any paused rows, so it is re-screened on your risk-based cadence; `false` pauses them. A pause keeps every past result and adjudication. Returns `{ ongoing, active, changed }`: `changed` counts the screening rows that moved, and `active: false` means screening is not running for your organisation at all, so nothing was enrolled. |
| `POST` | `/entities/:externalUserId/address/always-on` | Body `{ "enabled": true, "cadenceDays": 90 }` turns the entity's presence watch into an always-on chain that re-checks presence every `cadenceDays` (30, 60, 90, 180 or 365; default 90) and renews itself, minting a watch from the captured pin when none is live (`422 no_address_pin` when there is no pin). `{ "enabled": false }` stops the chain renewing; the check in flight still runs to its verdict. Returns `{ enabled, watchId, minted, cadenceDays }` or `{ enabled: false, stopped }`. Always-on monitoring is billed per entity per year at the cadence's rate. |

## Webhooks

Registering entities emits [webhook events](https://trust.myaza.co/documentation/webhooks/markdown) you can subscribe to:

| Event | When it fires | `data` |
|---|---|---|
| `entity.imported` | A new entity was created. | `entityId`, `identityId`, `externalUserId`, `environment` |
| `identity.resolved` | An entity was linked to a global identity (new or existing). | `entityId`, `identityId`, `environment`, `matchedExisting` |
| `entity.updated` | You edited an entity. | `entityId`, `externalUserId`, `identityId`, `type`, `changedFields` (names only, never values), `environment` |
| `entity.deleted` | You deleted an entity. | `entityId`, `externalUserId`, `identityId`, `type`, `deletedAt`, `environment` |
| `entity.restored` | Myaza restored an entity you had deleted, at your request. | `entityId`, `externalUserId`, `identityId`, `type`, `restoredAt`, `environment` |

## Errors

| Status | Body | Cause |
|---|---|---|
| `400` | `{ "error": "invalid_request", "details": … }` | The body failed validation; `details` is a field-level breakdown. |
| `401` | `{ "error": "Invalid API key" }` | Missing, unknown, or revoked key. |
| `403` | `{ "error": "secret_key_required" }` | A publishable (`pk_`) key was used; Identity Hub is secret-key only. |
| `400` | `{ "error": "field_not_editable", "fields": [...] }` | A patch tried to change identity facts on a Myaza-verified entity. |
| `404` | `{ "error": "entity_not_found" }` | No entity with that `externalUserId` in this org + environment, or it has been deleted. |

See [Errors](https://trust.myaza.co/documentation/errors/markdown) for the platform-wide list.

## Next steps

- [Event monitoring](https://trust.myaza.co/documentation/monitoring-events/markdown): stream this entity's transactions for scoring.
- [Screening](https://trust.myaza.co/documentation/screening/markdown): how watchlist enrolment and matches work.
