Skip to main content

Query API

The Query API is the v1 replacement for the legacy Generic API (POST /api/rest/v2/generic/all/{entity}). It reads any entity in Billerang through one unified interface: filtering, pagination, sorting, nested-entity loading, field selection, joins, grouping, and aggregation. It is read-only. Create, update, and delete always go through the entity-specific v1 resource (for example POST /api/v1/sellers), never through the Query API.

Both the resource interface and its implementation are live in the codebase today: org.billerang.api.query.QueryV1Resource and QueryV1ResourceImpl (billerang-backend/billerang-api/src/main/java/org/billerang/api/query/), registered in BillerangV1Application.getClasses(). Every example on this page was run against the live local stack; the captured responses are pasted verbatim.

Two dialects, one implementation

The Query API exposes the same read semantics through two HTTP dialects, side by side. Both are handled by the same QueryV1ResourceImpl methods, which delegate to GenericApiLoadService / GenericRequestMapper / GenericHelper, the same classes the legacy Generic API uses. That shared implementation is why list/count/get responses are the same shape for the same inputs whichever dialect a client picks.

GET dialect (query parameters)

GET /api/v1/query/{entity}?filters=...&fields=...&nested=...&limit=...

Idiomatic for v1: cacheable, bookmarkable, easy to inspect in a browser or a proxy log. Every example below uses this dialect unless stated otherwise.

POST dialect (JSON body, same payload as the legacy Generic API)

POST /api/v1/query/{entity}
Content-Type: application/json

{
"filters": { "countryCode": "FR" },
"nestedEntities": ["tradingCurrency"],
"limit": 25
}

Useful for filter payloads too large or structurally awkward for a query string (deep $filterN nesting, long inList arrays), and for clients migrating straight off the legacy Generic API's POST body without reshaping it. sortOrder values ASC/DESC are normalized to ASCENDING/DESCENDING on this dialect too, so both dialects accept both spellings.

Both dialects exist for list, count, and get by id:

ReadGETPOST
ListGET /api/v1/query/{entity}POST /api/v1/query/{entity}
CountGET /api/v1/query/{entity}/countPOST /api/v1/query/{entity}/count
Get by idGET /api/v1/query/{entity}/{id}POST /api/v1/query/{entity}/{id}

Source: QueryV1Resource.java, the list/listPost, count/countPost, get/getPost method pairs.

Endpoints

MethodPathDescription
GET/POST/api/v1/query/{entity}List and filter entities
GET/POST/api/v1/query/{entity}/countCount matching entities
GET/POST/api/v1/query/{entity}/{id}Get one entity by numeric id
GET/api/v1/query/entitiesList queryable entity names
GET/api/v1/query/entities/{entity}Field-level mapping for one entity

{entity} is the same camelCase or simple-name token the legacy Generic API accepts today (for example seller, billingAccount, walletOperation). Resolution goes through GenericHelper.getEntityClass(entityName), the same resolver the legacy Generic API uses.

Parameters (GET /api/v1/query/{entity})

ParameterTypeRequiredDefaultDescription
filtersstring (JSON-encoded)NononeFilter object, URL-encoded. See "Filter operators" below.
fieldsstring (CSV)Noall fieldsEquivalent to the legacy genericFields. Projects only the listed fields; also carries aggregation expressions (see "Aggregation").
nestedstring (CSV)NononeEquivalent to the legacy nestedEntities. Eagerly loads related entities.
depthnumberNo0Equivalent to nestedDepth. How many levels deep to expand nested entities.
offsetnumberNo0Number of results to skip.
limitnumberNo100Maximum number of results. Capped at 1000 regardless of the requested value.
sortBystringNoidField name to sort by.
sortOrderstringNoASCENDINGASC/DESC or ASCENDING/DESCENDING (see "sortOrder spelling").
joinTypestringNoINNERINNER, LEFT, or RIGHT. Controls the join Hibernate uses when a filter or fetch touches a related entity.
groupBystring (CSV)NononeField(s) to group by, for use with aggregation fields. Accepts dotted paths, e.g. seller.code.
havingstring (CSV)NononePost-aggregation filter clauses.
excludingstring (CSV)NononeField names to drop from the response, applied after fields/nested resolve.
forceCountbooleanNofalseForces a COUNT query even for entities configured as huge-volume (see "forceCount and huge entities").
extractListbooleanNoserver defaultPassthrough of the legacy extractList flag (return a bare array instead of the envelope, for entities configured to do so).

