CorebanqCorebanq Developer Docs
Personas

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
}

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

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 /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.

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_to array with related records

Get Persona by ID (v2)

GET /v2/personas/{persona_id}

Retrieve one persona with linked_to records.

Validation Rules

Personal Information

  1. Name Requirements:

    • First name required
    • Last name required
    • Maximum length: 100 characters
  2. 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.

CodeRaised by
personas_m.first_name_requiredPOST /v1/personas validator
personas_m.last_name_requiredsame
personas_m.first_name_too_longsame — over 100 characters
personas_m.last_name_too_longsame
personas_m.date_of_birth_requiredsame
personas_m.date_of_birth_futuresame
personas_m.date_of_birth_invalidsame — a date of birth more than 150 years ago
personas_m.age_under_18same
personas_m.failed_to_serializethe four routes that decode a body
personas_m.invalid_persona_idpersona_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.

StatusCodeCause
401common.unauthorizedNo bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missingThe 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 envelopeGraceful shutdown, and this one comes first. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler
503auth_m.internal_server_error → Internal server errorThe 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

ParameterTypeDescription
limitintegerDefault 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
offsetintegerDefault 0; negative is clamped to 0
sortstringComma-separated sort fields, - prefix for DESC
stackstringStack/group by a field
search.iduuid
search.created_atdate
search.created_byuuid
search.modified_atdate
search.modified_byuuid
search.activeboolean
search.metadatajsonb
search.first_namestring
search.last_namestring
search.date_of_birthdate
search.date_of_deathdate
search.nationalitystring
search.hash_idstring
search.linked_to.record_iduuidPersonas linked to a record. Not a column — see below
search.linked_to.record_typestringSame

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.

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

StatusWhere
200Reads, updates, both deletes (Ok200, not 204)
201POST /v1/personas, POST /v1/persona-links
400Decode failure, unparseable id, validation failure on create
401Auth middleware
403Endpoint grant, licence, or the record-level check saying no
404No such row, or no persona resolved for the user
429Rate limiter, when the switcher is on
500RBAC lookup failure, missing user context, database error — including a user link that points at a persona row that is gone
503Graceful 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

On this page