Filtering, search and sorting

List endpoints accept a filter query parameter: a MongoDB-style JSON document that is compiled to a SQL WHERE clause. Pass it as a URL-encoded JSON string on GET requests, or as a plain object in a JSON request body.

GET /customers?filter={"email":{"$includes":"@acme.com"}}

Most list endpoints also accept a search query parameter: a plain string matched case-insensitively against the record's most identifying fields. A customer matches on email, name and phone; an order on its number, remote ID, notes and its customer; a review on its title, body, customer and product. The same box appears on the corresponding list pages in the dashboard.

Search reaches through associations where a record has little text of its own: a review request (survey) matches on its order's number and customer, and loyalty activity matches on the member's customer. search and filter combine with AND, so a search can be narrowed by a structured filter in the same request. Matching is a substring containment, not relevance ranking, and it is backed by trigram indexes so it stays fast on large stores.

Sorting

List endpoints accept a sort query parameter: a field name, prefixed with - to reverse it. Each endpoint's reference page lists the fields it accepts. Most default to -createdAt, newest first.

GET /orders?sort=-totalCents

In the dashboard the same choice sits on the column headers. Every list screen can be sorted by its Date column, and starts on it newest first. A few columns people rank by are sortable too: rating on reviews, total and order number on orders, the Activity column on loyalty activity, stamps on stamp cards, name on wishlists, wishlist count on products. Click a header once to sort ascending, again to reverse it. A chevron marks the column in use, and the sort travels in the page address, so a sorted list is something you can bookmark or send to a colleague. The screen's starting order is left out of the address to keep it short. Survey flows are the one screen that starts alphabetically rather than on its date.

Columns that are counted or worked out for each row, such as an order's status or a customer's review count, are not sortable. They would need the whole table read on every page.

Shape

A filter is an object mapping field names to a value or an operator object. Multiple keys are combined with AND:

{ "title": "Test Product", "createdAt": { "$gt": "2020-01-01" } }

In the dashboard the filter bar calls this field Date. It is createdAt on the wire, so a saved quick filter or a link written before the label changed still works.

A bare value is shorthand for $eq:

{ "title": "Test Product" }      // title = 'Test Product'
{ "archivedAt": null }           // archivedAt IS NULL

Operators

Use an operator object to compare with something other than equality. You cannot mix operators and plain keys in the same object.

OperatorApplies toSQL
$eqany= value (or IS NULL for null)
$lt $lte $gt $gtecolumns, association fields, association counts< <= > >=
$includesstring columnsILIKE '%value%'
$incolumns, association fieldsIN (...) (empty array → no matches)
{ "title": { "$includes": "Test" } }
{ "status": { "$in": ["active", "trialing"] } }
{ "createdAt": { "$gt": "2020-01-01" } }

Nested JSONB keys

Use a dot path to filter on a key inside a JSONB column. Only $eq is supported (containment match):

{ "metadata.category": "electronics" }

Associations

Filter on a related table's field with a dot path. The endpoint matches rows that have at least one matching associated record:

{ "variants.title": "Variant 1" }

Use the bare association name with a comparison operator to filter on the count of related records:

{ "variants": { "$gt": 0 } }

Logical operators

Combine or negate sub-filters with $and, $or, and $not:

{ "$or": [{ "title": "A" }, { "title": "B" }] }
{ "$not": { "archivedAt": null } }
{ "$and": [{ "createdAt": { "$gt": "2020-01-01" } }, { "title": { "$includes": "x" } }] }

Paging through a long list

A list endpoint answers with one page and a nextCursor. Pass that value back as cursor to get the page after it; nextCursor is null once the last page has been returned. The default page is 50 records.

GET /customers?cursor=eyJpZCI6...

In the dashboard the same paging sits under the table as a Load more button. It appears only while there are more records to fetch, and each press appends the next page below what is already on screen rather than replacing it. Until the last page arrives the count beside the quick filters reads with a plus, "50+ results", because it counts what is loaded rather than everything that matches.

Errors

Filtering by an unknown field, mixing operators with plain keys, or passing a value that fails the column's schema returns a 400 validation error describing the problem. In the dashboard that lands as a panel where the table would be, headed "Couldn't load this list", with the same message underneath and buttons to try again or clear the filters.