Search Queries
Every list endpoint speaks one query contract: the same parameters, the same
operators, the same paging, and the same 400 errors. Learn it once here; an
endpoint's page in the API reference lists which
fields it filters and sorts on.
GET /api/v1/guilds?q=wyvern&filter[visibility][eq]=public&sort=-created_at&limit=2&total=true| Parameter | Description |
|---|---|
filter[<field>][<operator>] | A condition on one field, repeatable; all conditions apply |
sort | Field to order by, - first for descending (sort=-created_at); some lists take several keys, comma-separated |
limit | Rows per page; 25 by default, at most 100 unless the endpoint says otherwise |
cursor | Resume after a page: the page.next_cursor of the previous response |
page | Jump to a 1-based page number instead of following cursors |
total | true to count every matching row into page.total |
q | Full-text search, on lists that support it |
Anything else in the query string is either one of the endpoint's own named
flags (documented on its page) or ignored. The parameters of the previous
grammar are refused rather than ignored, so a client learns the new name:
filter=field:op:value, page_size, sort_by, sort_order, and offset
all return 400 with the replacement in msg.
Filters
A filter names a field and an operator in brackets, with the value as the
parameter value. Brackets are fine unencoded in most clients; encoded
(filter%5Bvisibility%5D%5Beq%5D=public) is equally accepted.
| Operator | Meaning | Example |
|---|---|---|
eq | Equals | filter[status][eq]=open |
ne | Does not equal | filter[status][ne]=closed |
in | Equals any of a comma-separated list | filter[status][in]=open,pending |
all | A tag list holds every value listed | filter[tags][all]=pvp,ranked |
gt gte lt lte | Ordered comparison | filter[level][gte]=5 |
prefix | Text starts with | filter[path][prefix]=config/ |
contains | Every word occurs, any order | filter[description][contains]=error |
phrase | The words occur together, in order | filter[name][phrase]=John Doe |
fuzzy | Typo-tolerant word match | filter[name][fuzzy]=jonh |
exists | The value is present (true) or absent (false) | filter[data.error_code][exists]=false |
Which operators a field accepts follows from its kind:
| Field kind | Operators |
|---|---|
| Identifier or enumeration | eq, ne, in; some identifiers, such as paths, also prefix |
| Tag list | eq, ne, in, all |
| Number or timestamp | eq, ne, in, gt, gte, lt, lte |
| Boolean | eq, ne |
| Searchable text | prefix, contains, phrase, fuzzy |
Values:
- Timestamps take RFC 3339 (
2026-01-01T00:00:00Z),now, or a time before now:-30m,-6h,-7d. A relative time is resolved on every request, sofilter[created_at][gte]=-24hkeeps meaning "the last day" while you page. - Enumerations take only their listed values; anything else is a
400naming the choices. inlists split on commas; write\,for a literal comma and\\for a backslash. At most 100 values.- Booleans are exactly
trueorfalse. - A request carries at most 20 filters, each value at most 200 characters, and each field and operator pair once.
- Text matching follows the search index's words, not raw bytes:
punctuation separates words, so
phrasemeans word adjacency (a@b,a b, anda-bare the same phrase) andcontainsneeds every word without caring about order. A value that must match as one opaque string is modelled as an identifier and filtered witheq.
A few lists also filter on keys inside free-form data, named
<family>.<key>: timeline events on data.<key> and narrative actors on
attributes.<key>. Their pages list the operators those families accept.
A field name used directly as a parameter (?visibility=public) is a 400
that tells you to write filter[visibility][eq]=public.
Sorting
sort=name orders ascending, sort=-name descending. Each list names the
fields it sorts by and its default order; a sort on anything else is a
400 listing the choices.
Paging
Every response carries the rows and a page object:
"page": {
"next_cursor": "eyJ2IjoxLCJzIjoiZmI1YzU2ZjM5ZTYyOTVkYyIsInAiOnsibyI6Mn19",
"has_more": true,
"total": 3
}| Field | Present |
|---|---|
next_cursor | When another page follows; pass it back as cursor |
has_more | Always |
total | When the request asked with total=true |
page | When the request asked for a page number with page |
Cursors are the default way to page: request the next page with the
same parameters plus cursor. A cursor is bound to the list and the filters,
sort, and search that produced it, so using it on another list or after
changing any of them is a 400; start again without one. Cursors are opaque; do not build or edit
them.
Page numbers (page=3) jump straight to a page. Use one or the other:
cursor and page together is a 400. Some lists page by a position their
store keeps (a stream id, a continuation token) and cannot jump to a page
number; they refuse page and say so.
Counting is opt-in because it costs a full count: ask with total=true.
Lists that cannot count without reading every row (filtered scans of a
stream, rankings) refuse total with a 400.
Full-text search
q searches the entity's text fields: each word matches by prefix, so
q=wyvern kni finds "Wyvern Knights". Combine it freely with filters and
sort. Lists without text search refuse q.
The JSON body form
A few endpoints take a query language richer than query parameters and read
it from a POST body; the ECS entity query is one. They use the same names for
everything this contract covers: sort, limit, cursor, page, total,
and return the same page object.
Related records
Four detail endpoints can embed related records (users, guilds, polls, and
tournaments) with include. It keeps its own option syntax:
GET /api/v1/guilds/{guild_id}?include=members(limit:5,sort:-joined_at,filter:role:eq:admin)Options are limit (100 by default, at most 1000), offset, one sort,
and one filter as field:op:value with eq, bool, or range
(min..max). Each endpoint's page lists the relations it embeds.
The CLI
Every CLI list command takes the same options:
snug guild list --filter 'visibility[eq]=public' --sort -created_at --limit 2 --total
snug guild list -q wyvern --all
snug guild list --limit 25 --page 3--filter is repeatable and takes field[op]=value; --cursor resumes
after a page; --all follows cursors to the last page and prints every row.
A real query
Guild service, envelope included:
GET /api/v1/guilds?q=wyvern&filter[visibility][eq]=public&sort=-created_at&limit=2&total=true{
"status": 200,
"msg": "OK",
"data": {
"guilds": [
{ "guild_id": "KHtHtdynhuPPBqAJcJmP", "name": "Wyvern Hoard", "visibility": "public", "created_at": "2026-09-26T22:09:13.381179Z" },
{ "guild_id": "jRHERNbebDcdGNmdnWeQ", "name": "Wyvern Knights", "visibility": "public", "created_at": "2026-09-26T22:09:13.062394Z" }
],
"page": {
"next_cursor": "eyJ2IjoxLCJzIjoiZmI1YzU2ZjM5ZTYyOTVkYyIsInAiOnsibyI6Mn19",
"has_more": true,
"total": 3
}
}
}The same request with &cursor=eyJ2Ijox... returns the last guild and
"page": { "has_more": false }. The rows key is named after the entity
(guilds, webhooks, ...) or is items; the page object is the same
everywhere.
Errors
A query the list cannot answer returns 400 with the parameter at fault
named first in msg - never a silently empty result:
msg | Cause |
|---|---|
page_size is not a list parameter; use limit | A retired parameter |
visibility is a list field; filter it with filter[visibility][eq]=<value> | A field used as a parameter |
filter[name][eq] filters on an unknown field; this list accepts visibility, join_policy, ... | Field not filterable here |
filter[visibility][gt] is not supported; visibility accepts eq, ne, in | Operator not allowed for the field's kind |
filter[visibility][in] expects one of public, private, got "secret" | Value outside an enumeration |
filter[created_at][gte] expects an RFC 3339 timestamp such as 2026-01-01T00:00:00Z, now, or a time before now such as -30m, -6h or -7d; got "yesterday" | Unreadable timestamp |
sort cannot order by "owner"; this list sorts by name, member_count, ... | Sort field not allowed |
sort combines 2 keys; this list sorts by at most 1 | Too many sort keys |
limit must be between 1 and 100 | Page size out of range |
cursor belongs to a different list, filter, sort, or search; start again without a cursor | Cursor reused on another list or with changed parameters |
cursor cannot be combined with page; use one or the other | Both paging styles at once |
total is not available on this list, which cannot count its rows | Counting a list that cannot count |
page is not available on this list; follow page.next_cursor instead | Page number on a cursor-only list |
Which fields each list filters and sorts on is documented on its endpoint's page in the API reference.
