Filtering & Sorting
Core Concept
Section titled “Core Concept”GET /v1/feedback-records accepts a set of filters that narrow the result, and sort/order that
control the sequence you page through. GET /v1/feedback-records/count accepts exactly the same
filters (minus pagination), so a count always describes the list it is counting.
The API reference lists every parameter individually. This page covers the part a per-parameter listing cannot express: how filters combine, and what they do with missing values.
How filters combine
Section titled “How filters combine”Two rules, and they are the whole model:
| Repeating one parameter | the values are OR-ed |
| Different parameters | the conditions are AND-ed |
So this request:
curl -G "$HUB/v1/feedback-records" \ -H "Authorization: Bearer $HUB_API_KEY" \ -d tenant_id=org-123 \ -d source_type=survey \ -d source_type=review \ -d sentiment=negativereads as “(survey OR review) AND negative”.
Every filter that accepts an id or a label can be repeated this way — source_type, source_id,
source_name, field_id, field_group_id, field_type, value_id, user_id, submission_id,
language, sentiment, emotions. Repeat the parameter; do not pass a comma-separated list,
which is read as one literal value:
-d source_type=survey -d source_type=review # ✅ two values-d "source_type=survey,review" # ❌ one value, literally "survey,review"Limits
Section titled “Limits”A filter takes at most 100 values, each at most 255 characters. The enum filters —
field_type, sentiment, emotions — are capped instead at the number of values they define,
since repeating one past that point cannot narrow anything further.
Crossing either bound is a 400, naming the offending parameter. The request is rejected, never
silently truncated to the first 100 — a filter that quietly dropped values would return a page that
looks complete and is not.
An empty value is ignored
Section titled “An empty value is ignored”?source_type= is the same request as omitting source_type altogether. It does not select
records whose source type is blank, and it is not an error:
-d source_type= # no constraint — identical to leaving it outThis holds for every query parameter, not just the repeatable filters, so a UI that always emits its whole filter set does not have to strip the empty ones before sending.
What this cannot express
Section titled “What this cannot express”There is no way to OR across different fields — “negative sentiment or rating ≤ 2” is not expressible. Filters always AND across fields. If you need that, run two requests and merge, or narrow with the filters and post-process.
Ranges
Section titled “Ranges”collected_at, created_at, updated_at, value_number, value_date and sentiment_score take
bounds. Every bound is inclusive — a record sitting exactly on it is included.
| Filter | Bounds |
|---|---|
collected_at — when the feedback was given |
since / until |
created_at — when Hub stored it |
created_since / created_until |
updated_at — when Hub last changed it |
updated_since / updated_until |
value_number |
value_number_min / value_number_max |
value_date |
value_date_min / value_date_max |
sentiment_score |
sentiment_score_min / sentiment_score_max |
Supplying a range backwards — a minimum above its maximum — returns 400, not an empty page:
{ "status": 400, "code": "validation", "invalid_params": [ { "name": "value_number_min", "reason": "must be less than or equal to value_number_max" } ]}collected_at is not created_at
Section titled “collected_at is not created_at”The two diverge whenever historical data is imported: the feedback was collected months ago but
created in Hub today. Use created_since to answer “what did this import bring in”, and since
to answer “what did people tell us in this period”.
Incremental extraction
Section titled “Incremental extraction”To keep a copy of a tenant’s records in sync without re-reading everything, filter on
updated_since. Hub stamps updated_at on every write to a record, so one filter picks up both
new and changed records:
Stamps updated_at |
Does not |
|---|---|
| creating the record | embeddings (stored apart from the record) |
every PATCH |
taxonomy assignment (not part of the listed record) |
| every translation, sentiment and emotion result | deleting the record — see below |
Every writer takes the timestamp from the database clock, at the moment its transaction starts.
# everything created or changed since the last run, in the order it changedcurl -G "$HUB/v1/feedback-records" \ -H "Authorization: Bearer $HUB_API_KEY" \ -d tenant_id=org-123 \ -d updated_since=2026-10-05T07:55:00Z \ -d updated_until=2026-10-05T09:00:00Z \ -d sort=updated_at \ -d order=asc \ -d limit=1000Sort by updated_at, ascending. Every page is then one range of the updated_at index, so a
sync reads only the records it returns, however large the tenant. Filtering on updated_since while
sorting by created_at also works, but the database cannot tell how few records changed and may
read the whole tenant for every page.
Ascending is the only direction updated_at can be sorted in, because updated_at changes while you
page. A record that changes mid-run normally gets a newer updated_at, which moves it ahead of the
cursor: it comes back later in the same run, and you upsert it again. Descending would move every such
record behind the cursor, where it would be skipped, so sort=updated_at with order=desc — or with
no order, which defaults to desc — is a 400.
Ascending still has one exception: updated_at is when a write started, so a write that started
before the cursor’s position and commits after the run has read past it lands behind the cursor, as
can the earlier of two writes to one record that commit out of order. Those are missing from this run
and picked up by the next one, as long as you choose the next updated_since as below.
Bound each run with updated_until. Otherwise a run never quite ends while records keep
changing: every write during the run moves a record ahead of the cursor, and a new record collects
several (translation, sentiment, emotions) in its first minutes. Start each run with a cheap request
(a page with limit=1) and read its Date header: that one value, S, is this run’s
updated_until, and S minus the overlap is the next run’s updated_since. Both bounds have to come
from the same S — an updated_until from your own clock can fall short of the next run’s
updated_since and leave records in between that neither run ever reads. Anything that changes after
S has a newer updated_at and belongs to the next run.
Choosing the next updated_since
Section titled “Choosing the next updated_since”Note when the run starts, and use that time minus an overlap as the next run’s updated_since.
Do not use the largest updated_at you received. updated_at is when a write started, and a
write becomes visible only when it commits: one that was still in flight when your run read past its
timestamp is missing from this run, and its updated_at is older than the newest one you received.
If you sort by created_at instead, there is a second way to lose a change: a record you paged past
early can change while later pages are read, and its new updated_at is still older than the newest
one on your last page. Either way, a watermark taken from the results skips the change for good; a
watermark taken from the run’s start, minus the overlap below, reads it next time.
- Take the start time from the server, not from your own clock. Every response carries an HTTP
Dateheader; use the one on the run’s first response, so drift between your clock and the server’s cannot open a gap. - Keep an overlap longer than any write to a record can take.
updated_atis when the writing transaction started, so a write that commits after your run began can carry a timestamp from before it; the overlap has to exceed the longest such transaction, plus the time the first response took (Dateis stamped after that page was read) and the clock difference between whatever stampedDate(Hub, or a proxy in front of it) and the database. Hub’s writes are short single-record transactions, so five minutes is a comfortable default, but Hub does not enforce a limit on how long one may take. A write stalled for longer than the overlap is missed by the incremental sync, which is one reason to keep the full sweep below. - The latest fetch wins, so never run two syncs at once (the full sweep below included). The overlap re-reads some records, so
upsert by
idand let the copy from the most recent run replace the older one. Do not compareupdated_atto pick a copy: two writes to one record can commit in the opposite order to the one they started in, so a newer state can carry an older timestamp. A later read always sees the later state, which only holds if one run finishes before the next begins.
Deleted records
Section titled “Deleted records”To reconcile deletions, periodically list every record id for the tenant: note the run’s start
from the first response’s Date header, then page through GET /v1/feedback-records with no
updated_since and a stable sort, such as sort=created_at. Afterwards, remove the local records
that were not returned and whose created_at is earlier than that start minus the overlap.
That condition matters: a record created after the sweep began may have been stored by your
incremental sync but never reached by the sweep, and deleting it would lose it for good.
This catches every way a record can disappear, including deletes by user and dataset purges. The sweep returns every record in full, so upsert what it returns as well: that also repairs any change the incremental sync missed, such as a write that took longer than the overlap. It reads the whole tenant, so run it less often than the incremental sync, for example daily against an hourly sync.
Enrichment filters
Section titled “Enrichment filters”sentiment, emotions and sentiment_score filter on
sentiment and emotion output. Records that have not been
enriched carry no values and are therefore never matched by them.
To find those records instead, use the presence filters — has_sentiment, has_emotions and
has_translation. Each is a three-state switch: omit it for no constraint, true for records that
have the value, false for records that do not.
# unenriched records, oldest first-d has_sentiment=false -d sort=collected_at -d order=ascemotions matches ANY of the labels you list, never all of them. A record tagged {joy} and a
record tagged {joy, anger} both match ?emotions=joy&emotions=anger.
Sorting
Section titled “Sorting”sort accepts collected_at (the default), created_at or updated_at; order accepts asc or
desc (default desc). Records tied on the sort column are ordered by id ascending, so the sequence
is always total and stable. updated_at is accepted only with order=asc — see
Incremental extraction.
Sort by the column you filter on. Filtering created_at while ordering by collected_at cannot use
the index for the ordering, and gets slower as a dataset grows.
Cursors are bound to their ordering
Section titled “Cursors are bound to their ordering”A cursor marks a position within one specific ordering. Presenting it with a different sort or
order returns 400 rather than a page that looks like a continuation but is not:
{ "invalid_params": [ { "name": "cursor", "reason": "was issued for a different sort/order; restart pagination without a cursor, or keep sort and order unchanged" } ]}Keep sort and order unchanged for the whole traversal. When the user changes the ordering, drop
the cursor and start from the first page.
Worked example
Section titled “Worked example”Negative or very negative feedback from two surveys, collected in Q1, newest first:
curl -G "$HUB/v1/feedback-records" \ -H "Authorization: Bearer $HUB_API_KEY" \ -d tenant_id=org-123 \ -d source_id=survey-arrivals \ -d source_id=survey-departures \ -d sentiment=negative \ -d sentiment=very_negative \ -d since=2026-01-01T00:00:00Z \ -d until=2026-03-31T23:59:59Z \ -d sort=collected_at \ -d order=desc \ -d limit=50Swap the path for /v1/feedback-records/count — dropping sort, order and limit — to get the
size of that set before fetching it.