Source: QueryParamMapper.build(...) (billerang-backend/billerang-api/src/main/java/org/billerang/api/query/QueryParamMapper.java), which builds a GenericPagingAndFiltering from these raw query params, and GenericRequestMapper.getPaginationConfiguration(...) (org.meveo.apiv2.generic.core.GenericRequestMapper), which turns that into the PaginationConfiguration the persistence layer actually executes.

sortOrder spelling

QueryParamMapper.normalizeSortOrder accepts all four spellings and normalizes them before they reach the persistence layer, which only knows ASCENDING/DESCENDING:

switch (upper) {
case "ASC": return "ASCENDING";
case "DESC": return "DESCENDING";
case "ASCENDING":
case "DESCENDING": return upper;
default: throw new BadRequestException(...);
}

Any other value returns 400 Bad Request with the message Invalid sortOrder '...'. Expected ASC, DESC, ASCENDING or DESCENDING.

joinType

static JoinType parseJoinType(String joinType) {
try {
return JoinType.valueOf(joinType.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid joinType '" + joinType + "'. Expected INNER, LEFT or RIGHT.");
}
}

jakarta.persistence.criteria.JoinType has three values: INNER, LEFT, RIGHT. Anything else is a 400.

Filter operators

filters carries a JSON object, URL-encoded as a single query parameter value (or, on the POST dialect, the filters key of the JSON body). Most keys follow the pattern <condition> <fieldName> (condition and field name separated by a space); the condition is optional and defaults to an equals-ignoring-case comparison. This table is the complete, verified operator list, cross-checked against three places in source that must agree: PersistenceService.getQuery()'s javadoc, the switch in NativeExpressionFactory.checkOnCondition(...) (billerang-backend/billerang-admin/ejbs/src/main/java/org/meveo/service/base/expressions/NativeExpressionFactory.java), and the constants declared on PersistenceService itself. No operators were added or removed when the Generic API was re-homed as the Query API; this is the same grammar the legacy API has always had.

Key patternMeaningExample
(bare field name)Equals (default when no condition given). Supports wildcards for strings.{"code": "SELLER_FR"}
ne <field>Not equals{"ne code": "SELLER_FR"}
eqOptional <field> / neOptional <field>Same as eq/ne, but the field value is optional (omitted from the WHERE clause if the filter value is null){"eqOptional code": "SELLER_FR"}
fromRange <field>field >= value (dates truncated to start of day){"fromRange amount": 100}
fromRangeExclusive <field>field > value{"fromRangeExclusive amount": 100}
fromOptionalRange <field>Same as fromRange, value optional{"fromOptionalRange amount": 100}
toRange <field>field < value (exclusive){"toRange amount": 500}
toRangeInclusive <field>field <= value{"toRangeInclusive amount": 500}
toOptionalRange <field> / toOptionalRangeInclusive <field>Same as above, value optional{"toOptionalRange amount": 500}
list <field>Value is in the field's own list-valued column{"list statuses": "ACTIVE"}
listInList <field>Value (a list) should be contained in the field's list valuesee javadoc
inList <field>Field value is in the given list{"inList status": ["ACTIVE","SUSPENDED"]}
not-inList <field>Field value is not in the given list{"not-inList status": ["CANCELLED"]}
inSqlList <field>Field value is in the result of a sub-selectsee addSqlListFilters
minmaxRange <f1> <f2>Value is between two field values (f1 <= value < f2){"minmaxRange f1 f2": "2026-01-01"}
minmaxRangeInclusive <f1> <f2>Same, f2 inclusive
minmaxOptionalRange <f1> <f2> / minmaxOptionalRangeInclusive <f1> <f2>Same, either field optional
overlapOptionalRange <f1> <f2>Value RANGE (a 2-element array) overlaps the [f1,f2] field range, exclusive bounds{"overlapOptionalRange validFrom validTo": ["2026-01-01","2026-06-01"]}
overlapOptionalRangeInclusive <f1> <f2>Same, inclusive bounds
likeCriterias <f1> <f2> ...Any of the listed fields matches (OR), case-insensitive; * in the value triggers a LIKE match{"likeCriterias param1 param2": "energy"}
wildcardOr <f1> <f2> ...Like likeCriterias, but always wraps the value in *...* automatically{"wildcardOr param1 param2": "energy"}
wildcardOrIgnoreCase <f1> <f2> ...Same as wildcardOr, explicit case-insensitive
or <f1> <f2> ...Alias: routes to the same handler as wildcardOrIgnoreCasesource: case "or": case SEARCH_WILDCARD_OR_IGNORE_CAS: in NativeExpressionFactory
anyMatch <f1> <f2> ...OR across several different field paths (not the same field on several rows) all compared against the same value, LEFT-joined{"anyMatch customer.id billingAccount.customerAccount.customer.id": "19"}
SQL (or SQL2, SQL..., any suffix)Raw SQL fragment used as-is. The SQL prefix can repeat with a different suffix to add more than one SQL clause.{"SQL": "a.code like 'SELLER%'"}
OR (as the whole key, value is a nested filter object)Wraps the nested object's filters in a single OR clause between them{"OR": {"code":"X","description":"Y"}}
AND (word can be suffixed, e.g. AND2)Lets the same field appear more than once with different conditions; the word is stripped before evaluationsee javadoc
(value) IS_NULLField is null{"status": "IS_NULL"}
(value) IS_NOT_NULLField is not null{"status": "IS_NOT_NULL"}
entity.fieldFilter on a related entity's field{"customerAccount.code": "CA_001"}
entity (value is a code)Filter by related entity's code directly{"customerAccount": "CA_001"}
$filterN (e.g. $filter1)Named nested sub-filter; groups its own contents (can nest $operator). Key is case-insensitive ($FILTER1 works too)see "Nested filters"
$operator (inside a $filterN block or as a top-level sibling)AND or OR; combines this filter's sibling keys. Key is case-insensitive ($OPERATOR works too)see "Nested filters"
type_classValue is a fully-qualified classname (or list of them); restricts polymorphic queries to a subclass. Combine with ne to exclude.{"type_class": "org.meveo.model.billing.Invoice"}
<field>.cfValues or cfValuesFilters on a custom field value, including on a related entity's custom fieldssee "Custom field (cfValues) filtering"

Fields not present in the table above (fromRangeExclusive, listInList, inSqlList, AND) are part of the same verified javadoc/switch but have no live example on this page; treat them as documented-but-not-yet live-demonstrated.

$operator and $filterN keys are case-insensitive

Both spellings used across the codebase and javadoc ($operator/$OPERATOR, $filter1/$FILTER1) are accepted; the backend matches these keys ignoring case. Historically uppercase $OPERATOR silently fell back to AND (case-sensitive lookup in PaginationConfiguration); this was fixed and is covered by regression tests in PersistenceServiceTest and GenericRequestMapperTest. Live-verified on this stack: lowercase and uppercase produce the identical OR result (see example 4 below).

Nested filters ($filterN + $operator)

$filterN (any key matching \$filter[0-9]+) groups a nested filter object so it can carry its own $operator (AND/OR) independent of the outer filter set. The outer filter set and any $filterN blocks are always joined by AND to each other; the OR/AND choice only applies to the keys inside one $filterN block (or, using the top-level $operator shown above, to the whole top-level filter set).

{
"$filter1": {
"$operator": "OR",
"code": "SELLER_FR",
"description": "US Seller"
}
}

This says: match sellers where code = 'SELLER_FR' OR description = 'US Seller'. See the live capture below; the top-level form and the $filter1-wrapped form were run and returned the same two rows, in both lowercase and uppercase key spellings.

Custom field (cfValues) filtering

A filter key of exactly cfValues, or a dotted path ending in .cfValues (e.g. customerAccount.cfValues), filters on a custom field value instead of a mapped column. Resolution: FactoryMapper.create(...) (billerang-backend/billerang-admin/ejbs/src/main/java/org/meveo/api/generics/filter/FactoryMapper.java) special-cases cfValues as a "simple field" (line: Set<String> simpleField = {"id", "type_class", "cfValues"}), and for a dotted <relation>.cfValues path it reflects the related field's generic type before building a CustomFieldMapper.

{"cfValues": {"loyaltyTier": [{"value": "GOLD"}]}}

CustomFieldMapper.toCustomFieldValue(...) (org/meveo/api/generics/filter/filtermapper/CustomFieldMapper.java) maps the filter value by the custom field's declared type:

CustomFieldTypeEnumMapped as
DATEparsed date
LONGlong
DOUBLEdouble
BOOLEANboolean
ENTITY / CHILD_ENTITYEntityReferenceWrapper (by classname + code)
everything else (default)string

Limitation, verified in source: the mapper's switch has no case for list, map, or matrix-valued custom fields — a CF declared as LIST, MAP, Matrix, or Multi-value falls through to the default: stringValue branch, which is not a correct representation of those types. Filtering by a list/map/matrix custom field through the Query API (or the legacy Generic API, since they share this code) is not supported; use the entity-specific v1 resource's own read path instead for those fields.

Nested-entity cfValues filtering (e.g. filtering sellers by a custom field declared on their linked customerAccount) is supported by the same mechanism: <relation>.cfValues resolves the relation's target type first, then applies the same CustomFieldMapper against that type's custom field templates.

groupBy / having + aggregation

Putting an aggregation expression in fields switches the whole request onto a different, native-SQL aggregation code path (GenericApiLoadService.findPaginatedRecords, the isAggregationQueries(...) branch). A field is treated as an aggregation expression when it starts with one of:

private boolean isAggregationField(String field) {
return field.startsWith("SUM(") || field.startsWith("COUNT(") || field.startsWith("AVG(")
|| field.startsWith("MAX(") || field.startsWith("MIN(") || field.startsWith("COALESCE(SUM(");
}

The parenthesized form is required (SUM(amountWithoutTax), not SUM amountWithoutTax — the space-separated form is filter-operator syntax, not aggregation syntax, and is a different mechanism entirely). Combine one or more aggregation fields with plain fields (typically the groupBy column itself) in the same fields list, and put the grouping column(s) in groupBy:

fields=seller.code,SUM(amountWithoutTax),COUNT(id)
groupBy=seller.code

See the live capture below for the exact response shape: the aggregation path returns one object per group, keyed by the literal field expressions you asked for ("seller.code", "SUM(amountWithoutTax)", "COUNT(id)"), and total is the number of returned groups, not the number of underlying rows.

having (CSV of post-aggregation conditions) rides the same groupBy mechanism at the PaginationConfiguration level (getGroupBy()/getHaving() on GenericPagingAndFiltering) but has no live example captured on this page.

TODO-VERIFY

having syntax (the exact condition-string grammar accepted in the CSV) was not live-tested. Confirm the expected format with the API Architect or by reading PaginationConfiguration's consumer of getHaving() before relying on it in an integration.

excluding

CSV list of field names to drop from the response after fields/nested have already been resolved. Passed straight through to JsonGenericMapper/findByClassNameAndId as excludedFields. No live example captured; behavior inferred from the parameter being threaded unchanged from QueryParamMapper through to the same excludedFields argument the legacy Generic API already uses.

forceCount and huge entities

Some entities are annotated @HugeEntity in the domain model (for example WalletOperation, RatedTransaction, Invoice, CDR, EDR, AccountOperation — see org.meveo.model.HugeEntity, an empty marker annotation). That annotation by itself does not skip the count. The count-skipping behavior is driven by a separate, tenant-configurable registry: FinanceSettings.getEntitiesWithHugeVolume(), a Map<String, HugeEntity> keyed by entity simple name (a different, DTO-level HugeEntity class, not the marker annotation). The actual logic, in GenericApiPersistenceDelegate.list(...):

boolean isHugeVolume = ... entitiesWithHugeVolume.keySet() ...
.anyMatch(e -> e.equalsIgnoreCase(entityClass.getSimpleName()));
Long count = null;
if (!isHugeVolume || searchConfig.getForceCount()) {
count = this.count(entityClass, searchConfig);
}

In plain terms: if an entity is not configured as huge-volume, it is always counted (total is always accurate). If it is configured as huge-volume, the count is skipped by default (paged calls with no total in a real deployment) unless the caller passes forceCount=true, which forces the count regardless.

The @HugeEntity-annotated entities are what GET /api/v1/query/entities?onlyHugeEntities=true reports (see "Metadata endpoints" below) — that flag reflects the annotation, not the FinanceSettings registry, so it is a hint about which entities are candidates for huge-volume configuration, not a live report of which ones currently skip counting.

On this local stack, FinanceSettings.entitiesWithHugeVolume is empty by default (verified: GET /api/v1/query/financeSettings returns no populated map), so walletOperation (an @HugeEntity) behaves like any other entity here: total is accurate with or without forceCount. Both calls below returned "total": 4747. In a deployment where an operator has configured WalletOperation (or RatedTransaction, Invoice, etc.) as huge-volume, expect the no-forceCount call to omit or zero out total and the forceCount=true call to pay the cost of a real COUNT query.

extractList

Passthrough boolean. When absent, QueryV1ResourceImpl falls back to genericOpencellRestful.shouldExtractList(), a server-side default (the same one the legacy Generic API uses) that governs whether certain entities serialize as a bare array instead of the {total, limit, offset, data} envelope. No live example captured for a case where the two differ; every capture on this page used the default envelope shape.

Response envelope

List (GET/POST /api/v1/query/{entity})

{
"total": 42,
"limit": 100,
"offset": 0,
"filters": { "code": "SELLER_EU_001" },
"data": [ { "id": 1, "code": "SELLER_EU_001", "...": "..." } ]
}

filters echoes the parsed filter object back. data shape honors fields/nested/depth. This envelope is ImmutableGenericPaginatedResource, serialized by JsonGenericMapper, the exact same builder the legacy Generic API constructs — so a Query API response and the equivalent legacy Generic API response are the same shape for the same logical query.

Count (GET/POST /api/v1/query/{entity}/count)

{ "total": 42 }

Get by id (GET/POST /api/v1/query/{entity}/{id})

{ "data": { "id": 1, "code": "SELLER_EU_001", "...": "..." } }

404s as jakarta.ws.rs.NotFoundException when the id does not resolve (QueryV1ResourceImpl.doGet, .orElseThrow(() -> new NotFoundException("entity " + entity + " with id " + id + " not found."))).

Metadata endpoints

List queryable entities

curl -H "Authorization: Bearer $TOKEN" https://acme.billerang.com/api/v1/query/entities
{ "entities": ["AccountOperation", "BillingAccount", "Customer", "Invoice", "Seller"] }

Optional flags onlyBusinessEntities and onlyHugeEntities (both boolean, default false) narrow the list, resolving each name back to a class via GenericHelper and checking BusinessEntity.class.isAssignableFrom(...) or entityClass.isAnnotationPresent(HugeEntity.class) respectively (QueryV1ResourceImpl.matchesEntityFlags).

withFullName

Optional boolean, default false. By default entities is a plain array of simple class names. Pass withFullName=true to get an array of {name, fullName} objects instead, exposing the fully qualified class name for callers (for example code generators) that need it to build an import or a reflective lookup:

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'withFullName=true' \
--data-urlencode 'onlyBusinessEntities=true' \
http://localhost:8080/api/v1/query/entities
{"entities":[
{"name":"AccountEntity","fullName":"org.meveo.model.AccountEntity"},
{"name":"AccountOperation","fullName":"org.meveo.model.payments.AccountOperation"},
{"name":"AccountingArticle","fullName":"org.meveo.model.article.AccountingArticle"},
{"name":"AccountingCode","fullName":"org.meveo.model.billing.AccountingCode"},
{"name":"AccountingScheme","fullName":"org.meveo.model.payments.AccountingScheme"}
]}

Live-verified against the running local stack; response truncated to the first 5 entries above (onlyBusinessEntities=true narrows the full list considerably, but it is still long).

Field mapping for one entity

curl -H "Authorization: Bearer $TOKEN" https://acme.billerang.com/api/v1/query/entities/billingAccount
{
"javaName": "BillingAccount",
"tableName": "billing_billing_account",
"fields": [
{ "javaField": "id", "javaType": "Long" },
{ "javaField": "code", "javaType": "String", "columnName": "code", "nullable": false },
{ "javaField": "customerAccount", "javaType": "CustomerAccount", "columnName": "customer_account_id", "isFK": true, "isAssociation": true }
]
}

Per-field keys are emitted only when applicable: javaField, javaType, columnName, nullable: false, isFK: true, isAssociation: true. Delegates to SchemaMappingService.getEntityMapping(...), the same service WorkbenchSchemaRsImpl uses. Unknown entity returns 404 with {"code": "ENTITY_NOT_FOUND", "message": "Unknown entity '...'"}.

Live-verified examples

All captures below ran against http://localhost:8080 on 2026-07-09, using a Keycloak client-credentials bearer token. Responses are pasted exactly as returned (only re-wrapped for line length where the original was one long line).

1. ne filter (sellers not named SELLER_FR)

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"ne code":"SELLER_FR"}' \
--data-urlencode 'fields=code,description' \
--data-urlencode 'limit=5' \
http://localhost:8080/api/v1/query/seller
{"total":10,"limit":5,"offset":0,"data":[
{"code":"SELLER_US","description":"US Seller"},
{"code":"MAIN_SELLER","description":"Demo Distributor"},
{"code":"INWI_MA","description":"Inwi Morocco"},
{"code":"NETFLIX_MA","description":"Netflix Maroc"},
{"code":"OPENAI_MA","description":"OpenAI Morocco"}
]}

