Working with records
List, search, filter, and mutate records over the REST API.
Records are the rows of an object. Every record endpoint is scoped by object name under /records/{objectName}, and they all share the same conventions for pagination, filtering, and lifecycle. This guide covers those conventions; each endpoint linked below has a reference page with the full payload details.
Choosing a read endpoint
| Endpoint | Use it for | Response envelope |
|---|---|---|
GET /records/{objectName} | Quick reads with query-string params only | { records, total } |
POST /records/{objectName}/list | Lists with filters, sorts, and pagination in the body | { records, total } |
POST /records/{objectName}/search | Full-text search (q required), same body options as list | { data, page } |
The search envelope differs
List endpoints return { records, total }. Search returns { data, page } where page includes { hasMore, nextOffset?, total, totalIsExact, countMode }. Handle both shapes if you call both. (The SDK client — and therefore the React hooks — normalizes list responses to { data, page } for you.)
Listing
The simplest read is a plain GET — it accepts only limit and offset as query params:
curl "https://api.standards.new/v1/records/contacts?limit=20" \
-H "Authorization: Bearer stndrds_your_api_key"For filters and sorts, use the POST variant — they live in the request body so they can grow without hitting URL length limits:
curl https://api.standards.new/v1/records/contacts/list \
-H "Authorization: Bearer stndrds_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"limit": 20,
"sorts": [{ "attribute": "lastName", "direction": "asc" }],
"filters": {
"combinator": "and",
"rules": [{ "attribute": "status", "operator": "is", "value": "active" }]
}
}'Each record in the response has the same shape:
{
"id": "1f0d4b0a-8a5e-4a8e-9d2f-3c6b7e9a1c2d",
"objectId": "7c9e2b1d-4f3a-4e6b-8a1c-2d5f8e9b0a3c",
"label": "Ada Lovelace",
"values": { "firstName": "Ada", "lastName": "Lovelace", "status": "active" },
"createdAt": "2026-07-01T09:30:00.000Z",
"updatedAt": "2026-07-18T14:02:11.000Z"
}label is a display name computed from the object's label expression defined in the schema, and values is keyed by attribute name.
Searching
Search runs a full-text query (q) and accepts every list option on top:
curl https://api.standards.new/v1/records/contacts/search \
-H "Authorization: Bearer stndrds_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "q": "lovelace", "limit": 10 }'The response uses the search envelope:
{
"data": [ { "id": "1f0d4b0a-8a5e-4a8e-9d2f-3c6b7e9a1c2d", "label": "Ada Lovelace", "values": { } } ],
"page": { "hasMore": false, "total": 1, "totalIsExact": false, "countMode": "estimated" }
}Pagination
All three read endpoints paginate with limit and offset:
limit— page size, default20. Values above100are clamped to100.0is a count-only page: no rows are returned.offset— number of records to skip, default0.countMode— howtotalis computed:"estimated"(default),"exact", or"none".
With limit: 0 and countMode of "exact" or "estimated", the response is { data: [], page: { hasMore: false, total, countMode } } — the total is counted, the page stays empty. With countMode: "none", the count is skipped and total is null.
For search, send the returned page.nextOffset on the next request whenever page.hasMore is true; do not calculate the next offset from data.length. Candidate authorization can produce an empty continued page, but such a page always advances nextOffset. Search totals are authorization-safe lower bounds (totalIsExact: false), never raw search-engine hit counts.
Use "none" when you don't need a total — search then returns total: null. The GET endpoint always uses "estimated".
Record visibility
Every record has a visibility of "workspace" or "private". Workspace is the API default. A private record is returned only to its owning actor; other callers receive the same not-found result as they would for a missing record.
List and search requests accept one visibility scope at a time:
{ "visibility": "private", "limit": 20 }The scope is applied before pagination. Search applies the same ownership predicate inside Meilisearch, and linked-record hydration removes references the caller cannot see, so neither totals nor relations reveal private records.
Change an existing record through the dedicated state-machine endpoint:
curl -X PATCH https://api.standards.new/v1/records/contacts/<recordId>/visibility \
-H "Authorization: Bearer stndrds_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "visibility": "private" }'Only the record's creator can make a workspace record private. Once private, only its owner can return it to "workspace". The server derives ownedBy; clients cannot assign or transfer ownership directly.
Filtering
A filter is a combinator ("and" or "or") plus a flat list of rules:
{
"combinator": "and",
"rules": [
{ "attribute": "status", "operator": "any_of", "value": ["lead", "active"] },
{ "attribute": "createdAt", "operator": "greater_than", "value": "2026-01-01" }
]
}There are 16 operators in total: is, is_not, contains, not_contains, starts_with, ends_with, is_empty, is_not_empty, less_than, greater_than, less_or_equal, greater_or_equal, is_within, on_day_month, any_of, none_of. Which ones are valid depends on the attribute's type:
| Attribute type | Operators |
|---|---|
| text | is, is_not, contains, not_contains, starts_with, ends_with, is_empty, is_not_empty |
| phone | is, is_not, contains, not_contains, starts_with, is_empty, is_not_empty |
| number / currency | is, is_not, less_than, greater_than, less_or_equal, greater_or_equal, is_empty, is_not_empty |
| checkbox | is (value: boolean) |
| date | is, is_not, less_than, greater_than, less_or_equal, greater_or_equal, is_within, on_day_month, is_empty, is_not_empty |
| select / status | is, is_not, any_of, none_of, is_empty, is_not_empty |
| multiselect | any_of, none_of, is_empty, is_not_empty |
| user / relation | is, is_not, any_of, none_of, is_empty, is_not_empty |
| formula | is, is_not, less_than, greater_than, less_or_equal, greater_or_equal, contains, not_contains, is_empty, is_not_empty |
| document | is_empty, is_not_empty |
| richtext / location | not filterable |
A few semantics worth spelling out:
contains/not_containsare case-insensitive substring matches — they apply to text-like values only (text, phone, formula). Set membership on multiselect, user, and relation attributes usesany_of/none_of("at least one of the record's values is in the rule list").- On date attributes,
less_than/greater_than/less_or_equal/greater_or_equalcompare calendar dates — the app labels them "is before", "is after", "is on or before", "is on or after", but the wire names are the same ordering family as numbers. - Checkbox filters use
iswith a boolean value:{ "attribute": "done", "operator": "is", "value": true }. A checkbox always has a value, so it has no emptiness operators —isis its only operator. on_day_monthmatches an anniversary regardless of year; its value is{ "day": 14, "month": 7 }.
The emptiness operators (is_empty, is_not_empty) take "value": null.
Two value shapes are normalized on input rather than rejected: on a currency attribute, the stored object shape { "code": "EUR", "value": 730000 } is accepted and read as the amount 730000; and any_of / none_of accept a single value instead of an array, read as a one-element list. A phone value in a filter is always a plain digit string — the { countryCode, phoneNumber } object is a write shape only.
Short input aliases
The canonical names above are the only ones stored and returned. On input, the API also accepts short standard codes and camelCase forms, normalized to the canonical operator before validation — never persisted:
| Alias | Canonical |
|---|---|
eq, equals, ==, = | is |
neq, !=, <> | is_not |
lt, < | less_than |
gt, > | greater_than |
lte, <= | less_or_equal |
gte, >= | greater_or_equal |
in | any_of |
not_in, notIn | none_of |
camelCase forms (startsWith, endsWith, isEmpty, isNotEmpty, …) | the matching snake_case operator |
Breaking change
The former operator names before, after, on_or_before, on_or_after, is_checked, is_not_checked, and day_month_eq are rejected with a validation error naming the replacement (e.g. before → less_than, is_checked → is with value true, day_month_eq → on_day_month). contains / not_contains on multiselect, user, and relation attributes are likewise rejected — use any_of / none_of. eq, neq, lt, gt, lte, and gte survive as input aliases only; they are no longer stored or returned. Persisted view filters were migrated to the canonical names automatically. On date attributes, stored lt / gt / lte / gte rules migrated to less_than / greater_than / less_or_equal / greater_or_equal thereby adopt calendar-day semantics — they now compare whole days (as before / after / on_or_before / on_or_after did), no longer exact timestamps.
Dates and timezones
One filter value is timezone-sensitive: the @today anchor described below. The server resolves it to a calendar day, and it defaults to UTC. Pass a timezone (IANA name, e.g. "Europe/Paris") in the request body, or send an x-timezone header, so "today" means the caller's today:
{ "timezone": "Europe/Paris", "filters": { "combinator": "and", "rules": [ … ] } }Everything else is timezone-independent. Fixed dates are compared exactly as you send them, @now resolves to a UTC instant, and the relative ranges below are computed against UTC day boundaries regardless of the timezone you pass.
Dynamic values
Three filter values are resolved at query time instead of being fixed:
{ "attribute": "owner", "operator": "is", "value": { "dynamic": "actor", "ref": "current" } }
{ "attribute": "dueDate", "operator": "is", "value": { "dynamic": "date", "anchor": "today" } }
{ "attribute": "updatedAt", "operator": "less_than", "value": { "dynamic": "date", "anchor": "now" } }The first matches the caller ("my records"). It resolves to both of the caller's identities — the actor id and, when there is one, the user id behind it — and matches either. That single rule therefore works on a user attribute like owner and on system fields like createdBy, which store different ids.
The other two resolve to the current date (in the request's timezone) and the current UTC timestamp. The SDK exposes all three as the helpers currentActor(), today(), and now() — see view filters.
Unresolvable rules are dropped, not rejected
If @me can't resolve — no actor in context — the server silently removes that rule instead of failing the request. The query still runs, unfiltered by it, so a saved view built on @me can return far more than expected in a context without an actor.
Relative date ranges
The is_within operator takes a relative range instead of a fixed date:
{ "attribute": "createdAt", "operator": "is_within", "value": { "amount": 7, "unit": "days", "direction": "past" } }unit is one of days, weeks, months, years; direction is past or future.
The range covers whole UTC days and always includes today: past spans from the
start of the day amount units ago to the end of today, future spans from the
start of today to the end of the day amount units ahead. months and years
use calendar arithmetic clamped to the end of the target month — one month before
31 March is 28 February, not a 30-day offset.
Filtering on relation properties
When a relation or document attribute carries qualified properties, add property to filter on the property value instead of the linked record. Set quantifier to "any" (at least one linked record matches) or "none" (no linked record matches):
{ "attribute": "members", "property": "role", "operator": "is", "value": "admin", "quantifier": "any" }Sorting
sorts is an array of { attribute, direction } rules applied in order:
{ "sorts": [{ "attribute": "status", "direction": "asc" }, { "attribute": "createdAt", "direction": "desc" }] }Reference hydration
By default, responses include the full linked records for every reference attribute (relations, documents, and user attributes) — this is called hydration. The fields param narrows that:
- Omit
fields— all reference attributes are hydrated. "fields": ["company"]— only the listed reference attributes are hydrated."fields": []— no reference attributes are resolved; their keys are absent from the response.
Unknown names are ignored. Skipping hydration is the cheapest option for large exports.
Creating records
POST /records/{objectName} takes the record values wrapped in data:
curl https://api.standards.new/v1/records/contacts \
-H "Authorization: Bearer stndrds_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "data": { "firstName": "Ada", "lastName": "Lovelace" } }'Missing required attributes fail with a 400 unless you pass "options": { "allowDraft": true }, which skips required-field validation and creates the record anyway. The response is a normal record — nothing on it marks the record as incomplete. Check completeness yourself against the object's schema if you need to know.
Updating records
PUT /records/{objectName}/{id} is a partial update: only the attributes you send change.
curl -X PUT https://api.standards.new/v1/records/contacts/<recordId> \
-H "Authorization: Bearer stndrds_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'No data wrapper on update
Unlike create, the update body is the partial values object itself — not wrapped in data. An empty body returns a 400.
Deleting, restoring, and purging
Deletion is a two-step lifecycle, with a distinct permission per step:
| Action | Endpoint | Permission | Effect |
|---|---|---|---|
| Delete | DELETE /records/{objectName}/{id} | delete | Soft delete — the record moves to the trash. |
| Restore | POST /records/{objectName}/{id}/restore | delete | Brings a trashed record back. Idempotent. |
| Purge | POST /records/{objectName}/{id}/purge | manage | Permanent delete. Returns 409 unless the record is already trashed. |
To see trashed records, pass ?deleted=true on the list endpoint, or ?includeDeleted=true when fetching a single record.
Trashed, deleted, and archived all name the same soft-deleted state. The API spells it deleted in query parameters, while the SDK uses both (useArchivedRecords, listDeletedRecords).
Bulk operations
| Endpoint | Body | Max items | On failure |
|---|---|---|---|
POST …/bulk-create | { "records": [ … ] } | 10,000 | Partial success — returns { created, errors: [{ index, error }] }. |
POST …/bulk-update | { "updates": [{ "id", "data" }] } | 100 | Processes the whole batch — does not stop early. If any item fails, the whole request returns a 400 with only the first error's message, and the array of already-written records is discarded from the response (those writes are not rolled back in the database). Validate inputs first. |
POST …/bulk-delete | { "ids": [ … ] } | 100 | Partial success — returns { deleted, errors: [{ id, error }] }. Soft-deletes, like single delete. |
Bulk create accepts the same options.allowDraft flag as single create. Larger imports must be split client-side.
Next steps
- Search — searching across every object at once.
- Errors — the envelope every failed request shares.
- Authentication — API keys and what they can access.
- Permissions — the actions each endpoint requires.