Description
Purpose and use
Personas hold reusable identity details for natural persons and organizations that appear in customer, signatory, shareholder, counterparty, or contact workflows. They keep identity facts separate from the business relationship that uses them.
Who uses this. Onboarding analysts, compliance teams, customer service, and operations staff use personas when verifying people, linking identities, and reviewing customer-related parties.
How it works. A persona stores identity, address, nationality, age, organization, and relationship details. Other modules link to that persona when they need the same person or organization in a customer, signatory, or counterparty context.
What users do. Users create or update persona data, link personas to business records, validate required identity fields, and search existing identities before creating duplicates.
Outcomes and side effects. Persona changes can affect onboarding evidence, signatory review, contact data, and compliance context. They do not by themselves approve a customer or post any ledger movement.
Related manuals: Customers, KYB, Addresses, Counterparties.
Overview
The Personas API provides functionality for managing personal and organizational identities, including:
- Personal information management
- Organization details
- Address management
- Identity linking
- Age and nationality validation
- Multi-entity relationships
Endpoints
Personal Identity Management
Create Persona
POST /v1/personas
Create a new personal identity record. This is the only route in the module that validates a
persona — neither PUT runs the validator.
The address fields below are accepted and silently dropped. CreatePersonaInput embeds
ActualAddress and RegAddress, but the record they are copied into, models.Persona, is
PersonaData + BaseModel and has no address members at all. Every actual_* and reg_* key is
decoded, ignored by commonutil.Populate, and never stored — and never comes back in the 201.
They are kept in the example below only because a caller sending them today gets no error. The
house-number keys are actual_house and reg_house — actual_house_number and reg_house_number,
which older revisions of this page and of the OpenAPI spec used, are the Go field names and match
nothing on the wire.
Request Body:
{
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"nationality": "CHE",
"actual_street": "Main Street",
"actual_house": "123",
"actual_zip_code": "8001",
"actual_city": "Zurich",
"actual_country": "CHE",
"reg_street": "Main Street",
"reg_house": "123",
"reg_zip_code": "8001",
"reg_city": "Zurich",
"reg_country": "CHE",
"metadata": {
"language": "de",
"preferred_contact": "email"
},
"active": true
}Success Response (201):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"nationality": "CHE",
"hash_id": "abc123",
"active": true,
"created_at": "2024-03-21T10:00:00Z",
"created_by": "123e4567-e89b-12d3-a456-426614174000"
}Get All Personas
GET /v1/personas
Retrieve all accessible personas.
Success Response (200):
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"nationality": "CHE",
"hash_id": "abc123",
"active": true,
"created_at": "2024-03-21T10:00:00Z",
"created_by": "123e4567-e89b-12d3-a456-426614174000"
}
]Get Persona by ID
GET /v1/personas/{persona_id}
Retrieve a specific persona's details.
Update Persona
PUT /v1/personas/{persona_id}
This route cannot deactivate a persona and cannot clear a field. The update is issued as
Updates(struct), and GORM skips every zero-valued member of a struct — so "active": false is
indistinguishable from active omitted, and "first_name": "" leaves the stored name alone. Only
non-zero values are written. Use PATCH /v1/misc/patch to deactivate.
It also does not validate. validateCreatePersonaInput runs on POST only, so this route will
write a name over the 100-character limit, a future date of birth, or an age under 18 — all of
which the create refuses.
The 200 body is the row as it was read with the written columns assigned back over it — GORM
points the update at the model it was handed and assigns each column it sets, so the fields the
request changed do come back with their new values. It is not a fresh read, so anything the
database produced on its own is missing.
Request Body:
{
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"nationality": "CHE",
"actual_street": "Main Street",
"actual_house": "321",
"actual_zip_code": "8001",
"actual_city": "Zurich",
"actual_country": "CHE",
"metadata": {
"preferred_contact": "email"
},
"active": false
}Delete Persona
DELETE /v1/personas/{persona_id}
A hard delete. models.BaseModel declares no gorm.DeletedAt, so this issues a real DELETE
and the row is gone — not the logical delete used elsewhere in the platform, and not recoverable
through the API. The RBAC grants written at create time are not cleaned up.
Answers 200 through Ok200 — {"status": 200, "message": "OK"} — not 204 and not the deleted
record.
User-Persona Operations
Get User's Persona
GET /v1/personas/user/{user_id}
Get persona associated with a user. The link has to be stored with the persona on the x side and
the user on the y side — the orientation POST /v1/persona-links writes.
A dangling link answers 500, not 404. No link at all is a 404, but a link whose persona
row is gone reaches a bare First() whose error is wrapped as common.database_error. Since
DELETE /v1/personas/{persona_id} is a hard delete that leaves the links behind, that is a state a
client will meet.
Get User's Persona (v2)
GET /v2/personas/user/{user_id}
Get persona associated with a user, including linked_to records.
Update User's Persona
PUT /v1/personas/user/{user_id}
Carries every caveat of PUT /v1/personas/{persona_id}, plus two of its own.
The link is resolved from the caller's own record grants, not from the path user's links.
links.FetchLinksByType is called with the authenticated caller's id and canReadAll hardcoded
to false, so it sees only links touching a user record the caller — or one of their roles — holds
a row for in rbac.record_permissions. rbac.RecPermission, which guards the route, returns true
for an Administrator without reading that table at all, so an Administrator passes the permission
check and then finds nothing. The caller needs an explicit record grant on the user in the path.
It only works when exactly one link touches that user. The collected ids are bound to
"id = ?", which GORM expands as a parenthesised list: one id gives id = ($1) and updates that
row, none gives id = (NULL), and two or more gives id = ($1,$2), which PostgreSQL rejects as a
comparison against a row constructor. All three failures land in the same branch and answer 404
having written nothing. The ids are not filtered by record type either, so the customer-to-user and
signatory-to-user links other modules create count toward the same total.
Request Body:
{
"first_name": "Jane",
"last_name": "Doe",
"nationality": "CHE",
"actual_street": "Main Street",
"actual_house": "321",
"actual_zip_code": "8001",
"actual_city": "Zurich",
"actual_country": "CHE",
"metadata": {
"preferred_contact": "phone"
},
"active": true
}Persona Links
Create Link
POST /v1/persona-links
Create a link between a persona and another entity. The persona is always the x side: the handler
sets rec_id_x to persona_id and rec_type_x to the persona record type, whatever the body says.
Name the other side with either rec_type_name or rec_type_id. rec_type_name wins when both are
sent; with neither, the reply is 400 common.invalid_input.
active is accepted and ignored. links.CreateLinkUtil hardcodes Active: true, so the link
is always created active — and there is no way to deactivate it afterwards: PUT /v1/persona-links/{id} answers 404 before it reaches the update, see Update Link. The helper also overwrites metadata.relation with personas-> — the registered record
type name is the plural personas, not persona.
Request Body:
{
"persona_id": "550e8400-e29b-41d4-a716-446655440000",
"rec_id": "123e4567-e89b-12d3-a456-426614174000",
"rec_type_name": "organizations",
"metadata": {
"role": "director",
"start_date": "2024-01-01"
}
}Get Link
GET /v1/persona-links/{id}
Retrieve a specific link.
The response is not shaped like the create body. Every link route serialises
common/links.Link, whose members are rec_idx, rec_type_x, rec_idy and rec_type_y plus the
usual base fields — and the two rec_type_* values are record type IDs, not names. There is no
persona_id, no rec_id and no rec_type_name in any response. models.PersonaLink, which does
declare those three, is never serialised by this module.
Success Response (200):
{
"id": "9f1c2b6e-2e8a-4b3a-9a1c-2c9f0f7a1b23",
"rec_idx": "550e8400-e29b-41d4-a716-446655440000",
"rec_type_x": "0f5a1d3c-77b1-4f0e-9a2e-6f1a1c2d3e4f",
"rec_idy": "123e4567-e89b-12d3-a456-426614174000",
"rec_type_y": "7c3b9a10-4d21-4a55-8f6e-1b2c3d4e5f60",
"active": true,
"metadata": {
"role": "director",
"start_date": "2024-01-01",
"relation": "personas->organizations"
},
"created_at": "2024-03-21T10:00:00Z",
"created_by": "123e4567-e89b-12d3-a456-426614174000"
}Update Link
PUT /v1/persona-links/{id}
The body is links.UpdateLinkInput, not models.UpdatePersonaLinkInput.
This route cannot succeed as written — every well-formed body is a 404. Before the link row
is read, UpdateLink calls validateRecordExistence, which hands checkRecExistence the
record-type id rather than rec_idx or rec_idy, and that counts rows with that id in the
table .. A record-type id is never a row id in the entity table, so the count is zero
and the handler answers 404 common.record_not_found without touching the link. The rest of this
section is what the body must satisfy to reach that point, and what will govern the write once the
handler is fixed.
This is a full replace, not a patch. updateLinkFields assigns rec_idx, rec_idy, metadata
and active unconditionally and then Saves, so a field left out is written as its zero value —
omitting rec_idx and rec_idy overwrites both not-null columns with the nil UUID, and omitting
metadata clears it. Send the whole link.
Each side needs exactly one record-type selector. rec_type_x or rec_type_x_id, and
rec_type_y or rec_type_y_id. Neither and both are the same 400 common.invalid_input, so a
body of only {"active": false} is refused rather than deactivating anything.
The name has to be a registered record type, and for personas that is the plural personas
(constants.PersonaRecName). An unregistered name is not a 400 — rbac.GetRecTypeID finds no row
and the handler answers 500 common.server_error.
Request Body:
{
"rec_idx": "550e8400-e29b-41d4-a716-446655440000",
"rec_type_x": "personas",
"rec_idy": "123e4567-e89b-12d3-a456-426614174000",
"rec_type_y": "organizations",
"metadata": {
"role": "advisor",
"start_date": "2024-04-01"
},
"active": false
}Delete Link
DELETE /v1/persona-links/{id}
Answers 200, not 204: the shared common/links helper finishes with apireply.Ok200, so the
body is {"status": 200, "message": "OK"}. A link that is already gone is a 404 — the helper
reads the row before deleting it.
List Links
GET /v1/persona-links
Links with the persona record type on either side, as a bare array — RBAC-scoped, not the complete set, and with no query parameters at all. See the query-parameter section below.
Personal Identity Management (v2)
Get All Personas (v2)
GET /v2/personas
Retrieve personas with get_all behavior (search, sort, pagination, stack).
Each returned persona item includes the same structure as /v2/personas/{persona_id}:
- persona fields
linked_toarray with related records
Get Persona by ID (v2)
GET /v2/personas/{persona_id}
Retrieve one persona with linked_to records.
Validation Rules
Personal Information
-
Name Requirements:
- First name required
- Last name required
- Maximum length: 100 characters
-
Date of birth:
- Required
- Must be in the past
- Must be no more than 150 years ago
- Must be at least 18 years old
- A date that is not a real calendar day is refused earlier, when the body is decoded
These run on POST /v1/personas only. Both PUT routes bypass them entirely.
Errors
The envelope
{"status": 400, "message": "First name is required", "code": "personas_m.first_name_required", "class": "validation"}message is the resolved i18n template for code. code, class, retryable and details are
stamped only outside the 2xx range. ClassForStatus has no empty branch: 400 and 422 are
validation; 408, 429, 502, 503 and 504 are temporary and additionally carry
retryable: true; everything else, 500 included, is business.
details never arrives on this module. It is stamped only by the shared validator inside
DecodeJSONInput, and a body that does not parse at all takes the other branch, which attaches
none. The four routes that decode a body do spread err.Details into
personas_m.failed_to_serialize, but CreatePersonaInput, UpdatePersonaInput and
CreatePersonaLinkInput declare no validate tags, so the validator has nothing to fail on and
that slice is always empty. Do not branch on the field here.
Codes
The prefix is personas_m., not personas. — earlier revisions of this page wrote the latter,
which matches no constant.
| Code | Raised by |
|---|---|
personas_m.first_name_required | POST /v1/personas validator |
personas_m.last_name_required | same |
personas_m.first_name_too_long | same — over 100 characters |
personas_m.last_name_too_long | same |
personas_m.date_of_birth_required | same |
personas_m.date_of_birth_future | same |
personas_m.date_of_birth_invalid | same — a date of birth more than 150 years ago |
personas_m.age_under_18 | same |
personas_m.failed_to_serialize | the four routes that decode a body |
personas_m.invalid_persona_id | persona_id/user_id that does not parse, on the routes that pass the id through |
Four more validator codes are registered but unreachable. personas_m.year_invalid guards a
1000–9999 range that the future and 150-year checks have already narrowed past;
personas_m.month_invalid and personas_m.day_invalid guard values time.Time cannot carry; and
personas_m.day_invalid_for_month guards a date time.Parse has already refused at decode time,
which is a personas_m.failed_to_serialize instead.
Three further codes this page used to list are not produced by any persona route:
personas_m.persona_not_found exists but is raised by modules/customers, never here;
personas_m.failed_to_validate is registered in and has no construction site at
all; and personas_m.date_of_death_invalid does not exist as a constant.
Several refusals carry no module code because the helper is called with no AppError: 403 is
always common.rbac_no_rec_access on the v1 routes, 404 is always common.record_not_found, and
the 400s on DELETE /v1/personas/{persona_id} and the two user_id routes fall back to
common.invalid_input.
The two API versions disagree about their refusal codes. v1 answers common.rbac_no_rec_access
for 403 and a bare common.record_not_found for 404; the v2 service answers common.forbidden
and common.record_not_found with common.database_error for storage failures, where v1 answers
common.server_error.
500 covers the RBAC check failing
Every v1 handler answers InternalServerError500 when rbac.RecPermission returns an error, and
only reaches Forbidden403 when the check succeeds and says no. So a 500 here is often a
permission-lookup failure rather than a bug in the request.
Rate limiting
auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but
only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag
once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent
from the chain entirely — not mounted and idle — and no route can answer 429. Changing the
flag takes a restart. When it does fire the
429 code is not common.too_many_requests — that key is only the fallback the reply helper
stamps when called with no AppError. The codes that really arrive are rate_limits_m.exceeded,
rate_limits_m.global_exceeded and rate_limits_m.failed_to_increment_ip_limit, plus two free-text
strings that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded.
Refusals the middleware writes before the handler runs
These apply to every route in this module and are produced by the root router's middleware
chain — health.LifecycleMiddleware first, then auth.Middleware — not by module code.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | No bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missing | The licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown, and this one comes first. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request |
The 503 is two different bodies, and a client has to branch on the shape rather than assume
the envelope. While the server is draining, health.LifecycleMiddleware writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} —
with no status, code, class or retryable field on it at all, and whose message is a fixed
English string, not an i18n key. The auth-cache 503 is the envelope, and its code is
auth_m.internal_server_error, not common.server_error: ensureCacheAvailable writes
errs.New(MsgInternalServerError) unqualified, which resolves to the auth package's own constant.
Branch on that code to tell an unhealthy cache from a genuine 500.
Query parameters
GET /v1/personas — none
This route reads no query parameter at all. There is no pagination, no filtering and no
sorting: limit, offset, active and nationality are ignored, and the entire permitted set
comes back as a bare array in one response. Inactive personas are included, because the query
applies no active filter. Use GET /v2/personas when you need any of that.
GET /v2/personas
| Parameter | Type | Description |
|---|---|---|
| limit | integer | Default 10, over 100 clamps to 100, non-positive becomes 10. -1 is the platform's "totals only" sentinel and survives the parser, but this route ignores it: the data query runs GetLimit, which maps every non-positive value to 10, so limit=-1 returns a normal page of 10 |
| offset | integer | Default 0; negative is clamped to 0 |
| sort | string | Comma-separated sort fields, - prefix for DESC |
| stack | string | Stack/group by a field |
| search.id | uuid | |
| search.created_at | date | |
| search.created_by | uuid | |
| search.modified_at | date | |
| search.modified_by | uuid | |
| search.active | boolean | |
| search.metadata | jsonb | |
| search.first_name | string | |
| search.last_name | string | |
| search.date_of_birth | date | |
| search.date_of_death | date | |
| search.nationality | string | |
| search.hash_id | string | |
| search.linked_to.record_id | uuid | Personas linked to a record. Not a column — see below |
| search.linked_to.record_type | string | Same |
search.linked_to.* are not columns, so the shared parser would drop them like any unknown key.
The handler lifts them back out of the raw query string and reinserts them into the search map
before the service runs. The .eq suffix form works for both.
Any other search. is dropped rather than rejected, so the response comes back
unfiltered instead of empty.
GET /v1/persona-links — none
Like the v1 persona list, this route reads no query parameter: rec_type_name, active, limit
and offset are ignored. What comes back is a bare array of links with the persona record type on
either side — and it is RBAC-scoped, not the complete set. A caller holding read_all on the
link record type gets every such link; anyone else gets only links touching a persona they or one of
their roles hold a grant on. Two callers legitimately see different arrays, so this must not be
cached as the full list. The empty cases differ in shape: no matching links is [], while no
persona grants at all short-circuits before the query and returns null.
Response status codes
| Status | Where |
|---|---|
| 200 | Reads, updates, both deletes (Ok200, not 204) |
| 201 | POST /v1/personas, POST /v1/persona-links |
| 400 | Decode failure, unparseable id, validation failure on create |
| 401 | Auth middleware |
| 403 | Endpoint grant, licence, or the record-level check saying no |
| 404 | No such row, or no persona resolved for the user |
| 429 | Rate limiter, when the switcher is on |
| 500 | RBAC lookup failure, missing user context, database error — including a user link that points at a persona row that is gone |
| 503 | Graceful shutdown, or the auth cache |
There is no 422 on any route in this module. Validation failures are 400.
Versioning
The API is versioned through the URL path. Supported versions are v1 and v2.
Example: /v1/personas
Previous Page
Creates a link between a persona and another record, delegating to the shared common/links helper. The persona side is fixed: rec_type_x is always the persona record type and rec_id_x is the persona_id from the body. active IS IGNORED. CreateLinkUtil hardcodes Active: true, so the link comes back active whatever the body asked for. metadata.relation is overwritten with "personas-><other type>" as well — the registered record type name is the plural personas, not persona. rec_type_name wins over rec_type_id: the helper resolves the name when it is present and only falls back to the id otherwise. With neither, the reply is 400 common.invalid_input.