CorebanqCorebanq Developer Docs
Counterparties

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_accounts on reads.

It does not support mutating own_accounts inline. Any non-empty own_accounts payload is currently rejected with 400.

Target model

EntityRole
counterpartyOrchestration 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.
recipientInternal 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 id or has no payment sources at all).

Core graph

Important rules

  • counterparty is the participant root entity.
  • contacts stay directly linked to the counterparty.
  • canonical email and phone data live in contacts; counterparties do not expose top-level email or phone fields.
  • 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_accounts are read-only in this module; mutate them in the dedicated accounts flows.
  • list endpoints follow the shared CoreBanq getAll contract.
  • 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 gets 404 Not Found for 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 to active = true, while Internal callers see both active and inactive rows. This extends to the nested bank_accounts embedded in a counterparty response: non-Internal callers only see active nested bank accounts (on both GET /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_id for regular counterparties and for inline recipient-backed source creation
  • optional mirror-link: set customer_id only when the counterparty represents that same customer entity; when set, customer_id must equal owner_id and is not used as the create scope
  • visibility contract: provide and read is_visible_to_others as the authoritative visibility flag; display_type is no longer returned in counterparties API responses
  • optional: type, alias, reference, avatar_id, contacts, bank_accounts, crypto_wallets, metadata
  • inline bank_accounts: active and is_validated default to true when omitted; explicit values are preserved for create and patch/reconcile flows
  • supported type values: 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:

  • limit
  • offset
  • sort
  • filter (JSON object)
  • search._text
  • search.

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:

  • contacts
  • bank_accounts
  • crypto_wallets
  • own_accounts
  • addresses
  • payers

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.id
  • search.name
  • search.alias
  • search.reference
  • search.display_type
  • search.type
  • search.customer_id
  • search.owner_id
  • search.is_visible_to_others
  • search.contacts__type
  • search.contacts__value
  • search.bank_accounts__iban
  • search.bank_accounts__bic
  • search.bank_accounts__bank
  • search.bank_accounts__is_primary
  • search.bank_accounts__is_validated
  • search.crypto_wallets__wallet_address
  • search.crypto_wallets__network
  • search.crypto_wallets__network_id
  • search.crypto_wallets__currency_id
  • search.crypto_wallets__is_primary
  • search.own_accounts__iban
  • search.own_accounts__currency
  • search.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:

  • contacts
  • bank_accounts
  • crypto_wallets
  • own_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:

  • name
  • type
  • alias
  • reference
  • avatar_id
  • active (set false for soft delete / deactivate)
  • is_visible_to_others
  • metadata

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:

  • contacts
  • bank_accounts
  • crypto_wallets
  • addresses

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 id keeps or updates an existing linked record;
  • for bank accounts, an item without id but with an IBAN matching an existing bank account on the same counterparty keeps / updates that existing account;
  • an item without id creates a new recipient-backed record inline;
  • own_accounts remains unsupported on patch and returns 400 when 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_id
  • type
  • value

Optional fields:

  • validated
  • preferred
  • metadata

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_number
  • number
  • region_code

List Contacts

GET /v1/counterparties/contacts

Returns contacts in the same shared getAll response shape.

Common search fields for contacts:

  • search.id
  • search.counterparty_id
  • search.type
  • search.value
  • search.validated
  • search.preferred
  • search.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:

  • type
  • value
  • validated
  • preferred
  • metadata

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_id
  • iban

Optional fields:

  • bic
  • bank
  • is_primary
  • active (defaults to true when omitted; explicit values are preserved)
  • is_validated (defaults to true when omitted; explicit values are preserved)
  • intermediate_bic
  • intermediate_bank
  • intermediate_route
  • metadata

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.id
  • search.counterparty_id
  • search.iban
  • search.bic
  • search.bank
  • search.is_primary
  • search.is_validated
  • search._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_id
  • wallet_address
  • exactly one of network_code or network_id
  • exactly one of currency_id or crp_asset_id

Optional fields:

  • is_self_custody
  • is_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.id
  • search.counterparty_id
  • search.wallet_address
  • search.network
  • search.network_id
  • search.currency_id
  • search.crp_asset_id
  • search.is_primary
  • search.is_self_custody
  • search.is_validated
  • search.status
  • search.active
  • search._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.

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

GETGenerate line chart data

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.

GETList counterparties

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.

On this page