CorebanqCorebanq Developer Docs
Countries

Description

Purpose and use

Countries define the jurisdiction and formatting data needed for onboarding, addresses, bank accounts, IBAN validation, payments, risk review, and reporting.

Who uses this. Onboarding teams, payment operations, compliance officers, product administrators, and support teams use country records when validating customer and payment data.

How it works. Each country stores ISO identifiers, activity state, an IBAN length, display attributes (icon, emoji, phone prefix, sort order), free-form tags and metadata. There is no currency field and no IBAN format pattern.

What users do. Users review supported countries, validate country codes, and deactivate countries that should not be used for new activity. Note that DELETE removes the row outright rather than deactivating it.

Outcomes and side effects. Country changes can affect onboarding validation, transfer eligibility, address forms, and risk rules. They do not change existing ledger balances.

Related manuals: Addresses, NOGA, Risk Assessment, Transfers.

Overview

The countries package manages country reference data: ISO codes, an IBAN length, display attributes and tags. It stores no IBAN format pattern and no currency association.

API Methods

Get All Countries

GET /v1/countries

Retrieves every supported country as a bare JSON array, ordered by order then name. There is no wrapper object and no pagination — /v2/countries is the paginated surface.

The filter parameter does not mean what its name suggests: omitted or empty returns all countries, filter=all returns all, and any other non-empty value restricts to active = true (so filter=active and filter=nonsense behave identically). Each repeated tag adds a tags @> [""] condition, so multiple tags are ANDed.

Response

[
  {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "iso_alpha_2": "CH",
    "iso_alpha_3": "CHE",
    "iso_numeric": "756",
    "icon": "ch",
    "icon_url": "/data/countries/ch.svg",
    "emoji": "🇨🇭",
    "phone_prefix": "+41",
    "order": 999,
    "tags": ["eu-adjacent"],
    "iban_length": 21,
    "name": "Switzerland",
    "description": null,
    "created_at": "2024-03-20T10:00:00Z",
    "created_by": "00000000-0000-0000-0000-000000000000",
    "modified_at": "2024-03-20T10:00:00Z",
    "modified_by": "00000000-0000-0000-0000-000000000000",
    "active": true,
    "metadata": {}
  }
]

An empty result is [], never null.

Get All Countries (v2, paginated)

GET /v2/countries

Paginated, search-oriented listing of countries, through the shared query parser.

No record-level permission filter applies. SrvGetAllCountriesV2 passes UserID: uuid.Nil — its own comment calls this a guest route — and the shared getAll path short-circuits on uuid.Nil before the permitted-records lookup, merging a nil scope. Any caller with a valid token therefore receives every row, and total/total_unfiltered are unfiltered too, regardless of record grants. RbacRecordType is passed but unused on this path. The v1 list has no record scoping either.

limit does not go through the shared 100-item clamp here: the handler calls query.GetLimitWithoutMaxFilter, which re-reads the raw parameter, so a large value is honoured as given. limit=-1 skips data fetching and returns only total/total_unfiltered; 0 and other negatives fall back to the default 10; a non-numeric value is 400 common.invalid_input.

Filterable and sortable fields: id, iso_alpha_2, iso_alpha_3, iso_numeric, icon, emoji, phone_prefix, order, iban_length, name, description, tags, created_at, created_by, modified_at, modified_by, active. Free-text search covers iso_alpha_2, iso_alpha_3, iso_numeric, icon, emoji, phone_prefix, name and description.

Two traps in that list:

  • metadata is not in validFieldsV2, and the status depends on which parameter names it. ?search.metadata=… is 500 query_m.invalid_search_field — validateFieldName builds that error with no WithCode, and an empty code sends HandleAppErrorWithCode to 500. ?stack=metadata is 400 query_m.invalid_field and ?sort=metadata is 400 query_m.invalid_sort_field (the latter subject to the data-pass caveat below).
  • icon_url is in validFieldsV2 but has no column behind it (models.Country marks it gorm:"-"), so ?sort=icon_url or ?search.icon_url=… builds SQL against a column that does not exist and returns 500. The sort half only fires on the data pass — see below.

sort understands only an optional leading - — sort=order, sort=-order. There is no :asc/:desc suffix; sort=order:asc is 400 query_m.invalid_sort_field.

But sort and stack are only validated when rows are actually fetched. getAllQueryStorage.applyParams sorts on the data pass alone (isCount == false), and SrvGetAllCountriesV2 skips that pass when the count is 0 or limit=-1. So ?sort=order:asc&limit=-1, and the same sort behind a search that matches nothing, both answer 200 with the bad key silently accepted. The search. checks are unaffected — applySearch runs on both passes. Do not read a 200 as proof the sort key is valid.

Two operator suffixes parse but do not validate, and one field is reserved. ParseFieldAndOperator recognises .ilike and .contains, so ?search.name.ilike=zurich splits into a field and an operator — and validateOperator then rejects it with 400 query_m.invalid_operator, because validOperators holds neither. Use the synonymous .like. They are legal on search._text, which is not a column at all: validFieldsV2 maps _text to an expression over iso_alpha_2, iso_alpha_3, iso_numeric, icon, emoji, phone_prefix, name and description, and ExtractTextSearch peels search._text.* off before ordinary field validation. It takes the wider set — .like, .ilike, .contains, .eq, .start_with, .end_with — a bare search._text means .like, and two _text operators in one query are 400 query_m.invalid_search_value.

