API reference
Customers
20 operations in this area of the contract. Every one declares the permission it requires.
Contract version 0.2.0All areas
Operations
/v1/cdd-formsThe CDD questions in force for a customer type and tier
BRD §7.2-05 requires the CDD/EDD fields and required documents per customer type to be configurable without a deploy, so the form is a read rather than a static asset.
The response carries the configuration provenance — `source` is `pack_default` or `tenant_override`, and `version` is the document version — so a submitted application records which form it was answering. Re-rendering a two-year-old application against today's definition would show a different set of questions than the customer actually saw.
The active document is re-validated against the storage vocabulary on the way out. A form containing a field the platform cannot store is refused HERE rather than at submit time with a customer at the counter (ADR-0024).
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customer_typerequired | query | string | `individual` or `corporate` in the shipped document (Q27). |
tierrequired | query | tier_1 | tier_2 | tier_3 | corporate | — |
Responses
200The resolved formCDDForm404The document defines no such customer type or tier. The message names what it does define.422—
/v1/customersThe customer book
Filters by status, risk and cursor, and free-text searches by `q` against the party's `display_name` and the customer's own `external_ref`, both by case-insensitive substring match against the trigram indexes migration 00022 added.
**`q` does not reach BVN or NIN.** Both are encrypted under the subject's own key with an HMAC blind index beside the ciphertext (ADR-0019), and the blind index is an exact-match structure — it answers "does this value equal this identifier", never "does this identifier contain this substring". `FindPartyByIdentifier` is the only lawful way to look one up, and it takes the whole value. Indexing plaintext identifiers to make substring search possible would put them back outside the encryption boundary this platform exists to keep them inside, so this endpoint does not do it. A front-office user searching by a partial BVN gets no matches on that term, not a slower or approximate one.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | draft | pending_documents | pending_edd | pending_approval | active | rejected | exited | — |
risk | query | low | medium | high | prohibited | — |
q | query | string | Matches the party's display name and the customer's own external_ref. Does not search BVN, NIN or any other encrypted identifier — see the operation description. |
limit | query | integer | Page size, capped at 200. |
cursor | query | string | Opaque keyset cursor from a previous response. Offset pagination is deliberately not offered: `OFFSET 500000` is a table scan, and if it were offered it would be used (ADR-0012). Only the collections that document this parameter can be resumed. The rest report `truncated: true` and refuse a cursor rather than silently returning the first page again — see the `truncated` field on the page schemas. |
Responses
200CustomersCustomerPage
/v1/customersOnboard a customer from a submitted CDD form
The write path BRD §4.1-03 gives the relationship/front-office persona, whose entire job description is "onboarding, CDD capture". Until this operation existed the role held `onboarding.customer.read` and nothing else, so the only job the BRD gives that persona could not be performed by anybody through this API at all.
One call, one transaction: the party with its names, identifiers and attributes; the customer relationship and its tier; and the submitted answers stored verbatim against the form version in force. Splitting it would permit a party with no customer — invisible to every list the front office has — or a customer whose CDD answers were never recorded.
**`answers` is keyed by the form's own field keys.** Call `GET /v1/cdd-forms` for the questions in force; a key that is not on the form, a required question left unanswered, or an answer to a question the form's `show_if` predicate did not ask, are all refused together as a `422` listing every problem at once. A front-office user correcting a form one field per round trip is how a five-minute account opening becomes a twenty-minute one.
**Numbers are decoded with `UseNumber` and are never rounded.** A number answered on the form is compared against the field's declared type as an integer, not as a float.
**No identifier is recorded as verified.** `verified_by_source` is unreachable from this path: identifiers are captured, encrypted and blind-indexed as UNVERIFIED, because there is no NIBSS or NIMC adapter and the previous behaviour took the caller's word for it (Q26).
Identifier requirement sets are enforced with the rest of the form. The official CBN baseline is Tier 1 individuals: BVN or NIN; Tiers 2/3: BVN and NIN. Submission proves presence only. Relationship approval separately requires authoritative electronic verification and refuses a mock or unverified value.
The form's document requirements are snapshotted onto the application. A relationship starts in `pending_documents` when that snapshot has requirements and advances to `pending_approval` as evidence is collected; a form with no required evidence starts directly in `pending_approval`. Approval still requires independent verification of every required, unexpired object (Q32 owns whether the configured list is correct).
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | The caller's own reference for this operation. ADR-0012 requires it on mutating endpoints because payment-adjacent callers retry and a duplicate filing is a regulatory incident. On the ingest endpoints it is not a separate identity: it supplies `external_ref` when the body omits it, and the uniqueness of (tenant, source, external_ref, booking_ts) in the database is what makes the retry harmless (ADR-0014). Sending both with different values is refused rather than resolved. |
Responses
201The customer, the party behind it, the stored application and the form it answeredOnboardResult400—409An identifier given already belongs to a party this platform holds. Refused rather than attached: deciding two records are the same person is a merge, and a merge performed implicitly by an account-opening screen is how two customers become one.422—503No CDD form definitions can be resolved on this installation, so no customer can be onboarded. A deployment fault rather than a problem with the request.
/v1/customers/{customerID}A customer profile with its risk decomposition
Returns the profile, the party behind it, its accounts, the latest risk assessment with per-factor contributions, and any resolved beneficial owners — in one response.
Identifiers are returned masked. Decrypting them is a separate, audited action (ADR-0019), because reading a customer's BVN is an event a supervisor is entitled to see a record of.
`assigned_org_unit` is present when the relationship has been routed to a branch or team (`POST /v1/customers/{customerID}/assign`), naming the unit's id and current name rather than only its id — the name is looked up at read time, not denormalised, so a renamed unit does not require rewriting every customer pointed at it.
`pending_application_id` names the customer's onboarding application still awaiting a decision, so the customer workspace and the application-review screen (`GET /v1/onboarding/applications/{applicationID}`) can be linked directly. Absent once the application has been decided, or if the customer has none outstanding.
`screening_hits` carries the party's sanctions and PEP matches, under a SECOND permission on top of the one that opens this route: a caller holding `onboarding.customer.read` but not `screening.hit.read` gets the profile with the key absent rather than empty, so reading the customer book cannot be used to acquire a screening surface the role was never granted.
`corporate_profile` is present when the party is a legal entity, and carries whether its beneficial-ownership register has been VERIFIED and not merely declared — see `GET /v1/customers/{customerID}/risk-assessments` for the sibling endpoint this response does NOT try to be: `risk_assessment` above is only the latest rating, and the full history lives there rather than growing this response without bound.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The customer workspaceCustomerDetail404—
/v1/customers/{customerID}Correct external_ref, or propose a governed customer-profile change
Until this operation existed there was no way to correct a customer once onboarded: `external_ref`, `segment`, `kyc_tier` and structured CDD facts could all be typed wrong at the counter and never fixed.
The two classes of field in this request are handled differently, deliberately.
**`external_ref` is applied directly**, in the same transaction, and the response is a plain `200` with the corrected customer. It is the institution's own customer number and feeds nothing else this platform reasons over — no rule, no threshold, no form resolution reads it — so a typo fixed here cannot silently move what the customer is measured against.
**`segment`, `kyc_tier` and `profile` are NOT applied directly, however this request arrives.** `segment` selects the monitoring thresholds every transaction on this customer's accounts is compared against; `kyc_tier` selects the CDD form and the documents BRD §8.1 requires for the relationship. Both are exactly the class of change this codebase already requires a second reviewer on elsewhere — a configuration threshold, a monitoring rule, the risk model that assigns tiers in the first place, a case's outcome. Address geography, occupation/industry, nationality/country, source of funds, cash intensity and beneficial ownership are themselves screening corroboration, risk inputs or CDD evidence. A manual correction to any of them raises a maker-checker proposal (`workflow.ActionCustomerFieldChange`) instead of changing anything. `reason` is required whenever any governed field is sent: it is what the checker reads before deciding and is retained in workflow and audit metadata. Because it is readable free text, it must not contain names, addresses, identifiers, source-of-funds details or any other profile/PII value.
The staged profile fields themselves never enter the workflow request's readable JSONB or the append-only audit metadata. They are canonicalised (including NBS state/LGA codes for Nigerian addresses), encrypted under the party's subject DEK in immutable staging, and represented to workflow only by an opaque id, a digest of unpredictable ciphertext, and sorted field names. The separately supplied non-sensitive `reason` remains readable and auditable. On approval the same transaction applies the values, retains superseded ownership edges, invalidates any attestation over a replaced register, runs ongoing screening for the party and replacement owners, writes a `profile_update` risk assessment and opens/reconciles a `risk_change` KYC review. Any failed control rolls the whole approval back.
Identifiers and documents are deliberately not accepted. Identifier verification has its own authority-backed lifecycle; evidence documents are immutable.
Sending `external_ref` together with `segment` or `kyc_tier` in one request applies the first immediately and raises the proposal for the second in the same transaction; the response is a `202` naming the pending approval, and `customer` in that body already reflects the applied `external_ref`.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200external_ref was corrected, and nothing else was requestedCustomer202A segment, KYC-tier or structured profile change is pending a second pair of eyesobject400—404—409Another external_ref is already in use, or a governed customer-profile proposal is already pending for this customer.422—
/v1/customers/{customerID}/assignRoute a customer relationship to an owning branch or team
Sets or clears `assigned_org_unit_id`, the same organisational hierarchy `POST /v1/iam/org-units` builds and `PATCH /v1/iam/users/{userID}` already routes staff into — applied here to the customer relationship rather than to a member of staff.
Send `org_unit_id` to assign, or `clear_org_unit` to unroute. The unit is validated by the composite tenant-carrying foreign key rather than by a lookup: a unit id from another tenant is refused the same way an unknown one is.
Its own permission, separate from `onboarding.customer.write`: which desk owns a relationship is an operational routing decision, not a correction to the relationship's own data, and a tenant may reasonably grant one right without the other.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The customer, with its assignment changedCustomer400—404The customer does not exist, or org_unit_id names no unit in this tenant.422—
/v1/customers/{customerID}/beneficial-ownersDeclare a corporate customer's beneficial owners
Direct capture is an onboarding-only writer, available while the customer is `draft` or `pending_documents`. Once review can have begun, a beneficial-owner mutation must use the encrypted customer-profile change and maker-checker approval workflow; this endpoint refuses rather than bypassing that control.
A declaration, not a verification: it records what the customer told the institution. `owns_party_id` is how a chain is expressed — omit it and the owner holds a stake in the customer itself, set it and the owner holds a stake in another party already in the chain, which is what makes the resolver return more than a flat list.
Direct stakes are summed across declarations and refused past 100%. Every mutation invalidates the prior register attestation and active-edge verification before writing, because a checker did not examine the changed ownership graph.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
201The resolved ownership chain, which may be longer than what was declaredobject400—404—409That party is already a declared direct owner, or onboarding has progressed beyond direct capture and the governed customer-profile change workflow is required.422—
/v1/customers/{customerID}/beneficial-owners/verificationRecord that a declared ownership register was checked against evidence
A separate right from declaring the register, deliberately: the declaration is what the customer said and the verification is what the institution confirmed, and one person holding both makes the second a formality.
BRD §8.1 requires beneficial ownership "capture and verification" and does not say what verification is. Each value of `method` is a human attesting they compared the declared register against a document they held; none is a direct registry query, because the integrations adapter that would make one does not exist. Whether CBN requires a direct CAC check is a legal interpretation rather than a default this API gets to write (Q28).
`reference` is required: a verification with no evidence reference is a boolean somebody set.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The corporate profile with its verification provenanceCorporateProfile404—422—
/v1/customers/{customerID}/eddComplete an enhanced due diligence review
`app.customer.edd_completed_at` had no writer at all, in any deployment, by any route — so a customer whom risk scoring moved into `pending_edd` could never leave that state, and "EDD is triggered automatically for high-risk customers" was true and useless.
The resulting onboarding status is derived from `outcome` server-side and is not a field on this request: letting a caller name the resulting state is how a declined EDD becomes an active account. `cleared` activates the customer; `declined` rejects them.
Findings are required. An enhanced due diligence review that concluded nothing in writing did not happen, and this text is what an examiner reads when asking why a high-risk customer was accepted. A database CHECK pairs the completion timestamp with the actor, so a background job cannot sign one off — enhanced due diligence is a judgement.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The customer in its new stateCustomer404—409The customer is not in a state this completes from — not flagged for EDD, or already completed. The message says which, because "not found" would send a compliance officer looking for a customer who is right there.422—
/v1/customers/{customerID}/exitFormally offboard an active customer relationship
Moves `onboarding_status` to `exited` — a value the CHECK constraint on `app.customer` has accepted since this platform's first onboarding migration, and that nothing, before this operation, could ever write.
Only reachable from `active`. A relationship that never reached `active` is declined or rejected through the existing EDD and application-review paths, not exited: those are the record of a relationship that did not start, and this is the record of one that formally ended.
`reason` is required. It is not routed through four-eyes the way a segment or KYC-tier change is (`PATCH /v1/customers/{customerID}`): exiting does not change what the platform measures the customer against, it stops measuring the customer at all, and the record of why is the required reason and the audit event this writes, the same shape `completeCustomerEDD`'s findings requirement already establishes for a judgement of this kind.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The customer, now exitedCustomer404—409The customer is not active, so there is no relationship to exit.422—
/v1/customers/{customerID}/identifiers/revealDecrypt an active customer's identifiers
`GET /v1/onboarding/applications/{applicationID}` reveals an applicant's identifiers exactly once, at review. Nothing gave that same audited reveal to an MLRO investigating a case, or answering a supervisory or regulatory request, against a customer who has been active for two years — the front office's one look at the counter was the only reveal this platform could ever produce for a relationship's whole lifetime.
`RevealIdentifiers` (the same method the application review calls) already writes its own `pii.read` audit event on every call, unconditionally — this endpoint adds no further permission check inside the transaction beyond the one this operation declares, because the decryption and its log entry are one atomic act, not two.
A crypto-shredded subject's identifiers come back as an empty list rather than a 404 or an error: the customer relationship and its audit history survive an NDPA erasure, the identity fields behind it do not (Q5). No amount of holding this permission resurrects a destroyed key.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The customer's identifiers, with `value` populatedobject404—
/v1/customers/{customerID}/profileReveal the mutable structured customer profile for correction
Returns only the structured facts supported by the governed maintenance workflow. Address is decrypted under the party's subject key, so this is separate from the ordinary customer workspace: opening the editor is an explicit sensitive read recorded both in the central access catalogue and as a field-specific `pii.read` event.
`legacy_address`, when present, is an older free-text value retained for readability. A correction must submit a structured `address`; Nigerian addresses are resolved to the embedded NBS state/LGA catalogue and stored as canonical codes.
Identifiers and document content are absent by design and cannot be changed here.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
Responses
200The audited mutable-profile revealCustomerProfile404—
/v1/customers/{customerID}/risk-assessmentsA customer's full rating history
`GET /v1/customers/{customerID}` returns only the LATEST risk assessment, because "why is this customer rated low today" is the question a customer profile answers first and making an analyst fetch a whole history for it would be wrong the other way. This is the history behind that one number.
`app.risk_assessment` is append-only by design (BRD §8.2): a re-score writes a new row rather than overwriting the old one, so a rating from eighteen months ago, and the model version it was pinned to, is still readable exactly as it stood. Until this operation existed nothing on the API surface could reach any assessment but the latest — the history was always in the database and had no door.
Each entry carries its own per-factor decomposition, the same shape the latest assessment on the customer response does, newest first.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customerIDrequired | path | string | — |
limit | query | integer | Page size, capped at 200. |
cursor | query | string | Opaque keyset cursor from a previous response. Offset pagination is deliberately not offered: `OFFSET 500000` is a table scan, and if it were offered it would be used (ADR-0012). Only the collections that document this parameter can be resumed. The rest report `truncated: true` and refuse a cursor rather than silently returning the first page again — see the `truncated` field on the page schemas. |
Responses
200The rating history, newest firstRiskAssessmentPage404—
/v1/identifiers/verifyVerify a captured identifier against its authoritative source
**This operation answers `503` unless an identity provider is configured. No production NIBSS/NIMC provider ships with this build, and that refusal is the honest state rather than a bug.**
BRD §8.1 requires "BVN/NIN verification integration". The provider registry and bounded adapter seam exist, but there is no production NIBSS adapter, NIMC adapter, credentials or commercial agreement. No authority is invented here. A deterministic mock may be enabled only outside production; its result is stored with `verification_assurance=mock` and cannot satisfy the CBN approval gate.
It is documented and routed rather than omitted for two reasons. The refusal becomes a machine-readable state of the product instead of a `404` that reads as "we forgot"; and the generated client carries the shape a real answer will have, so integrating a provider is a server change rather than a contract change.
What this replaces was worse than absent. `verified_by_source` used to be filled from a string the CALLER supplied, so a front-office user could type "NIBSS" beside a number nobody had checked and every screen and every examiner afterwards read a verified identifier. That field is now unreachable from the write path. The service refuses BEFORE decrypting anything, so no PII access is logged for a call that could never succeed.
See open question Q26 for the remaining provider procurement, credential, retry and operational-ownership decisions. An outage may leave capture pending, but it cannot be presented as an authoritative verification or activate the relationship.
Show parameters and responsesHide parameters and responses
Responses
200The identifier, now carrying the authority that verified itPartyIdentifier404—422—503No verification provider is configured. The identifier stays captured and UNVERIFIED, which is the true state (Q26).
/v1/onboarding/applications/{applicationID}A submitted CDD application, with the applicant's identifiers revealed
The second pair of eyes on an application. Loading one DECRYPTS the applicant's identifiers and therefore writes a `pii.read` event into the hash chain (ADR-0019), which is why it carries its own permission rather than riding on the customer read: approving an application whose BVN nobody could read is the same defect as approving a form whose answers cannot be stored — a control exercised on something nobody examined.
`submitted_data` is the answer set exactly as it arrived, against the form version named on the record, so the application can be re-rendered as the customer saw it.
A crypto-shredded subject comes back with no identifiers rather than an error: the application and the relationship survive an NDPA erasure; the identity behind them does not (Q5).
**Opening an application under review SCREENS its subject** (`mode: onboarding`), and the result comes back on `screening`. That is where BRD §8.4's screening-at-onboarding happens: the review is the moment the control is exercised, so the hits arrive in the same payload as the decrypted identifiers and a checker reads the name, the BVN and what the lists say in one pass. A decided application is not re-screened — reading a historical record is not a control — and a screening failure does not block the review; it comes back as `screening_unavailable` with the reason, so "not screened" can never read as "clear".
The first successful onboarding run is pinned to the application. Reopening the review does not manufacture duplicate runs or hits; analysts decide the pinned run's hits in the screening queue and approval remains blocked until none are unresolved.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
applicationIDrequired | path | string | — |
Responses
200The application, its subject and the identity data a checker needsApplicationReview404—410The subject's key was destroyed under a data-subject erasure request.
/v1/onboarding/applications/{applicationID}/decisionApprove or reject a customer application
Enforces maker-checker. Approval is fail-closed and atomic: every required document must be verified and unexpired, the pinned onboarding screening run must have no unresolved hit, a fresh explainable risk assessment is written and applied, then the relationship moves to `active` or `pending_edd`. Rejection requires a written reason and moves the relationship to `rejected`.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
applicationIDrequired | path | string | — |
Responses
200Application and customer state after the decisionApplicationDecision404—409Maker-checker violation, missing evidence, unresolved screening hits or stale state422—
/v1/onboarding/applications/{applicationID}/documentsUpload immutable CDD evidence
Stores an encrypted PDF or image against the document requirements snapshotted when the application was submitted. The object store receives ciphertext under an opaque, server-generated key; the database records a plaintext SHA-256 digest and encrypted storage reference. Reusing the same UUID `Idempotency-Key` with the same bytes returns the same record, while reusing it for different evidence is refused.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
applicationIDrequired | path | string | — |
Idempotency-Keyrequired | header | string | — |
doc_typerequired | query | string | — |
filenamerequired | query | string | — |
expires_on | query | string | — |
Responses
201Evidence stored, encrypted and linked to the applicationOnboardingDocument400—404—409—413Document exceeds the 20 MiB evidence limit415Only PDF, JPEG and PNG evidence is accepted422—
/v1/onboarding/documents/{documentID}/contentDownload and audit access to CDD evidence
Decrypts the immutable object, verifies its SHA-256 digest and writes a `pii.read` event in the same transaction. The response media type is the one recorded at upload.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
documentIDrequired | path | string | — |
Responses
200Original evidence bytes404—410The subject's key was destroyed under an erasure request
/v1/onboarding/documents/{documentID}/decisionVerify or reject one evidence object
A maker-checker decision: the uploader cannot verify or reject their own object. Evidence is immutable; a rejected object remains in history and the front office uploads a new one.
Show parameters and responsesHide parameters and responses
Parameters
| Name | In | Type | Description |
|---|---|---|---|
documentIDrequired | path | string | — |
Responses
200Evidence decision recordedOnboardingDocument404—409—422—
/v1/reference-data/nigeria-locationsRead the Nigerian state, LGA and FCT area-council catalogue
Returns the one immutable NBS-coded catalogue used by onboarding validation. Source labels are preserved verbatim; explicitly recorded constitutional aliases are metadata, not a claim that Baobyte has corrected the source. The response distinguishes the six FCT area councils from state LGAs and carries source URLs plus the exact embedded-dataset digest so an application can prove which vocabulary it displayed.
Show parameters and responsesHide parameters and responses
Responses
200Nigerian location reference data and provenanceNigeriaLocationCatalog