Records
Records are the rows inside a table. The records API supports create, read, update, delete, and rich querying.
Create a record
POST /api/table/<table_id>/records/create
Request body
{
"data": {
"name": "Acme Corp",
"email": "hello@acme.test",
"status": "Active"
}
}
Example
curl -X POST https://infolobby.com/api/table/101/records/create \
-H "Authorization: Bearer il_live_..." \
-H "Content-Type: application/json" \
-d '{"data":{"name":"Acme Corp","email":"hello@acme.test","status":"Active"}}'
Response
The full record with the assigned ID.
Field defaults. Any field configured with a default (for example a date field set to Current Date/Time) is filled in when you leave it out of
data— the same way it would be on the new-record form. Send the field explicitly asnullor""to store a deliberate blank instead. A date-only default resolves to today's date in the account owner's timezone; a date & time default is the current UTC instant.Validation rules. A table may carry a validation rule that runs before every create, update, and delete. If it blocks the write, the request fails with a
500whose body is the rule's message and nothing is saved (for a record with line items, the parent and all lines are rejected as one unit). A rule can also gate on origin — API writes are seen asapi— so a table may accept your integration's writes while blocking edits made by hand.Required fields. Fields marked Required are enforced here, not just in the browser. Leaving one out of
data(or sendingnull,"", or[]) fails with a500whose body names the field, and nothing is saved.0andfalsecount as values. A field's default fills it in before the check runs, so a required field with a default never needs to be sent. Line items are checked per line, and one bad line rejects the whole parent-and-lines unit.
Get a record
GET /api/table/<table_id>/record/<record_id>/get
Update a record
POST /api/table/<table_id>/record/<record_id>/update
Request body
{
"data": {
"status": "Archived"
}
}
Only include fields you want to change.
Required fields on update. The check runs against the record as it will look after your change, so a required field you leave out of
datais satisfied by its stored value. Sendingnull,"", or[]for one fails with a500. Note that if a record already has an empty required field (it was created before the field was made required, or came in through an import), every update to it is refused until that field is filled. Send the missing value along with your change to fix it in one call.
Lookup fields accept either the related record ID or an object carrying id. All of
"user": 2, "user": {"id": 2}, and "user": {"id": 2, "title": "Andreas"} are valid —
title is display-only and is ignored on write, so you do not need to know it. The
referenced record must exist.
Field value formats
- Select — a single select returns the chosen option as a string (
"Active", ornullwhen empty); a multiple select returns an array (["A","B"]). On write you may pass a single value, a one-item array, or a comma-separated string. - User — returns
{ "id", "email", "name" }for a single user field (ornull), and an array of those for a multiple user field. On write you may pass a user id, an email, an object with either, an array, or@me. Values must reference current workspace members. The email shown is always the member's current address, so it stays correct even after they change it. - Lookup — single returns
{ "id", "title" }(ornull); multiple returns an array of those. Date / Datetime / Time — values are returned and accepted in UTC, with no timezone conversion:
dateasYYYY-MM-DD,datetimeasYYYY-MM-DD HH:MM:SS,timeasHH:MM:SS. Adateis a plain calendar date and is never shifted. The web grid displays these converted to the signed-in user's local timezone, so a value you write in UTC may look shifted in the browser — that is expected. See Dates, times, and timezones.Signature returns the full signature record as an object (or
nullwhen unsigned): the typed name, the server timestamp, the fingerprint of the document signed, the capture details, and a tamper seal. See Signature Fields.On write you may send only what a person can actually assert:
signer_name,intent(must betrue),consent(must betrue), and optionallybrowser_timeandbrowser_tz. Everything else is computed on our servers and anything else you send is discarded. That includes the timestamp, the IP address, the document fingerprint and the seal: a caller-supplied value for any of those would be exactly the forgery the field exists to prevent.To record a refusal instead, send
{"status": "declined", "signer_name": "...", "decline_reason": "..."}. A decline needs no affirmations, and is as final as a signature.The field is write-once. A second write is rejected, and so is clearing it. Re-sending the value you read back is safe: an unchanged signature is recognised and dropped, so a fetch-modify-write of the whole record still works.
How the signature arrived is recorded on it and sealed into it, so a signature written through the API is labelled as such wherever it is displayed. That is deliberate: recording a wet-ink or in-person signature through an integration is legitimate, and a signature that misrepresents its own origin is the actual risk.
A single-valued field always returns one value (or null); a multi-valued field always returns an array ([] when empty).
Delete a record
POST /api/table/<table_id>/record/<record_id>/delete
Silent writes
Every record write notifies the people following that record (or following the table, for a create). When your integration is syncing, backfilling, or doing routine housekeeping, that can flood people with notifications nobody asked for.
Add "silent": true to the request body to suppress those notifications for
that one call:
curl -X POST https://infolobby.com/api/table/101/record/5/update \
-H "Authorization: Bearer il_live_..." \
-H "Content-Type: application/json" \
-d '{"data":{"status":"Synced"},"silent":true}'
silent is a top-level key, a sibling of data, not a field inside it.
It is accepted on:
| Endpoint |
|---|
records/create |
records/create_with_children |
record/<record_id>/update |
record/<record_id>/update_with_children |
record/<record_id>/delete |
record/<record_id>/comments/create (see Comments) |
record/<record_id>/files/create and files/delete (see Files) |
What silent does not do. It mutes follower and subscriber notifications only. All of the following still happen exactly as they would on a normal write:
- People you
@mentionin a comment are still notified. - Users newly assigned through a user field are still notified.
- The change is still written to record history and the activity feed.
- Automations (flows) triggered by the write still run, with their own notification behaviour.
A silent write is quiet, not invisible. Leave the flag off whenever a person should actually notice the change.
Batch delete (records/delete_batch) runs on a background queue that never
raises notifications, so the flag is unnecessary there.
Master-detail records (line items)
Some tables have sub-tables — repeating detail rows ("line items") that belong to a parent record: invoice lines, order items, timesheet entries. A sub-table row is a weak entity: it has no independent identity and is writable only through its parent. There is no standalone create/update/delete endpoint for a sub-table row — a direct write to a sub-table is rejected. Instead, write the parent and all its lines together, atomically.
Discovering a table's sub-tables
A sub-table is a real table, so it appears in the parent's space via GET /api/space/<space_id>/tables/list and has its own schema. Its settings object marks the relationship:
settings key |
Meaning |
|---|---|
detail_of |
The parent (master) table id. Its presence is what makes a table a sub-table. |
owner_field |
The field id of the hidden lookup that links each line back to its parent record. Read it from settings — do not hard-code it, as the generated id varies per table. |
So to find every sub-table of table 101, list the space's tables and keep those whose settings.detail_of == 101. The sub-table id you collect this way is the key you use in the children object below.
Create a record with its lines
POST /api/table/<table_id>/records/create_with_children
The parent and every line commit in one transaction — any failure rolls the whole batch back. children is keyed by sub-table id; each value is an array of line rows.
Request body
{
"data": { "customer": "Northwind Traders", "status": "Sent" },
"children": {
"915": [
{ "qty": 2, "product": 4012 },
{ "qty": 1, "product": 4020 }
]
}
}
Each line row is a {field_id: value} object using the sub-table's own field ids — the same value formats as a normal record (see Field value formats). Do not set the owner field on a line; the parent link is assigned automatically. Rollup and calc fields are computed, so omit them too.
Example
curl -X POST https://infolobby.com/api/table/101/records/create_with_children \
-H "Authorization: Bearer il_live_..." \
-H "Content-Type: application/json" \
-d '{
"data": { "customer": "Northwind Traders", "status": "Sent" },
"children": {
"915": [
{ "qty": 2, "product": 4012 },
{ "qty": 1, "product": 4020 }
]
}
}'
The response is the created parent record (with any rollup fields recomputed from the lines).
Update a record's lines
POST /api/table/<table_id>/record/<record_id>/update_with_children
This replaces the line set — it does not merge. For every sub-table you include, the submitted array becomes the complete set of lines: rows carrying their key id are updated, rows without an id are created, and any existing row you leave out is deleted. A fetch-modify-write that sends back only the lines it changed will destroy the rest. Sending
"children": {"915": []}deletes every line of sub-table 915.To leave a sub-table's lines alone, omit its key entirely — or use
record/<id>/update, which never touches lines.
Same body shape. Parent rollup fields are recomputed from the resulting line set.
Include each surviving line's key value under the sub-table's key field id — the type: "key" field in its schema (often id) — so the line is matched and updated rather than dropped and recreated. In this example the first line (key 7788) is updated, any other existing lines are deleted, and the second row (no key) is inserted:
curl -X POST https://infolobby.com/api/table/101/record/5001/update_with_children \
-H "Authorization: Bearer il_live_..." \
-H "Content-Type: application/json" \
-d '{
"data": { "status": "Paid" },
"children": {
"915": [
{ "id": 7788, "qty": 3, "product": 4012 },
{ "qty": 5, "product": 4020 }
]
}
}'
Reading lines
Sub-tables are real tables, so their rows are still readable — only writes are gated to the parent. Query a sub-table like any other table with records/query. To read the lines of one parent record, filter on the owner field (from settings.owner_field) against the parent record id. If a sub-table's owner_field is invoice, the lines of invoice 5001 are:
curl -X POST https://infolobby.com/api/table/915/records/query \
-H "Authorization: Bearer il_live_..." \
-H "Content-Type: application/json" \
-d '{"where":{"invoice":5001}}'
The owner field is a single lookup back to the parent; include it in fields to get each line's parent as { "id", "title" }, where id is the parent record id. calc and rollup columns on a line are virtual and do not appear in query results; see below to preview them before saving.
Previewing calc and rollup values before saving
POST /api/table/<table_id>/records/preview_children
Computes the calc values each line would have and the parent's rollup/calc values for a given data + children payload without persisting anything — useful when building your own line-entry UI and you want live totals as the user types. The body is the same shape as create_with_children. It requires write (edit) permission on the table, like the write path, and returns { "master": {...}, "children": {...} }.
Query records
POST /api/table/<table_id>/records/query
Request body
{
"fields": ["name", "email", "status"],
"where": {
"status": "Active"
},
"order_by": "name",
"order_dir": "A",
"limit": 50,
"offset": 0
}
limit defaults to 100 and is capped at 1000. Values outside that range are
clamped (a limit of 0 or a negative number falls back to the default rather than
returning the whole table). offset defaults to 0 and negatives are treated as 0.
With no order_by, rows come back in descending key-field order (newest first).
An order_by naming a field that does not exist is ignored rather than rejected, and
you get that same default ordering.
Response encoding
Important:
records/queryreturns a flat row per record, and it encodes values differently from Get a record.record/getinflates each value into its typed form;queryreturns the stored representation as read from the database. If you want the typed shapes, read the record individually.
| Field type | record/get (inside data) |
records/query (flat) |
|---|---|---|
string, textarea |
string | string |
number |
number | string |
select (single) |
"A" |
"A" |
select (multiple) |
["A","B"] |
"A,B" — comma-joined string |
user |
{"id":1,"email":"…","name":"…"} |
JSON string of [{"email":"…","id":1}] — no name |
link |
["https://…"] |
JSON string with escaped slashes |
file (empty) |
[] |
null |
file (non-empty) |
array of objects | JSON string |
lookup |
{"id":5,"title":"Janyx"} |
"5", plus a companion key "<field>.json" holding {"id":5,"title":"Janyx"} as a string |
| empty value | "" |
null |
id |
1 at top level, "1" inside data |
"1" |
Two things to watch:
queryemits keys containing a dot (cust.json) for lookup fields, which breaks naive dotted-path accessors.- The comma-joined multi-select is lossy — a choice whose title contains a comma cannot be round-tripped. Read such records individually.
Field ids listed in fields that do not exist on the table are silently dropped. If
every id is unknown the filter is discarded and you get all fields. The key field is
always included whether you ask for it or not.
Supported where forms:
- Simple equality:
{"status": "Active"} - Comparison:
{"age": [">", 21]}
Or use the more verbose filters array:
{
"filters": [
{"column": "status", "compare": "=", "value": "Active"},
{"column": "age", "compare": ">", "value": 21}
]
}
compare accepts =, !=, <>, <, <=, >, >=, plus LIKE, NOT LIKE, IN
and NOT IN. Note that LIKE takes a raw pattern — unlike the CONTAINS alias
below, no % wrappers are added for you. IN / NOT IN values are coerced to integers,
so they are only useful for id columns; they must be supplied through where
({"where":{"id":["IN",[1,2]]}}) rather than filters.
field may be used instead of column. The following aliases are also accepted, and are
case-insensitive:
| Alias | Meaning |
|---|---|
EQ |
= |
NE, NEQ |
!= |
GT |
> |
GTE |
>= |
LT |
< |
LTE |
<= |
C, CONTAINS |
contains (LIKE %value%) |
NC, NOT_CONTAINS |
not contains (NOT LIKE %value%) |
SW, STARTS_WITH |
starts with (LIKE value%) |
EW, ENDS_WITH |
ends with (LIKE %value) |
EMPTY, IS_EMPTY |
field is empty |
NEMPTY, IS_NOT_EMPTY |
field is not empty |
For IS_EMPTY and IS_NOT_EMPTY, the value field is ignored and may be omitted
entirely. Every other operator requires a value key — a filter without one is
rejected with Invalid filter. ("value": null counts as present and is read as "".)
File fields support IS_EMPTY, IS_NOT_EMPTY, CONTAINS, and NOT_CONTAINS.
The contains checks match against the original filenames only (not mime types
or storage paths); NOT_CONTAINS also matches records with no files at all.
Combining filters with AND/OR
Each filter may carry an optional connector of "AND" (default) or "OR"
that controls how it joins the filter before it. The first filter's
connector is always treated as AND. Connectors are evaluated left to right
with SQL precedence (AND binds tighter than OR).
{
"filters": [
{"column": "status", "compare": "=", "value": "Open"},
{"column": "status", "compare": "=", "value": "Sent", "connector": "OR"}
]
}
This returns records where status is either Open or Sent. Omit
connector entirely for the historical all-AND behaviour.
Query through a saved view
Pass view_id to query with the filters and sort saved on a table view.
The view fully replaces request-supplied filters, where, order_by,
and order_dir:
{
"view_id": 42,
"search": "acme",
"limit": 100
}
Pass 0 for the table's synthesised Default view (unfiltered). search
is still applied on top of the view's filters. See the
Views API reference for the full view endpoints.
Internal layout renderers may pass append_filters: true with view_id
to add range/layout filters on top of the saved view. Without that flag,
the saved view remains authoritative.
Date tokens
These tokens resolve server-side and are valid only in the filters array. They are not resolved in the where shorthand, and they are not accepted when creating or updating records. Write an explicit UTC date in those cases.
Tokens are resolved only for date, calc, and rollup columns. On any other column the value is compared literally, so a text field holding the word today matches the word.
Timezone: tokens are calendar dates, so they resolve in the requesting user's timezone. With an account API key that is the account owner's timezone; with a personal API key it is the key owner's. The one exception is now, which targets date and time columns. Those store UTC, so now is UTC.
| Token | Resolves to |
|---|---|
today |
Current date |
now |
Current date and time, in UTC |
yesterday |
Previous day |
start_of_week |
Monday of the current week |
start_of_month |
First day of the current month |
start_of_year |
January 1 of the current year |
start_of_last_week |
Monday of the previous week |
start_of_last_month |
First day of the previous month |
start_of_last_year |
January 1 of the previous year |
-Nd, +Nd |
N days ago / from now |
-Nw, +Nw |
N weeks ago / from now |
-Nm, +Nm |
N months ago / from now |
-Ny, +Ny |
N years ago / from now |
Example: all records created in the last 30 days:
{
"filters": [
{"column": "created", "compare": ">=", "value": "-30d"}
]
}
Current user token
User fields accept the literal @me, which resolves server-side to the
authenticated user's email address. Useful in saved views so each viewer
sees their own records without per-user filter duplication.
{
"filters": [
{"column": "assignee", "compare": "=", "value": "@me"}
]
}
For session and personal API-key requests, @me resolves to the logged-in
user's email. For account API keys, it resolves to the account owner's email.
Search
{
"search": "acme"
}
search is a case-insensitive substring match across the record's fields.
searchonly scans the columns named infields. If you supply afieldsprojection,searchmatches within those columns only — it does not search the full record. A search for"acme"with"fields": ["id"]matches nothing, becauseidis numeric and never contains the word. Omitfields(or include the columns you want searched) to get the expected results.
The term is split on spaces and each word is matched separately: a record must contain
all the words (AND), and each word may match in any column (OR). So
"acme widget" matches a record whose name is Widget and whose customer is Acme, not
just the literal phrase.
Wildcards are not escaped:
%and_in a search term act as SQLLIKEwildcards. A search for%matches every record. Strip or escape them client-side if you are passing through user input.
Count records
Return how many records match a query without fetching the rows.
POST /api/table/<table_id>/records/count
Accepts the same body as Query records (view_id,
filters, where, search) — pagination fields are ignored.
{
"count": 128,
"total": 4000,
"total_approx": false,
"restricted": true
}
| Field | Meaning |
|---|---|
count |
Records matching the current filters/search/view |
total |
Records in the whole table |
total_approx |
true when total is an approximate (InnoDB) estimate — used on large tables to avoid a full scan; false when exact |
restricted |
true when the query narrows the table (filters/search/view). When false, count equals total |
Record IDs
Return just the id list matching a query — useful to drive a bulk operation over an entire filtered set.
POST /api/table/<table_id>/records/ids
Accepts the filtering half of the Query records body — view_id,
filters, where, search. Returns a flat array of record ids:
[1, 5, 12, 39, 40]
limit,offset,order_byandorder_dirare ignored here. The response is unbounded and unordered — on a large table this can be a very big array. Userecords/countfirst if you need to know the size, andrecords/querywhen you need ordering or paging.
Batch delete
POST /api/table/<table_id>/records/delete_batch
{
"record_ids": [1, 5, 12, 39]
}
Returns {"ok": true, "count": <n>}, where count is how many of the supplied ids
actually existed and were queued for deletion. Ids that do not exist are skipped, so a
count lower than the number you sent means some ids were stale. Deletion runs in a
background worker, so the rows may still be readable for a moment after the call returns.
File fields
File-type fields hold an array of attachment metadata objects. Reading a record returns the full array under each file field. Most callers should manage attachments through the dedicated Files API, which handles upload, append, delete, and download. Use this record/update endpoint with a {name, path, type, size, host} array only when you need to replace the entire attachment list of a field in one call.
When you set a file field this way you can also supply {url, ...} or {data, ...} source descriptors to pull a file from a URL or base64, and copying an attachment from another record makes an independent copy of the stored file. See File Attachments → Setting files by URL or base64 for the full item shapes and rules.