A negative offset is neither rejected nor honoured: parseLimitOffset returns 0 for it, so ?offset=-10 silently returns the first page.

filter is accepted and then ignored: nothing extracts it into the query parameters, so it changes no result. Its one real effect is a 400 common.invalid_input when the value is not valid JSON — parseStandardQueryParameter unmarshals it before discarding it. Use search. to filter.

Get Country by ID

GET /v1/countries/{id}

Retrieves detailed information about a specific country.

Create Country

POST /v1/countries

Creates a new country entry in the system.

No input validation runs on this route. models.CreateCountryInput carries binding:"required,len=2" tags, but commonutil.ValidateStruct uses validator.New() and nothing in the repository calls SetTagName("binding") — the validator's default tag is validate, so those constraints are never evaluated. POST /v1/countries with {} succeeds and inserts a row with empty ISO codes and an empty name. A second such call fails on the unique index and answers 500, not 400 or 409. The fields below are required by the database and by good sense, not by the handler.

Request

{
  "name": "Switzerland",
  "iso_alpha_2": "CH",
  "iso_alpha_3": "CHE",
  "iso_numeric": "756",
  "iban_length": 21,
  "active": true,
  "metadata": {
    "eu_member": false,
    "sepa_member": true,
    "risk_level": "low",
    "supported_documents": ["passport", "id_card", "residence_permit"]
  }
}

Response

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "name": "Switzerland",
  "iso_alpha_2": "CH",
  "iso_alpha_3": "CHE",
  "iso_numeric": "756",
  "iban_length": 21,
  "active": true,
  "metadata": {
    "eu_member": false,
    "sepa_member": true,
    "risk_level": "low",
    "supported_documents": ["passport", "id_card", "residence_permit"]
  },
  "order": 999,
  "tags": null,
  "description": null,
  "created_at": "2024-03-20T10:00:00Z",
  "modified_at": "2024-03-20T10:00:00Z"
}

order, tags and description carry no omitempty in transform.CountryResponse, so they are always present — and their values for the request above are not the ones you might expect. tags is null rather than [] (a nil models.Tags), description is null rather than "" (a nil *string), and order is 999, not 0: the column carries gorm:"default:999", which GORM parses into a DefaultValueInterface, so the column goes into the INSERT with the literal 999 substituted for the zero value and set on the struct in the same step — no RETURNING for this column. Both an omitted order and an explicit "order": 0 therefore store 999 — a country cannot be created with order 0. PUT behaves differently, writing every column, so 0 goes through there. icon_url is absent from this body, and from the PUT 200: CreateCountry and UpdateCountry return the record straight from the insert or save, while the derived icon_url is only filled in by the two list routes and get-by-id. created_by and modified_by are absent for any caller without the internal role — see the audit-field note below.

Update Country

PUT /v1/countries/{id}

Updates an existing country's information.

Input

{
  "name": "string",
  "icon": "string",
  "emoji": "string",
  "phone_prefix": "string",
  "order": 0,
  "description": "string",
  "iban_length": 0,
  "active": true,
  "tags": [],
  "metadata": {}
}

The ISO codes are not in the update struct — sending iso_alpha_2, iso_alpha_3 or iso_numeric here is silently discarded and the codes stay as they are.

The body is merged onto the stored row, and omission is not uniform: name, icon, emoji, phone_prefix and description are retained when omitted (an explicit "" is skipped too, so they cannot be cleared through this endpoint), while tags, metadata, order, iban_length and active are overwritten with their zero values. A body of {"name": "..."} therefore clears tags and metadata, sets order and iban_length to 0 and deactivates the country.

Delete Country

DELETE /v1/countries/{id}

Deletes the country. This is a hard delete: nothing in the model chain carries a gorm.DeletedAt, so the row is removed. Answers 200 with {status, message} — not 204, and not a deactivation.

Error Codes

CodeDescription
countries_m.bad_record_idPath id is not a uuid
countries_m.country_not_foundCountry not found
common.failed_to_serializeCreate/update body is not valid JSON
common.invalid_input/v2/countries limit or offset could not be parsed, or filter is not valid JSON
common.forbiddenRecord permission on the country record type denied
common.database_errorQuery, insert, update or delete failed
query_m.invalid_field/v2/countries stack names an unknown field, or a search field name fails the character regex (?search.foo-bar=1) — 400 in both cases
query_m.invalid_search_field/v2/countries search. names an unknown field — 500, not 400
query_m.invalid_search_valueA bad value on a recognised field — 400
query_m.invalid_sort_fieldsort uses the field:asc form, or names an unknown field — 400
query_m.invalid_operatorAn operator suffix outside the accepted set — 400. .ilike and .contains parse but are legal only on search._text
common.server_errorauth.GetUserID could not read the caller from the context — 500. Only on the four routes that read the caller: get-by-id, create, update, delete. Neither list route calls it. Also every failure of the rate limiter itself, whose own code is discarded

