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:
metadatais not invalidFieldsV2, and the status depends on which parameter names it.?search.metadata=…is 500query_m.invalid_search_field—validateFieldNamebuilds that error with noWithCode, and an empty code sendsHandleAppErrorWithCodeto 500.?stack=metadatais 400query_m.invalid_fieldand?sort=metadatais 400query_m.invalid_sort_field(the latter subject to the data-pass caveat below).icon_urlis invalidFieldsV2but has no column behind it (models.Countrymarks itgorm:"-"), so?sort=icon_urlor?search.icon_url=…builds SQL against a column that does not exist and returns 500. Thesorthalf 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.CreateCountryInputcarriesbinding:"required,len=2"tags, butcommonutil.ValidateStructusesvalidator.New()and nothing in the repository callsSetTagName("binding")— the validator's default tag isvalidate, so those constraints are never evaluated.POST /v1/countrieswith{}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
| Code | Description |
|---|---|
| countries_m.bad_record_id | Path id is not a uuid |
| countries_m.country_not_found | Country not found |
| common.failed_to_serialize | Create/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.forbidden | Record permission on the country record type denied |
| common.database_error | Query, 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_value | A bad value on a recognised field — 400 |
| query_m.invalid_sort_field | sort uses the field:asc form, or names an unknown field — 400 |
| query_m.invalid_operator | An operator suffix outside the accepted set — 400. .ilike and .contains parse but are legal only on search._text |
| common.server_error | auth.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.
| Status | Source | code in the body |
|---|---|---|
| 401 | auth.Middleware — missing, malformed, expired or blacklisted bearer token | common.unauthorized |
| 401 | auth.RateLimitMiddleware — no cached API-permission entry matches this method and path | permission denied — free text, not a dotted key |
| 403 | rbac.CanCallAPIv0 denied the endpoint grant | common.rbac_no_rec_access |
| 403 | licence check | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed |
| 429 | auth.RateLimitMiddleware | see below |
| 500 | the rate limiter's own failures | common.server_error — the specific code is discarded |
| 503 | health.LifecycleMiddleware — graceful shutdown | none — a different body shape |
| 503 | auth.ensureCacheAvailable — auth cache unreachable | auth_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 handlingcommon/auth- Authenticationcommon/logger- Logging functionalitycommon/rbac- Role-based access control