2. inList filter

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"inList code":["SELLER_FR","SELLER_US","MAIN_SELLER"]}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":3,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"},
{"code":"MAIN_SELLER","description":"Demo Distributor"}
]}

3. Get by id, with field selection

curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/query/seller/-3?fields=id,code,description"
{"data":{"id":-3,"code":"SELLER_FR","description":"France Shops"}}

4. $operator OR — top-level and nested, any key case

Top-level $operator (uppercase $OPERATOR returns the identical result):

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"$operator":"OR","code":"SELLER_FR","description":"US Seller"}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":2,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"}
]}

The same OR wrapped in a named $filter1 block, here with the uppercase $OPERATOR spelling (live-verified: same two rows; $FILTER1 also works):

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"$filter1":{"$OPERATOR":"OR","code":"SELLER_FR","description":"US Seller"}}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":2,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"}
]}

5. groupBy + SUM()/COUNT() aggregation

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'fields=seller.code,SUM(amountWithoutTax),COUNT(id)' \
--data-urlencode 'groupBy=seller.code' \
--data-urlencode 'limit=5' \
http://localhost:8080/api/v1/query/walletOperation
{"total":2,"limit":5,"offset":0,"data":[
{"seller.code":"PLUXEE_BE","SUM(amountWithoutTax)":-100.600000000000,"COUNT(id)":4746},
{"seller.code":"EDENRED_FR","SUM(amountWithoutTax)":-48.000000000000,"COUNT(id)":1}
]}