These are the literal errs.MsgCode keys, which is what the code field of an error body carries. The countries_m.failed_to_serialize listed here before does not exist: declares exactly two keys, and the create/update handlers reuse the shared common.failed_to_serialize instead — they discard the decoder's own common.invalid_input and substitute it.

Two more shared codes reach these routes and are not in the table above, because they do not come from the module: common.unauthorized on the 401 and common.rbac_no_rec_access on the middleware's 403.

Refusals that never reach the handler

All six routes are wrapped by auth.WrapWithMiddlewares, and two more middlewares sit on the root router in front of it. A client sees these on every route, whatever the operation does.

StatusSourcecode in the body
401auth.Middleware — missing, malformed, expired or blacklisted bearer tokencommon.unauthorized
401auth.RateLimitMiddleware — no cached API-permission entry matches this method and pathpermission denied — free text, not a dotted key
403rbac.CanCallAPIv0 denied the endpoint grantcommon.rbac_no_rec_access
403licence checklicense_m.license_invalid, license_m.license_expired, license_m.module_not_licensed
429auth.RateLimitMiddlewaresee below
500the rate limiter's own failurescommon.server_error — the specific code is discarded
503health.LifecycleMiddleware — graceful shutdownnone — a different body shape
503auth.ensureCacheAvailable — auth cache unreachableauth_m.internal_server_error

countries does not implement loader.LicensedModule, so the loader does not skip it at boot and license_m.module_not_licensed really is reachable here — CanCallAPIv0 derives the module from the request path and checks it per request, in both the administrator-bypass and the normal RBAC branch. (On the six modules the loader does gate, that code cannot appear: their routes are simply absent and an unlicensed tenant gets chi's plain 404.) Two further codes the middleware matches cannot reach a client at all — license_m.license_service_unavailable is never written into the licence-error context, and license_m.license_key_missing needs licenseService == nil, a state a running process cannot be in because rbac.Init routes an InitLicenseService failure through logger.Fatalf.

The limiter's own 401. With the switcher on, auth.RateLimitMiddleware runs ahead of authentication and, for a caller whose token parses, hands the request to rbac.CanCallAPI. findMatchingEndpoint scans the cached user_limits::* keys for one whose method and path match; when none does, it returns errs.New("permission denied").WithCode(401) and handleRateLimitError writes it through Unauthorized401. So a caller whose roles carry no API-permission row for this route is refused at 401 with code: "permission denied" — a free-text English string — and never reaches the 403 that the RBAC middleware would have written. The sibling errs.New("failed to fetch endpoints").WithCode(500) loses its code on the way out, like the other limiter failures, and reads common.server_error.

429. auth.RateLimitMiddleware is mounted on the root router before authentication, and only when app-config rate_limits.rate_limits_switcher is true — with the flag off no route here can answer 429. Because it runs first, a request with no valid token can be rate-limited without ever reaching the 401. Five values can land in code, and only three are dotted keys: rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit, and the literal English strings Global rate limit exceeded (anonymous per-IP counter) and rate limit exceeded. class is temporary and retryable is true on all of them; no Retry-After header is sent.

503 has two body shapes. During a graceful shutdown health.LifecycleMiddleware writes a bare map — no status, code, class or retryable, and a fixed English message with no i18n key:

{
  "overall_status": "unhealthy",
  "message": "Service is shutting down",
  "timestamp": "2026-08-28T15:04:05.123456789+02:00"
}

timestamp is a bare time.Now(), and encoding/json marshals a time.Time with RFC3339Nano in the process time zone: the fractional second is present and of variable length (trailing zeros are dropped, and a whole-second instant loses it altogether), and the suffix is Z only when the process runs in UTC. Parse it, do not pattern-match it.

The other 503 — an unreachable auth cache — is the envelope, with code: "auth_m.internal_server_error". Note the auth_m prefix: ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package auth, so it is not the common.server_error constant of the same Go name. Branch on the shape, not on the status.

And that second 503 is only observable with the rate limiter off. auth.RateLimitMiddleware is registered before auth.Middleware and touches the same Redis/valkey, so with the switcher on a dead cache is answered by the limiter first — 500 for an authenticated caller, 429 for an anonymous one. That 500 is generic: handleRateLimitError's default branch calls apireply.InternalServerError500(w, r) without the AppError, so auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and rate_limits_m.failed_to_increment_ip_limit are discarded and the body reads common.server_error. Do not use the 503 as your cache-down signal in a deployment that rate limits.

created_by and modified_by are stripped for most callers

Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares also runs RemoveAuditFieldsHandler. It triggers on the Content-Type: application/json that apireply.WithJSON always sets, re-marshals the whole body and deletes created_by and modified_by recursively for any caller without the internal role. The keys are then absent, not null, and because the body round-trips through a Go map, key order is not preserved either.

Dependencies

  • common/errs - Error handling
  • common/auth - Authentication
  • common/logger - Logging functionality
  • common/rbac - Role-based access control

On this page