Objects and records
Read, filter, write and search records with the envelope, the sixteen operators and the lifecycle every object shares.
An object is a table you declare: a name, a label expression, and a list of attributes. A record is one row of that object. Every object gets the same REST surface under /records/:object, the same envelope, the same filters and the same lifecycle, so what you learn on one object applies to all of them.
Objects
An attribute has one of sixteen types. Each is declared with a builder from @stndrds/client; see SDK / Attributes for options and inferred types.
| Type | Holds |
|---|---|
text | A short string. |
richtext | Formatted text. Not filterable, except for emptiness. |
number | A number, optionally with a unit and precision. |
checkbox | true or false. |
date | A calendar date or a timestamp. |
phone | A phone number, written as { countryCode, phoneNumber }, filtered as digits. |
currency | An amount and a currency code. |
status | One value from a declared list, with a colour. |
select | One value from a declared list. |
multiselect | Several values from a declared list. |
location | A postal address; filter on city, postalCode or country. |
user | A workspace user. |
relation | A link to records of another object, single or .many(). |
formula | A value computed from the record. |
rollup | A value aggregated across a relation. |
document | A pack of files attached to the record. |
Relations link records across objects, and a bilateral relation keeps both sides in step. Documents hold files under a record. Views describe how the app lists and opens records. SDK / Relations and documents and SDK / Views cover them.
Records
Every record carries these fields beside its attribute values:
| Field | Meaning |
|---|---|
id | UUID of the record. |
objectName | Name of the owning object. |
label | Display name computed from the object's label expression. |
values | Attribute values, keyed by attribute name. The SDK flattens values onto the record. |
visibility | workspace or private. |
ownedBy | The owning actor of a private record, null otherwise. |
metadata | Free-form object you can write, for UI state or your own flags. |
createdAt, updatedAt | Timestamps. |
createdBy, lastUpdatedBy | Actor ids. |
deletedAt, deletedBy | Set while the record sits in the trash. |
writeVersion | Optimistic-lock token, incremented on every write. |
schemaVersion | Schema version the record was last migrated to. |
These names are reserved. An attribute cannot be called id, label, values or metadata.
Reading
Three routes read a collection. All three answer the same envelope.
| Route | Use it for |
|---|---|
GET /records/:object | Quick reads with limit, offset, countMode and deleted=true in the query string. |
POST /records/:object/list | Filters, sorts, a visibility scope, fields and a timezone in the body. |
POST /records/:object/search | Full-text search. q is required; the body takes every list option on top. |
curl https://api.standards.new/v1/records/contacts/list \
-H "Authorization: Bearer $STANDARDS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "limit": 20, "sorts": [{ "attribute": "lastName", "direction": "asc" }] }'{
"data": [{ "id": "…", "objectName": "contacts", "label": "Ada Lovelace", "values": { "lastName": "Lovelace" } }],
"page": { "hasMore": true, "total": null, "countMode": "none", "nextOffset": 20 }
}GET /records/:object/:id reads one record. Add ?includeDeleted=true to read one from the trash.
Pagination and counting
limitdefaults to20. Anything above100is clamped to100.limit: 0returns no rows. With acountMode, it is the cheapest way to count.offsetdefaults to0. It must stay below2147483647 - limit, or the request is a400.countModeisnone(default),estimatedorexact. Withnone,totalisnulland the database skips the count. Sendexactonly when a number is displayed.hasMoretells you whether a next page exists. Followpage.nextOffsetwhen present rather than computing it fromdata.length: search can return an empty page that still advances the cursor.
Filtering
A filter is one combinator, and or or, over a flat list of rules. There is no nesting.
{
"combinator": "and",
"rules": [
{ "attribute": "status", "operator": "any_of", "value": ["lead", "active"] },
{ "attribute": "createdAt", "operator": "greater_than", "value": "2026-01-01" }
]
}Sixteen operators exist. Which ones apply depends on the attribute type.
| Operator | Types | Meaning |
|---|---|---|
is | text, phone, number, currency, checkbox, status, select, date, user, relation, formula | Equals. On a checkbox, value is a boolean. |
is_not | text, phone, number, currency, status, select, date, user, relation, formula | Does not equal. |
contains | text, phone, formula | Case-insensitive substring. |
not_contains | text, phone, formula | Substring absent. |
starts_with | text, phone | Case-insensitive prefix. |
ends_with | text | Case-insensitive suffix. |
greater_than | number, currency, date, formula | Strictly after or above. Dates compare by calendar day. |
greater_or_equal | number, currency, date, formula | On or after, at or above. |
less_than | number, currency, date, formula | Strictly before or below. |
less_or_equal | number, currency, date, formula | On or before, at or below. |
any_of | status, select, multiselect, user, relation | At least one stored value is in the list. |
none_of | status, select, multiselect, user, relation | No stored value is in the list. |
is_empty | every type but checkbox and rollup | No value. value is null. |
is_not_empty | every type but checkbox and rollup | Has a value. value is null. |
is_within | date | Relative range: { "amount": 7, "unit": "days", "direction": "past" }. |
on_day_month | date | Anniversary, any year: { "day": 14, "month": 7 }. |
System fields filter too: id, label, createdAt, updatedAt, createdBy, lastUpdatedBy and the others take the operators of their kind.
Aliases. On input, the API also accepts eq, neq, lt, gt, lte, gte, in, notIn, not_in, and the camelCase forms (startsWith, isEmpty, doesNotContain …). They are normalized to the canonical name above before validation and never stored. The former before, after, on_or_before, on_or_after, is_checked, is_not_checked and day_month_eq are rejected with a message naming the replacement.
Dynamic values. Three values resolve at query time:
{ "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 actor token (@me) matches the caller, on a user attribute as on createdBy. today resolves to a calendar day in the request's timezone, which defaults to UTC; send timezone in the body or an x-timezone header (Europe/Paris) so today means the caller's today. now is the current UTC instant. When @me cannot resolve, the rule is dropped, not rejected.
Relation and document properties. When a relation carries qualified properties, add property to filter on the property and quantifier (any or none) to say how many linked records must match:
{ "attribute": "members", "property": "role", "operator": "is", "value": "admin", "quantifier": "any" }A location filters the same way, on property: "city", "postalCode" or "country" (an ISO3 code), with the text operators and no quantifier.
Sorting
sorts is an ordered list of { attribute, direction }, asc or desc. Sorts apply in the order given.
Reference hydration
By default every relation, document and user attribute comes back as the linked records. fields narrows that:
- omit
fields: every reference attribute is hydrated; "fields": ["company"]: only those attributes are hydrated;"fields": []: nothing is hydrated and the keys are absent.
Skipping hydration is the cheapest read for exports.
Writing
Create
POST /records/:object takes the values under data and options beside them:
curl https://api.standards.new/v1/records/contacts \
-H "Authorization: Bearer $STANDARDS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "firstName": "Ada", "lastName": "Lovelace" }, "options": { "visibility": "private" } }'options.allowDraftskips required-attribute validation. The record is created and nothing marks it incomplete.options.visibilitysetsworkspace(default) orprivate.options.linkTo({ objectName, recordId, attribute, expectedWriteVersion }) creates the record and links it to an existing one in one transaction. The owner's newwriteVersioncomes back in a response header.
The answer is 201 with the record.
Update
PUT /records/:object/:id is a partial update: send only the attributes that change. The body is the values object itself, with no data wrapper. An empty body is a 400.
curl -X PUT https://api.standards.new/v1/records/contacts/$RECORD_ID \
-H "Authorization: Bearer $STANDARDS_API_KEY" \
-H "If-Match: 4" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'If-Match carries the writeVersion you last read. A stale version rejects the write; omit the header to write unconditionally. Sending null, "" or [] clears an attribute, required or not: required() gates creation, not later writes.
Delete, restore, purge
| Action | Route | Permission | Effect |
|---|---|---|---|
| Delete | DELETE /records/:object/:id | delete | Moves the record to the trash. 204. |
| Restore | POST /records/:object/:id/restore | delete | Brings it back. |
| Purge | POST /records/:object/:id/purge | manage | Removes it for good. 409 unless it is already in the trash. |
GET /records/:object?deleted=true lists the trash.
Bulk
| Route | Body | Max items | Result |
|---|---|---|---|
POST /records/:object/bulk-create | { "records": [ … ], "options"? } | 10 000 | { created, errors: [{ index, error }] } |
POST /records/:object/bulk-update | { "updates": [{ "id", "data" }] } | 100 | { updated, errors: [{ id, error }] } |
POST /records/:object/bulk-delete | { "ids": [ … ] } | 100 | { deleted, errors: [{ id, error }] } |
Each item is validated before the batch runs. A failed item lands in errors while its siblings proceed; the batch is not transactional, so a bulk update that reports one error has still written the others.
Visibility
A record is workspace or private. A private record is returned only to its owner; anyone else gets the same 404 as for a missing id. List and search accept a visibility scope to read one side only. Hydration and totals never leak private records.
PATCH /records/:object/:id/visibility with { "visibility": "private" } flips a record. Only the creator can make a workspace record private, and only the owner can open it back up. ownedBy is set by the server and cannot be assigned.
Search
One object. POST /records/:object/search runs a full-text query with every list option on top. q is required; an empty string means "filters only". Totals are lower bounds (totalIsExact: false) because permission checks run after ranking.
curl https://api.standards.new/v1/records/contacts/search \
-H "Authorization: Bearer $STANDARDS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "q": "lovelace", "limit": 10 }'The whole workspace. GET /search?q= looks across every object you can read and returns hits, not records.
| Query param | Effect |
|---|---|
q | The text to search. Required. |
objects | Comma-separated object names to restrict to. |
grouped=true | One group per object instead of a flat list. |
deleted | archived for the trash only, all for both. Default: live records. |
countMode=none | Skip the exact count. Any other value keeps it. |
limit, offset | Flat mode only; default 20. |
A hit carries recordId, objectId, objectName, objectLabel, label, createdAt, updatedAt, and deletedAt when the record is in the trash. Flat responses are { results, total, totalIsExact, hasMore, nextOffset? }; grouped ones replace results with groups: [{ objectName, objectLabel, results, count }]. Fetch the record by id when you need its values.
Next steps
- SDK / Records — the typed query builder over the same routes.
- CLI / Records — the same reads and writes from a terminal.
- Concepts / Errors — the envelope every failed request shares.
- Concepts / Files and documents — what a
documentattribute holds.