(total here is the number of groups returned, 2, not the number of underlying wallet operations.)

6. forceCount on a @HugeEntity (walletOperation)

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'limit=3' \
--data-urlencode 'forceCount=true' \
http://localhost:8080/api/v1/query/walletOperation

Returned "total":4747 with 3 wallet operations in data.

Same call without forceCount:

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'limit=3' \
http://localhost:8080/api/v1/query/walletOperation

Also returned "total":4747 with the same 3 wallet operations in data. Identical total in both cases, because WalletOperation is not currently configured in FinanceSettings.entitiesWithHugeVolume on this stack (see "forceCount and huge entities" above for what changes when it is).

Bonus: SQL raw fragment and count endpoint

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode "filters={\"SQL\":\"a.code like 'SELLER%'\"}" \
--data-urlencode 'fields=code' \
http://localhost:8080/api/v1/query/seller
{"total":2,"limit":100,"offset":0,"data":[{"code":"SELLER_FR"},{"code":"SELLER_US"}]}
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/query/walletOperation/count
{ "total": 4747 }

Migrating from the Generic API

The Query API is a re-homing of the same read semantics the Generic API has always had, now available under /api/v1 in both a GET-with-query-params shape and a POST-with-JSON-body shape identical to the legacy call. Nothing about filters, pagination, joins, grouping, or nested loading changed; only the transport shape is new (plus the GET dialect, which the legacy API never had).

