List countries (v2)
Returns countries in the standard paginated envelope through the shared query parser. 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 fields are deliberately NOT listed. metadata is not in validFieldsV2 at all, so querying it answers 500 query_m.invalid_search_field — NOT a 400, and not query_m.invalid_field. validateFieldName builds MsgInvalidSearchField with no WithCode, and an empty Code sends HandleAppErrorWithCode to 500. Do not confuse it with query_m.invalid_field: that one has two unrelated sources, both answering 400 — validateFieldName raises it when the name fails the character regex, and the stack parser raises it for an unknown stack field. An unknown SORT field is query_m.invalid_sort_field, 400. icon_url IS in validFieldsV2 but has no column behind it (models.Country marks it gorm:"-"), so filtering or sorting on it builds SQL referencing a column that does not exist and returns 500 — a pre-existing defect in validFieldsV2, not something to rely on. Sorting on it is subject to the same data-pass caveat as any other sort key: see the sort parameter. NO RECORD-LEVEL PERMISSION FILTER APPLIES. The service 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. So any caller with a valid token receives every row and unfiltered total/total_unfiltered counts, regardless of record grants. RbacRecordType is passed but unused on this path. The v1 list has no record scoping either.
Authorization
bearerAuth In: header
Query Parameters
Maximum records to return. This route does NOT go through the shared 100-item clamp: the handler calls query.GetLimitWithoutMaxFilter, which re-reads the raw parameter, so a large limit is honoured as given. 0 and negatives other than -1 fall back to 10; a non-numeric value is rejected with 400 common.invalid_input. -1 is the count-only mode — ShouldSkipDataFetching returns total and total_unfiltered with an empty data array.
Records to skip. A negative value is neither rejected nor honoured — parseLimitOffset returns 0 for it, so ?offset=-10 silently returns the first page.
Sort expression. ParseSortFields splits on commas and understands ONLY an optional leading minus: sort=order ascending, sort=-order descending, sort=-order,name for two keys. There is no :asc / :desc suffix — the whole token is looked up in validFieldsV2, so sort=order:asc is not found and answers 400 query_m.invalid_sort_field. BUT THAT CHECK ONLY RUNS WHEN ROWS ARE FETCHED. getAllQueryStorage.applyParams sorts only on the data pass (isCount == false), and SrvGetAllCountriesV2 skips that pass entirely when the count is 0 or limit=-1. So ?sort=order:asc&limit=-1, and the same sort against a search that matches nothing, both answer 200 with the bad key silently accepted. stack behaves the same way. A 200 is not proof the sort key is valid.
JSON-encoded filter criteria — ACCEPTED AND THEN IGNORED on this route. ParseQueryJSON never extracts a filter key into models.Parameters, and get_all_service assigns Filter from an input field SrvGetAllCountriesV2 never sets, so it stays nil. ?filter={"active":true} returns exactly the same unfiltered page as omitting it. The only real effect is the 400 when the value is not valid JSON. Use search. to filter.
Free-text search over the searchable fields — iso_alpha_2, iso_alpha_3, iso_numeric, icon, emoji, phone_prefix, name and description. Also accepted as search. Both forms are translated into _text.like; the explicit search._text. form described under search. reaches the same expression with a choice of operator.
Group results by this field; data then comes back as an object keyed by the field value.
Return distinct values of this field.
Per-field filter with an optional operator suffix (.eq, .ne, .gte, .lte, .gt, .lt, .in, .nin, .like, .start_with, .end_with). An unrecognised FIELD is answered 500 query_m.invalid_search_field, not 400 — that branch of validateFieldName omits WithCode. A name that fails the character regex is a normal 400, query_m.invalid_field, and a bad VALUE on a recognised field is a normal 400, query_m.invalid_search_value. TWO SUFFIXES PARSE BUT DO NOT VALIDATE. ParseFieldAndOperator also recognises .ilike and .contains, so ?search.name.ilike=zurich splits cleanly into a field and an operator — and then validateOperator rejects it, because validOperators holds neither. The answer is 400 query_m.invalid_operator, a fifth query_m code. Use .like, which is synonymous. THE RESERVED _text FIELD. search._text is not a column: validFieldsV2 maps it to an expression concatenating 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, so the 500 above does not apply to it. It takes the WIDER operator set — .like, .ilike, .contains, .eq, .start_with, .end_with — which is where .ilike and .contains are legal. A bare search._text means .like. Anything outside that set is 400 query_m.invalid_operator, and two _text operators in one query are 400 query_m.invalid_search_value.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/v2/countries"{
"data": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"iso_alpha_2": "CH",
"iso_alpha_3": "CHE",
"iso_numeric": "756",
"icon": "string",
"icon_url": "string",
"emoji": "🇨🇭",
"phone_prefix": "+41",
"order": 999,
"tags": [
"string"
],
"iban_length": 0,
"name": "Switzerland",
"description": "string",
"created_at": "2019-08-24T14:15:22Z",
"created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
"modified_at": "2019-08-24T14:15:22Z",
"modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
"active": true,
"metadata": {}
}
],
"total": 0,
"total_unfiltered": 0,
"metadata": {},
"keys": [
"string"
],
"has_more": true
}{
"status": 404,
"message": "Country not found",
"code": "countries_m.country_not_found",
"class": "business",
"retryable": true,
"details": [
{
"field": "string",
"rule": "string",
"param": "string",
"message": "string"
}
]
}{
"status": 404,
"message": "Country not found",
"code": "countries_m.country_not_found",
"class": "business",
"retryable": true,
"details": [
{
"field": "string",
"rule": "string",
"param": "string",
"message": "string"
}
]
}{
"status": 404,
"message": "Country not found",
"code": "countries_m.country_not_found",
"class": "business",
"retryable": true,
"details": [
{
"field": "string",
"rule": "string",
"param": "string",
"message": "string"
}
]
}{
"status": 429,
"message": "rate limit exceeded",
"code": "rate_limits_m.exceeded",
"class": "temporary",
"retryable": true
}{
"status": 404,
"message": "Country not found",
"code": "countries_m.country_not_found",
"class": "business",
"retryable": true,
"details": [
{
"field": "string",
"rule": "string",
"param": "string",
"message": "string"
}
]
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05.123456789+02:00"
}Deletes the country. This is a HARD delete — GORM's Delete on models.Country removes the row, because nothing in the model chain (Country -> StrictNameModelV2 -> BaseModelV2) carries a gorm.DeletedAt. The manual previously described it as a deactivation. Answers 200 with the standard success envelope, not 204.
Description
Next Page