Skip to content

Filtering & Sorting

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.

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:

Terminal window
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=negative

reads 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:

Terminal window
-d source_type=survey -d source_type=review # ✅ two values
-d "source_type=survey,review" # ❌ one value, literally "survey,review"

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.

?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:

Terminal window
-d source_type= # no constraint — identical to leaving it out

This 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.

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.

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" }
]
}

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”.

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.

Terminal window
# everything created or changed since the last run, in the order it changed
curl -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=1000

Sort 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.

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 Date header; 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_at is 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 (Date is stamped after that page was read) and the clock difference between whatever stamped Date (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 id and let the copy from the most recent run replace the older one. Do not compare updated_at to 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.

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.

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.

Terminal window
# unenriched records, oldest first
-d has_sentiment=false -d sort=collected_at -d order=asc

emotions 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.

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.

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.

Negative or very negative feedback from two surveys, collected in Q1, newest first:

Terminal window
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=50

Swap the path for /v1/feedback-records/count — dropping sort, order and limit — to get the size of that set before fetching it.