Before (Generic API, POST only):

POST /api/rest/v2/generic/all/seller
Content-Type: application/json

{
"filters": { "countryCode": "FR" },
"nestedEntities": ["tradingCurrency", "tradingCountry"],
"limit": 25,
"offset": 0,
"sortBy": "code",
"sortOrder": "ASCENDING"
}

After, GET dialect:

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"countryCode":"FR"}' \
--data-urlencode 'nested=tradingCurrency,tradingCountry' \
--data-urlencode 'limit=25' \
--data-urlencode 'offset=0' \
--data-urlencode 'sortBy=code' \
--data-urlencode 'sortOrder=ASCENDING' \
https://acme.billerang.com/api/v1/query/seller

After, POST dialect (same body, new host path):

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"filters":{"countryCode":"FR"},"nestedEntities":["tradingCurrency","tradingCountry"],"limit":25,"offset":0,"sortBy":"code","sortOrder":"ASCENDING"}' \
https://acme.billerang.com/api/v1/query/seller

Field mapping (GET dialect only — the POST dialect keeps the legacy names verbatim): genericFields becomes fields, nestedEntities becomes nested, nestedDepth becomes depth. filters, limit, offset, sortBy, and sortOrder keep the same name and meaning in both dialects.

Implementation reference

org.billerang.api.query.QueryV1Resource and QueryV1ResourceImpl exist in billerang-backend/billerang-api/src/main/java/org/billerang/api/query/, alongside a test class, and QueryV1ResourceImpl is registered in BillerangV1Application.getClasses() (resources.add(QueryV1ResourceImpl.class);), so every route described on this page is live. docs/adr/ADR-v1-query-api.md itself is still marked Status: Proposed at the top of the file; based on the live verification in this page, that appears to be a stale status field rather than a sign the API is unbuilt.