Description
Counterparties & Contacts
The counterparties module manages reusable payment participants, their direct contact records, and the payment-source projections exposed through the normalized counterparty graph.
Purpose and use
Counterparties let users maintain people, businesses, bank accounts, crypto wallets, and own-account destinations that may be used in transfer and invoice workflows. They reduce repeated beneficiary entry and keep payment-source ownership clear.
Who uses this. Payment operations, customer support, relationship managers, finance teams, and customers use counterparties when preparing or investigating payment destinations.
How it works. A counterparty owns contacts and links payment sources. Bank accounts and crypto wallets can be linked by ID or created inline through a recipient-backed flow so the transfer layer can still route payments through recipient records.
What users do. Users create counterparties, add contacts, link bank accounts or wallets, review own accounts, update metadata, and choose a counterparty destination during payment setup.
Outcomes and side effects. Counterparty changes affect future payment selection and beneficiary audit history. They do not alter completed transfers or ledger postings.
Related manuals: Transfers, Invoices, Customers.
This module is the orchestration root for counterparty data. It can:
- create and update counterparties;
- create direct contacts or reassign existing contacts by ID;
- link existing bank accounts and crypto wallets by ID;
- create new bank accounts and crypto wallets inline through a recipient-backed flow;
- expose
own_accountson reads.
It does not support mutating own_accounts inline. Any non-empty own_accounts payload is currently rejected with 400.
Target model
| Entity | Role |
|---|---|
counterparty | Orchestration root. The only entity API consumers create, update, and address by ID. It owns contacts, controls which bank accounts and crypto wallets are linked, and exposes own_accounts in read responses. |
recipient | Internal execution entity. A backing row in recipients.recipients that is created lazily (once per counterparty) the first time an inline bank account or crypto wallet is created. It exists solely to satisfy the FK requirements of recipients.bank_accounts and crp.crp_recipients. It is never surfaced in the counterparties API response and must not be addressed directly by callers. |
The separation means:
- Callers think in terms of counterparties and their payment sources.
- The payment-routing layer continues to operate on recipients without any change.
- A counterparty may exist without a backing recipient (e.g. when it only links existing payment sources by
idor has no payment sources at all).
Core graph
Important rules
counterpartyis the participant root entity.contactsstay directly linked to the counterparty.- canonical email and phone data live in
contacts; counterparties do not expose top-levelemailorphonefields. - bank accounts and crypto wallets remain recipient-backed even when created from counterparties endpoints.
- inline bank / crypto creation ensures a backing recipient exists with an empty billing address placeholder before the recipient is inserted.
own_accountsare read-only in this module; mutate them in the dedicated accounts flows.- list endpoints follow the shared CoreBanq
getAllcontract. - read access is RBAC-scoped and customer isolation is enforced by the runtime.
- standalone contact creation requires parent counterparty update access and is executed transactionally.
- Internal-role visibility. Counterparties and their bank accounts (and, for consistency, recipients and recipient bank accounts) share one visibility policy for inactive (
active = false) rows: a caller without the RBAC Internal role gets404 Not Foundfor an inactive record on any get-by-id or update/delete, exactly as if the row did not exist; a caller with the Internal role can read and mutate inactive records. List/search endpoints follow the same split — non-Internal callers are filtered server-side toactive = true, while Internal callers see both active and inactive rows. This extends to the nestedbank_accountsembedded in a counterparty response: non-Internal callers only see active nested bank accounts (on bothGET /v1/counterparties/{id}and the list endpoint), while Internal callers see inactive ones too, and can search/sort/count on them (e.g.bank_accounts__active, an inactive IBAN). Mutations additionally re-check the Internal role atomically inside the mutation's own transaction (via a row lock), so a record cannot be patched or deleted out from under a concurrent deactivation.
Endpoints
Create Counterparty
POST /v1/counterparties
Creates a new counterparty within an owner customer scope.
In the same request you may:
- create direct contacts inline or reassign existing contacts by
id; - link existing bank accounts and crypto wallets by
id; - create new bank accounts and crypto wallets inline — those records are still created through a recipient-backed flow;
- persist arbitrary
metadata.
You may not mutate own_accounts here; any non-empty own_accounts array returns 400.
Nested contact objects support only id, type, value, and metadata. Use the standalone contacts API when you need to set validated or preferred.
Request highlights
- required:
name,owner_id,is_visible_to_others - ownership scope: provide
owner_idfor regular counterparties and for inline recipient-backed source creation - optional mirror-link: set
customer_idonly when the counterparty represents that same customer entity; when set,customer_idmust equalowner_idand is not used as the create scope - visibility contract: provide and read
is_visible_to_othersas the authoritative visibility flag;display_typeis no longer returned in counterparties API responses - optional:
type,alias,reference,avatar_id,contacts,bank_accounts,crypto_wallets,metadata - inline
bank_accounts:activeandis_validateddefault totruewhen omitted; explicit values are preserved for create and patch/reconcile flows - supported
typevalues:business,personal
Example
{
"owner_id": "8c6f6a0f-0b6e-46f2-996a-0f35e72cf3d8",
"name": "Acme Corp",
"is_visible_to_others": true,
"type": "business",
"alias": "acme",
"reference": "REF-001",
"contacts": [
{
"type": "email",
"value": "finance@acme.example",
"metadata": {}
}
],
"bank_accounts": [
{
"iban": "CH9300762011623852957",
"bank": "Acme Treasury Bank",
"is_primary": true,
"active": false,
"is_validated": false,
"metadata": {
"source": "manual"
}
}
],
"metadata": {
"source": "manual"
}
}List Counterparties
GET /v1/counterparties
Returns counterparties in the standard project getAll envelope:
{
"data": [],
"total": 0,
"total_unfiltered": 0,
"has_more": false
}Each counterparty item keeps its public JSON shape stable: nullable scalar fields are returned explicitly as null, and collection fields are returned as empty arrays when there are no linked rows instead of being omitted from the payload.
Supported query pattern:
limitoffsetsortfilter(JSON object)search._textsearch.
Reserved text search supports the shared getAll operators .like, .eq, .start_with, and .end_with. For counterparties, _text searches both root counterparty fields and nested aggregate data from:
contactsbank_accountscrypto_walletsown_accountsaddressespayers
For a mirror-linked root counterparty, responses also include active addresses owned by the
linked customer. These entries are identifiable by rec_type = customers and participate in
the same nested-address text search as counterparty-owned addresses.
The .like operator also preserves aggregate phrase matching across adjacent fields within those surfaces, while .eq, .start_with, and .end_with match the concrete text fields inside the aggregate. For callers without the Internal role, deactivated (active = false) bank accounts are excluded from both _text and search.bank_accounts__*; Internal callers can match them through either search form.
Common search fields for counterparties:
search.idsearch.namesearch.aliassearch.referencesearch.display_typesearch.typesearch.customer_idsearch.owner_idsearch.is_visible_to_otherssearch.contacts__typesearch.contacts__valuesearch.bank_accounts__ibansearch.bank_accounts__bicsearch.bank_accounts__banksearch.bank_accounts__is_primarysearch.bank_accounts__is_validatedsearch.crypto_wallets__wallet_addresssearch.crypto_wallets__networksearch.crypto_wallets__network_idsearch.crypto_wallets__currency_idsearch.crypto_wallets__is_primarysearch.own_accounts__ibansearch.own_accounts__currencysearch.own_accounts__status
Useful ownership patterns:
- only the mirror-linked “self” counterparty:
search.owner_id.eq=+search.customer_id.eq= - all owner-scoped counterparties except that mirror-linked self row:
search.owner_id.eq=+search.customer_id.ne=
search.customer_id.ne is null-safe for counterparties list filtering. Regular owner-scoped counterparties usually keep customer_id = null, and those rows are treated as “not equal” to a concrete customer UUID instead of being dropped by SQL null comparison semantics.
Get Counterparty by ID
GET /v1/counterparties/{id}
Returns the full counterparty aggregate, including:
contactsbank_accountscrypto_walletsown_accounts(read-only from this module)
Nullable scalar response fields stay present as null, and aggregate collections stay present as empty arrays when no linked records exist. Nested payment sources also keep products_allowed present as an array; it is empty when no product matrix projection has been attached. Address responses include address_hash.
An inactive counterparty (active = false) is hidden from callers without the RBAC Internal role — the endpoint returns 404 Not Found as if the counterparty did not exist. A caller with the Internal role can retrieve inactive counterparties.
Patch Counterparty
PATCH /v1/counterparties/{id}
Partially updates mutable scalar fields such as:
nametypealiasreferenceavatar_idactive(setfalsefor soft delete / deactivate)is_visible_to_othersmetadata
owner_id is the ownership / access-customer scope and is immutable through this generic PATCH endpoint. Sending owner_id in a patch request is rejected with 400; ownership transfer must be handled by a dedicated flow.
Counterparties responses no longer expose display_type; write requests and read responses use is_visible_to_others. For backward-compatible querying, search.display_type remains available as a derived filter.
Entity arrays use full-replace semantics when they are present in the payload:
contactsbank_accountscrypto_walletsaddresses
Rules:
- omit an entity array to leave that entity type unchanged;
- send an empty array to remove all linked entities of that type;
- for bank accounts / crypto wallets, an item with
idkeeps or updates an existing linked record; - for bank accounts, an item without
idbut with an IBAN matching an existing bank account on the same counterparty keeps / updates that existing account; - an item without
idcreates a new recipient-backed record inline; own_accountsremains unsupported on patch and returns400when provided.
The addresses array replaces only addresses owned by the counterparty
(rec_type = counterparties). Customer-owned addresses inherited by a mirror-linked root
counterparty remain unchanged when the array is empty or omits them. Their IDs cannot be
updated through this endpoint and return 404; manage those records through the customer
address flow instead.
IBAN reuse is checked across counterparties that share the same owner_id; counterparties under different owners may use the same IBAN. During patch, the target counterparty itself is excluded from this owner-scope check so unchanged bank-account payloads do not conflict with themselves.
If the counterparty is currently inactive, the caller must hold the RBAC Internal role to patch it; otherwise the row is treated as not found and the request returns 404 Not Found. This check is enforced atomically inside the patch transaction via a row lock.
Full-replace reconciliation of bank_accounts operates on the accounts the caller can see. A caller without the Internal role never sees inactive nested bank accounts, so those links are left in place; a caller with the Internal role sees them and can therefore unlink an inactive bank account by leaving it out of the array (or by sending []). The response is reloaded with the same visibility, so it reflects exactly which nested accounts remain for that caller.
As with create, nested contact payloads do not expose validated / preferred; use the standalone contacts API for those fields.
Contacts API
Create Contact
POST /v1/counterparties/contacts
Creates a contact linked to an existing counterparty.
Required fields:
counterparty_idtypevalue
Optional fields:
validatedpreferredmetadata
The service verifies parent counterparty update access before the insert is allowed and performs the write in a transaction.
When type = phone, metadata is normalized with phone-derived fields such as:
country_numbernumberregion_code
List Contacts
GET /v1/counterparties/contacts
Returns contacts in the same shared getAll response shape.
Common search fields for contacts:
search.idsearch.counterparty_idsearch.typesearch.valuesearch.validatedsearch.preferredsearch.active
Get / Update / Delete Contact
GET /v1/counterparties/contacts/{id}
PUT /v1/counterparties/contacts/{id}
DELETE /v1/counterparties/contacts/{id}
Contacts are addressed as standalone records after creation, but remain logically attached to their parent counterparty.
The update endpoint accepts partial changes for:
typevaluevalidatedpreferredmetadata
If the effective contact type is phone and the value changes, the phone-derived metadata is refreshed automatically.
Unlike bank accounts (which are soft-deleted), deleting a contact permanently removes the row. Delete returns 204 with an empty body.
Bank Accounts API
Create Counterparty Bank Account
POST /v1/counterparties/bank-accounts
Creates a recipient-backed bank account and links it to an existing counterparty.
Required fields:
counterparty_idiban
Optional fields:
bicbankis_primaryactive(defaults totruewhen omitted; explicit values are preserved)is_validated(defaults totruewhen omitted; explicit values are preserved)intermediate_bicintermediate_bankintermediate_routemetadata
The service verifies parent counterparty update access, ensures the backing recipient exists, creates the bank account, links it through cp_accounts, and grants record access.
{
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"iban": "CH9300762011623852957",
"bic": "POFICHBEXXX",
"bank": "PostFinance",
"is_primary": true,
"active": false,
"is_validated": false,
"metadata": {
"currency": "CHF"
}
}List Counterparty Bank Accounts
GET /v1/counterparties/bank-accounts
Returns counterparty bank accounts in the shared getAll response shape.
Common search fields:
search.idsearch.counterparty_idsearch.ibansearch.bicsearch.banksearch.is_primarysearch.is_validatedsearch._text.ilike
Non-Internal callers are filtered to active = true bank accounts only, so search.active is not offered as a filter here; callers holding the RBAC Internal role see both active and inactive bank accounts.
{
"data": [
{
"id": "34bd62b1-64f9-4e68-b77b-7cd52baf8077",
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"recipient_id": "e3a5cf19-4d7b-4417-bc3a-206970aaf822",
"iban": "CH9300762011623852957",
"bic": "POFICHBEXXX",
"bank": "PostFinance",
"is_primary": true,
"is_validated": false,
"active": true,
"metadata": {
"currency": "CHF"
}
}
],
"total": 1,
"total_unfiltered": 1,
"has_more": false
}Get Counterparty Bank Account by ID
GET /v1/counterparties/bank-accounts/{id}
Returns one bank account if the caller has record read access. An inactive bank account (active = false) is hidden from callers without the RBAC Internal role — the endpoint returns 404 Not Found as if the record did not exist. A caller with the Internal role can retrieve inactive bank accounts.
{
"id": "34bd62b1-64f9-4e68-b77b-7cd52baf8077",
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"recipient_id": "e3a5cf19-4d7b-4417-bc3a-206970aaf822",
"cp_account_id": "ecdb4436-3ab3-4c3d-8b1d-0603d4e2833a",
"iban": "CH9300762011623852957",
"bic": "POFICHBEXXX",
"bank": "PostFinance",
"is_primary": true,
"is_validated": false,
"active": true,
"metadata": {
"currency": "CHF"
}
}Update Counterparty Bank Account
PATCH /v1/counterparties/bank-accounts/{id}
Partially updates mutable bank-account fields. Omitted fields remain unchanged; an empty update returns the current record. If the bank account is currently inactive, the caller must hold the RBAC Internal role to update it; otherwise the row is treated as not found and the request returns 404 Not Found. This check is enforced atomically inside the update transaction via a row lock.
{
"bank": "PostFinance Treasury",
"is_primary": true,
"metadata": {
"currency": "CHF",
"source": "manual"
}
}Delete Counterparty Bank Account
DELETE /v1/counterparties/bank-accounts/{id}
Soft-deletes the bank account: the record is deactivated (active = false) rather than removed, and the cp_accounts link row is kept. Deactivated accounts are excluded from subsequent list, get, update, and counterparty-aggregate reads for callers without the RBAC Internal role (they get 404 Not Found as if the record did not exist), and no longer resolve by IBAN for non-Internal callers. Callers with the Internal role can still see and update a deactivated bank account. Deleting a bank account that is already inactive itself requires the Internal role; otherwise it 404s. Returns 204 with an empty body.
Crypto Wallets API
Create Counterparty Crypto Wallet
POST /v1/counterparties/crypto-wallets
Creates a recipient-backed crypto wallet and links it to an existing counterparty.
Required fields:
counterparty_idwallet_address- exactly one of
network_codeornetwork_id - exactly one of
currency_idorcrp_asset_id
Optional fields:
is_self_custodyis_primary
{
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"network_code": "ethereum",
"currency_id": "dc721b5d-8f7b-4c2a-9d4a-7ac8b4e6e7a1",
"wallet_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"is_self_custody": true,
"is_primary": true
}List Counterparty Crypto Wallets
GET /v1/counterparties/crypto-wallets
Returns counterparty crypto wallets in the shared getAll response shape.
Common search fields:
search.idsearch.counterparty_idsearch.wallet_addresssearch.networksearch.network_idsearch.currency_idsearch.crp_asset_idsearch.is_primarysearch.is_self_custodysearch.is_validatedsearch.statussearch.activesearch._text.ilike
{
"data": [
{
"id": "b6df7c36-1847-48aa-ae05-cdd02e81f53b",
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"recipient_id": "e3a5cf19-4d7b-4417-bc3a-206970aaf822",
"wallet_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"network": "ethereum",
"is_self_custody": true,
"is_validated": false,
"is_primary": true,
"status": "inactive",
"active": true
}
],
"total": 1,
"total_unfiltered": 1,
"has_more": false
}Get Counterparty Crypto Wallet by ID
GET /v1/counterparties/crypto-wallets/{id}
Returns one crypto wallet if the caller has record read access.
{
"id": "b6df7c36-1847-48aa-ae05-cdd02e81f53b",
"counterparty_id": "9cb28a4b-8f76-4a28-8e59-1068c502454d",
"recipient_id": "e3a5cf19-4d7b-4417-bc3a-206970aaf822",
"cp_account_id": "65e982bc-9260-4de7-a904-07353f4ad425",
"wallet_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"network": "ethereum",
"is_self_custody": true,
"is_validated": false,
"is_primary": true,
"status": "inactive",
"active": true
}Update Counterparty Crypto Wallet
PATCH /v1/counterparties/crypto-wallets/{id}
Partially updates mutable crypto-wallet fields. Omitted fields remain unchanged; an empty update returns the current record.
{
"network_code": "polygon",
"wallet_address": "0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71",
"is_primary": true
}Delete Counterparty Crypto Wallet
DELETE /v1/counterparties/crypto-wallets/{id}
Permanently deletes the standalone counterparty crypto-wallet record and its cp_accounts source link, in one transaction. Returns 204 with an empty body.
Related artifacts
- OpenAPI source:
OpenAPI schema - Postman collection:
postman-collections/Corebanq - Counterparties.postman_collection.json
The Postman collection is maintained in the dedicated postman-collections repository, while the module documentation lives next to the module implementation inside modules/counterparties/docs.
Generate line chart data in Nivo format. Only rec_type=currency_pairs is implemented; the chart type is fixed to line by the handler and is not a request parameter. Series cap: when the grouping produces more series than charts.generation.max_series (AppConfig, default 10), the response is TRUNCATED to that many series and still answers 200. There is no flag in the body saying truncation happened, and no error — a client that groups by a high-cardinality field silently sees only an arbitrary N of them (the truncation takes Go map iteration order, which is randomised — the set you get is not the first N by any ordering and can differ between identical requests). Search filters: a value of null on .eq or .ne is a null test (IS NULL / IS NOT NULL). On ANY OTHER operator, null is accepted and the whole filter is then SILENTLY DROPPED by ParseSearchQuery — ?search.rate.gt=null does not narrow the result set and does not error. Rate source: search.source is honoured when given; when omitted, the AppConfig value fx.convert.default_source is injected before the query runs (the same default FX convert uses). If that key is empty, no source filter is applied at all. Data points: y is null on points produced by fill_gaps, so a series may legitimately contain nulls between real values. Ordering: THERE IS NONE. The storage layer issues Select(...).Limit(...).Offset(...) with no ORDER BY, and the charts handler never reads the sort parameter — query.ParseQueryParameters accepts it, nothing consumes it. With the effective default limit of 10, x_field=date returns an arbitrary ten rows in an arbitrary order, so the "line" is not chronological and paging with offset is not stable between requests. Permissions: the module's own record-type check cannot refuse this route. checkPermissions returns early for reference-data tables, and referenceDataTables contains currency_pairs — the only accepted rec_type — so rbac.RecPermission is never called and neither the common.forbidden path nor the unregistered-record-type 403 can fire. Every 403 a caller sees here comes from the auth or licence middleware.
Handler: GetAllCounterparties → GetAllCounterpartiesSrv. Returns GetAllResponseAPI with CounterpartyResponse items. Returns counterparties visible to the requesting user in the standard Corebanq getAll response shape. For self-vs-others filtering, combine search.owner_id.eq with search.customer_id.eq or search.customer_id.ne; the not-equal variant is null-safe for nullable customer_id. Non-Internal callers are filtered to active=true counterparties only, and their nested bank_accounts include only active=true bank accounts; deactivated (soft-deleted) counterparties and bank accounts are excluded. Callers holding the Internal RBAC role see both active and inactive counterparties and bank accounts (no forced filter), and can search/sort on nested bank-account fields such as bank_accounts__active or an inactive IBAN.